mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcefdd12cb | ||
|
|
fd775252b8 | ||
|
|
acd7dc65f6 | ||
|
|
a82d272a27 | ||
|
|
1bb5ddba96 | ||
|
|
ae14a55885 | ||
|
|
9308016cf3 | ||
|
|
b5252a01f8 | ||
|
|
4b22902fa4 | ||
|
|
267ebce457 | ||
|
|
ee8013cba2 | ||
|
|
4ce07eaf31 | ||
|
|
e0d4e4072d | ||
|
|
d4aeece78e | ||
|
|
02adcbc4f8 | ||
|
|
a9d44b3d76 | ||
|
|
3d310ee184 | ||
|
|
0adf1eae76 | ||
|
|
e10d3dd3c7 | ||
|
|
fce0e482d3 | ||
|
|
4d57d00ddb | ||
|
|
7cc90a4378 | ||
|
|
ff0f1a545f | ||
|
|
6eb69a036a | ||
|
|
3c8a47fc76 | ||
|
|
735ec7efa0 | ||
|
|
c33f842c9a | ||
|
|
2340e39fb4 | ||
|
|
0221d1f70e | ||
|
|
c6fe105b46 | ||
|
|
6ec1bc118d | ||
|
|
098dd5425e | ||
|
|
9eeed5094f | ||
|
|
ada4f66105 | ||
|
|
b25c90e338 | ||
|
|
47995056cb | ||
|
|
46ee5b7764 | ||
|
|
c8f0e421fe | ||
|
|
3db6a4443e | ||
|
|
77d20ad189 | ||
|
|
bd6b2adef0 | ||
|
|
7457ada25a | ||
|
|
ab94a0b27c | ||
|
|
41e86af663 | ||
|
|
19e752b7ca | ||
|
|
3d4f71dbad | ||
|
|
ff4d858efc | ||
|
|
59271c9e38 | ||
|
|
d87fb6d0fa | ||
|
|
646d87ab9e | ||
|
|
9e687f27a3 | ||
|
|
b699322e6f | ||
|
|
3c01e3b7b1 | ||
|
|
cb256976d7 | ||
|
|
1424461e26 | ||
|
|
86bd472c41 | ||
|
|
8984ea14f1 | ||
|
|
1af42bf012 | ||
|
|
f606a7e8b7 | ||
|
|
6d938a34af | ||
|
|
77c5721dff | ||
|
|
c5561ab6dc | ||
|
|
0b647e1b32 | ||
|
|
1437bb9d0e | ||
|
|
66249aabd8 | ||
|
|
02c63d1d9f | ||
|
|
00b7dc933e | ||
|
|
9f089fb6db | ||
|
|
542267042e | ||
|
|
9e282ae634 | ||
|
|
6a625b1da7 | ||
|
|
4982bd8f3c | ||
|
|
908e33ccd3 | ||
|
|
d429480f80 | ||
|
|
dada995398 | ||
|
|
69c9b05657 | ||
|
|
db66880e25 | ||
|
|
0b5ac0b61b | ||
|
|
a902961942 | ||
|
|
7c2fa532d4 | ||
|
|
36267f9fca | ||
|
|
c2d99d6944 | ||
|
|
0b1be20780 | ||
|
|
cc9f5f6906 | ||
|
|
d5bca4dc57 |
@@ -1,4 +1,3 @@
|
||||
# Populate and change this to .env
|
||||
IMPORTER_DB_API_TOKEN=
|
||||
IMPORTER_DB_API_URL=https://importer.cs.helsinki.fi
|
||||
PATE_TOKEN=
|
||||
|
||||
@@ -44,20 +44,22 @@ will take care of setting up Oodikone for you. See README for more details."
|
||||
msg ""
|
||||
}
|
||||
|
||||
# Define custom shell prompt for the interactive select loop
|
||||
PS3="Please enter your choice: "
|
||||
set_custom_select_prompt() {
|
||||
PS3="Please enter your choice: "
|
||||
|
||||
options=(
|
||||
"Set up oodikone from scratch."
|
||||
"Reset all real data."
|
||||
"Reset sis-importer data."
|
||||
"Quit."
|
||||
)
|
||||
options=(
|
||||
"Set up oodikone from scratch."
|
||||
"Reset all real data."
|
||||
"Reset single database."
|
||||
"Quit."
|
||||
)
|
||||
}
|
||||
|
||||
# Run scripts before showing interactive prompt
|
||||
# Run scripts and set the prompt
|
||||
show_welcome
|
||||
init_dirs
|
||||
get_username
|
||||
set_custom_select_prompt
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
@@ -66,8 +68,10 @@ while true; do
|
||||
set_up_oodikone_from_scratch;;
|
||||
"Reset all real data.")
|
||||
reset_all_real_data;;
|
||||
"Reset sis-importer data.")
|
||||
reset_sis_importer_data;;
|
||||
"Reset single database.")
|
||||
reset_single_database
|
||||
set_custom_select_prompt # Set prompt back to initial values
|
||||
;;
|
||||
"Quit.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const moment = require('moment')
|
||||
const _ = require('lodash')
|
||||
|
||||
const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
|
||||
includeSettings.forEach(setting => {
|
||||
@@ -13,6 +16,8 @@ const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
describe('Population Statistics tests', () => {
|
||||
const pathToCSBach2017 =
|
||||
'/populations?months=36&semesters=FALL&semesters=SPRING&studyRights={%22programme%22%3A%22KH50_005%22}&tag&year=2017'
|
||||
const pathToCSMaster2019 =
|
||||
'/populations?months=27&semesters=FALL&semesters=SPRING&studyRights=%7B%22programme%22%3A%22MH50_009%22%7D&tag&year=2019'
|
||||
describe('when using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
@@ -123,6 +128,51 @@ describe('Population Statistics tests', () => {
|
||||
cy.contains('Credit accumulation (for 170 students)')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Credits Gained tab works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the population')
|
||||
|
||||
const months = Math.ceil(moment.duration(moment().diff(moment('2017-08-1'))).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))
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', '(n=149)')
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
let value = 'credits'
|
||||
|
||||
if (start !== null) {
|
||||
value = `${start} ≤ ${value}`
|
||||
}
|
||||
|
||||
if (end !== null) {
|
||||
value = `${value} < ${end}`
|
||||
}
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', value)
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-body'] td:nth-child(3)").then($els => {
|
||||
const sum = [...$els].map($el => parseInt($el.innerText)).reduce((a, b) => a + b, 0)
|
||||
|
||||
expect(sum).to.equal(149)
|
||||
})
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('not.exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
|
||||
cy.visit(pathToCSMaster2019)
|
||||
cy.contains('Credit statistics').click()
|
||||
cy.get('.credits-gained-divider').click()
|
||||
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Yhteispisteet']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Muu']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click()
|
||||
@@ -132,9 +182,6 @@ describe('Population Statistics tests', () => {
|
||||
cy.get("[data-cy='credit-stats-mean']").should('contain', '123.89')
|
||||
cy.get("[data-cy='credit-stats-stdev']").should('contain', '61.32')
|
||||
cy.get("[data-cy='credit-stats-min']").should('contain', '11')
|
||||
cy.get("[data-cy='credit-stats-q1']").should('contain', '70')
|
||||
cy.get("[data-cy='credit-stats-q2']").should('contain', '129')
|
||||
cy.get("[data-cy='credit-stats-q3']").should('contain', '168')
|
||||
cy.get("[data-cy='credit-stats-max']").should('contain', '314')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,16 +4,16 @@ describe('Users tests', () => {
|
||||
describe('Using user with just grp-oodikone-user, no other rights', () => {
|
||||
it('shows only frontpage, trends feedback', () => {
|
||||
cy.init('', 'norights') // login with norights user
|
||||
cy.get('.navBar').contains('Trends').should('exist')
|
||||
cy.get('.navBar').contains('Give feedback').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Trends').should('exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Give feedback').should('exist')
|
||||
|
||||
cy.get('.navBar').contains('Study programme').should('not.exist')
|
||||
cy.get('.navBar').contains('Student statistics').should('not.exist')
|
||||
cy.get('.navBar').contains('Course statistics').should('not.exist')
|
||||
cy.get('.navBar').contains('Teachers').should('not.exist')
|
||||
cy.get('.navBar').contains('Users').should('not.exist')
|
||||
cy.get('.navBar').contains('Faculty').should('not.exist')
|
||||
cy.get('.navBar').contains('Updater').should('not.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('Teachers').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Users').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Faculty').should('not.exist')
|
||||
cy.get('[data-cy="navBar"]').contains('Updater').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,12 +29,12 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
cy.get('.navBar').contains('Study programme').should('exist')
|
||||
cy.get('.navBar').contains('Student statistics').should('exist')
|
||||
cy.get('.navBar').contains('Course statistics').should('exist')
|
||||
// TODO: admin should probably see this as well, since can access this page: cy.get('.navBar').contains('Teachers').should('exist')
|
||||
cy.get('.navBar').contains('Users').should('exist')
|
||||
cy.get('.navBar').contains('Trends').should('exist')
|
||||
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')
|
||||
// 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')
|
||||
})
|
||||
|
||||
it("mocking normal user shows only the mocked user's programmes", () => {
|
||||
|
||||
Generated
+34
-6593
File diff suppressed because it is too large
Load Diff
@@ -49,5 +49,9 @@
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
"stylelint-config-standard": "^22.0.0",
|
||||
"stylelint-prettier": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +110,54 @@ reset_all_real_data() {
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
reset_sis_importer_data() {
|
||||
infomsg "Downloading sis-importer-db dump"
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
|
||||
download_real_dump $database $url
|
||||
reset_databases $database
|
||||
reset_single_database() {
|
||||
# Define custom shell prompt for the nested interactive select loop
|
||||
PS3="Please enter which database to reset: "
|
||||
|
||||
options=(
|
||||
"kone-db"
|
||||
"sis-db"
|
||||
"sis-importer-db"
|
||||
"user-db"
|
||||
"Return to main menu."
|
||||
)
|
||||
|
||||
local database=""
|
||||
local url=""
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
case $opt in
|
||||
"kone-db")
|
||||
local database=$KONE_DB_NAME
|
||||
local url=$KONE_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"sis-db")
|
||||
local database=$SIS_DB_NAME
|
||||
local url=$SIS_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"sis-importer-db")
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"user-db")
|
||||
local database=$USER_DB_NAME
|
||||
local url=$USER_DB_REAL_DUMP_URL
|
||||
break 2;;
|
||||
"Return to main menu.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
";;
|
||||
esac
|
||||
break
|
||||
done
|
||||
done
|
||||
|
||||
if [ -n "$database" ]; then
|
||||
infomsg "Downloading $database dump"
|
||||
download_real_dump $database $url
|
||||
reset_databases $database
|
||||
fi
|
||||
}
|
||||
|
||||
set_up_oodikone_from_scratch() {
|
||||
|
||||
@@ -18,7 +18,6 @@ const pateToken = process.env.PATE_API_TOKEN || ''
|
||||
|
||||
// Importer client
|
||||
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
|
||||
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
|
||||
|
||||
// Networking: Urls & ports
|
||||
const baseUrl = isDev ? '/api' : ''
|
||||
@@ -62,8 +61,6 @@ const addSlashToEnd = url => (url.endsWith('/') ? url : url + '/')
|
||||
|
||||
const ACCESS_TOKEN_HEADER_KEY = 'x-access-token'
|
||||
|
||||
const PWD = process.env.PWD || '/usr/src/app'
|
||||
|
||||
module.exports = {
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
@@ -91,6 +88,4 @@ module.exports = {
|
||||
frontUrl,
|
||||
backendPort,
|
||||
importerToken,
|
||||
importerUrl,
|
||||
PWD,
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ const initializeDatabaseConnection = async () => {
|
||||
const migrations = await migrator.up()
|
||||
logger.info({ message: 'Kone Migrations up to date', meta: migrations })
|
||||
} catch (e) {
|
||||
logger.error('Kone Migration error')
|
||||
logger.error({ message: 'Kone Migration error: ', meta: e })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
const { BIGINT, STRING, DATE } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.createTable(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
study_guidance_group_id: {
|
||||
type: STRING,
|
||||
unique: true,
|
||||
},
|
||||
study_programme: {
|
||||
type: STRING,
|
||||
},
|
||||
year: {
|
||||
type: STRING,
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
},
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -135,6 +135,28 @@ const CustomPopulationSearch = sequelizeKone.define('custom_population_searches'
|
||||
},
|
||||
})
|
||||
|
||||
const StudyGuidanceGroupTag = sequelizeKone.define(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
studyGuidanceGroupId: {
|
||||
type: Sequelize.STRING,
|
||||
unique: true,
|
||||
},
|
||||
studyProgramme: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
year: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
|
||||
MandatoryCourse.belongsTo(MandatoryCourseLabels, {
|
||||
foreignKey: 'label',
|
||||
sourceKey: 'id',
|
||||
@@ -157,4 +179,5 @@ module.exports = {
|
||||
CustomPopulationSearch,
|
||||
sequelizeKone,
|
||||
ExcludedCourse,
|
||||
StudyGuidanceGroupTag,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const router = require('express').Router()
|
||||
const userService = require('../services/userService')
|
||||
const { getUserDataFor } = require('../services/userService')
|
||||
const { sendFeedbackToToska } = require('../services/mailservice')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
@@ -8,7 +8,8 @@ router.post('/email', async (req, res) => {
|
||||
const {
|
||||
decodedToken: { userId },
|
||||
} = req
|
||||
const { email, full_name } = await userService.byUsername(userId)
|
||||
|
||||
const { email, full_name } = await getUserDataFor(userId)
|
||||
|
||||
const result = await sendFeedbackToToska({
|
||||
feedbackContent: content,
|
||||
|
||||
@@ -2,7 +2,6 @@ const router = require('express').Router()
|
||||
const Student = require('../services/students')
|
||||
const userService = require('../services/userService')
|
||||
const Unit = require('../services/units')
|
||||
const { mismatchedStudents } = require('../services/student_credit_total_mismatches')
|
||||
|
||||
const filterStudentTags = (student, userId) => {
|
||||
return {
|
||||
@@ -11,13 +10,6 @@ const filterStudentTags = (student, userId) => {
|
||||
}
|
||||
}
|
||||
|
||||
const creditTotalMismatch = student => {
|
||||
return {
|
||||
...student,
|
||||
mismatch: mismatchedStudents.has(student.studentNumber),
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/students', async (req, res) => {
|
||||
const {
|
||||
roles,
|
||||
@@ -58,10 +50,7 @@ router.get('/students/:id', async (req, res) => {
|
||||
const results = await Student.withId(studentId)
|
||||
return results.error
|
||||
? res.status(400).json({ error: 'error finding student' }).end()
|
||||
: res
|
||||
.status(200)
|
||||
.json(creditTotalMismatch(filterStudentTags(results, decodedToken.id)))
|
||||
.end()
|
||||
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
|
||||
}
|
||||
|
||||
const uid = req.decodedToken.userId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const router = require('express').Router()
|
||||
const { logger } = require('../util/logger')
|
||||
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
|
||||
const { getAllGroupsAndStudents, changeGroupTags } = require('../services/studyGuidanceGroups')
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const {
|
||||
@@ -13,4 +13,27 @@ router.get('/', async (req, res) => {
|
||||
return res.json(await getAllGroupsAndStudents(sisPersonId))
|
||||
})
|
||||
|
||||
router.put('/:id/tags', async (req, res) => {
|
||||
const {
|
||||
decodedToken: { uid, sisPersonId },
|
||||
body: tags,
|
||||
params: { id: groupId },
|
||||
} = req
|
||||
|
||||
if (!sisPersonId) {
|
||||
logger.error(`User ${uid} tried to change study guidance group tags but personId was ${sisPersonId} in header`)
|
||||
return res.status(400).json({ error: 'Not possible to change groups without personId' })
|
||||
}
|
||||
|
||||
const groupsUserHasAccessTo = (await getAllGroupsAndStudents(sisPersonId)).map(group => group.id)
|
||||
|
||||
if (!groupsUserHasAccessTo.includes(groupId)) {
|
||||
logger.error(
|
||||
`User ${uid} tried to change study guidance group tags, but didn't have permission for the group ${groupId}`
|
||||
)
|
||||
return res.status(400).json({ error: 'Access denied' })
|
||||
}
|
||||
return res.status(200).json(await changeGroupTags({ groupId, tags }))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -25,9 +25,11 @@ router.get('/access_groups', async (req, res) => {
|
||||
|
||||
router.post('/modifyaccess', async (req, res) => {
|
||||
try {
|
||||
const { uid } = req.body
|
||||
const { username } = req.body
|
||||
const result = await userService.modifyAccess(req.body)
|
||||
const user = await userService.byId(uid)
|
||||
const user = {
|
||||
username,
|
||||
}
|
||||
await addUserToBlacklist(user, req.decodedToken)
|
||||
res.status(200).json(result)
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,6 +3,154 @@ const { Organization, ProgrammeModule } = require('../models')
|
||||
const Op = Sequelize.Op
|
||||
const { dbConnections } = require('../database/connection')
|
||||
|
||||
// temp refactoring faculty <-> userservice mappings away from userservice
|
||||
const facultiesAndProgrammesInUserService = [
|
||||
['H10', 'MH10_001'],
|
||||
['H10', 'MH40_011'],
|
||||
['H10', 'KH10_001'],
|
||||
['H20', 'KH20_001'],
|
||||
['H20', 'MH20_001'],
|
||||
['H20', 'MH20_002'],
|
||||
['H30', 'KH30_001'],
|
||||
['H30', 'KH30_002'],
|
||||
['H30', 'MH30_002'],
|
||||
['H30', 'MH30_003'],
|
||||
['H30', 'MH30_004'],
|
||||
['H30', 'MH30_005'],
|
||||
['H30', 'MH30_001'],
|
||||
['H40', 'KH40_001'],
|
||||
['H40', 'KH40_002'],
|
||||
['H40', 'KH40_003'],
|
||||
['H40', 'KH40_004'],
|
||||
['H40', 'KH40_005'],
|
||||
['H40', 'KH40_006'],
|
||||
['H40', 'MH40_001'],
|
||||
['H40', 'MH40_002'],
|
||||
['H40', 'MH40_003'],
|
||||
['H40', 'MH40_004'],
|
||||
['H40', 'MH40_005'],
|
||||
['H40', 'MH40_006'],
|
||||
['H40', 'MH40_007'],
|
||||
['H40', 'MH40_008'],
|
||||
['H40', 'MH40_009'],
|
||||
['H40', 'MH40_010'],
|
||||
['H40', 'MH40_011'],
|
||||
['H40', 'MH40_012'],
|
||||
['H40', 'MH40_013'],
|
||||
['H40', 'MH40_014'],
|
||||
['H40', 'MH40_015'],
|
||||
['H40', 'MH70_001'],
|
||||
['H40', 'MH70_006'],
|
||||
['H50', 'KH50_001'],
|
||||
['H50', 'KH50_002'],
|
||||
['H50', 'KH50_003'],
|
||||
['H50', 'KH50_004'],
|
||||
['H50', 'KH50_005'],
|
||||
['H50', 'KH50_006'],
|
||||
['H50', 'KH50_007'],
|
||||
['H50', 'MH50_001'],
|
||||
['H50', 'MH50_002'],
|
||||
['H50', 'MH50_003'],
|
||||
['H50', 'MH50_004'],
|
||||
['H50', 'MH50_005'],
|
||||
['H50', 'MH50_006'],
|
||||
['H50', 'MH50_007'],
|
||||
['H50', 'MH50_008'],
|
||||
['H50', 'MH50_009'],
|
||||
['H50', 'MH50_010'],
|
||||
['H50', 'MH50_011'],
|
||||
['H50', 'MH50_012'],
|
||||
['H50', 'MH50_013'],
|
||||
['H55', 'KH55_001'],
|
||||
['H55', 'MH55_001'],
|
||||
['H57', 'KH57_001'],
|
||||
['H57', 'KH57_002'],
|
||||
['H57', 'KH57_003'],
|
||||
['H57', 'MH50_002'],
|
||||
['H57', 'MH50_013'],
|
||||
['H57', 'MH57_001'],
|
||||
['H57', 'MH57_002'],
|
||||
['H57', 'MH57_003'],
|
||||
['H57', 'MH57_004'],
|
||||
['H57', 'MH57_005'],
|
||||
['H57', 'MH80_007'],
|
||||
['H60', 'KH60_001'],
|
||||
['H60', 'MH60_001'],
|
||||
['H70', 'KH40_001'],
|
||||
['H70', 'KH50_001'],
|
||||
['H70', 'KH70_001'],
|
||||
['H70', 'KH70_002'],
|
||||
['H70', 'KH70_003'],
|
||||
['H70', 'KH70_004'],
|
||||
['H70', 'MH50_001'],
|
||||
['H70', 'MH57_005'],
|
||||
['H70', 'MH70_001'],
|
||||
['H70', 'MH70_002'],
|
||||
['H70', 'MH70_003'],
|
||||
['H70', 'MH70_004'],
|
||||
['H70', 'MH70_005'],
|
||||
['H70', 'MH70_006'],
|
||||
['H70', 'MH70_007'],
|
||||
['H70', 'MH70_008'],
|
||||
['H70', 'MH70_009'],
|
||||
['H70', 'MH70_010'],
|
||||
['H74', 'KH74_001'],
|
||||
['H80', 'KH57_002'],
|
||||
['H80', 'KH80_001'],
|
||||
['H80', 'KH80_002'],
|
||||
['H80', 'KH80_003'],
|
||||
['H80', 'KH80_004'],
|
||||
['H80', 'MH57_002'],
|
||||
['H80', 'MH57_003'],
|
||||
['H80', 'MH57_005'],
|
||||
['H80', 'MH80_001'],
|
||||
['H80', 'MH80_002'],
|
||||
['H80', 'MH80_003'],
|
||||
['H80', 'MH80_004'],
|
||||
['H80', 'MH80_005'],
|
||||
['H80', 'MH80_006'],
|
||||
['H80', 'MH80_007'],
|
||||
['H90', 'KH90_001'],
|
||||
['H30', '447000'],
|
||||
['H30', '447001'],
|
||||
['H30', '447002'],
|
||||
['H60', 'EDUK730'],
|
||||
['H60', 'K-MUUT-ERIL'],
|
||||
['H60', 'ED400'],
|
||||
['H60', 'ED200'],
|
||||
['H60', 'ED700'],
|
||||
['H60', 'ED100'],
|
||||
['H60', 'ED300'],
|
||||
['H60', 'ED600'],
|
||||
['H60', '8009'],
|
||||
['H60', '8118'],
|
||||
['H60', '8600'],
|
||||
['H60', 'DUK-PED'],
|
||||
['H60', '65'],
|
||||
['H60', '1102'],
|
||||
['H60', '20018'],
|
||||
['H60', '8601'],
|
||||
['H60', '9290'],
|
||||
['H60', '8172'],
|
||||
['H60', '83600'],
|
||||
['H60', 'DUM-PED'],
|
||||
['H60', 'ED500'],
|
||||
['H60', '83601'],
|
||||
['H60', '0130'],
|
||||
['H60', '20005-e'],
|
||||
['H60', '1040'],
|
||||
['H60', '8703'],
|
||||
['H60', '0005'],
|
||||
['H60', '7900'],
|
||||
['H60', '0152 '],
|
||||
['H90', 'MH90_001'],
|
||||
]
|
||||
|
||||
const facultiesAndProgrammesForTrends = facultiesAndProgrammesInUserService.map(f => ({
|
||||
faculty_code: f[0],
|
||||
programme_code: f[1],
|
||||
}))
|
||||
|
||||
// Have facultyfetching to work like it worked during oodi-db time
|
||||
const facultiesInOodi = [
|
||||
'H10',
|
||||
@@ -66,4 +214,5 @@ module.exports = {
|
||||
degreeProgrammeCodesOfFaculty,
|
||||
isFaculty,
|
||||
providersOfFaculty,
|
||||
facultiesAndProgrammesForTrends,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ const logger = require('../util/logger')
|
||||
const { dbConnections } = require('../database/connection')
|
||||
|
||||
const recursivelyGetModuleAndChildren = async (code, type) => {
|
||||
// TODO use only sis connection once sis is stable
|
||||
const connection = dbConnections.sequelize
|
||||
try {
|
||||
const [result] = await connection.query(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
const { getImporterClient } = require('../util/importerClient')
|
||||
const importerClient = getImporterClient()
|
||||
const { StudyGuidanceGroupTag } = require('../models/models_kone')
|
||||
|
||||
const getAllStudentsUserHasInGroups = async sisPersonId => {
|
||||
if (!importerClient) {
|
||||
@@ -19,10 +20,33 @@ const getAllGroupsAndStudents = async sisPersonId => {
|
||||
return []
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return Object.values(data)
|
||||
const tagsByGroupId = (await StudyGuidanceGroupTag.findAll()).reduce((acc, curr) => {
|
||||
const { studyProgramme, year } = curr
|
||||
return { ...acc, [curr.studyGuidanceGroupId]: { studyProgramme, year } }
|
||||
}, {})
|
||||
return Object.values(data).map(group => ({ ...group, tags: tagsByGroupId[group.id] }))
|
||||
}
|
||||
|
||||
const changeGroupTags = async ({ groupId, tags }) => {
|
||||
const { studyProgramme, year } = tags
|
||||
const tagToUpdate = studyProgramme ? { studyProgramme } : { year }
|
||||
|
||||
const [result] = await StudyGuidanceGroupTag.upsert(
|
||||
{
|
||||
studyGuidanceGroupId: groupId,
|
||||
...tagToUpdate,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
studyGuidanceGroupId: groupId,
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllStudentsUserHasInGroups,
|
||||
getAllGroupsAndStudents,
|
||||
changeGroupTags,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ const {
|
||||
dbConnections: { sequelize },
|
||||
} = require('../database/connection')
|
||||
const { Studyright, StudyrightElement, ElementDetail } = require('../models')
|
||||
const { getUserElementDetails } = require('./userService')
|
||||
const moment = require('moment')
|
||||
const { redisClient } = require('../services/redis')
|
||||
const _ = require('lodash')
|
||||
@@ -140,15 +139,6 @@ const getAllStudyrightElementsAndAssociations = async () => {
|
||||
return JSON.parse(studyright_elements)
|
||||
}
|
||||
|
||||
const getStudyrightElementsAndAssociationsForUser = async username => {
|
||||
const studyrightelementcodes = await getUserElementDetails(username)
|
||||
if (studyrightelementcodes.length === 0) {
|
||||
return []
|
||||
}
|
||||
const associations = await getAssociatedStudyrights(studyrightelementcodes)
|
||||
return formatStudyrightElements(studyrightelementcodes, associations)
|
||||
}
|
||||
|
||||
const getAllProgrammes = async () => {
|
||||
const elementDetails = ElementDetail.findAll({
|
||||
where: {
|
||||
@@ -324,21 +314,13 @@ const getFilteredAssociations = async codes => {
|
||||
return associations
|
||||
}
|
||||
|
||||
const getUserAssociations = async userid => {
|
||||
const codes = await getUserElementDetails(userid)
|
||||
const associations = await getFilteredAssociations(codes)
|
||||
return associations
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
byStudent,
|
||||
studentNumbersWithAllStudyRightElements,
|
||||
getAssociatedStudyrights,
|
||||
getAllStudyrightElementsAndAssociations,
|
||||
getStudyrightElementsAndAssociationsForUser,
|
||||
getAssociations,
|
||||
getFilteredAssociations,
|
||||
getUserAssociations,
|
||||
refreshAssociationsInRedis,
|
||||
getAllProgrammes,
|
||||
getAllElementDetails,
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
const axios = require('axios')
|
||||
const { redisClient } = require('../../services/redis')
|
||||
const { USERSERVICE_URL } = require('../../conf-backend')
|
||||
const {
|
||||
dbConnections: { sequelize },
|
||||
} = require('../../database/connection')
|
||||
|
||||
const userServiceClient = axios.create({
|
||||
baseURL: USERSERVICE_URL,
|
||||
headers: { secret: process.env.USERSERVICE_SECRET },
|
||||
})
|
||||
|
||||
const getRedisCDS = async REDIS_KEY => {
|
||||
const raw = await redisClient.getAsync(REDIS_KEY)
|
||||
return raw && JSON.parse(raw)
|
||||
@@ -177,6 +170,5 @@ const getTargetStudentCounts = async ({ codes, includeOldAttainments, excludeNon
|
||||
module.exports = {
|
||||
getRedisCDS,
|
||||
saveToRedis,
|
||||
userServiceClient,
|
||||
getTargetStudentCounts,
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ const {
|
||||
} = require('../../database/connection')
|
||||
const { mapToProviders } = require('../../util/utils')
|
||||
const { ElementDetail, Organization } = require('../../models')
|
||||
const { getRedisCDS, saveToRedis, userServiceClient } = require('./shared')
|
||||
const { getRedisCDS, saveToRedis } = require('./shared')
|
||||
const { facultiesAndProgrammesForTrends } = require('../../services/organisations')
|
||||
|
||||
const REDIS_KEY_STATUS = 'STATUS_DATA_V3'
|
||||
const REDIS_KEY_STATUS = 'STATUS_DATA_V5'
|
||||
|
||||
const getCurrentStudyYearStartDate = _.memoize(
|
||||
async unixMillis =>
|
||||
@@ -117,13 +118,13 @@ const getOrganizationStatistics = async (organizations, start, end) => {
|
||||
if (organizationStats[orgId] === undefined) {
|
||||
organizationStats[orgId] = {
|
||||
direct: { credits: 0, students: new Set() },
|
||||
accumulated: { credits: 0, students: new Set() },
|
||||
aggregated: { credits: 0, students: new Set() },
|
||||
}
|
||||
}
|
||||
|
||||
const orgStats = organizationStats[orgId]
|
||||
|
||||
const statsRefs = isDirect ? [orgStats.direct, orgStats.accumulated] : [orgStats.accumulated]
|
||||
const statsRefs = isDirect ? [orgStats.direct, orgStats.aggregated] : [orgStats.aggregated]
|
||||
|
||||
statsRefs.forEach(statsRef => {
|
||||
statsRef.credits += stats.credits
|
||||
@@ -142,20 +143,20 @@ const getOrganizationStatistics = async (organizations, start, end) => {
|
||||
updateOrgStats(orgId, { credits, students }, true)
|
||||
|
||||
if (parentOrgId) {
|
||||
updateOrgStats(parentOrgId, organizationStats[orgId].accumulated, false)
|
||||
updateOrgStats(parentOrgId, organizationStats[orgId].aggregated, false)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(organizationStats).reduce((acc, [id, { direct, accumulated }]) => {
|
||||
return Object.entries(organizationStats).reduce((acc, [id, { direct, aggregated }]) => {
|
||||
acc[id] = {
|
||||
id,
|
||||
direct: {
|
||||
students: direct.students.size,
|
||||
credits: direct.credits,
|
||||
},
|
||||
accumulated: {
|
||||
students: accumulated.students.size,
|
||||
credits: accumulated.credits,
|
||||
aggregated: {
|
||||
students: aggregated.students.size,
|
||||
credits: aggregated.credits,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,7 +178,7 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
|
||||
WHERE
|
||||
cr.attainment_date BETWEEN :a AND :b
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode IN (4, 9)
|
||||
AND cr.credittypecode IN (4, 9)
|
||||
GROUP BY co.id, o.code
|
||||
-- HAVING SUM(cr.credits) > 0
|
||||
`,
|
||||
@@ -188,13 +189,40 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
|
||||
)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2') => {
|
||||
const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
|
||||
o.code AS organizationcode, co.code, co.id
|
||||
FROM credit cr
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
INNER JOIN course_providers cp ON cp.coursecode = co.id
|
||||
INNER JOIN organization o ON o.id = cp.organizationcode
|
||||
AND cr.attainment_date BETWEEN :a AND :b
|
||||
AND (cr."isStudyModule" = false OR cr."isStudyModule" IS NULL)
|
||||
AND cr.credittypecode IN (4, 9) AND cp.organizationcode = 'hy-org-48645785'
|
||||
AND cr.student_studentnumber NOT IN
|
||||
(SELECT student_studentnumber FROM studyright
|
||||
WHERE studyright.student_studentnumber = cr.student_studentnumber
|
||||
AND cr.attainment_date BETWEEN studyright.startdate AND studyright.enddate)
|
||||
GROUP BY co.id, o.code -- HAVING SUM(cr.credits) > 0
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { a, b },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2', avoin) => {
|
||||
return years.map(
|
||||
year =>
|
||||
new Promise(async res => {
|
||||
const diff = currentYear - year
|
||||
const { from, to } = getRange(diff)
|
||||
const creditsByCourse = await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
const creditsByCourse = avoin
|
||||
? await getTotalOpenUniCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
: await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
res(
|
||||
creditsByCourse.map(c => {
|
||||
c['year'] = year
|
||||
@@ -207,8 +235,14 @@ const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum',
|
||||
|
||||
const getOrgStats = (stats, orgId) =>
|
||||
stats[orgId] ?? {
|
||||
accumulated: { students: 0, credits: 0 },
|
||||
direct: { students: 0, credits: 0 },
|
||||
accumulated: {
|
||||
aggregated: { students: 0, credits: 0 },
|
||||
direct: { students: 0, credits: 0 },
|
||||
},
|
||||
total: {
|
||||
aggregated: { students: 0, credits: 0 },
|
||||
direct: { students: 0, credits: 0 },
|
||||
},
|
||||
}
|
||||
|
||||
const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
@@ -218,67 +252,120 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const startYear = startDate.getFullYear()
|
||||
const startTime = startDate.getTime()
|
||||
const yearRange = _.range(2017, startYear + 1)
|
||||
const getRangeAcc = diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to:
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
|
||||
})
|
||||
const getRangeTotal = diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
|
||||
})
|
||||
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to:
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - diff * YEAR_TO_MILLISECONDS),
|
||||
}),
|
||||
getRangeAcc,
|
||||
'acc',
|
||||
'students'
|
||||
'students',
|
||||
false
|
||||
)
|
||||
const yearlyOpenUniAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
getRangeAcc,
|
||||
'acc',
|
||||
'students',
|
||||
true
|
||||
)
|
||||
|
||||
const yearlyTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * YEAR_TO_MILLISECONDS),
|
||||
to: new Date(startTime - (diff - 1) * YEAR_TO_MILLISECONDS),
|
||||
}),
|
||||
getRangeTotal,
|
||||
'total',
|
||||
'students'
|
||||
'students',
|
||||
false
|
||||
)
|
||||
|
||||
const yearlyOpenUniTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
getRangeTotal,
|
||||
'total',
|
||||
'students',
|
||||
true
|
||||
)
|
||||
|
||||
/* Gather all required data */
|
||||
const [yearlyAccCredits, yearlyTotalCredits, elementDetails, faculties, { data: facultyProgrammes }] =
|
||||
await Promise.all([
|
||||
Promise.all(yearlyAccCreditsPromises),
|
||||
Promise.all(yearlyTotalCreditsPromises),
|
||||
ElementDetail.findAll(),
|
||||
Organization.findAll(),
|
||||
userServiceClient.get('/faculty_programmes'),
|
||||
])
|
||||
|
||||
const yearlyOrgStatPromises = yearRange.map(async year => [
|
||||
year,
|
||||
await getOrganizationStatistics(
|
||||
faculties,
|
||||
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
|
||||
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
|
||||
),
|
||||
const [
|
||||
yearlyAccCredits,
|
||||
yearlyTotalCredits,
|
||||
yearlyOpenUniAccCredits,
|
||||
yearlyOpenUniTotalCredits,
|
||||
elementDetails,
|
||||
faculties,
|
||||
] = await Promise.all([
|
||||
Promise.all(yearlyAccCreditsPromises),
|
||||
Promise.all(yearlyTotalCreditsPromises),
|
||||
Promise.all(yearlyOpenUniAccCreditsPromises),
|
||||
Promise.all(yearlyOpenUniTotalCreditsPromises),
|
||||
ElementDetail.findAll(),
|
||||
Organization.findAll(),
|
||||
])
|
||||
|
||||
const facultyProgrammes = facultiesAndProgrammesForTrends
|
||||
|
||||
const yearlyOrgStatPromises = yearRange.map(async year => {
|
||||
const accumulatedStats = await getOrganizationStatistics(
|
||||
faculties,
|
||||
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
|
||||
showByYear === 'true'
|
||||
? new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
|
||||
: new Date(unixMillis - (startYear - year) * YEAR_TO_MILLISECONDS)
|
||||
)
|
||||
|
||||
const totalStats =
|
||||
year !== startYear &&
|
||||
(await getOrganizationStatistics(
|
||||
faculties,
|
||||
new Date(startTime - (startYear - year) * YEAR_TO_MILLISECONDS),
|
||||
new Date(startTime - (startYear - year - 1) * YEAR_TO_MILLISECONDS)
|
||||
))
|
||||
|
||||
return [
|
||||
year,
|
||||
_.merge(
|
||||
_.mapValues(totalStats, total => ({ total })),
|
||||
_.mapValues(accumulatedStats, accumulated => ({ accumulated }))
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const orgStats = await Promise.all(yearlyOrgStatPromises)
|
||||
|
||||
const yearlyOrgStats = Object.fromEntries(orgStats)
|
||||
const [[, currentOrgStats], [, prevOrgStats]] = orgStats.reverse()
|
||||
|
||||
const defaultYearStats = _.chain(_.range(2017, startYear + 1))
|
||||
.map(year => [year, { acc: 0, accStudents: 0 }])
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
const getOrgYearlyStats = orgId =>
|
||||
Object.entries(yearlyOrgStats).reduce((acc, [year, stats]) => {
|
||||
const { credits, students } = getOrgStats(stats, orgId).accumulated
|
||||
_.chain(yearlyOrgStats)
|
||||
.mapValues(stats => {
|
||||
const orgStats = getOrgStats(stats, orgId)
|
||||
|
||||
acc[year] = {
|
||||
acc: credits,
|
||||
accStudents: students,
|
||||
total: credits,
|
||||
totalStudents: students,
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
return {
|
||||
acc: orgStats.accumulated.aggregated.credits,
|
||||
accStudents: orgStats.accumulated.aggregated.students,
|
||||
total: orgStats.total?.aggregated?.credits,
|
||||
totalStudents: orgStats.total?.aggregated?.students,
|
||||
}
|
||||
})
|
||||
.defaults(defaultYearStats)
|
||||
.value()
|
||||
|
||||
/* Construct some helper maps */
|
||||
const facultyCodeToFaculty = faculties.reduce((res, curr) => {
|
||||
@@ -292,6 +379,9 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return res
|
||||
}, {})
|
||||
|
||||
programmeToFaculties['H906'] = ['H906']
|
||||
programmeToFaculties['H930'] = ['H930']
|
||||
|
||||
const providerToProgramme = elementDetails.reduce((res, curr) => {
|
||||
const [p] = mapToProviders([curr.code])
|
||||
res[p] = {
|
||||
@@ -301,26 +391,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return res
|
||||
}, {})
|
||||
|
||||
providerToProgramme['H906'] = {
|
||||
code: 'H906',
|
||||
name: { en: 'Language Centre', fi: 'Kielikeskus', sv: 'Språkcentrum' },
|
||||
}
|
||||
|
||||
providerToProgramme['H930'] = {
|
||||
code: 'H930',
|
||||
name: { en: 'Open University', fi: 'Avoin yliopisto', sv: 'Öppna universitetet' },
|
||||
}
|
||||
|
||||
const organizationCodeToOrganization = faculties.reduce((acc, org) => {
|
||||
acc[org.code] = org
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const sumMerge = (a, b) => {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a + b
|
||||
}
|
||||
|
||||
const mergele = (a, b) => {
|
||||
if (!a) return _.clone(b)
|
||||
if (!b) return _.clone(a)
|
||||
return _.mergeWith(a, b, sumMerge)
|
||||
}
|
||||
|
||||
/* Calculate course level stats and group by providers */
|
||||
const coursesGroupedByProvider = Object.entries(
|
||||
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
|
||||
_.groupBy(
|
||||
[
|
||||
..._.flatten(yearlyAccCredits),
|
||||
..._.flatten(yearlyTotalCredits),
|
||||
..._.flatten(yearlyOpenUniAccCredits),
|
||||
..._.flatten(yearlyOpenUniTotalCredits),
|
||||
],
|
||||
'organizationcode'
|
||||
)
|
||||
).reduce((acc, [organizationcode, courseCredits]) => {
|
||||
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
|
||||
(acc, [courseCode, yearlyInstances]) => {
|
||||
acc[courseCode] = { yearly: {}, name: yearlyInstances[0].name }
|
||||
const array = [2020, 2019, 2018, 2017]
|
||||
acc[courseCode] = {
|
||||
yearly: {},
|
||||
name: yearlyInstances[0].name,
|
||||
type: 'course',
|
||||
code: courseCode,
|
||||
}
|
||||
|
||||
const array = _.range(2017, startYear + 1)
|
||||
|
||||
array.forEach(year => {
|
||||
acc[courseCode]['yearly'][year] = {}
|
||||
acc[courseCode]['yearly'][year]['acc'] = 0
|
||||
acc[courseCode]['yearly'][year]['accStudents'] = 0
|
||||
acc[courseCode]['yearly'][year]['total'] = 0
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = 0
|
||||
acc[courseCode]['yearly'][year]['total'] = undefined
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
|
||||
})
|
||||
|
||||
yearlyInstances.forEach(instance => {
|
||||
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
|
||||
|
||||
@@ -332,6 +460,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
|
||||
}
|
||||
})
|
||||
|
||||
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
|
||||
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
|
||||
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
|
||||
@@ -348,22 +477,42 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const programme = providerToProgramme[organizationcode]
|
||||
|
||||
if (programme && programme.code) {
|
||||
if (programme.code === 'H906' || programme.code === 'H930') {
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
|
||||
acc[programme.code] = {
|
||||
type: 'programme',
|
||||
code: programme.code,
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly: _.mergeWith({}, ...yearlyValues, mergele),
|
||||
current: _.sumBy(courseValues, 'current') | undefined,
|
||||
previous: _.sumBy(courseValues, 'previous') | undefined,
|
||||
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
|
||||
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
const orgId = organizationCodeToOrganization[organizationcode]?.id
|
||||
|
||||
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
|
||||
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
|
||||
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated.aggregated
|
||||
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated.aggregated
|
||||
|
||||
const yearly = getOrgYearlyStats(orgId)
|
||||
|
||||
acc[programme.code] = {
|
||||
type: 'programme',
|
||||
code: programme.code,
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly,
|
||||
current,
|
||||
previous,
|
||||
currentStudents,
|
||||
previousStudents,
|
||||
currentStudents: currentStudents,
|
||||
previousStudents: previousStudents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,16 +525,35 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
if (!facultyCodes) return acc
|
||||
facultyCodes.forEach(facultyCode => {
|
||||
if (!facultyCode) return
|
||||
if (facultyCode === 'H906' || facultyCode === 'H930') {
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
type: 'faculty',
|
||||
code: facultyCode,
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: programmeStats.yearly,
|
||||
current: programmeStats.current,
|
||||
previous: programmeStats.previous,
|
||||
currentStudents: programmeStats.currentStudents,
|
||||
previousStudents: programmeStats.previousStudents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!acc[facultyCode]) {
|
||||
const orgId = organizationCodeToOrganization[facultyCode].id
|
||||
|
||||
const { students: currentStudents, credits: current } = getOrgStats(currentOrgStats, orgId).accumulated
|
||||
.aggregated
|
||||
const { students: previousStudents, credits: previous } = getOrgStats(prevOrgStats, orgId).accumulated
|
||||
.aggregated
|
||||
|
||||
const yearly = getOrgYearlyStats(orgId)
|
||||
|
||||
acc[facultyCode] = {
|
||||
type: 'faculty',
|
||||
code: facultyCode,
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly,
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
const axios = require('axios')
|
||||
const _ = require('lodash')
|
||||
const { USERSERVICE_URL } = require('../conf-backend')
|
||||
const UnitService = require('./units')
|
||||
const elementDetailService = require('./elementdetails')
|
||||
const { filterStudentnumbersByAccessrights } = require('./students')
|
||||
const { userDataCache } = require('./cache')
|
||||
const client = axios.create({ baseURL: USERSERVICE_URL, headers: { secret: process.env.USERSERVICE_SECRET } })
|
||||
const { getImporterClient } = require('../util/importerClient')
|
||||
const logger = require('../util/logger')
|
||||
const { facultiesAndProgrammesForTrends } = require('../services/organisations')
|
||||
|
||||
const ping = async () => {
|
||||
const url = '/ping'
|
||||
const response = await axios.get(url)
|
||||
return response.data
|
||||
const client = axios.create({ baseURL: USERSERVICE_URL, headers: { secret: process.env.USERSERVICE_SECRET } })
|
||||
|
||||
// Private helpers
|
||||
const enrichProgrammesFromFaculties = faculties =>
|
||||
facultiesAndProgrammesForTrends.filter(f => faculties.includes(f.faculty_code)).map(f => f.programme_code)
|
||||
|
||||
const enrichWithProgrammes = toEnrich => {
|
||||
if (Array.isArray(toEnrich)) {
|
||||
return toEnrich.map(obj => enrichWithProgrammes(obj))
|
||||
}
|
||||
if (toEnrich.rights) {
|
||||
return {
|
||||
...toEnrich,
|
||||
rights: _.union(toEnrich.rights, enrichProgrammesFromFaculties(toEnrich.faculties)),
|
||||
}
|
||||
}
|
||||
|
||||
if (toEnrich.elementdetails) {
|
||||
return {
|
||||
...toEnrich,
|
||||
elementdetails: _.union(
|
||||
toEnrich.elementdetails,
|
||||
enrichProgrammesFromFaculties(toEnrich.faculty.map(f => f.faculty_code))
|
||||
),
|
||||
}
|
||||
}
|
||||
return toEnrich
|
||||
}
|
||||
|
||||
const findAll = async () => {
|
||||
const response = await client.get('/findall')
|
||||
return response.data
|
||||
}
|
||||
|
||||
const checkStudyGuidanceGroupsAccess = async hyPersonSisuId => {
|
||||
if (!hyPersonSisuId) {
|
||||
logger.error('Not possible to get groups without personId header')
|
||||
@@ -30,101 +48,20 @@ const checkStudyGuidanceGroupsAccess = async hyPersonSisuId => {
|
||||
return data && Object.values(data).length > 0
|
||||
}
|
||||
|
||||
const login = async (uid, full_name, hyGroups, affiliations, email, hyPersonSisuId) => {
|
||||
const hasStudyGuidanceGroupAccess = await checkStudyGuidanceGroupsAccess(hyPersonSisuId)
|
||||
const response = await client.post('/login', {
|
||||
uid,
|
||||
full_name,
|
||||
hyGroups,
|
||||
affiliations,
|
||||
email,
|
||||
hyPersonSisuId,
|
||||
hasStudyGuidanceGroupAccess,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
const superlogin = async (uid, asUser) => {
|
||||
const response = await client.post('/superlogin', {
|
||||
uid,
|
||||
asUser,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
const byUsername = async uid => {
|
||||
const url = `/user/${uid}`
|
||||
const byUsernameData = async username => {
|
||||
// OK, enriched
|
||||
const url = `/user/${username}/user_data`
|
||||
const response = await client.get(url)
|
||||
return response.data
|
||||
return enrichWithProgrammes(response.data)
|
||||
}
|
||||
|
||||
const byUsernameData = async uid => {
|
||||
const url = `/user/${uid}/user_data`
|
||||
const response = await client.get(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const getUserElementDetails = async username => {
|
||||
const url = `/user/elementdetails/${username}`
|
||||
const response = await client.get(url)
|
||||
const elementdetailcodes = response.data
|
||||
return elementDetailService.byCodes(elementdetailcodes)
|
||||
}
|
||||
|
||||
const byId = async id => {
|
||||
const url = `/user/id/${id}`
|
||||
const response = await client.get(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const updateUser = async (uid, fields) => {
|
||||
userDataCache.del(uid)
|
||||
const url = `/user/${uid}`
|
||||
const response = await client.put(url, fields)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const getRolesFor = async user => {
|
||||
const url = `/get_roles/${user}`
|
||||
const response = await client.get(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const enableElementDetails = async (uid, codes) => {
|
||||
const response = await client.post('/add_rights', { uid, codes })
|
||||
return response.data.user
|
||||
}
|
||||
|
||||
const removeElementDetails = async (uid, codes) => {
|
||||
const response = await client.post('/remove_rights', { uid, codes })
|
||||
return response.data.user
|
||||
}
|
||||
|
||||
const setFaculties = async (uid, faculties) => {
|
||||
const response = await client.post('/set_faculties', { uid, faculties })
|
||||
return response.data.user
|
||||
}
|
||||
|
||||
const getUnitsFromElementDetails = async username => {
|
||||
const elementDetails = await getUserElementDetails(username)
|
||||
return elementDetails.map(element => UnitService.parseUnitFromElement(element))
|
||||
}
|
||||
|
||||
const modifyAccess = async body => {
|
||||
const response = await client.post('/modifyaccess', body)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const getAccessGroups = async () => {
|
||||
const response = await client.get('/access_groups')
|
||||
return response.data
|
||||
}
|
||||
|
||||
const getUserDataFor = async uid => {
|
||||
let userData = userDataCache.get(uid)
|
||||
// Exported helpers
|
||||
const getUserDataFor = async username => {
|
||||
// no need to enrich
|
||||
let userData = userDataCache.get(username)
|
||||
if (!userData) {
|
||||
userData = await byUsernameData(uid)
|
||||
userDataCache.set(uid, userData)
|
||||
userData = await byUsernameData(username)
|
||||
userDataCache.set(username, userData)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -133,9 +70,21 @@ const getUserDataFor = async uid => {
|
||||
}
|
||||
}
|
||||
|
||||
const getUnitsFromElementDetails = async username => {
|
||||
// No need to enrich
|
||||
let userData = userDataCache.get(username)
|
||||
if (!userData) {
|
||||
userData = await byUsernameData(username)
|
||||
userDataCache.set(username, userData)
|
||||
}
|
||||
const elementDetails = await elementDetailService.byCodes(userData.rights)
|
||||
return elementDetails.map(element => UnitService.parseUnitFromElement(element))
|
||||
}
|
||||
|
||||
const getStudentsUserCanAccess = async (studentnumbers, roles, userId) => {
|
||||
// No need to enrich
|
||||
let studentsUserCanAccess
|
||||
if (roles && roles.includes('admin')) {
|
||||
if (roles?.includes('admin')) {
|
||||
studentsUserCanAccess = new Set(studentnumbers)
|
||||
} else {
|
||||
const unitsUserCanAccess = await getUnitsFromElementDetails(userId)
|
||||
@@ -145,12 +94,52 @@ const getStudentsUserCanAccess = async (studentnumbers, roles, userId) => {
|
||||
return studentsUserCanAccess
|
||||
}
|
||||
|
||||
// FOR ROUTES 1-to-1 mapping
|
||||
const modifyAccess = async body => enrichWithProgrammes((await client.post('/modifyaccess', body)).data)
|
||||
|
||||
const getAccessGroups = async () => enrichWithProgrammes((await client.get('/access_groups')).data)
|
||||
|
||||
const enableElementDetails = async (uid, codes) =>
|
||||
enrichWithProgrammes((await client.post('/add_rights', { uid, codes })).data.user)
|
||||
|
||||
const findAll = async () => enrichWithProgrammes((await client.get('/findall')).data)
|
||||
|
||||
const setFaculties = async (uid, faculties) =>
|
||||
enrichWithProgrammes((await client.post('/set_faculties', { uid, faculties })).data.user)
|
||||
|
||||
const removeElementDetails = async (uid, codes) =>
|
||||
enrichWithProgrammes((await client.post('/remove_rights', { uid, codes })).data.user)
|
||||
|
||||
const updateUser = async (username, fields) => {
|
||||
userDataCache.del(username)
|
||||
return enrichWithProgrammes((await client.put(`/user/${username}`, fields)).data)
|
||||
}
|
||||
|
||||
const login = async (uid, full_name, hyGroups, affiliations, email, hyPersonSisuId) => {
|
||||
const hasStudyGuidanceGroupAccess = await checkStudyGuidanceGroupsAccess(hyPersonSisuId)
|
||||
return (
|
||||
await client.post('/login', {
|
||||
uid,
|
||||
full_name,
|
||||
hyGroups,
|
||||
affiliations,
|
||||
email,
|
||||
hyPersonSisuId,
|
||||
hasStudyGuidanceGroupAccess,
|
||||
})
|
||||
).data
|
||||
}
|
||||
|
||||
const superlogin = async (uid, asUser) =>
|
||||
(
|
||||
await client.post('/superlogin', {
|
||||
uid,
|
||||
asUser,
|
||||
})
|
||||
).data
|
||||
|
||||
module.exports = {
|
||||
ping,
|
||||
byUsername,
|
||||
updateUser,
|
||||
byId,
|
||||
getUserElementDetails,
|
||||
login,
|
||||
superlogin,
|
||||
enableElementDetails,
|
||||
@@ -159,7 +148,6 @@ module.exports = {
|
||||
modifyAccess,
|
||||
getAccessGroups,
|
||||
getUnitsFromElementDetails,
|
||||
getRolesFor,
|
||||
setFaculties,
|
||||
getUserDataFor,
|
||||
getStudentsUserCanAccess,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
const axios = require('axios')
|
||||
const { importerToken, importerUrl } = require('../conf-backend')
|
||||
const { importerToken } = require('../conf-backend')
|
||||
const logger = require('./logger')
|
||||
|
||||
const importerClient = axios.create({
|
||||
headers: {
|
||||
token: importerToken,
|
||||
},
|
||||
baseURL: importerUrl,
|
||||
baseURL: 'https://importer.cs.helsinki.fi',
|
||||
})
|
||||
|
||||
const getImporterClient = () => {
|
||||
if (!importerToken || !importerUrl) {
|
||||
logger.error("Importer token or importer url not set, can't return client!")
|
||||
if (!importerToken) {
|
||||
logger.error("Importer token not set, can't return client!")
|
||||
return null
|
||||
}
|
||||
return importerClient
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
const winston = require('winston')
|
||||
const path = require('path')
|
||||
const { isProduction, PWD } = require('../conf-backend')
|
||||
|
||||
const { isDev } = require('../conf-backend')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
// Write logfile to docker containers pwd
|
||||
const filename = path.join(PWD, 'debug.log')
|
||||
transports.push(new winston.transports.File({ filename, level: 'debug' }))
|
||||
let transports = []
|
||||
|
||||
if (!isProduction) {
|
||||
if (isDev) {
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
@@ -20,9 +15,7 @@ if (!isProduction) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -40,9 +33,11 @@ if (isProduction) {
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat, level: 'info' }))
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = logger
|
||||
|
||||
Generated
+73
-11
@@ -9,10 +9,10 @@
|
||||
"@sentry/browser": "^6.9.0",
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
"axios": "^0.21.1",
|
||||
"formik": "^2.2.9",
|
||||
"highcharts": "^9.1.2",
|
||||
"highcharts-custom-events": "^3.0.10",
|
||||
"history": "^4.10.1",
|
||||
"jstat": "^1.9.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
@@ -9263,6 +9263,47 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/formik": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
|
||||
"integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://opencollective.com/formik"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"deepmerge": "^2.1.1",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react-fast-compare": "^2.0.1",
|
||||
"tiny-warning": "^1.0.2",
|
||||
"tslib": "^1.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/formik/node_modules/deepmerge": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
|
||||
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/formik/node_modules/react-fast-compare": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
||||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
|
||||
},
|
||||
"node_modules/formik/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -13018,11 +13059,6 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jstat": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/jstat/-/jstat-1.9.5.tgz",
|
||||
"integrity": "sha512-cWnp4vObF5GmB2XsIEzxI/1ZTcYlcfNqxQ/9Fp5KFUa0Jf/4tO0ZkGVnqoEHDisJvYgvn5n3eWZbd2xTVJJPUQ=="
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz",
|
||||
@@ -29448,6 +29484,37 @@
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"formik": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
|
||||
"integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
|
||||
"requires": {
|
||||
"deepmerge": "^2.1.1",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react-fast-compare": "^2.0.1",
|
||||
"tiny-warning": "^1.0.2",
|
||||
"tslib": "^1.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepmerge": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
|
||||
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="
|
||||
},
|
||||
"react-fast-compare": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
||||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
|
||||
},
|
||||
"tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -32224,11 +32291,6 @@
|
||||
"universalify": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"jstat": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/jstat/-/jstat-1.9.5.tgz",
|
||||
"integrity": "sha512-cWnp4vObF5GmB2XsIEzxI/1ZTcYlcfNqxQ/9Fp5KFUa0Jf/4tO0ZkGVnqoEHDisJvYgvn5n3eWZbd2xTVJJPUQ=="
|
||||
},
|
||||
"jsx-ast-utils": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz",
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
"@sentry/browser": "^6.9.0",
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
"axios": "^0.21.1",
|
||||
"formik": "^2.2.9",
|
||||
"highcharts": "^9.1.2",
|
||||
"highcharts-custom-events": "^3.0.10",
|
||||
"history": "^4.10.1",
|
||||
"jstat": "^1.9.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
|
||||
@@ -57,17 +57,7 @@ export default {
|
||||
Nuoli kuvaa muutosta, paljonko opintopisteitä on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna. Vain suoritetut ja hyväksiluetut opintopisteet huomioidaan.
|
||||
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
|
||||
|
||||
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen opintopiste-kertymän tähän päivään
|
||||
mennessä vuonna X sekä koko lukuvuoden X opintopisteet muodossa "kerääntymä vuonna X / koko lukuvuoden X opintopisteet".
|
||||
|
||||
Valinta "Näytä kalenterivuosittain" laskee kertymän kalenterivuoden alusta,
|
||||
eikä lukuvuoden alusta.
|
||||
|
||||
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
|
||||
Avoimen yliopiston suorituksia ei vielä lasketa mukaan.
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä. Mahdolliset valinnat ovat selitettyinä alla.
|
||||
`,
|
||||
statusGraduated: `
|
||||
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
|
||||
|
||||
@@ -19,16 +19,28 @@ export default {
|
||||
|
||||
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
|
||||
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”`,
|
||||
CreditStatistics: `**Opintopistekertymä**
|
||||
|
||||
Taulukon rivit skaalautuvat valitun tarkastelujakson mukaan.
|
||||
Jos tarkastelujaksoksi valitsee 12 kuukautta, on ylin kategoria 55 opintopistettä tai yli, eli opiskelija etenee tavoiteajassa.
|
||||
Taulukosta näkee silmäyksellä tavoiteajassa etenevät (ylin kategoria) ja esim. nollasuorittajat.
|
||||
|
||||
**Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.**
|
||||
CreditStatistics: `
|
||||
**Credits gained**
|
||||
|
||||
Taulukko kertoo opintopistekertymän valitulle opiskelijapopulaatiolle. Opintopisteluokat on suhteutettu tarkasteltavan populaation aloitusvuoteen.
|
||||
Ylin luokka kertoo siksi aina tavoiteajassa etenevien määrän ja suhteellisen osuuden.
|
||||
|
||||
Huom! nollasuorittajissa on mukana myös poissaolevaksi ilmoittautuneet, ellei heitä ole erikseen suodatettu populaatiosta pois.
|
||||
|
||||
Kategorioita voi myös itsessään käyttää suodattimina. Klikkaamalla rivin vasemmassa reunassa olevaa suodatinta jäävät näkyviin vain sen kategorian opiskelijat.
|
||||
Suodatin aukeaa samalla ikkunan vasempaan laitaan, josta sitä voi käyttää vapaavalintaisilla arvoilla.
|
||||
|
||||
**Credit statistics**
|
||||
|
||||
Taulukko kertoo kaikkien tarkasteluun valittujen opiskelijoiden opintopistemääristä tähän päivään mennessä. Statistiikka on suodatettu käytettyjen filttereiden mukaan.
|
||||
Esimerkiksi, jos mukaan on valittu vain naisopiskelijat, statistiikat koskevat vain heitä.
|
||||
|
||||
+ **Total** on kokonaisopintopistemäärä kyseiselle populaatiolle.
|
||||
+ **Mean** kuvaa opintopistemäärän opiskelijakohtaista keskiarvoa.
|
||||
+ **Keskihajonta** kuvaa vaihtelua opintopistemäärissä. Karkeasti, mitä suurempi keskihajonta, sitä enemmän opintopistemäärissä on vaihtelua opiskelijoiden kesken.
|
||||
+ **Min ja Max** kertovat pienimmän ja suurimman opintopistemäärän, jotka jollain opiskelijoilla kyseisessä populaatiossa on.
|
||||
|
||||
Mikäli valintatapatieto kyseiselle populaatiolle on saatavissa, näytetään myös samat statistiikat per valintatapa.
|
||||
`,
|
||||
CoursesOfPopulation: `**Courses of population**
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
|
||||
export const isNewHYStudyProgramme = code => !!(code && code.match(/^[A-Z]*[0-9]*_[0-9]*$/))
|
||||
|
||||
// These are the new Bachelor's programmes in Matlu, that have BH possibility
|
||||
export const bachelorHonoursProgrammes = [
|
||||
'KH50_001',
|
||||
'KH50_002',
|
||||
@@ -235,6 +236,8 @@ export const bachelorHonoursProgrammes = [
|
||||
'KH50_008',
|
||||
]
|
||||
|
||||
// Basic modules from Sisu programme structures. All modules that can be found for the past years' structures
|
||||
// taken into account
|
||||
export const bachelorHonoursBasicModules = {
|
||||
KH50_001: ['MAT110', 'MAT120', 'MAT130', 'MAT120', 'TKT1'],
|
||||
KH50_002: ['FYS1100', 'FYS1200'],
|
||||
@@ -246,6 +249,8 @@ export const bachelorHonoursBasicModules = {
|
||||
KH50_008: ['BSCH1000', 'BSCS1000', 'BSMA1000', 'BSMS1000', 'BSPH1000'],
|
||||
}
|
||||
|
||||
// Similarly intermediate modules from Sisu programme structures. All modules that can be found for the past years' structures
|
||||
// taken into account
|
||||
export const bachelorHonoursIntermediateModules = {
|
||||
KH50_001: ['mat217', 'MAT210', 'MAT220', 'MAT240', 'MAT213', 'MAT230', 'MAT218', 'MAT011'],
|
||||
KH50_002: ['FYS2100', 'FYS2200', 'FYS2300', 'FYS2400', 'FYS2500', 'FYS2600'],
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Divider, Segment, Button, Popup, Loader, Icon, Checkbox, Form, Breadcrumb } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import TSA from '../../common/tsa'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
@@ -27,6 +28,52 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
}
|
||||
|
||||
const actionTooltips = {
|
||||
showByYear: {
|
||||
label: 'Näytä kalenterivuosittain',
|
||||
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
|
||||
long: `
|
||||
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
|
||||
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
|
||||
`,
|
||||
},
|
||||
|
||||
showYearlyValues: {
|
||||
label: 'Näytä edelliset vuodet',
|
||||
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
|
||||
long: `
|
||||
Näyttää tilastot vuodesta 2017 eteenpäin.
|
||||
|
||||
Jokaiselta vuodelta näytetään kaksi tilastoa: ajanjakson kokonaistilasto ja ns. tähän mennessä "*kertynyt*" tilasto.
|
||||
Kokonaistilasto vastaa nimensä mukaisesti koko ajanjaksoa, kun taas *kertynyt* tilasto kattaa ajanjakson vuoden
|
||||
tai lukuvuoden alusta "Näytä päivänä"-valintaa vastaavaan päivämäärään tuona vuotena. Luvut näytetään muodossa *<kerynyt>*/*<kokonais>*.
|
||||
Kuluvalta vuodelta näytetään ainoastaan kertynyt tilasto.
|
||||
`,
|
||||
},
|
||||
|
||||
showRelativeValues: {
|
||||
label: 'Näytä suhteutettuna opiskelijoiden määrään',
|
||||
short:
|
||||
'Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.',
|
||||
long: `
|
||||
Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.
|
||||
Opiskelijoiden määrä perustuu ajanjaksolla kyseisen organisaation alaisista kursseista suoritusmerkintöjä saaneiden opiskelijoiden määrään.
|
||||
Luku siis sisältää muutkin kuin kyseiseen ohjelman tai osaston opinto-oikeuden omaavat opiskelijat.
|
||||
`,
|
||||
},
|
||||
|
||||
showCountingFrom: {
|
||||
label: 'Näytä päivänä',
|
||||
short: 'Valitse päivä johon asti kertyneet tilastot näytetään.',
|
||||
long: `
|
||||
Tämä valinta määrittää päivämäärän, jota käyttäen kertyneet tilastot lasketaan.
|
||||
Esimerkiksi "Näytä kalenterivuosittain" valinnan ollessa pois päältä,
|
||||
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
|
||||
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
|
||||
const title = getTextIn(stats.name)
|
||||
const clickable = !!stats.drill
|
||||
@@ -64,10 +111,22 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
signDisplay: 'always',
|
||||
})
|
||||
|
||||
const roundToPrecision = (value, precision) => Math.round(value * 10 ** precision) / 10 ** precision
|
||||
const getDisplayValue = (value, denominator) => {
|
||||
let displayValue
|
||||
|
||||
const getDisplayValue = (value, denominator) =>
|
||||
showRelativeValues ? roundToPrecision(denominator === 0 ? 0 : value / denominator, 2) : value
|
||||
if (denominator === 0) {
|
||||
displayValue = value === 0 ? 0 : Infinity
|
||||
} else if (showRelativeValues) {
|
||||
displayValue = value / denominator
|
||||
} else {
|
||||
displayValue = value
|
||||
}
|
||||
|
||||
return displayValue.toLocaleString('fi', {
|
||||
minimumFractionDigits: showRelativeValues ? 2 : 0,
|
||||
maximumFractionDigits: showRelativeValues ? 2 : 0,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment
|
||||
@@ -94,12 +153,16 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
<span className="status-title">{title}</span>
|
||||
<Popup position="bottom center" size="tiny" trigger={<span className="status-title">{title}</span>}>
|
||||
{stats.code}
|
||||
</Popup>
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<Icon
|
||||
style={{
|
||||
color: getColor(change),
|
||||
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
|
||||
transitionProperty: 'transform',
|
||||
transitionDuration: '200ms',
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
@@ -115,31 +178,34 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
</div>
|
||||
</div>
|
||||
{showYearlyValues && (
|
||||
<div className="years">
|
||||
<div className={`years item-type-${stats.type}`}>
|
||||
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
|
||||
return (
|
||||
<span style={{ display: 'contents' }} className="year-row">
|
||||
<b>
|
||||
<span className="year-row">
|
||||
<b className="year-label">
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>
|
||||
{stats.type === 'course' ? (
|
||||
<>
|
||||
<span className="year-value">{yearStats.accStudents}</span>
|
||||
{!!yearStats.totalStudents && (
|
||||
<>
|
||||
<span className="separator">{yearStats.totalStudents !== undefined && '/'}</span>
|
||||
<span className="year-value">{yearStats.totalStudents}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="unit">students</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="year-value"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `${getDisplayValue(yearStats.acc, yearStats.accStudents)
|
||||
.toLocaleString('fi', { minimumFractionDigits: 2 })
|
||||
.replace(/,?0*$/, match => `<span class="decimals">${match}</span>`)}`,
|
||||
}}
|
||||
/>
|
||||
<span className="unit">credits{showRelativeValues ? '/student' : ''}</span>
|
||||
<span className="year-value accumulated">
|
||||
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
|
||||
</span>
|
||||
<span className="separator">{yearStats.total !== undefined && '/'}</span>
|
||||
<span className="year-value total">
|
||||
{yearStats.total !== undefined && getDisplayValue(yearStats.total, yearStats.totalStudents)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -151,185 +217,22 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
)
|
||||
}
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
const StatusContent = ({ data, settings, onDrill }) => {
|
||||
const { showByYear, showRelativeValues, showYearlyValues } = settings
|
||||
|
||||
const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', false)
|
||||
const [showRelativeValues, setShowRelativeValues] = useLocalStorage('showRelativeValues', false)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
const [codes, setCodes] = useState([])
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && isValidDate(selectedDate)) {
|
||||
getStatusDispatch({ date: selectedDate.valueOf(), showByYear })
|
||||
}
|
||||
}, [selectedDate, showByYear])
|
||||
|
||||
useEffect(() => {
|
||||
if (codes.length > 0) {
|
||||
const updatedDrillStack = codes.reduce((acc, code) => {
|
||||
const drilled = data[code]
|
||||
// check if the code is on first level of drilldown
|
||||
if (drilled) acc.push(drilled.drill)
|
||||
// if not on first level then use the previous object in array
|
||||
// this might be source of bugs but its the best I could come up with
|
||||
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
|
||||
return acc
|
||||
}, [])
|
||||
setDrillStack(updatedDrillStack)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleShowYearlyValuesToggled = () => {
|
||||
const yearlyValues = showYearlyValues
|
||||
setShowYearlyValues(!showYearlyValues)
|
||||
sendAnalytics(`S Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowByYearToggled = () => {
|
||||
const byYear = showByYear
|
||||
setShowByYear(!showByYear)
|
||||
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowRelativeValuesChanged = () => {
|
||||
sendAnalytics(`S Show relative values toggle ${!showRelativeValues ? 'on' : 'off'}`)
|
||||
setShowRelativeValues(!showRelativeValues)
|
||||
}
|
||||
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const updatedDrillStack = _.dropRight([...drillStack], 1)
|
||||
const updatedCodes = _.dropRight([...codes], 1)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const renderSettings = () => {
|
||||
return (
|
||||
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
|
||||
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
|
||||
<Icon name="setting" />
|
||||
<span>Asetukset</span>
|
||||
</span>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
|
||||
<Segment style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: 'red',
|
||||
transform: 'rotateY(0deg) rotate(45deg)',
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '35px',
|
||||
border: '1px solid #dededf',
|
||||
borderRight: 'none',
|
||||
borderBottom: 'none',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä edelliset vuodet"
|
||||
onChange={handleShowYearlyValuesToggled}
|
||||
checked={showYearlyValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä kalenterivuosittain"
|
||||
onChange={handleShowByYearToggled}
|
||||
checked={showByYear}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä suhteutettuna opiskelijoiden määrään"
|
||||
onChange={handleShowRelativeValuesChanged}
|
||||
checked={showRelativeValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Form>
|
||||
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={selectedDate}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
onChange={setSelectedDate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle ja ohjelmaa klikkaamalla pystyt porautumaan kurssitasolle.
|
||||
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
|
||||
/>
|
||||
)
|
||||
|
||||
if (!data || loading)
|
||||
return (
|
||||
<Segment style={{ padding: '40px' }} textAlign="center">
|
||||
<Dimmer inverted active />
|
||||
<Loader active={loading} />
|
||||
</Segment>
|
||||
)
|
||||
|
||||
const orderedAbsDiffs = _.orderBy(
|
||||
Object.values(_.last(drillStack) || data).map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
return Math.abs(
|
||||
Math.floor(
|
||||
(getP(
|
||||
showRelativeValues ? current / currentStudents : current,
|
||||
showRelativeValues ? previous / previousStudents : previous
|
||||
) -
|
||||
1) *
|
||||
1000
|
||||
) / 10
|
||||
)
|
||||
const orderedAbsDiffs = _.chain(data)
|
||||
.map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
const currentValue = showRelativeValues ? current / currentStudents : current
|
||||
const previousValue = showRelativeValues ? previous / previousStudents : previous
|
||||
return Math.abs(Math.floor((getP(currentValue, previousValue) - 1) * 1000) / 10)
|
||||
})
|
||||
)
|
||||
.orderBy()
|
||||
.value()
|
||||
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -340,16 +243,16 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
}}
|
||||
>
|
||||
{_.orderBy(
|
||||
Object.entries(_.last(drillStack) || data),
|
||||
([, { current, previous, currentStudents, previousStudents }]) =>
|
||||
data,
|
||||
({ current, previous, currentStudents, previousStudents }) =>
|
||||
getP(current || currentStudents, previous || previousStudents), // oh god<r
|
||||
['desc']
|
||||
).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill, code)
|
||||
).map(stats => {
|
||||
const handleClick = () => onDrill(stats.code)
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
key={stats.code}
|
||||
stats={stats}
|
||||
handleClick={handleClick}
|
||||
showRelativeValues={showRelativeValues}
|
||||
@@ -361,20 +264,355 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{renderSettings()}
|
||||
<Message>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</Message>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.status,
|
||||
loading: coolDataScience.pending.status,
|
||||
const WithHelpTooltip = ({ children, tooltip, onOpenDetails, ...rest }) => {
|
||||
const popupContext = useRef()
|
||||
const [popupOpen, setPopupOpen] = useState(false)
|
||||
|
||||
const trigger = (
|
||||
<div>
|
||||
{children}
|
||||
<div ref={popupContext} style={{ display: 'inline-block', paddingTop: '0.2em', cursor: 'help' }}>
|
||||
<Icon
|
||||
onClick={() => setPopupOpen(!popupOpen)}
|
||||
ref={popupContext}
|
||||
style={{ marginLeft: '0.3em', color: '#888' }}
|
||||
name="question circle outline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const popupProps = _.defaults(rest, {
|
||||
position: 'right center',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
hoverable
|
||||
size="tiny"
|
||||
open={popupOpen}
|
||||
onOpen={() => setPopupOpen(true)}
|
||||
onClose={() => setPopupOpen(false)}
|
||||
trigger={trigger}
|
||||
context={popupContext}
|
||||
{...popupProps}
|
||||
on="hover"
|
||||
mouseEnterDelay={1000}
|
||||
>
|
||||
<div>{tooltip}</div>
|
||||
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={onOpenDetails}>
|
||||
Lue lisää...
|
||||
</span>
|
||||
</Popup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const isValidDate = d => moment().diff(moment(d)) > 0
|
||||
|
||||
const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
|
||||
const { showYearlyValues, showByYear, showRelativeValues, selectedDate } = settings
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
|
||||
const changeSetting = (property, value) => {
|
||||
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
|
||||
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
[property]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const itemStyles = {
|
||||
margin: '0.5rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>
|
||||
<div style={itemStyles}>
|
||||
<WithHelpTooltip tooltip={actionTooltips.showYearlyValues.short} onOpenDetails={onOpenDetails}>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label={actionTooltips.showYearlyValues.label}
|
||||
checked={showYearlyValues}
|
||||
onChange={() => changeSetting('showYearlyValues', !showYearlyValues)}
|
||||
/>
|
||||
</WithHelpTooltip>
|
||||
</div>
|
||||
<div style={itemStyles}>
|
||||
<WithHelpTooltip tooltip={actionTooltips.showByYear.short} onOpenDetails={onOpenDetails}>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label={actionTooltips.showByYear.label}
|
||||
checked={showByYear}
|
||||
onChange={() => changeSetting('showByYear', !showByYear)}
|
||||
/>
|
||||
</WithHelpTooltip>
|
||||
</div>
|
||||
<div style={itemStyles}>
|
||||
<WithHelpTooltip tooltip={actionTooltips.showRelativeValues.short} onOpenDetails={onOpenDetails}>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label={actionTooltips.showRelativeValues.label}
|
||||
checked={showRelativeValues}
|
||||
onChange={() => changeSetting('showRelativeValues', !showRelativeValues)}
|
||||
/>
|
||||
</WithHelpTooltip>
|
||||
</div>
|
||||
<div style={itemStyles}>
|
||||
<Form>
|
||||
<Form.Field
|
||||
error={selectedDate !== null && !isValidDate(selectedDate)}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<WithHelpTooltip tooltip={actionTooltips.showCountingFrom.short} onOpenDetails={onOpenDetails}>
|
||||
<span style={{ fontSize: '0.9em' }}>{actionTooltips.showCountingFrom.label}</span>
|
||||
</WithHelpTooltip>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={moment(selectedDate)}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
inputProps={{ placeholder: 'Valise päivämäärä' }}
|
||||
onChange={value => {
|
||||
changeSetting('selectedDate', value.format(DATE_FORMAT) === moment().format(DATE_FORMAT) ? null : value)
|
||||
}}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultSettings = () => ({
|
||||
showYearlyValues: true,
|
||||
showByYear: false,
|
||||
showRelativeValues: false,
|
||||
showSettings: true,
|
||||
selectedDate: null,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { getStatusDispatch: getStatus })(Status)
|
||||
const Status = () => {
|
||||
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
|
||||
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false)
|
||||
const moreDetailsRef = useRef(null)
|
||||
const history = useHistory()
|
||||
const location = useLocation()
|
||||
|
||||
const drillStack = location.state?.drillStack ?? []
|
||||
|
||||
const data = useSelector(state => state.coolDataScience.data.status)
|
||||
const loading = useSelector(state => state.coolDataScience.pending.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const settings = useMemo(() => _.defaults(explicitSettings, getDefaultSettings()), [explicitSettings])
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
useEffect(() => {
|
||||
const { selectedDate, showByYear } = settings
|
||||
|
||||
let date
|
||||
|
||||
if (!selectedDate) {
|
||||
date = moment().valueOf()
|
||||
} else if (isValidDate(selectedDate)) {
|
||||
date = moment(selectedDate).valueOf()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(
|
||||
getStatus({
|
||||
date,
|
||||
showByYear,
|
||||
})
|
||||
)
|
||||
}, [settings.selectedDate, settings.showByYear])
|
||||
|
||||
const pushToDrillStack = code => {
|
||||
history.push('/trends/status', {
|
||||
drillStack: [...drillStack, code],
|
||||
})
|
||||
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const stack = _.chain(drillStack)
|
||||
.reduce(
|
||||
([stack, data], code) => {
|
||||
let content = code
|
||||
let next = null
|
||||
|
||||
if (data) {
|
||||
const entry = data[code]
|
||||
|
||||
if (entry) {
|
||||
content = getTextIn(entry.name)
|
||||
next = entry.drill
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
key: code,
|
||||
content,
|
||||
link: !!next,
|
||||
active: !next,
|
||||
onClick: () => {
|
||||
history.push('/trends/status', {
|
||||
drillStack: [...stack.map(({ key }) => key).splice(1), code],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return [[...stack, item], next]
|
||||
},
|
||||
[[], data]
|
||||
)
|
||||
.value()[0]
|
||||
|
||||
const hy = {
|
||||
key: '',
|
||||
content: getTextIn({
|
||||
fi: 'Helsingin Yliopisto',
|
||||
en: 'University of Helsinki',
|
||||
sv: 'Helsingfors universitet',
|
||||
}),
|
||||
link: true,
|
||||
onClick: () =>
|
||||
history.push('/trends/status', {
|
||||
drillStack: [],
|
||||
}),
|
||||
}
|
||||
|
||||
return [hy, ...stack]
|
||||
}, [data, drillStack])
|
||||
|
||||
const drilledData = useMemo(
|
||||
() =>
|
||||
_.chain(drillStack)
|
||||
.reduce((data, code) => data && _.values(data).find(item => item.code === code)?.drill, data)
|
||||
.defaultTo({})
|
||||
.values()
|
||||
.value(),
|
||||
[data, drillStack]
|
||||
)
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const newDrillStack = [...drillStack]
|
||||
newDrillStack.pop()
|
||||
history.push('/trends/status', {
|
||||
drillStack: newDrillStack,
|
||||
})
|
||||
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }} ref={moreDetailsRef}>
|
||||
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="settings" /> Asetukset
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={settingsOpen}
|
||||
onOpen={() => setSettingsOpen(true)}
|
||||
onClose={() => {
|
||||
// Close the popup only after the event has had a chance to propagate.
|
||||
setTimeout(() => setSettingsOpen(false), 0)
|
||||
}}
|
||||
style={{ padding: '0.25em 0em', maxHeight: '80vh' }}
|
||||
>
|
||||
<StatusSettings
|
||||
settings={settings}
|
||||
onSettingsChange={setSettings}
|
||||
onOpenDetails={() => {
|
||||
if (moreDetailsRef.current) {
|
||||
moreDetailsRef.current.scrollIntoView({
|
||||
block: 'start',
|
||||
inline: 'end',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
setUsageDetailsOpen(true)
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="question circle" /> Käyttöohje
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={usageDetailsOpen}
|
||||
onOpen={() => setUsageDetailsOpen(true)}
|
||||
onClose={() => setUsageDetailsOpen(false)}
|
||||
style={{ padding: '0', paddingBottom: '0.5em', maxHeight: '80vh', overflowY: 'auto' }}
|
||||
>
|
||||
<div style={{ padding: '1em' }}>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</div>
|
||||
{_.toPairs(actionTooltips).map(([key, { label, long }]) => (
|
||||
<div key={key}>
|
||||
<Divider />
|
||||
<div style={{ padding: '0 1em' }}>
|
||||
<b>
|
||||
Valinta "<i>{label}</i>"
|
||||
</b>
|
||||
<div style={{ margin: '0.5em', fontSize: '0.9em' }}>
|
||||
<ReactMarkdown escapeHtml={false}>{long.replace(/(^|\n)[ \t]+/g, '\n')}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Popup>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px', alignItems: 'center' }}>
|
||||
<Breadcrumb icon="right angle" sections={breadcrumb} size="large" />
|
||||
{drillStack.length > 0 && (
|
||||
<Icon
|
||||
onClick={popFromDrillStack}
|
||||
style={{ cursor: 'pointer', color: 'rgba(0,0,0,.28)', marginLeft: '0.4em', marginTop: '-0.17em' }}
|
||||
name="arrow left"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && !loading ? (
|
||||
<StatusContent settings={settings} onDrill={pushToDrillStack} data={drilledData} />
|
||||
) : (
|
||||
<Loader active={loading} inline="centered" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Status
|
||||
|
||||
@@ -5,8 +5,22 @@ import Graduated from './StatusGraduated'
|
||||
|
||||
const StatusPanel = () => {
|
||||
const panes = [
|
||||
{ menuItem: 'Attainments', render: () => <Status /> },
|
||||
{ menuItem: 'Graduations', render: () => <Graduated /> },
|
||||
{
|
||||
menuItem: 'Attainments',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Status />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'Graduations',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Graduated />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
return <Tab panes={panes} />
|
||||
}
|
||||
|
||||
@@ -10,41 +10,45 @@
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
|
||||
.unit,
|
||||
.decimals {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.years {
|
||||
margin-top: 10px;
|
||||
text-align: start;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: grid;
|
||||
grid-template-columns: fit-content(0) fit-content(0) auto;
|
||||
grid-template-columns: repeat(4, fit-content(0));
|
||||
}
|
||||
|
||||
.year-row .unit {
|
||||
padding-left: 0.5em;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
font-size: 0.9em;
|
||||
.subtitle {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
white-space: nowrap;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.year-value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
.year-row {
|
||||
position: relative;
|
||||
padding-left: 0.5em;
|
||||
display: inline-block;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.status-card .years .year-row:hover .unit,
|
||||
.status-card .years .year-row:hover .decimals,
|
||||
.status-card:hover .years:not(:hover) .year-row:first-child .unit,
|
||||
.status-card:hover .years:not(:hover) .year-row:first-child .decimals {
|
||||
opacity: 1;
|
||||
.year-row .unit {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
.year-row .separator {
|
||||
color: #aaa;
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
|
||||
.status-date-time-input {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { Component } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { connect } from 'react-redux'
|
||||
import moment from 'moment'
|
||||
import { arrayOf, bool, object, string, func, number, shape } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import Highcharts from 'highcharts/highstock'
|
||||
import { Button } from 'semantic-ui-react'
|
||||
@@ -220,7 +219,7 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
},
|
||||
xAxis: {
|
||||
max: students.maxDate,
|
||||
min: students.minDateWithCredits || students.minDate,
|
||||
min: students.minDateWithCredits || students.minDate || this.props.startYear,
|
||||
ordinal: false,
|
||||
},
|
||||
series: dataOfSelected,
|
||||
@@ -308,25 +307,6 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.defaultProps = {
|
||||
absences: [],
|
||||
singleStudent: false,
|
||||
render: true,
|
||||
trayOpen: false,
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.propTypes = {
|
||||
singleStudent: bool,
|
||||
students: arrayOf(object).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
setChartHeight: func.isRequired,
|
||||
currentGraphSize: number.isRequired,
|
||||
language: string.isRequired,
|
||||
absences: arrayOf(shape({})),
|
||||
render: bool,
|
||||
trayOpen: bool,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
spinner: state.graphSpinner,
|
||||
currentGraphSize: state.settings.chartHeight,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import './navigationBar.css'
|
||||
import { Menu, Dropdown, Button, Label } from 'semantic-ui-react'
|
||||
import { NavLink, Link } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { isEqual } from 'lodash'
|
||||
import { getUserRoles, checkUserAccess } from '../../common'
|
||||
import { logout as logoutAction } from '../../redux/auth'
|
||||
import './navigationBar.css'
|
||||
import LanguagePicker from '../LanguagePicker'
|
||||
import { isDev, adminerUrls } from '../../conf'
|
||||
import { useShowAsUser } from '../../common/hooks'
|
||||
@@ -134,7 +134,7 @@ const NavigationBar = props => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu stackable fluid className="navBar">
|
||||
<Menu data-cy="navBar" stackable fluid className="navBar">
|
||||
{renderHome()}
|
||||
{renderNavigationRoutes()}
|
||||
{renderUserMenu()}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* stylelint-disable */
|
||||
|
||||
.navBar {
|
||||
display: grid !important;
|
||||
grid-auto-flow: column !important;
|
||||
@@ -25,56 +23,3 @@
|
||||
.logoText {
|
||||
color: var(--black) ;;
|
||||
}
|
||||
|
||||
.sis-destruction-siren {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
animation: 0.5s infinite running sis-siren;
|
||||
}
|
||||
|
||||
@keyframes sis-siren {
|
||||
from {
|
||||
transform: rotateZ(0);
|
||||
}
|
||||
to {
|
||||
transform: rotateZ(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sis-danger-zone-button {
|
||||
background-color: #d01919;
|
||||
animation: 2s ease-in-out infinite running sis-danger-zone-button-heartbeat;
|
||||
}
|
||||
|
||||
@keyframes sis-danger-zone-button-heartbeat {
|
||||
from {
|
||||
background-color: #d01919;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
to {
|
||||
background-color: rgba(230, 107, 107, 0.76);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sis-danger-zone-button::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 0.28571429rem;
|
||||
box-shadow: 0 0 25px 5px rgba(224, 114, 40, 0.75);
|
||||
opacity: 1;
|
||||
animation: 2s ease-in-out infinite running sis-danger-zone-opacity;
|
||||
}
|
||||
|
||||
@keyframes sis-danger-zone-opacity {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-14
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table, Progress, Icon } from 'semantic-ui-react'
|
||||
import { number, func } from 'prop-types'
|
||||
|
||||
import ExternalCreditFilterToggle from './ExternalCreditFilterToggle'
|
||||
|
||||
const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }) => {
|
||||
@@ -76,17 +76,4 @@ const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }
|
||||
)
|
||||
}
|
||||
|
||||
CollapsibleCreditRow.propTypes = {
|
||||
min: number,
|
||||
max: number,
|
||||
studentCount: func.isRequired,
|
||||
filteredLength: number.isRequired,
|
||||
months: number.isRequired,
|
||||
}
|
||||
|
||||
CollapsibleCreditRow.defaultProps = {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}
|
||||
|
||||
export default CollapsibleCreditRow
|
||||
|
||||
+50
-53
@@ -1,62 +1,59 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getMonths } from '../../../common/query'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import CollapsibleCreditRow from './CollapsibleCreditRow'
|
||||
import React, { useState } from 'react'
|
||||
import { Divider, Icon, Grid, Label } from 'semantic-ui-react'
|
||||
|
||||
const CreditsGainedTab = ({ filteredStudents }) => {
|
||||
const months = getMonths(useLocation())
|
||||
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
|
||||
import CreditsGainedTable from './CreditsGainedTable'
|
||||
|
||||
const studentCount = (min, max = Infinity) =>
|
||||
max === 0
|
||||
? creditList.filter(credits => credits === 0).length
|
||||
: creditList.filter(credits => credits < max && credits >= min).length
|
||||
const admissionTypes = [
|
||||
'Todistusvalinta',
|
||||
'Koepisteet',
|
||||
'Yhteispisteet',
|
||||
'Avoin väylä',
|
||||
'Kilpailumenestys',
|
||||
'Muu',
|
||||
null,
|
||||
]
|
||||
|
||||
const limits = [
|
||||
[Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
|
||||
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
|
||||
[1, Math.ceil(months * (15 / 12))],
|
||||
[null, 0],
|
||||
]
|
||||
const CreditsGainedTab = ({ allStudents, query }) => {
|
||||
const [show, setShow] = useState(false)
|
||||
if (!allStudents || !allStudents.length || !query) return null
|
||||
|
||||
const { studyRights } = query
|
||||
|
||||
const filterFunction = (student, type) =>
|
||||
student.studyrights.some(
|
||||
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
|
||||
)
|
||||
|
||||
const getCreditsGainedTable = type => {
|
||||
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
|
||||
return <CreditsGainedTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
|
||||
}
|
||||
|
||||
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell collapsing />
|
||||
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Number of Students
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{limits.map(([min, max]) => (
|
||||
<CollapsibleCreditRow
|
||||
key={`table-row-${min}-${max}`}
|
||||
min={min}
|
||||
max={max}
|
||||
studentCount={studentCount}
|
||||
filteredLength={filteredStudents.length}
|
||||
months={Number(months)}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={16} data-cy="credits-gained-main-table">
|
||||
<CreditsGainedTable type="All students of the population" filteredStudents={allStudents} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{admissionTypesAvailable && (
|
||||
<Divider
|
||||
className="credits-gained-divider"
|
||||
horizontal
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
By admission type <Icon name={`angle ${show ? 'down' : 'right'}`} />
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</Divider>
|
||||
)}
|
||||
{show && <Grid.Row>{admissionTypes.map(type => getCreditsGainedTable(type))}</Grid.Row>}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
CreditsGainedTab.propTypes = {
|
||||
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
}
|
||||
|
||||
export default CreditsGainedTab
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getMonths } from '../../../common/query'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import CollapsibleCreditRow from './CollapsibleCreditRow'
|
||||
|
||||
const CreditsGainedTable = ({ filteredStudents, type }) => {
|
||||
const months = getMonths(useLocation())
|
||||
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
|
||||
|
||||
if (!filteredStudents || !filteredStudents.length || !type) return null
|
||||
|
||||
const studentCount = (min, max = Infinity) =>
|
||||
max === 0
|
||||
? creditList.filter(credits => credits === 0).length
|
||||
: creditList.filter(credits => credits < max && credits >= min).length
|
||||
|
||||
const limits = [
|
||||
[Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
|
||||
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
|
||||
[1, Math.ceil(months * (15 / 12))],
|
||||
[null, 0],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="credits-gained-table" data-cy={`credits-gained-table-${type}`}>
|
||||
<h3>{type}</h3>
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell collapsing />
|
||||
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Number of Students
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body data-cy="credits-gained-table-body">
|
||||
{limits.map(([min, max]) => (
|
||||
<CollapsibleCreditRow
|
||||
key={`table-row-${min}-${max}`}
|
||||
min={min}
|
||||
max={max}
|
||||
studentCount={studentCount}
|
||||
filteredLength={filteredStudents.length}
|
||||
months={Number(months)}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditsGainedTable
|
||||
+45
-48
@@ -1,55 +1,52 @@
|
||||
import React from 'react'
|
||||
import { jStat } from 'jstat'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import { Divider, Grid, Label } from 'semantic-ui-react'
|
||||
|
||||
const StatisticsTab = ({ filteredStudents }) => {
|
||||
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
|
||||
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
|
||||
const quartiles = jStat.quartiles(credits)
|
||||
import StatisticsTable from './StatisticsTable'
|
||||
|
||||
const admissionTypes = [
|
||||
'Todistusvalinta',
|
||||
'Koepisteet',
|
||||
'Yhteispisteet',
|
||||
'Avoin väylä',
|
||||
'Kilpailumenestys',
|
||||
'Muu',
|
||||
null,
|
||||
]
|
||||
|
||||
const StatisticsTab = ({ allStudents, query }) => {
|
||||
if (!allStudents || !allStudents.length || !query) return null
|
||||
|
||||
const { studyRights } = query
|
||||
|
||||
const filterFunction = (student, type) =>
|
||||
student.studyrights.some(
|
||||
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
|
||||
)
|
||||
|
||||
const getStatisticsTable = type => {
|
||||
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
|
||||
return <StatisticsTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
|
||||
}
|
||||
|
||||
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
|
||||
|
||||
return (
|
||||
<Table celled collapsing className="statistics-table">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell data-cy="credit-stats-table-name-header">
|
||||
{`Statistic for n = ${credits.length} Students`}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>Average</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-mean">{formatNumber(jStat.mean(credits), 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Standard Deviation</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(jStat.stdev(credits), 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Min</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-min">{formatNumber(jStat.min(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>1st Quartile (25 %)</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-q1">{formatNumber(quartiles[0], 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>2nd Quartile (50 %)</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-q2">{formatNumber(quartiles[1], 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>3rd Quartile (75 %)</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-q3">{formatNumber(quartiles[2], 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Max</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-max">{formatNumber(jStat.max(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Grid padded centered>
|
||||
<Grid.Row>
|
||||
<StatisticsTable type="All students of the population" filteredStudents={allStudents} />
|
||||
</Grid.Row>
|
||||
{admissionTypesAvailable && (
|
||||
<>
|
||||
<Divider horizontal>
|
||||
By admission type
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</Divider>
|
||||
<Grid.Row>{admissionTypes.map(type => getStatisticsTable(type))}</Grid.Row>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import * as _ from 'lodash'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
|
||||
const StatisticsTable = ({ filteredStudents, type }) => {
|
||||
if (!filteredStudents || !filteredStudents.length) return null
|
||||
const credits = filteredStudents.map(student => getStudentTotalCredits(student))
|
||||
const formatNumber = (x, decimals) => (Number.isNaN(x) || !x ? 0 : x).toFixed(decimals)
|
||||
const mean = _.mean(credits)
|
||||
const stdev = Math.sqrt(_.sum(_.map(credits, c => (c - mean) * (c - mean))) / credits.length)
|
||||
|
||||
return (
|
||||
<div className="statistics-table">
|
||||
<h3>{type}</h3>
|
||||
<Table celled collapsing>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell data-cy="credit-stats-table-name-header">
|
||||
{`Statistic for n = ${credits.length} Students`}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Credits Earned</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>Total</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-total">{formatNumber(_.sum(credits))}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Average</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-mean">{formatNumber(mean, 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Standard Deviation</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-stdev">{formatNumber(stdev, 2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Min</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-min">{formatNumber(_.min(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Max</Table.Cell>
|
||||
<Table.Cell data-cy="credit-stats-max">{formatNumber(_.max(credits), 0)}</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatisticsTable
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
#credit-gain-stats .statistics-table {
|
||||
margin: 0 auto;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#credit-gain-stats .credits-gained-table {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#credit-gain-stats .credits-gained-divider:hover {
|
||||
color: gray;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#credit-gain-stats .collapsing + th,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
import { useTabChangeAnalytics } from '../../../common/hooks'
|
||||
import InfoBox from '../../InfoBox'
|
||||
@@ -8,11 +7,11 @@ import CreditsGainedTab from './CreditsGainedTab'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
import './creditGainStats.css'
|
||||
|
||||
const CreditGainStats = ({ filteredStudents }) => {
|
||||
const CreditGainStats = ({ filteredStudents, query }) => {
|
||||
const renderCreditsGainTab = useCallback(() => {
|
||||
return (
|
||||
<Tab.Pane attached={false}>
|
||||
<CreditsGainedTab filteredStudents={filteredStudents} />
|
||||
<CreditsGainedTab allStudents={filteredStudents} query={query} />
|
||||
</Tab.Pane>
|
||||
)
|
||||
}, [filteredStudents])
|
||||
@@ -20,7 +19,7 @@ const CreditGainStats = ({ filteredStudents }) => {
|
||||
const renderQuartersTab = useCallback(() => {
|
||||
return (
|
||||
<Tab.Pane attached={false}>
|
||||
<StatisticsTab filteredStudents={filteredStudents} />
|
||||
<StatisticsTab allStudents={filteredStudents} query={query} />
|
||||
</Tab.Pane>
|
||||
)
|
||||
}, [filteredStudents])
|
||||
@@ -51,8 +50,4 @@ const CreditGainStats = ({ filteredStudents }) => {
|
||||
)
|
||||
}
|
||||
|
||||
CreditGainStats.propTypes = {
|
||||
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
}
|
||||
|
||||
export default CreditGainStats
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { object, arrayOf, bool, shape, node } from 'prop-types'
|
||||
import { Message, Accordion, Label } from 'semantic-ui-react'
|
||||
import { Message, Accordion } from 'semantic-ui-react'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
@@ -111,7 +111,7 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
content: {
|
||||
content: !query.years ? (
|
||||
<div ref={creditGainRef}>
|
||||
<CreditGainStats filteredStudents={filteredStudents} />
|
||||
<CreditGainStats query={query} filteredStudents={filteredStudents} />
|
||||
</div>
|
||||
) : (
|
||||
<div>This table is omitted when searching population of multiple years</div>
|
||||
@@ -124,9 +124,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Age distribution
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import qs from 'query-string'
|
||||
import { func, arrayOf, shape, bool, string, object, oneOfType } from 'prop-types'
|
||||
import { Form, Button, Message, Icon, Grid } from 'semantic-ui-react'
|
||||
import uuidv4 from 'uuid/v4'
|
||||
import Datetime from 'react-datetime'
|
||||
@@ -21,7 +20,6 @@ import { momentFromFormat, reformatDate, textAndDescriptionSearch, getTextIn, ca
|
||||
import { useSearchHistory } from '../../common/hooks'
|
||||
import { setLoading } from '../../redux/graphSpinner'
|
||||
import './populationSearch.css'
|
||||
import { dropdownType } from '../../constants/types'
|
||||
import SearchHistory from '../SearchHistory'
|
||||
|
||||
const YEAR_DATE_FORMAT = 'YYYY'
|
||||
@@ -568,28 +566,6 @@ const PopulationSearchForm = props => {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationSearchForm.propTypes = {
|
||||
language: string.isRequired,
|
||||
getProgrammes: func.isRequired,
|
||||
getPopulationStatistics: func.isRequired,
|
||||
getPopulationCourses: func.isRequired,
|
||||
getMandatoryCourses: func.isRequired,
|
||||
queries: shape({}).isRequired,
|
||||
studyProgrammes: shape({}), //eslint-disable-line
|
||||
studyTracks: arrayOf(dropdownType), //eslint-disable-line
|
||||
setLoading: func.isRequired,
|
||||
pending: bool, //eslint-disable-line
|
||||
getSemesters: func.isRequired,
|
||||
semesters: shape({}).isRequired,
|
||||
history: shape({}).isRequired,
|
||||
location: shape({}).isRequired,
|
||||
getTagsByStudytrackAction: func.isRequired,
|
||||
tags: oneOfType([arrayOf(shape({ tag_id: string, tagname: string })), object]).isRequired,
|
||||
onProgress: func.isRequired,
|
||||
clearSelected: func.isRequired,
|
||||
clearPopulations: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ semesters, settings, populations, populationProgrammes, tags }) => {
|
||||
const { language } = settings
|
||||
const { pending } = populationProgrammes
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Segment, Header, Divider, Form, Button, Icon } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
@@ -41,19 +40,6 @@ const PopulationSearch = ({ populationFound, history, location, loading }) => {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationSearch.propTypes = {
|
||||
history: PropTypes.shape({
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
populationFound: PropTypes.bool.isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => ({
|
||||
populationFound: populations.data.students !== undefined,
|
||||
loading: !!populations.pending,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useChunk } from '../../common/hooks'
|
||||
|
||||
@@ -166,39 +165,4 @@ const SortableTable = ({
|
||||
)
|
||||
}
|
||||
|
||||
SortableTable.propTypes = {
|
||||
tableProps: shape({}),
|
||||
getRowKey: func.isRequired,
|
||||
getRowProps: func,
|
||||
columns: arrayOf(
|
||||
shape({
|
||||
key: string.isRequired,
|
||||
title: oneOfType([element, string]),
|
||||
headerProps: shape({}),
|
||||
getRowVal: func,
|
||||
getRowContent: func,
|
||||
getCellProps: func,
|
||||
cellProps: shape({}),
|
||||
group: bool,
|
||||
children: arrayOf(),
|
||||
})
|
||||
).isRequired,
|
||||
data: arrayOf(shape({})).isRequired,
|
||||
defaultdescending: bool,
|
||||
defaultsortkey: string,
|
||||
collapsingHeaders: bool,
|
||||
showNames: bool,
|
||||
chunkifyBy: string,
|
||||
}
|
||||
|
||||
SortableTable.defaultProps = {
|
||||
tableProps: undefined,
|
||||
getRowProps: undefined,
|
||||
defaultdescending: false,
|
||||
defaultsortkey: null,
|
||||
collapsingHeaders: false,
|
||||
showNames: undefined,
|
||||
chunkifyBy: undefined,
|
||||
}
|
||||
|
||||
export default SortableTable
|
||||
|
||||
@@ -45,15 +45,7 @@ const StudentInfoCard = ({ student }) => {
|
||||
<div className="startDate">{`Started: ${reformatDate(student.started, DISPLAY_DATE_FORMAT)}`}</div>
|
||||
<div className="startDate">{email}</div>
|
||||
</Card.Meta>
|
||||
<Card.Description>
|
||||
{`Credits: ${student.credits || 0}`}
|
||||
{student.mismatch && '*'}
|
||||
<p>
|
||||
<i>
|
||||
{student.mismatch && '*There is difference between the total credits of this student in Oodi and Sisu'}
|
||||
</i>
|
||||
</p>
|
||||
</Card.Description>
|
||||
<Card.Description>{`Credits: ${student.credits || 0}`}</Card.Description>
|
||||
<div style={{ paddingTop: '4px' }}>
|
||||
<EnrollmentAccordion semesterEnrollments={student.semesterenrollments} />
|
||||
<p style={{ fontSize: 14 }}>{`Updated at ${formattedTimestamp}`}</p>
|
||||
|
||||
@@ -20,7 +20,7 @@ import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationP
|
||||
import InfoBox from '../InfoBox'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
|
||||
const SingleStudyGroupContent = ({ population, language }) => {
|
||||
const SingleStudyGroupContent = ({ population, group, language }) => {
|
||||
const custompop = population?.students || []
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const [trayOpen] = useFilterTray()
|
||||
@@ -79,6 +79,7 @@ const SingleStudyGroupContent = ({ population, language }) => {
|
||||
render={false}
|
||||
trayOpen={trayOpen}
|
||||
language={language}
|
||||
startYear={new Date(parseInt(group?.tags?.year, 10), 7, 1).getTime()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -208,7 +209,7 @@ const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
|
||||
Loading
|
||||
</Loader>
|
||||
) : null}
|
||||
{renderGroup ? <SingleStudyGroupContent population={population} language={language} /> : null}
|
||||
{renderGroup ? <SingleStudyGroupContent population={population} group={group} language={language} /> : null}
|
||||
</SingleStudyGroupViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
+143
-18
@@ -1,38 +1,159 @@
|
||||
import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Formik } from 'formik'
|
||||
import { Form, Button, Icon } from 'semantic-ui-react'
|
||||
import Datetime from 'react-datetime'
|
||||
import SortableTable from '../SortableTable'
|
||||
import { getTextIn } from '../../common'
|
||||
import { getTextIn, textAndDescriptionSearch } from '../../common'
|
||||
import StyledMessage from './StyledMessage'
|
||||
import { changeStudyGuidanceGroupTags } from '../../redux/studyGuidanceGroups'
|
||||
import { getElementDetails } from '../../redux/elementdetails'
|
||||
import { useToggle } from '../../common/hooks'
|
||||
|
||||
const LinkToGroup = ({ group, language }) => (
|
||||
<Link
|
||||
style={{
|
||||
color: 'black',
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '.78571429em .78571429em',
|
||||
}}
|
||||
to={`/studyguidancegroups/${group.id}`}
|
||||
>
|
||||
{getTextIn(group.name, language)}
|
||||
</Link>
|
||||
)
|
||||
|
||||
const prettifyCamelCase = str => {
|
||||
const splitted = str.match(/[A-Za-z][a-z]*/g) || []
|
||||
return splitted.map(w => w.charAt(0).toLowerCase() + w.substring(1)).join(' ')
|
||||
}
|
||||
|
||||
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Formik
|
||||
initialValues={{ [tagName]: '' }}
|
||||
onSubmit={values => {
|
||||
dispatch(changeStudyGuidanceGroupTags(group.id, values))
|
||||
toggleEdit()
|
||||
}}
|
||||
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
|
||||
>
|
||||
{formik => (
|
||||
<Form onSubmit={formik.handleSubmit} style={{ display: 'flex', gap: '8px' }}>
|
||||
{tagName === 'studyProgramme' ? (
|
||||
<Form.Select
|
||||
name={tagName}
|
||||
search={textAndDescriptionSearch}
|
||||
fluid
|
||||
placeholder={
|
||||
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
|
||||
}
|
||||
options={selectFieldItems}
|
||||
closeOnChange
|
||||
value={formik.values[tagName]}
|
||||
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
|
||||
/>
|
||||
) : (
|
||||
<Datetime
|
||||
name={tagName}
|
||||
dateFormat="YYYY"
|
||||
timeFormat={false}
|
||||
initialvalue={group.tags?.[tagName]}
|
||||
renderYear={(props, selectableYear) => <td {...props}>{selectableYear}</td>}
|
||||
closeOnSelect
|
||||
value={formik.values[tagName]}
|
||||
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
|
||||
/>
|
||||
)}
|
||||
<Button type="submit" style={{ margin: '0 0 1em 0' }}>
|
||||
Add {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
{group.tags?.[tagName] ? (
|
||||
<Button icon style={{ margin: '0 0 1em 0' }} onClick={() => toggleEdit()}>
|
||||
Close
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TagCell = ({ tagName, group, studyProgrammes }) => {
|
||||
const [showEdit, toggleEdit] = useToggle()
|
||||
const value = group.tags?.[tagName]
|
||||
const text = tagName === 'studyProgramme' ? studyProgrammes.find(p => p.value === value)?.text : value
|
||||
|
||||
return group.tags?.[tagName] && !showEdit ? (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
|
||||
<p>{text}</p>
|
||||
<Button type="button" onClick={() => toggleEdit()}>
|
||||
Edit {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<AssociateTagForm group={group} tagName={tagName} toggleEdit={toggleEdit} selectFieldItems={studyProgrammes} />
|
||||
)
|
||||
}
|
||||
|
||||
const StudyGuidanceGroupOverview = () => {
|
||||
const dispatch = useDispatch()
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
const { data: elementDetails } = useSelector(({ elementdetails }) => elementdetails)
|
||||
|
||||
useEffect(() => {
|
||||
if (elementDetails && elementDetails.length > 0) return
|
||||
dispatch(getElementDetails())
|
||||
}, [dispatch])
|
||||
|
||||
const studyProgrammesFilteredForDropdown =
|
||||
elementDetails
|
||||
?.filter(elem => elem.code.startsWith('KH') || elem.code.startsWith('MH'))
|
||||
.map(elem => ({
|
||||
key: elem.code,
|
||||
value: elem.code,
|
||||
description: elem.code,
|
||||
text: getTextIn(elem.name, language),
|
||||
})) || []
|
||||
|
||||
const headers = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'name',
|
||||
getRowVal: group => getTextIn(group.name, language),
|
||||
getRowContent: group => (
|
||||
<Link
|
||||
style={{
|
||||
color: 'black',
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '.78571429em .78571429em',
|
||||
}}
|
||||
to={`/studyguidancegroups/${group.id}`}
|
||||
>
|
||||
{getTextIn(group.name, language)}
|
||||
</Link>
|
||||
),
|
||||
getRowContent: group => <LinkToGroup group={group} language={language} />,
|
||||
cellProps: {
|
||||
style: {
|
||||
padding: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'studyProgramme',
|
||||
title: 'Study programme',
|
||||
getRowVal: () => 'studyProgramme',
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
getRowContent: group => (
|
||||
<TagCell tagName="studyProgramme" studyProgrammes={studyProgrammesFilteredForDropdown} group={group} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'associatedyear',
|
||||
title: 'Associated year',
|
||||
getRowVal: () => 'associatedYear',
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
getRowContent: group => <TagCell tagName="year" group={group} />,
|
||||
},
|
||||
]
|
||||
|
||||
if (groups.length === 0) {
|
||||
@@ -41,7 +162,11 @@ const StudyGuidanceGroupOverview = () => {
|
||||
return (
|
||||
<>
|
||||
<StyledMessage>
|
||||
<p>Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin.</p>
|
||||
<p>
|
||||
Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin. Voit halutessasi
|
||||
lisätä ohjausryhmään aloitusvuoden ja koulutusohjelman, jolloin yksittäisen ohjausryhmän näkymään avautuu
|
||||
lisäominaisuuksia.{' '}
|
||||
</p>
|
||||
</StyledMessage>
|
||||
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { string, func, shape, bool, oneOfType, array } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import ProductivityTable from '../ProductivityTable'
|
||||
import ThroughputTable from '../ThroughputTable'
|
||||
@@ -53,29 +52,6 @@ const Overview = props => {
|
||||
)
|
||||
}
|
||||
|
||||
Overview.propTypes = {
|
||||
studyprogramme: string.isRequired,
|
||||
dispatchGetProductivity: func.isRequired,
|
||||
dispatchGetThroughput: func.isRequired,
|
||||
dispatchGetBachelors: func.isRequired,
|
||||
history: shape({}).isRequired,
|
||||
productivity: shape({
|
||||
error: bool,
|
||||
pending: bool,
|
||||
data: shape({}),
|
||||
}).isRequired, // eslint-disable-line
|
||||
throughput: shape({
|
||||
error: bool,
|
||||
pending: bool,
|
||||
data: shape({}),
|
||||
}).isRequired, // eslint-disable-line
|
||||
bachelors: shape({
|
||||
error: bool,
|
||||
pending: bool,
|
||||
data: oneOfType([shape({}), array]),
|
||||
}).isRequired // eslint-disable-line
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ studyProgrammeProductivity, studyProgrammeThroughput, studyProgrammeBachelors }) => ({
|
||||
productivity: studyProgrammeProductivity,
|
||||
throughput: studyProgrammeThroughput,
|
||||
|
||||
@@ -30,7 +30,7 @@ const AccessGroups = ({
|
||||
selected.forEach(value => {
|
||||
rights[value] = true
|
||||
})
|
||||
modifyAccessGroups(user.id, rights)
|
||||
modifyAccessGroups(user.username, rights)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,32 +1,77 @@
|
||||
import { callController } from '../apiConnection'
|
||||
import { callController, actionTypes } from '../apiConnection'
|
||||
|
||||
const prefix = 'GET_STUDY_GUIDANCE_GROUPS_'
|
||||
const getPrefix = 'GET_STUDY_GUIDANCE_GROUPS_'
|
||||
const getTypes = {
|
||||
...actionTypes(getPrefix),
|
||||
}
|
||||
|
||||
export const getStudyGuidanceGroups = () => {
|
||||
const route = '/studyguidancegroups'
|
||||
return callController(route, prefix)
|
||||
return callController(route, getPrefix)
|
||||
}
|
||||
|
||||
const reducer = (state = { data: [] }, action) => {
|
||||
const changeTagsPrefix = 'CHANGE_STUDY_GUIDANCE_GROUP_TAGS_'
|
||||
const changeTagsTypes = {
|
||||
...actionTypes(changeTagsPrefix),
|
||||
}
|
||||
|
||||
export const changeStudyGuidanceGroupTags = (groupId, tags) => {
|
||||
const route = `/studyguidancegroups/${groupId}/tags`
|
||||
return callController(route, changeTagsPrefix, tags, 'put')
|
||||
}
|
||||
const initialState = {
|
||||
pending: false,
|
||||
error: false,
|
||||
data: [],
|
||||
}
|
||||
|
||||
const reducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case `${prefix}ATTEMPT`:
|
||||
case getTypes.attempt:
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
pending: true,
|
||||
}
|
||||
case `${prefix}FAILURE`:
|
||||
case getTypes.failure:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: true,
|
||||
}
|
||||
case `${prefix}SUCCESS`:
|
||||
case getTypes.success:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
data: action.response,
|
||||
}
|
||||
case changeTagsTypes.attempt:
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
case changeTagsTypes.failure:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: true,
|
||||
}
|
||||
case changeTagsTypes.success:
|
||||
return {
|
||||
...state,
|
||||
pending: false,
|
||||
error: false,
|
||||
data: state.data.map(group =>
|
||||
group.id === action.response.studyGuidanceGroupId
|
||||
? {
|
||||
...state.data.find(group => group.id === action.response.studyGuidanceGroupId),
|
||||
tags: (({ studyGuidanceGroupId, ...rest }) => rest)(action.response), // omit id
|
||||
}
|
||||
: group
|
||||
),
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ export const getUsers = () => {
|
||||
return callController(route, prefix)
|
||||
}
|
||||
|
||||
export const modifyAccessGroups = (uid, accessgroups) => {
|
||||
export const modifyAccessGroups = (username, accessgroups) => {
|
||||
const route = '/users/modifyaccess'
|
||||
const prefix = 'MODIFY_ACCESSGROUPS_'
|
||||
const method = 'post'
|
||||
const data = { uid, accessgroups }
|
||||
const data = { username, accessgroups }
|
||||
return callController(route, prefix, data, method)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
require('express-async-errors')
|
||||
const express = require('express')
|
||||
const Sentry = require('@sentry/node')
|
||||
|
||||
const User = require('./src/services/users')
|
||||
const AccessGroup = require('./src/services/accessgroups')
|
||||
const FacultyProgrammes = require('./src/services/facultyprogrammes')
|
||||
const { initializeDatabaseConnection } = require('./src/database/connection')
|
||||
const initializeSentry = require('./src/util/sentry')
|
||||
const errorMiddleware = require('./src/middlewares/errorMiddleware')
|
||||
|
||||
initializeDatabaseConnection()
|
||||
.then(() => {
|
||||
@@ -26,12 +27,6 @@ initializeDatabaseConnection()
|
||||
res.json(users.map(User.getUserData))
|
||||
})
|
||||
|
||||
app.get('/user/:uid', async (req, res) => {
|
||||
const uid = req.params.uid
|
||||
const user = await User.byUsername(uid)
|
||||
res.json(User.getUserData(user))
|
||||
})
|
||||
|
||||
app.get('/user/:uid/user_data', async (req, res) => {
|
||||
const uid = req.params.uid
|
||||
const user = await User.byUsernameMinified(uid)
|
||||
@@ -40,28 +35,14 @@ initializeDatabaseConnection()
|
||||
const faculties = user.faculty.map(({ faculty_code }) => faculty_code)
|
||||
|
||||
res.json({
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
roles,
|
||||
rights,
|
||||
faculties,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/user/elementdetails/:username', async (req, res) => {
|
||||
const username = req.params.username
|
||||
const user = await User.byUsername(username)
|
||||
const programmes = User.getUserProgrammes(user)
|
||||
res.json(programmes)
|
||||
})
|
||||
|
||||
app.get('/user/id/:id', async (req, res) => {
|
||||
const id = req.params.id
|
||||
const user = await User.byId(id)
|
||||
|
||||
console.log(JSON.stringify(user))
|
||||
|
||||
res.json(User.getUserData(user))
|
||||
})
|
||||
|
||||
app.post('/login', async (req, res) => {
|
||||
const { uid, full_name, hyGroups, affiliations, email, hyPersonSisuId, hasStudyGuidanceGroupAccess } = req.body
|
||||
console.log(uid, full_name, 'logging in!')
|
||||
@@ -99,10 +80,10 @@ initializeDatabaseConnection()
|
||||
})
|
||||
|
||||
app.post('/modifyaccess', async (req, res) => {
|
||||
const { uid, accessgroups } = req.body
|
||||
const { username, accessgroups } = req.body
|
||||
try {
|
||||
await User.modifyRights(uid, accessgroups)
|
||||
const user = await User.byId(uid)
|
||||
await User.modifyRights(username, accessgroups)
|
||||
const user = await User.byUsername(username)
|
||||
res.status(200).json(User.getUserData(user))
|
||||
} catch (e) {
|
||||
res.status(400).json({ e })
|
||||
@@ -152,24 +133,7 @@ initializeDatabaseConnection()
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/get_roles/:user', async (req, res) => {
|
||||
const user = req.params.user
|
||||
try {
|
||||
const roles = await User.getRoles(user)
|
||||
res.status(200).json(roles)
|
||||
} catch (e) {
|
||||
res.status(400).json({ e })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/faculty_programmes', async (req, res) => {
|
||||
try {
|
||||
const facultyProgrammes = await FacultyProgrammes.findAll()
|
||||
res.status(200).json(facultyProgrammes)
|
||||
} catch (e) {
|
||||
res.status(400).json({ e })
|
||||
}
|
||||
})
|
||||
app.use(errorMiddleware)
|
||||
|
||||
const server = app.listen(port, () => console.log(`Userservice listening on port ${port}!`))
|
||||
process.on('SIGTERM', () => {
|
||||
|
||||
Generated
+5
@@ -2302,6 +2302,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"express-async-errors": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
|
||||
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng=="
|
||||
},
|
||||
"extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@sentry/node": "^6.11.0",
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
"express": "^4.17.1",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"moment": "^2.24.0",
|
||||
"pg": "^8.7.1",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.dropTable('faculty_programmes')
|
||||
},
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const Sentry = require('@sentry/node')
|
||||
const { ApplicationError } = require('../util/customErrors')
|
||||
|
||||
const errorHandler = (error, req, res, next) => {
|
||||
console.error(`${error.message} ${error.name} ${error.stack}`)
|
||||
console.error(`Body of failing request: ${JSON.stringify(req.body)}`)
|
||||
|
||||
// enrich with user details from req body
|
||||
Sentry.captureException({
|
||||
...error,
|
||||
reqBody: req.body,
|
||||
})
|
||||
|
||||
if (res.headersSent) {
|
||||
return next(error)
|
||||
}
|
||||
|
||||
const normalizedError = error instanceof ApplicationError ? error : new ApplicationError(error.message)
|
||||
|
||||
return res.status(normalizedError.status).json(normalizedError)
|
||||
}
|
||||
|
||||
module.exports = errorHandler
|
||||
@@ -59,22 +59,6 @@ const UserFaculties = sequelize.define('user_faculties', {
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
})
|
||||
const FacultyProgrammes = sequelize.define('faculty_programmes', {
|
||||
faculty_code: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
programme_code: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
},
|
||||
})
|
||||
|
||||
const User = sequelize.define(
|
||||
'users',
|
||||
@@ -154,11 +138,6 @@ User.belongsToMany(Affiliation, {
|
||||
Affiliation.belongsToMany(User, { through: 'user_affiliation' })
|
||||
|
||||
User.hasMany(UserFaculties, { as: 'faculty', foreignKey: 'userId' })
|
||||
UserFaculties.hasMany(FacultyProgrammes, {
|
||||
as: 'programme',
|
||||
foreignKey: 'faculty_code',
|
||||
sourceKey: 'faculty_code',
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
@@ -167,6 +146,5 @@ module.exports = {
|
||||
HyGroup,
|
||||
Affiliation,
|
||||
UserFaculties,
|
||||
FacultyProgrammes,
|
||||
sequelize,
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
const { sequelize, forceSyncDatabase } = require('../database/connection')
|
||||
const { FacultyProgrammes } = require('../models/index')
|
||||
const facultyProgrammeService = require('./facultyprogrammes')
|
||||
|
||||
const facultyprogrammes = [
|
||||
{
|
||||
faculty_code: 'H10',
|
||||
programme_code: 'MH10_001',
|
||||
},
|
||||
{
|
||||
faculty_code: 'H50',
|
||||
programme_code: 'MH50_003',
|
||||
},
|
||||
]
|
||||
|
||||
beforeAll(async () => {
|
||||
await forceSyncDatabase()
|
||||
FacultyProgrammes.bulkCreate(facultyprogrammes)
|
||||
})
|
||||
afterAll(async () => {
|
||||
await forceSyncDatabase()
|
||||
await sequelize.close()
|
||||
})
|
||||
|
||||
describe('after running migrations the faculties faculty table', () => {
|
||||
test('includes some correct faculty & programme codes', async () => {
|
||||
const faculties = await facultyProgrammeService.findAll()
|
||||
expect(faculties.some(f => f.faculty_code === 'H10' && f.programme_code === 'MH10_001')).toBe(true)
|
||||
expect(faculties.some(f => f.faculty_code === 'H50' && f.programme_code === 'MH50_003')).toBe(true)
|
||||
})
|
||||
|
||||
test('does not include incorrect faculty & programme codes', async () => {
|
||||
const faculties = await facultyProgrammeService.findAll()
|
||||
expect(faculties.some(f => f.faculty_code === 'KH4000')).toBe(false)
|
||||
expect(faculties.some(f => f.programme_code === 'SWAGLITTINEN')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
const { FacultyProgrammes } = require('../models/index')
|
||||
|
||||
const findAll = () => FacultyProgrammes.findAll()
|
||||
|
||||
module.exports = { findAll }
|
||||
@@ -2,17 +2,7 @@ const Sequelize = require('sequelize')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const _ = require('lodash')
|
||||
const moment = require('moment')
|
||||
const { flatMap } = require('lodash')
|
||||
const {
|
||||
User,
|
||||
UserElementDetails,
|
||||
AccessGroup,
|
||||
HyGroup,
|
||||
Affiliation,
|
||||
FacultyProgrammes,
|
||||
UserFaculties,
|
||||
sequelize,
|
||||
} = require('../models')
|
||||
const { User, UserElementDetails, AccessGroup, HyGroup, Affiliation, UserFaculties, sequelize } = require('../models')
|
||||
const AccessService = require('./accessgroups')
|
||||
const AffiliationService = require('./affiliations')
|
||||
const HyGroupService = require('./hygroups')
|
||||
@@ -46,7 +36,6 @@ const createMissingGroups = async (group, service) => {
|
||||
const savedGroups = await service.findAll()
|
||||
group.forEach(async code => {
|
||||
if (!savedGroups.map(sg => sg.code).includes(code)) {
|
||||
console.log(`Creating grp ${code}`)
|
||||
await service.create(code)
|
||||
}
|
||||
})
|
||||
@@ -97,7 +86,6 @@ const login = async (uid, full_name, hyGroups, affiliations, mail, hyPersonSisuI
|
||||
await createMissingGroups(affiliations, AffiliationService)
|
||||
|
||||
if (!user) {
|
||||
console.log('New user')
|
||||
user = await createUser(uid, full_name, mail, hyPersonSisuId, lastLogin)
|
||||
|
||||
const userHyGroups = await HyGroupService.byCodes(hyGroups)
|
||||
@@ -109,7 +97,6 @@ const login = async (uid, full_name, hyGroups, affiliations, mail, hyPersonSisuI
|
||||
isNew = true
|
||||
} else {
|
||||
user = await updateUser(user, { full_name, email: mail, sisu_person_id: hyPersonSisuId, last_login: lastLogin })
|
||||
user = await updateUser(user, { full_name, email: mail })
|
||||
await updateGroups(user, affiliations, hyGroups)
|
||||
}
|
||||
|
||||
@@ -152,10 +139,6 @@ const userIncludes = [
|
||||
separate: true,
|
||||
model: UserFaculties,
|
||||
as: 'faculty',
|
||||
include: {
|
||||
model: FacultyProgrammes,
|
||||
as: 'programme',
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Affiliation,
|
||||
@@ -194,11 +177,6 @@ const byUsernameMinified = async username => {
|
||||
separate: true,
|
||||
model: UserFaculties,
|
||||
as: 'faculty',
|
||||
include: {
|
||||
model: FacultyProgrammes,
|
||||
as: 'programme',
|
||||
attributes: ['programme_code'],
|
||||
},
|
||||
attributes: ['faculty_code'],
|
||||
},
|
||||
],
|
||||
@@ -245,13 +223,8 @@ const byId = async id => {
|
||||
return user
|
||||
}
|
||||
|
||||
const getUserProgrammes = user => {
|
||||
const elementdetails = [
|
||||
...user.programme.map(p => p.elementDetailCode),
|
||||
...flatMap(user.faculty, f => f.programme.map(p => p.programme_code)),
|
||||
]
|
||||
return elementdetails
|
||||
}
|
||||
const getUserProgrammes = user => user.programme.map(p => p.elementDetailCode)
|
||||
|
||||
const getUserAccessGroups = async username => {
|
||||
const user = await byUsername(username)
|
||||
return await user.getAccessgroup()
|
||||
@@ -287,8 +260,7 @@ const setFaculties = async (uid, faculties) => {
|
||||
})
|
||||
}
|
||||
|
||||
const modifyRights = async (uid, rights) => {
|
||||
console.log(uid, rights)
|
||||
const modifyRights = async (username, rights) => {
|
||||
const rightsToAdd = Object.entries(rights)
|
||||
.map(([code, val]) => {
|
||||
if (val === true) {
|
||||
@@ -304,7 +276,7 @@ const modifyRights = async (uid, rights) => {
|
||||
})
|
||||
.filter(code => code)
|
||||
|
||||
const user = await byId(uid)
|
||||
const user = await byUsername(username)
|
||||
const accessGroupsToAdd = await AccessService.byCodes(rightsToAdd)
|
||||
const accessGroupsToRemove = await AccessService.byCodes(rightsToRemove)
|
||||
|
||||
@@ -320,9 +292,9 @@ const determineAccessToCourseStats = async (user, hyGroups) => {
|
||||
const accessGroups = (user && user.accessgroup) || []
|
||||
const alreadyAccess = accessGroups.some(({ group_code }) => group_code === 'courseStatistics')
|
||||
if (hyGroups.includes(courseStatisticsGroup) && !alreadyAccess) {
|
||||
await modifyRights(user.id, { courseStatistics: true })
|
||||
await modifyRights(user.username, { courseStatistics: true })
|
||||
} else if (!hyGroups.includes(courseStatisticsGroup) && alreadyAccess) {
|
||||
await modifyRights(user.id, { courseStatistics: false })
|
||||
await modifyRights(user.username, { courseStatistics: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +302,7 @@ const determineAccessToTeachersForOne = async (user, hyGroups) => {
|
||||
const accessGroups = (user && user.accessgroup) || []
|
||||
const alreadyAccess = accessGroups.some(({ group_code }) => group_code === 'teachers')
|
||||
if (hyGroups.includes(hyOneGroup) && !alreadyAccess) {
|
||||
await modifyRights(user.id, { teachers: true })
|
||||
await modifyRights(user.username, { teachers: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,9 +310,9 @@ const determineAccessToStudyGuidanceGroups = async (user, hasStudyGuidanceGroupA
|
||||
const accessGroups = (user && user.accessgroup) || []
|
||||
const alreadyAccess = accessGroups.some(({ group_code }) => group_code === 'studyGuidanceGroups')
|
||||
if (hasStudyGuidanceGroupAccess && !alreadyAccess) {
|
||||
await modifyRights(user.id, { studyGuidanceGroups: true })
|
||||
await modifyRights(user.username, { studyGuidanceGroups: true })
|
||||
} else if (!hasStudyGuidanceGroupAccess && alreadyAccess) {
|
||||
await modifyRights(user.id, { studyGuidanceGroups: false })
|
||||
await modifyRights(user.username, { studyGuidanceGroups: false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class ApplicationError extends Error {
|
||||
constructor(message, status, extra) {
|
||||
super()
|
||||
|
||||
Error.captureStackTrace(this, this.constructor)
|
||||
|
||||
this.name = this.constructor.name
|
||||
|
||||
this.message = message || 'Something went wrong. Please try again.'
|
||||
|
||||
this.status = status || 500
|
||||
|
||||
this.extra = extra || {}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ApplicationError,
|
||||
}
|
||||
@@ -2,14 +2,12 @@ const winston = require('winston')
|
||||
const { isDev } = require('../config')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
|
||||
transports.push(new winston.transports.File({ filename: 'debug.log' }))
|
||||
let transports = []
|
||||
|
||||
if (isDev) {
|
||||
const devFormat = printf(({ level, message, timestamp, ...rest }) => {
|
||||
return `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
})
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
@@ -17,9 +15,7 @@ if (isDev) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDev) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -36,12 +32,13 @@ if (!isDev) {
|
||||
...rest,
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
}
|
||||
|
||||
@@ -2,16 +2,12 @@ const winston = require('winston')
|
||||
const { isDev } = require('../config')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
transports.push(new winston.transports.File({ filename: 'debug.log' }))
|
||||
}
|
||||
let transports = []
|
||||
|
||||
if (isDev) {
|
||||
const devFormat = printf(({ level, message, timestamp, ...rest }) => {
|
||||
return `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
})
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
@@ -19,9 +15,7 @@ if (isDev) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDev) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -38,12 +32,13 @@ if (!isDev) {
|
||||
...rest,
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user