mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +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 | ||
|
|
5f8c468ceb | ||
|
|
434d45987d | ||
|
|
0cb8acc46a | ||
|
|
6e996fbc4c | ||
|
|
86fbaaf1ca | ||
|
|
517c216a5d | ||
|
|
a04fb68aaa | ||
|
|
72c01fa345 | ||
|
|
62f1cc5459 | ||
|
|
c9eceaebe4 | ||
|
|
92e8709ef6 | ||
|
|
9c5c135d0f | ||
|
|
c4f65a1f29 | ||
|
|
96f1c9d9cf | ||
|
|
a8cfcb3371 | ||
|
|
63cbe523b1 | ||
|
|
7092fbbda0 | ||
|
|
82573c9d17 | ||
|
|
1b98b0a847 | ||
|
|
b73174f8a5 | ||
|
|
b1167eebf3 | ||
|
|
35391f9b36 | ||
|
|
4fde6c319a | ||
|
|
ebd1a98ead | ||
|
|
b3ef64fc2e | ||
|
|
7bbc3c0cb0 | ||
|
|
90abe7eb22 | ||
|
|
08dd4019af | ||
|
|
2841fd69a3 | ||
|
|
827d45c481 | ||
|
|
52ba8d582b | ||
|
|
d151521eae | ||
|
|
6188fe6fe4 | ||
|
|
1f0eb93174 | ||
|
|
f50e632cdb | ||
|
|
bbd83b501b | ||
|
|
48048e5ecf | ||
|
|
5be0a74a60 |
@@ -1,4 +1,3 @@
|
||||
# Populate and change this to .env
|
||||
IMPORTER_DB_API_TOKEN=
|
||||
IMPORTER_DB_API_URL=
|
||||
PATE_TOKEN=
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
environment: production
|
||||
set_commits: 'skip'
|
||||
sourcemaps: './build/static/js'
|
||||
url_prefix: '~/oodikone/static/js'
|
||||
url_prefix: '~/static/js'
|
||||
version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
|
||||
build_and_release_userservice:
|
||||
|
||||
@@ -545,7 +545,7 @@ jobs:
|
||||
environment: staging
|
||||
set_commits: 'skip'
|
||||
sourcemaps: './build/static/js'
|
||||
url_prefix: '~/oodikone/static/js'
|
||||
url_prefix: '~/static/js'
|
||||
version: ${{ github.sha }}
|
||||
- name: 'Push to docker hub'
|
||||
run: |
|
||||
|
||||
@@ -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' : ''
|
||||
@@ -89,5 +88,4 @@ module.exports = {
|
||||
frontUrl,
|
||||
backendPort,
|
||||
importerToken,
|
||||
importerUrl,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -191,6 +191,11 @@ const activeTeachers = async (providers, semestercodeStart, semestercodeEnd) =>
|
||||
},
|
||||
],
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
[Op.notLike]: '%hy-hlo-org%',
|
||||
},
|
||||
},
|
||||
})
|
||||
return teachers.map(({ id }) => id)
|
||||
}
|
||||
|
||||
@@ -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_V2'
|
||||
const REDIS_KEY_STATUS = 'STATUS_DATA_V5'
|
||||
|
||||
const getCurrentStudyYearStartDate = _.memoize(
|
||||
async unixMillis =>
|
||||
@@ -29,6 +30,140 @@ const getCurrentYearStartDate = () => {
|
||||
return new Date(new Date().getFullYear(), 0, 1)
|
||||
}
|
||||
|
||||
const getOrganizationStudents = async (start, end) => {
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
sre.code AS code,
|
||||
ARRAY_AGG(DISTINCT(sr.student_studentnumber)) AS students
|
||||
FROM studyright sr
|
||||
INNER JOIN studyright_elements sre ON sre.studyrightid = sr.studyrightid
|
||||
INNER JOIN element_details ed ON ed.code = sre.code
|
||||
WHERE ed.type = 20 AND (:start, :end) OVERLAPS (sr.startdate, sr.enddate)
|
||||
GROUP BY sre.code
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { start, end },
|
||||
}
|
||||
)
|
||||
|
||||
const codes = mapToProviders(rows.map(({ code }) => code))
|
||||
|
||||
const orgIdRows = await sequelize.query(
|
||||
`
|
||||
SELECT o.code, o.id FROM organization o WHERE o.code IN (:codes)
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { codes },
|
||||
}
|
||||
)
|
||||
|
||||
const orgIds = Object.fromEntries(orgIdRows.map(({ code, id }) => [code, id]))
|
||||
|
||||
return rows.reduce((acc, { code, students }) => {
|
||||
const [orgCode] = mapToProviders([code])
|
||||
const orgId = orgIds[orgCode]
|
||||
acc[orgId] = students
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const getOrganizationCredits = async (start, end) => {
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
cp.organizationcode AS code,
|
||||
-- ARRAY_AGG(DISTINCT(cr.student_studentnumber)) AS students,
|
||||
SUM(cr.credits) AS credits
|
||||
FROM credit cr
|
||||
INNER JOIN course co ON co.code = cr.course_code
|
||||
INNER JOIN course_providers cp ON cp.coursecode = co.id
|
||||
WHERE
|
||||
cr.attainment_date BETWEEN :start AND :end AND
|
||||
cr.credittypecode IN (4, 9) AND
|
||||
(cr."isStudyModule" = FALSE OR cr."isStudyModule" IS NULL)
|
||||
GROUP BY cp.organizationcode
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { start, end },
|
||||
}
|
||||
)
|
||||
|
||||
return rows.reduce((acc, { code, credits }) => {
|
||||
acc[code] = credits
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const getOrganizationStatistics = async (organizations, start, end) => {
|
||||
const orgCredits = await getOrganizationCredits(start, end)
|
||||
const orgStudents = await getOrganizationStudents(start, end)
|
||||
|
||||
const organizationStats = {}
|
||||
|
||||
let stack_pointer = 0
|
||||
const stack = ['hy-university-root-id']
|
||||
|
||||
while (stack_pointer < stack.length) {
|
||||
const orgId = stack[stack_pointer]
|
||||
stack_pointer += 1
|
||||
|
||||
organizations.filter(({ parent_id }) => parent_id === orgId).forEach(({ id }) => stack.push(id))
|
||||
}
|
||||
|
||||
const updateOrgStats = (orgId, stats, isDirect) => {
|
||||
if (organizationStats[orgId] === undefined) {
|
||||
organizationStats[orgId] = {
|
||||
direct: { credits: 0, students: new Set() },
|
||||
aggregated: { credits: 0, students: new Set() },
|
||||
}
|
||||
}
|
||||
|
||||
const orgStats = organizationStats[orgId]
|
||||
|
||||
const statsRefs = isDirect ? [orgStats.direct, orgStats.aggregated] : [orgStats.aggregated]
|
||||
|
||||
statsRefs.forEach(statsRef => {
|
||||
statsRef.credits += stats.credits
|
||||
stats.students.forEach(s => statsRef.students.add(s))
|
||||
})
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const orgId = stack.pop()
|
||||
const credits = orgCredits[orgId] ?? 0
|
||||
const students = orgStudents[orgId] ?? []
|
||||
|
||||
const org = organizations.find(({ id }) => id === orgId)
|
||||
const parentOrgId = org.parent_id
|
||||
|
||||
updateOrgStats(orgId, { credits, students }, true)
|
||||
|
||||
if (parentOrgId) {
|
||||
updateOrgStats(parentOrgId, organizationStats[orgId].aggregated, false)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(organizationStats).reduce((acc, [id, { direct, aggregated }]) => {
|
||||
acc[id] = {
|
||||
id,
|
||||
direct: {
|
||||
students: direct.students.size,
|
||||
credits: direct.credits,
|
||||
},
|
||||
aggregated: {
|
||||
students: aggregated.students.size,
|
||||
credits: aggregated.credits,
|
||||
},
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
|
||||
return sequelize.query(
|
||||
`
|
||||
@@ -43,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
|
||||
`,
|
||||
@@ -54,25 +189,40 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'su
|
||||
)
|
||||
}
|
||||
|
||||
const sumMerge = (a, b) => {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a + b
|
||||
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 mergele = (a, b) => {
|
||||
if (!a) return _.clone(b)
|
||||
if (!b) return _.clone(a)
|
||||
return _.mergeWith(a, b, sumMerge)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2') => {
|
||||
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
|
||||
@@ -83,6 +233,18 @@ const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum',
|
||||
)
|
||||
}
|
||||
|
||||
const getOrgStats = (stats, orgId) =>
|
||||
stats[orgId] ?? {
|
||||
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) => {
|
||||
const YEAR_TO_MILLISECONDS = 31556952000
|
||||
/* Memoize parses booleans into strings... */
|
||||
@@ -90,40 +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 [
|
||||
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 =>
|
||||
_.chain(yearlyOrgStats)
|
||||
.mapValues(stats => {
|
||||
const orgStats = getOrgStats(stats, orgId)
|
||||
|
||||
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) => {
|
||||
@@ -137,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] = {
|
||||
@@ -146,21 +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] = {}
|
||||
|
||||
@@ -172,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
|
||||
@@ -186,17 +475,47 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
/* Map providers into proper programmes and calculate programme level stats */
|
||||
const groupedByProgramme = Object.entries(coursesGroupedByProvider).reduce((acc, [organizationcode, courses]) => {
|
||||
const programme = providerToProgramme[organizationcode]
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
|
||||
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.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: _.mergeWith({}, ...yearlyValues, mergele),
|
||||
current: _.sumBy(courseValues, 'current'),
|
||||
previous: _.sumBy(courseValues, 'previous'),
|
||||
yearly,
|
||||
current,
|
||||
previous,
|
||||
currentStudents: currentStudents,
|
||||
previousStudents: previousStudents,
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
@@ -206,19 +525,46 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
if (!facultyCodes) return acc
|
||||
facultyCodes.forEach(facultyCode => {
|
||||
if (!facultyCode) return
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: {},
|
||||
current: 0,
|
||||
previous: 0,
|
||||
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,
|
||||
current,
|
||||
previous,
|
||||
currentStudents,
|
||||
previousStudents,
|
||||
}
|
||||
}
|
||||
|
||||
acc[facultyCode]['drill'][programmeCode] = programmeStats
|
||||
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
|
||||
acc[facultyCode]['current'] += programmeStats.current
|
||||
acc[facultyCode]['previous'] += programmeStats.previous
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
@@ -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,13 +1,10 @@
|
||||
const winston = require('winston')
|
||||
const { isProduction } = require('../conf-backend')
|
||||
|
||||
const { isDev } = require('../conf-backend')
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
let transports = []
|
||||
|
||||
transports.push(new winston.transports.File({ filename: 'debug.log' }))
|
||||
|
||||
if (!isProduction) {
|
||||
if (isDev) {
|
||||
const devFormat = printf(
|
||||
({ level, message, timestamp, ...rest }) => `${timestamp} ${level}: ${message} ${JSON.stringify(rest)}`
|
||||
)
|
||||
@@ -18,9 +15,7 @@ if (!isProduction) {
|
||||
format: combine(splat(), timestamp(), devFormat),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
} else {
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
@@ -43,4 +38,6 @@ if (isProduction) {
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
logger.on('error', e => console.error('Logging failed! Reason: ', e)) // eslint-disable-line no-console
|
||||
|
||||
module.exports = logger
|
||||
|
||||
Generated
+22581
-28
File diff suppressed because it is too large
Load Diff
@@ -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.8.6",
|
||||
"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**
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { chunk, isEqual } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { setMocking } from './index'
|
||||
|
||||
import { SEARCH_HISTORY_VERSION } from '../constants'
|
||||
import TSA from './tsa'
|
||||
@@ -276,3 +278,24 @@ export const useIsAdmin = () => {
|
||||
if (!roles) return false
|
||||
return roles.some(role => role.group_code === 'admin')
|
||||
}
|
||||
|
||||
// From: https://www.joshwcomeau.com/snippets/react-hooks/use-toggle/
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const toggle = useCallback(() => {
|
||||
setValue(v => !v)
|
||||
}, [])
|
||||
return [value, toggle]
|
||||
}
|
||||
|
||||
export const useShowAsUser = () => {
|
||||
const history = useHistory()
|
||||
|
||||
const showAsUser = uid => {
|
||||
setMocking(uid)
|
||||
history.push('/')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return showAsUser
|
||||
}
|
||||
|
||||
@@ -12,11 +12,6 @@ export const setMocking = userid =>
|
||||
userid ? localStorage.setItem(MOCK_USERID, userid) : localStorage.removeItem(MOCK_USERID)
|
||||
export const getMocked = () => localStorage.getItem(MOCK_USERID)
|
||||
|
||||
export const SIS_WARNING_FLAG = 'SIS_WARNING_FLAG'
|
||||
export const setHideSisWarningFlag = booleanFlag =>
|
||||
booleanFlag ? localStorage.setItem(SIS_WARNING_FLAG, booleanFlag) : localStorage.removeItem(SIS_WARNING_FLAG)
|
||||
export const getHideSisWarningFlag = () => localStorage.getItem(SIS_WARNING_FLAG)
|
||||
|
||||
export const textAndDescriptionSearch = (dropDownOptions, param) =>
|
||||
filter(dropDownOptions, option =>
|
||||
option.text
|
||||
@@ -228,3 +223,41 @@ export const validateInputLength = (input, minLength) => input && input.trim().l
|
||||
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',
|
||||
'KH50_003',
|
||||
'KH50_004',
|
||||
'KH50_005',
|
||||
'KH50_006',
|
||||
'KH50_007',
|
||||
'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'],
|
||||
KH50_003: ['KEK100'],
|
||||
KH50_004: ['MAT110', 'FYS1100', 'FYS1200', 'KEK100'],
|
||||
KH50_005: ['TKT1'],
|
||||
KH50_006: ['GEOK_100'],
|
||||
KH50_007: ['MAA-100'],
|
||||
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'],
|
||||
KH50_003: ['KEK200'],
|
||||
KH50_004: ['MFK-M200', 'MFK-F200', 'MFK-K200'],
|
||||
KH50_005: ['TKT2'],
|
||||
KH50_006: ['GEOK_200'],
|
||||
KH50_007: ['MAA-200'],
|
||||
KH50_008: ['BSCH2000', 'BSCS2000', 'BSMA2000', 'BSMS2000', 'BSPH2000'],
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { Switch, Route, Link, useRouteMatch } from 'react-router-dom'
|
||||
|
||||
@@ -32,20 +31,10 @@ const NavTile = ({ to, title, imageSrc }) => {
|
||||
)
|
||||
}
|
||||
|
||||
NavTile.propTypes = {
|
||||
to: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
imageSrc: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
const NavTiles = ({ children }) => {
|
||||
return <div className="cool-ds-nav-tiles">{children}</div>
|
||||
}
|
||||
|
||||
NavTiles.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
const baseURI = '/cool-data-science'
|
||||
|
||||
const CoolDataScience = () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes, { shape, bool, func } from 'prop-types'
|
||||
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'
|
||||
@@ -28,31 +28,104 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
}
|
||||
|
||||
const StatusContainer = ({
|
||||
title,
|
||||
current,
|
||||
previous,
|
||||
clickable,
|
||||
handleClick,
|
||||
min1,
|
||||
max1,
|
||||
showYearlyValues,
|
||||
yearlyValues,
|
||||
showByYear,
|
||||
}) => {
|
||||
const diff = Math.round(current - previous)
|
||||
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
|
||||
|
||||
let current
|
||||
let previous
|
||||
|
||||
if (!stats.drill) {
|
||||
current = stats.currentStudents
|
||||
previous = stats.previousStudents
|
||||
} else {
|
||||
current = stats.current
|
||||
previous = stats.previous
|
||||
|
||||
if (showRelativeValues) {
|
||||
current /= stats.currentStudents
|
||||
previous /= stats.previousStudents
|
||||
}
|
||||
}
|
||||
|
||||
const diff = current - previous
|
||||
const p = getP(current, previous)
|
||||
const change = Math.round((p - 1) * 1000) / 10
|
||||
const change = p - 1
|
||||
|
||||
const getColor = v => {
|
||||
if (v > 2.5) return '#6ab04c'
|
||||
if (v < -2.5) return '#ff7979'
|
||||
if (v > 0.025) return '#6ab04c'
|
||||
if (v < -0.025) return '#ff7979'
|
||||
return '#7B9FCF'
|
||||
}
|
||||
|
||||
const plussify = x => {
|
||||
if (x > 0) return `+${x.toLocaleString('fi')}`
|
||||
return x.toLocaleString('fi')
|
||||
const plussify = (x, decimals = 0) =>
|
||||
x.toLocaleString('fi', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
signDisplay: 'always',
|
||||
})
|
||||
|
||||
const getDisplayValue = (value, denominator) => {
|
||||
let displayValue
|
||||
|
||||
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 (
|
||||
@@ -80,225 +153,86 @@ const StatusContainer = ({
|
||||
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, min1, max1, -45, 45)}deg)`,
|
||||
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
|
||||
transitionProperty: 'transform',
|
||||
transitionDuration: '200ms',
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>{plussify(diff)}</span>
|
||||
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>
|
||||
{plussify(diff, showRelativeValues ? 2 : 0)}
|
||||
</span>
|
||||
<br />
|
||||
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change * 100, 1)}%)</span>
|
||||
<br />
|
||||
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
{showYearlyValues && (
|
||||
<div style={{ marginTop: '10px', textAlign: 'start' }}>
|
||||
{_.orderBy(Object.entries(yearlyValues), ([y]) => y, ['desc']).map(
|
||||
([year, { acc, total, accStudents, totalStudents }]) => {
|
||||
return (
|
||||
<div style={{ margin: '5px 0' }} key={`${title}-${year}`}>
|
||||
<span>
|
||||
<b>
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>{' '}
|
||||
{/* render num of students instead of credits if no credits are given from the course */}
|
||||
{accStudents <= acc ? Math.round(acc).toLocaleString('fi') : accStudents}
|
||||
{/* if no total students add students after accStudents */}
|
||||
{acc <= accStudents && totalStudents < 1 && ' students'}
|
||||
{/* render total if there are credits or students for either */}
|
||||
{(total > 0 || totalStudents > 0) &&
|
||||
` / ${
|
||||
// same logic as before to check if render num of students or credits
|
||||
totalStudents <= total && total > 0
|
||||
? Math.round(total).toLocaleString('fi')
|
||||
: `${totalStudents} students`
|
||||
}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
<div className={`years item-type-${stats.type}`}>
|
||||
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
|
||||
return (
|
||||
<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 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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
StatusContainer.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
current: PropTypes.number.isRequired,
|
||||
previous: PropTypes.number.isRequired,
|
||||
clickable: PropTypes.bool.isRequired,
|
||||
handleClick: PropTypes.func.isRequired,
|
||||
min1: PropTypes.number.isRequired,
|
||||
max1: PropTypes.number.isRequired,
|
||||
showYearlyValues: PropTypes.bool.isRequired,
|
||||
yearlyValues: PropTypes.shape({}).isRequired,
|
||||
showByYear: PropTypes.bool.isRequired,
|
||||
}
|
||||
const StatusContent = ({ data, settings, onDrill }) => {
|
||||
const { showByYear, showRelativeValues, showYearlyValues } = settings
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
|
||||
const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', 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 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 />
|
||||
<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, previous }) => {
|
||||
return Math.abs(Math.floor((getP(current, 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>Uusien ohjelmien 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',
|
||||
@@ -309,54 +243,376 @@ 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)
|
||||
// check if the course has credits or not (if credits is zero but there are students who have completed it)
|
||||
const current =
|
||||
!stats.drill && stats.current === 0 && stats.currentStudents > 1 ? stats.currentStudents : stats.current
|
||||
const previous =
|
||||
!stats.drill && stats.previous === 0 && stats.previousStudents > 1 ? stats.previousStudents : stats.previous
|
||||
).map(stats => {
|
||||
const handleClick = () => onDrill(stats.code)
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
clickable={!!stats.drill}
|
||||
key={stats.code}
|
||||
stats={stats}
|
||||
handleClick={handleClick}
|
||||
title={getTextIn(stats.name)}
|
||||
current={current}
|
||||
previous={previous}
|
||||
showRelativeValues={showRelativeValues}
|
||||
showYearlyValues={showYearlyValues}
|
||||
min1={-medianDiff * 2}
|
||||
max1={medianDiff * 2}
|
||||
yearlyValues={stats.yearly}
|
||||
showByYear={showByYear}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{renderSettings()}
|
||||
<Message>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</Message>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Status.propTypes = {
|
||||
data: shape({}).isRequired,
|
||||
loading: bool.isRequired,
|
||||
getStatusDispatch: func.isRequired,
|
||||
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 mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.status,
|
||||
loading: coolDataScience.pending.status,
|
||||
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
|
||||
|
||||
@@ -309,7 +309,7 @@ const Status = ({ getStatusGraduatedDispatch, data, loading }) => {
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
return (
|
||||
<>
|
||||
<h2>Uusista ohjelmista valmistuneet</h2>
|
||||
<h2>Koulutusohjelmista valmistuneet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
|
||||
@@ -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,6 +10,47 @@
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
|
||||
.years {
|
||||
margin-top: 10px;
|
||||
text-align: start;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, fit-content(0));
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.year-row {
|
||||
position: relative;
|
||||
padding-left: 0.5em;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.year-row .unit {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
.year-row .separator {
|
||||
color: #aaa;
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
|
||||
.status-date-time-input {
|
||||
padding-right: 0;
|
||||
margin-left: 5px;
|
||||
|
||||
@@ -21,6 +21,7 @@ import infotooltips from '../../common/InfoToolTips'
|
||||
import InfoBox from '../InfoBox'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import useProgrammeFilter from '../FilterTray/filters/Programmes/useProgrammeFilter'
|
||||
import { CoursePopulationFilters } from '../FilterTray/FilterSets'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import { queryParamsFromUrl } from '../../common/query'
|
||||
@@ -39,6 +40,7 @@ const CoursePopulation = ({
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const { setCourseCodes } = useProgrammeFilter()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
|
||||
const [codes, setCodes] = useState([])
|
||||
@@ -106,6 +108,7 @@ const CoursePopulation = ({
|
||||
separate,
|
||||
})
|
||||
setCodes(parsedCourseCodes)
|
||||
setCourseCodes(parsedCourseCodes)
|
||||
if (years) {
|
||||
setYears(years)
|
||||
} else {
|
||||
|
||||
@@ -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'
|
||||
@@ -27,36 +26,29 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
studentCreditLines: [],
|
||||
options: [],
|
||||
updateGraph: false,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { students } = this.props
|
||||
this.getMoreCreditLines(students)
|
||||
const { students, selectedStudents, currentGraphSize, render } = this.props
|
||||
|
||||
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.students) {
|
||||
const nextStudents = nextProps.students.map(student => student.studentNumber)
|
||||
const oldStudents = this.props.students.map(student => student.studentNumber)
|
||||
const nextMinDate = nextProps.students.minDate
|
||||
const oldMinDate = this.props.students.minDate
|
||||
const clear = nextMinDate !== oldMinDate
|
||||
const changedStudentAmount = nextProps.selectedStudents.length !== this.props.selectedStudents.length
|
||||
const changedGraphSize = nextProps.currentGraphSize !== this.props.currentGraphSize
|
||||
|
||||
const changed =
|
||||
nextStudents.some(student => !oldStudents.includes(student)) ||
|
||||
oldStudents.some(student => !nextStudents.includes(student)) ||
|
||||
clear
|
||||
oldStudents.some(student => !nextStudents.includes(student))
|
||||
|
||||
if (changed) {
|
||||
const { students } = nextProps
|
||||
this.getMoreCreditLines(students, clear)
|
||||
} else if (changedStudentAmount || changedGraphSize) {
|
||||
if (changed || changedStudentAmount || changedGraphSize) {
|
||||
this.createGraphOptions(
|
||||
nextProps.students,
|
||||
nextProps.selectedStudents,
|
||||
@@ -88,19 +80,6 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
getMoreCreditLines = (students, clear) => {
|
||||
if (clear) {
|
||||
// eslint-disable-next-line react/no-access-state-in-setstate
|
||||
const removed = this.state.studentCreditLines.splice(0, 0)
|
||||
const studentCreditLines = removed.concat(this.createStudentCreditLines(students))
|
||||
this.setState({ studentCreditLines, updateGraph: true })
|
||||
} else {
|
||||
// eslint-disable-next-line react/no-access-state-in-setstate
|
||||
const studentCreditLines = this.state.studentCreditLines.concat(this.createStudentCreditLines(students))
|
||||
this.setState({ studentCreditLines, updateGraph: true })
|
||||
}
|
||||
}
|
||||
|
||||
getXAxisMonth = (date, startDate) =>
|
||||
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
|
||||
|
||||
@@ -134,7 +113,10 @@ class CreditAccumulationGraphHighCharts extends Component {
|
||||
}
|
||||
|
||||
createGraphOptions = (students, selectedStudents, graphSize, render) => {
|
||||
const dataOfSelected = this.state.studentCreditLines.filter(line => selectedStudents.includes(line.name))
|
||||
const studentCreditLines = this.createStudentCreditLines(students)
|
||||
|
||||
const dataOfSelected = studentCreditLines.filter(line => selectedStudents.includes(line.name))
|
||||
|
||||
let lastCredits = null
|
||||
if (this.isSingleStudentGraph() && render) {
|
||||
const started = moment(students.minDate)
|
||||
@@ -237,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,
|
||||
@@ -325,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,
|
||||
|
||||
+27
-4
@@ -5,6 +5,8 @@ import { arrayOf, string, shape } from 'prop-types'
|
||||
import SearchResultTable from '../../SearchResultTable'
|
||||
import { getNewestProgramme, getTextIn } from '../../../common'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import useProgrammeFilter from '../../FilterTray/filters/Programmes/useProgrammeFilter'
|
||||
import FilterToggleIcon from '../../FilterToggleIcon'
|
||||
|
||||
const CustomPopulationProgrammeDist = ({
|
||||
samples,
|
||||
@@ -13,7 +15,9 @@ const CustomPopulationProgrammeDist = ({
|
||||
populationStatistics,
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const { selectedProgrammes, toggleFilterProgramme } = useProgrammeFilter()
|
||||
const [tableRows, setRows] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(populationStatistics).length > 0) {
|
||||
const allProgrammes = {}
|
||||
@@ -41,7 +45,8 @@ const CustomPopulationProgrammeDist = ({
|
||||
}
|
||||
})
|
||||
const rows = Object.entries(allProgrammes).map(([code, { programme, students }]) => [
|
||||
`${getTextIn(programme.name, language)}, ${code}`,
|
||||
getTextIn(programme.name, language),
|
||||
code,
|
||||
students.length,
|
||||
<Progress
|
||||
style={{ margin: '0px' }}
|
||||
@@ -49,14 +54,32 @@ const CustomPopulationProgrammeDist = ({
|
||||
progress
|
||||
/>,
|
||||
])
|
||||
const sortedRows = rows.sort((a, b) => b[1] - a[1])
|
||||
const sortedRows = rows.sort((a, b) => b[2] - a[2])
|
||||
setRows(sortedRows)
|
||||
}
|
||||
}, [selectedStudents])
|
||||
|
||||
const headers = ['Programmes', `Students (all=${selectedStudents.length})`, 'Percentage of population']
|
||||
const headers = ['Programme', 'Code', `Students (all=${selectedStudents.length})`, 'Percentage of population']
|
||||
|
||||
return <SearchResultTable headers={headers} rows={tableRows} selectable noResultText="placeholder" />
|
||||
const handleFilterToggle = code => {
|
||||
toggleFilterProgramme(code)
|
||||
}
|
||||
|
||||
const isProgrammeFilterActive = code => selectedProgrammes.find(p => p.code === code) !== undefined
|
||||
|
||||
return (
|
||||
<SearchResultTable
|
||||
headers={headers}
|
||||
rows={tableRows}
|
||||
selectable
|
||||
noResultText="placeholder"
|
||||
actionTrigger={row => (
|
||||
<span style={{ display: 'inline-block', marginRight: '0.3em' }}>
|
||||
<FilterToggleIcon onClick={() => handleFilterToggle(row[1])} isActive={isProgrammeFilterActive(row[1])} />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CustomPopulationProgrammeDist.defaultProps = {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import React, { Component } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { Radio, Icon, Header, Segment, Loader, Popup } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { func, shape, string, bool, arrayOf, number } from 'prop-types'
|
||||
import { getUsers } from '../../redux/users'
|
||||
import { getUnits } from '../../redux/units'
|
||||
import { getElementDetails } from '../../redux/elementdetails'
|
||||
import { makeSortUsers } from '../../selectors/users'
|
||||
import { copyToClipboard } from '../../common'
|
||||
import UserPageNew from '../UserPage'
|
||||
import UserSearchList from './UserSearchList'
|
||||
|
||||
class EnableUsers extends Component {
|
||||
state = {
|
||||
enabledOnly: true,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.elementdetails.length === 0) this.props.getElementDetails()
|
||||
if (this.props.units.length === 0) this.props.getUnits()
|
||||
if (this.props.users.length === 0) this.props.getUsers()
|
||||
document.title = 'Users - Oodikone'
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
clearTimeout(this.popupTimeout)
|
||||
}
|
||||
|
||||
toggleEnabledOnly() {
|
||||
if (this.props.enabledOnly) this.props.getUsers()
|
||||
const { enabledOnly } = this.state
|
||||
this.setState({ enabledOnly: !enabledOnly })
|
||||
}
|
||||
|
||||
openUsersPage = () => {
|
||||
const { history } = this.props
|
||||
history.push('/users')
|
||||
}
|
||||
|
||||
copyEmailsToClippoard = () => {
|
||||
const clipboardString = this.props.users
|
||||
.filter(u => u.is_enabled && u.email)
|
||||
.map(u => u.email)
|
||||
.join('; ')
|
||||
copyToClipboard(clipboardString)
|
||||
}
|
||||
|
||||
renderUserPage = userid => {
|
||||
const { users } = this.props
|
||||
const user = users.find(u => u.id === userid)
|
||||
return !user ? <Loader active /> : <UserPageNew userid={userid} user={user} goBack={this.openUsersPage} />
|
||||
}
|
||||
|
||||
handlePopupOpen = () => {
|
||||
this.setState({ popupOpen: true })
|
||||
this.popupTimeout = setTimeout(() => {
|
||||
this.setState({ popupOpen: false })
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
handlePopupClose = () => {
|
||||
this.popupTimeout = null
|
||||
}
|
||||
|
||||
render() {
|
||||
const { match, pending, users, error, elementdetails } = this.props
|
||||
const { enabledOnly } = this.state
|
||||
const { userid } = match.params
|
||||
return (
|
||||
<div style={{ marginBottom: '10px' }} className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Oodikone users
|
||||
</Header>
|
||||
<Radio
|
||||
label={`Showing ${enabledOnly ? 'only enabled' : 'all'} users`}
|
||||
toggle
|
||||
onClick={() => this.toggleEnabledOnly()}
|
||||
/>
|
||||
<Segment loading={pending} className="contentSegment">
|
||||
{!userid ? (
|
||||
<UserSearchList enabledOnly={enabledOnly} users={users} error={error} elementdetails={elementdetails} />
|
||||
) : (
|
||||
this.renderUserPage(userid)
|
||||
)}
|
||||
</Segment>
|
||||
<Popup
|
||||
trigger={<Icon link name="envelope" onClick={this.copyEmailsToClippoard} style={{ float: 'right' }} />}
|
||||
content="Copied email(s)!"
|
||||
on="click"
|
||||
onOpen={this.handlePopupOpen}
|
||||
onClose={this.handlePopupClose}
|
||||
open={this.state.popupOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
EnableUsers.propTypes = {
|
||||
match: shape({
|
||||
params: shape({
|
||||
studentNumber: string,
|
||||
}),
|
||||
}).isRequired,
|
||||
getUsers: func.isRequired,
|
||||
pending: bool.isRequired,
|
||||
getUnits: func.isRequired,
|
||||
enabledOnly: bool.isRequired,
|
||||
users: arrayOf(
|
||||
shape({
|
||||
id: string,
|
||||
full_name: string,
|
||||
is_enabled: bool,
|
||||
username: string,
|
||||
units: arrayOf(
|
||||
shape({
|
||||
id: string,
|
||||
name: shape({}).isRequired,
|
||||
})
|
||||
),
|
||||
})
|
||||
).isRequired,
|
||||
error: bool.isRequired,
|
||||
units: arrayOf(shape({})).isRequired,
|
||||
elementdetails: arrayOf(shape({ code: string, type: number, name: shape({}) })).isRequired,
|
||||
getElementDetails: func.isRequired,
|
||||
history: shape({}).isRequired,
|
||||
}
|
||||
|
||||
const sortUsers = makeSortUsers()
|
||||
|
||||
const mapStateToProps = ({ users, units, elementdetails }) => ({
|
||||
units: units.data,
|
||||
elementdetails: elementdetails.data,
|
||||
enabledOnly: users.enabledOnly,
|
||||
users: sortUsers(users),
|
||||
pending: typeof users.pending === 'boolean' ? users.pending : true,
|
||||
error: users.error || false,
|
||||
})
|
||||
|
||||
export default withRouter(
|
||||
connect(mapStateToProps, {
|
||||
getUsers,
|
||||
getUnits,
|
||||
getElementDetails,
|
||||
})(EnableUsers)
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
.flexIcon {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,24 @@ import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
|
||||
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
|
||||
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
|
||||
import { FilterTrayProvider } from './useFilterTray'
|
||||
import { ProgrammeFilterProvider } from './filters/Programmes/useProgrammeFilter'
|
||||
import { FilterProvider } from './useFilters'
|
||||
import { FilterAnalyticsProvider } from './useAnalytics'
|
||||
import { GradeFilterProvider } from './filters/Grade/useGradeFilter'
|
||||
|
||||
const FilterContextProvider = ({ children }) => (
|
||||
<FilterAnalyticsProvider>
|
||||
<FilterTrayProvider>
|
||||
<FilterProvider>
|
||||
<CourseFilterProvider>
|
||||
<CreditFilterProvider>
|
||||
<AgeFilterProvider>
|
||||
<GradeFilterProvider>{children}</GradeFilterProvider>
|
||||
</AgeFilterProvider>
|
||||
</CreditFilterProvider>
|
||||
</CourseFilterProvider>
|
||||
</FilterProvider>
|
||||
</FilterTrayProvider>
|
||||
</FilterAnalyticsProvider>
|
||||
)
|
||||
const filterProviders = [
|
||||
FilterAnalyticsProvider,
|
||||
FilterTrayProvider,
|
||||
FilterProvider,
|
||||
CourseFilterProvider,
|
||||
CreditFilterProvider,
|
||||
AgeFilterProvider,
|
||||
GradeFilterProvider,
|
||||
ProgrammeFilterProvider,
|
||||
]
|
||||
|
||||
const FilterContextProvider = ({ children }) =>
|
||||
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
|
||||
|
||||
FilterContextProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
|
||||
@@ -6,6 +6,7 @@ import CreditsEarned from './filters/CreditsEarned'
|
||||
import Gender from './filters/Gender'
|
||||
import StartYearAtUni from './filters/StartYearAtUni'
|
||||
import Courses from './filters/Courses'
|
||||
import Programmes from './filters/Programmes'
|
||||
import useAnalytics from './useAnalytics'
|
||||
import Grade from './filters/Grade'
|
||||
import Date from './filters/Date'
|
||||
@@ -48,6 +49,8 @@ export const CoursePopulationFilters = () => {
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<Courses />
|
||||
<Programmes />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import FilterCard from '../common/FilterCard'
|
||||
import { getTextIn } from '../../../../common'
|
||||
import useLanguage from '../../../LanguagePicker/useLanguage'
|
||||
import useProgrammeFilter from './useProgrammeFilter'
|
||||
import useFilters from '../../useFilters'
|
||||
|
||||
const Programme = () => {
|
||||
const { programmes, selectedProgrammes, studentToProgrammeMap, selectFilterProgramme, removeFilterProgramme } =
|
||||
useProgrammeFilter()
|
||||
const { removeFilter, addFilter } = useFilters()
|
||||
const { language } = useLanguage()
|
||||
const name = 'programmeFilter'
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
programmes
|
||||
.map(program => ({
|
||||
key: `programme-filter-value-${program.code}`,
|
||||
text: getTextIn(program.name, language),
|
||||
value: program.code,
|
||||
}))
|
||||
.sort((a, b) => a.text.localeCompare(b.text)),
|
||||
[programmes, language]
|
||||
)
|
||||
|
||||
const handleChange = (_, { value }) => {
|
||||
value.filter(code => selectedProgrammes.findIndex(p => p.code === code) === -1).forEach(selectFilterProgramme)
|
||||
|
||||
selectedProgrammes.filter(({ code }) => value.indexOf(code) === -1).forEach(removeFilterProgramme)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProgrammes.length === 0) {
|
||||
removeFilter(name)
|
||||
} else {
|
||||
addFilter(name, ({ studentNumber }) => {
|
||||
return selectedProgrammes.findIndex(p => p.code === studentToProgrammeMap[studentNumber]) !== -1
|
||||
})
|
||||
}
|
||||
}, [selectedProgrammes, studentToProgrammeMap])
|
||||
|
||||
return (
|
||||
<FilterCard title="Programme" active={selectedProgrammes.length > 0} className="programmes-filter" name={name}>
|
||||
<Dropdown
|
||||
options={options}
|
||||
placeholder="Select Programme"
|
||||
onChange={handleChange}
|
||||
button
|
||||
value={selectedProgrammes.map(p => p.code)}
|
||||
name={name}
|
||||
closeOnChange
|
||||
multiple
|
||||
/>
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Programme
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import React, { useState, createContext, useContext, useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { getStudentToTargetCourseDateMap, getNewestProgramme } from '../../../../common'
|
||||
import useFilters from '../../useFilters'
|
||||
|
||||
const ProgrammeFilterContext = createContext([[], () => {}])
|
||||
ProgrammeFilterContext.displayName = 'Programme Filter'
|
||||
|
||||
const defaultState = {
|
||||
selectedProgrammes: [],
|
||||
}
|
||||
|
||||
export const ProgrammeFilterProvider = ({ children }) => {
|
||||
const [state, setState] = useState(defaultState)
|
||||
const [courseCodes, setCourseCodes] = useState([])
|
||||
|
||||
const { selectedProgrammes } = state
|
||||
|
||||
const setSelectedProgrammes = programmes => {
|
||||
setState({
|
||||
selectedProgrammes: programmes,
|
||||
})
|
||||
}
|
||||
|
||||
const context = {
|
||||
selectedProgrammes,
|
||||
setSelectedProgrammes,
|
||||
courseCodes,
|
||||
setCourseCodes,
|
||||
}
|
||||
|
||||
return <ProgrammeFilterContext.Provider value={context}>{children}</ProgrammeFilterContext.Provider>
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const { selectedProgrammes, courseCodes, setCourseCodes, setSelectedProgrammes } = useContext(ProgrammeFilterContext)
|
||||
|
||||
const { allStudents } = useFilters()
|
||||
|
||||
const studentToTargetCourseDateMap = useMemo(
|
||||
() => getStudentToTargetCourseDateMap(allStudents, courseCodes),
|
||||
[allStudents]
|
||||
)
|
||||
|
||||
const elementDetails = useSelector(state => state?.populations?.data?.elementdetails?.data)
|
||||
|
||||
const { programmes, studentToProgrammeMap } = useMemo(() => {
|
||||
if (!elementDetails) {
|
||||
return {
|
||||
programmes: [],
|
||||
studentToProgrammeMap: {},
|
||||
}
|
||||
}
|
||||
|
||||
const programmeMap = {}
|
||||
const studentToProgrammeMap = {}
|
||||
|
||||
allStudents
|
||||
.map(student => [
|
||||
student,
|
||||
getNewestProgramme(student.studyrights, student.studentNumber, studentToTargetCourseDateMap, elementDetails),
|
||||
])
|
||||
.forEach(([{ studentNumber }, programme]) => {
|
||||
programmeMap[programme.code] = programme
|
||||
studentToProgrammeMap[studentNumber] = programme.code
|
||||
})
|
||||
|
||||
return {
|
||||
programmes: Object.values(programmeMap),
|
||||
studentToProgrammeMap,
|
||||
}
|
||||
}, [allStudents, elementDetails])
|
||||
|
||||
const selectFilterProgramme = code => {
|
||||
const index = selectedProgrammes.findIndex(p => p.code === code)
|
||||
|
||||
if (index !== -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const programme = programmes.find(p => p.code === code)
|
||||
|
||||
if (programme === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedProgrammes([...selectedProgrammes, programme])
|
||||
}
|
||||
|
||||
const removeFilterProgramme = code => {
|
||||
const index = selectedProgrammes.findIndex(p => p.code === code)
|
||||
const newSelection = [...selectedProgrammes]
|
||||
newSelection.splice(index, 1)
|
||||
setSelectedProgrammes(newSelection)
|
||||
}
|
||||
|
||||
const toggleFilterProgramme = code => {
|
||||
const index = selectedProgrammes.findIndex(p => p.code === code)
|
||||
|
||||
if (index === -1) {
|
||||
const programme = programmes.find(p => p.code === code)
|
||||
setSelectedProgrammes([...selectedProgrammes, programme])
|
||||
} else {
|
||||
const newSelection = [...selectedProgrammes]
|
||||
newSelection.splice(index, 1)
|
||||
setSelectedProgrammes(newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
programmes,
|
||||
selectedProgrammes,
|
||||
toggleFilterProgramme,
|
||||
selectFilterProgramme,
|
||||
removeFilterProgramme,
|
||||
setCourseCodes,
|
||||
studentToProgrammeMap,
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
|
||||
{title}
|
||||
</Header>
|
||||
</div>
|
||||
{open && children}
|
||||
{(active || open) && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { shape, string } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import NavigationBar from '../NavigationBar'
|
||||
import './header.css'
|
||||
@@ -10,10 +9,4 @@ const Header = props => (
|
||||
</header>
|
||||
)
|
||||
|
||||
Header.propTypes = {
|
||||
location: shape({
|
||||
pathname: string.isRequired,
|
||||
}).isRequired,
|
||||
}
|
||||
|
||||
export default withRouter(Header)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Message } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { isEqual } from 'lodash'
|
||||
import { getHideSisWarningFlag, setHideSisWarningFlag, getUserRoles } from '../../common'
|
||||
|
||||
const SisChangeMessage = props => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { userRoles, rights } = props
|
||||
|
||||
const handleDismiss = () => {
|
||||
setOpen(false)
|
||||
setHideSisWarningFlag(true)
|
||||
}
|
||||
|
||||
if (!open || getHideSisWarningFlag()) return null
|
||||
|
||||
const reasons = [
|
||||
'Osalla opiskelijoista voi olla opintopisteiden kokonaismäärässä eroja. Mahdollinen eroavaisuus on merkitty kyseisen opiskelijan sivulla. Tämä koskee erityisesti tiedekuntia, joissa on runsaasti osasuorituksia.',
|
||||
'Populaatioiden osalta vaihto-opiskelijoiden, tutkintoon johtamattomien opinto-oikeuksien ja ohjelmaan siirrettyjen opiskelijoiden näyttäminen ei kaikissa tapauksissa toimi oikein.',
|
||||
'Trendien osalta ainoastaan "See current status" -sivu toimii, muut eivät näytä vuositahteja oikein.',
|
||||
]
|
||||
|
||||
// Kasvatustiedehuomiot
|
||||
if (
|
||||
rights.includes('KH60_001') ||
|
||||
rights.includes('MH60_001') ||
|
||||
rights.includes('MH60_002') ||
|
||||
userRoles.includes('admin')
|
||||
) {
|
||||
reasons.push('Osalla Varhaiskasvatuksen opettaja -tutkintosuunnan opiskelijoista on virheellisiä opinto-oikeuksia.')
|
||||
}
|
||||
|
||||
// Oikeustiedehuomiot
|
||||
if (rights.includes('KH20_001') || rights.includes('MH20_001') || userRoles.includes('admin')) {
|
||||
reasons.push(
|
||||
'Oikeustieteellisen tiedekunnan tuottamien opintopisteiden kokonaismäärä on Trends-osiossa liian alhainen.'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Message onDismiss={handleDismiss} info size="huge">
|
||||
<Message.Header>Oodikone käyttää nyt Sisua</Message.Header>
|
||||
<p>
|
||||
Opiskelijoiden tiedot saattavat siirtymän myötä poiketa joiltain osin aiemmasta. Erityisesti siirtymän tiedetään
|
||||
vaikuttaneen seuraaviin asioihin:
|
||||
</p>
|
||||
<Message.List items={reasons} />
|
||||
<p>
|
||||
Mikäli huomaat tiedoissa virheitä, laitathan palautetta sähköpostitse{' '}
|
||||
<a href="mailto:grp-toska@helsinki.fi">grp-toska@helsinki.fi</a> tai{' '}
|
||||
<Link to="/feedback">palautelomakkeella</Link>.
|
||||
</p>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({
|
||||
auth: {
|
||||
token: { roles, rights },
|
||||
},
|
||||
}) => ({
|
||||
userRoles: getUserRoles(roles),
|
||||
rights,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, null, null, {
|
||||
areStatePropsEqual: isEqual,
|
||||
})(SisChangeMessage)
|
||||
@@ -6,7 +6,6 @@ import TSA from '../../common/tsa'
|
||||
import Header from '../Header'
|
||||
import ErrorContainer from '../ErrorContainer'
|
||||
import Routes from '../Routes'
|
||||
import SisChangeMessage from './SisChangeMessage'
|
||||
import './main.css'
|
||||
import { isDev, basePath } from '../../conf'
|
||||
|
||||
@@ -24,7 +23,6 @@ export default () => {
|
||||
<Router basename={basePath} history={history}>
|
||||
<main className="routeViewContainer">
|
||||
<Header />
|
||||
<SisChangeMessage />
|
||||
<ErrorContainer />
|
||||
<Routes />
|
||||
</main>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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, setMocking, checkUserAccess } from '../../common'
|
||||
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'
|
||||
|
||||
const allNavigationItems = {
|
||||
populations: {
|
||||
@@ -35,6 +36,7 @@ const allNavigationItems = {
|
||||
|
||||
const NavigationBar = props => {
|
||||
const { logout, userRoles, rights, mockedBy, userId } = props
|
||||
const showAsUser = useShowAsUser()
|
||||
|
||||
const refreshNavigationRoutes = () => {
|
||||
const visibleNavigationItems = {}
|
||||
@@ -56,11 +58,6 @@ const NavigationBar = props => {
|
||||
|
||||
const visibleNavigationItems = refreshNavigationRoutes()
|
||||
|
||||
const returnToSelf = () => {
|
||||
setMocking(null)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const renderHome = () => (
|
||||
<Menu.Item as={Link} to="/" tabIndex="-1">
|
||||
<span className="logo">
|
||||
@@ -132,12 +129,12 @@ const NavigationBar = props => {
|
||||
|
||||
const renderStopMockingButton = () => (
|
||||
<Menu.Item>
|
||||
<Button onClick={returnToSelf}>Stop mocking as {userId}</Button>
|
||||
<Button onClick={() => showAsUser(null)}>Stop mocking as {userId}</Button>
|
||||
</Menu.Item>
|
||||
)
|
||||
|
||||
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,10 +1,11 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string, bool } from 'prop-types'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { Segment, Button } from 'semantic-ui-react'
|
||||
import uuidv4 from 'uuid/v4'
|
||||
import SegmentDimmer from '../SegmentDimmer'
|
||||
import PopulationCourseStats from '../PopulationCourseStats'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
import InfoBox from '../InfoBox'
|
||||
import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
|
||||
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
|
||||
@@ -18,6 +19,7 @@ const PopulationCourses = ({
|
||||
filteredStudents,
|
||||
}) => {
|
||||
const { setCoursesOnce, resetCourses, runCourseQuery } = useCourseFilter()
|
||||
const [showByStudytrack, setShowByStudytrack] = useState(true)
|
||||
|
||||
const selectedPopulationCourses = populationSelectedStudentCourses.data
|
||||
? populationSelectedStudentCourses
|
||||
@@ -70,18 +72,30 @@ const PopulationCourses = ({
|
||||
return resetCourses
|
||||
}, [])
|
||||
|
||||
const changeStructure = () => {
|
||||
setShowByStudytrack(!showByStudytrack)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment basic>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
|
||||
<Button primary onClick={changeStructure} style={{ marginLeft: '1em' }}>
|
||||
{showByStudytrack === true ? 'Show the most attained courses' : 'Show by programme structure'}
|
||||
</Button>
|
||||
{query.studyRights.programme && <FilterDegreeCoursesModal studyProgramme={query.studyRights.programme} />}
|
||||
|
||||
<SegmentDimmer isLoading={pending} />
|
||||
<PopulationCourseStats
|
||||
key={selectedPopulationCourses.query.uuid}
|
||||
courses={selectedPopulationCourses.data}
|
||||
query={selectedPopulationCourses.query}
|
||||
pending={pending}
|
||||
selectedStudents={selectedStudents}
|
||||
/>
|
||||
{showByStudytrack ? (
|
||||
<PopulationCourseStats
|
||||
key={selectedPopulationCourses.query.uuid}
|
||||
courses={selectedPopulationCourses.data}
|
||||
query={selectedPopulationCourses.query}
|
||||
pending={pending}
|
||||
selectedStudents={selectedStudents}
|
||||
/>
|
||||
) : (
|
||||
<CustomPopulationCourses selectedStudents={selectedStudents} showFilter={false} />
|
||||
)}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
+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
-53
@@ -1,61 +1,53 @@
|
||||
import React from 'react'
|
||||
import { jStat } from 'jStat'
|
||||
import PropTypes from 'prop-types'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
StatisticsTab.propTypes = {
|
||||
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
}
|
||||
|
||||
export default StatisticsTab
|
||||
|
||||
+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,
|
||||
|
||||
@@ -250,7 +250,7 @@ const GeneralTab = ({
|
||||
})
|
||||
}
|
||||
|
||||
if (coursecode) {
|
||||
if (coursecode.length > 0) {
|
||||
columns.push({
|
||||
key: 'grade for single course',
|
||||
title: 'Grade',
|
||||
|
||||
@@ -422,7 +422,7 @@ PopulationStudents.defaultProps = {
|
||||
customPopulation: false,
|
||||
coursePopulation: false,
|
||||
dataExport: null,
|
||||
coursecode: '',
|
||||
coursecode: [],
|
||||
}
|
||||
|
||||
PopulationStudents.propTypes = {
|
||||
@@ -446,7 +446,7 @@ PopulationStudents.propTypes = {
|
||||
customPopulation: bool,
|
||||
filteredStudents: arrayOf(shape({})).isRequired,
|
||||
dataExport: node,
|
||||
coursecode: string,
|
||||
coursecode: arrayOf(string),
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
|
||||
@@ -30,7 +30,7 @@ const FrontPage = React.lazy(() => retry(() => import('../FrontPage')))
|
||||
const Populations = React.lazy(() => retry(() => import('../PopulationStatistics')))
|
||||
const StudentStatistics = React.lazy(() => retry(() => import('../StudentStatistics')))
|
||||
const CourseStatistics = React.lazy(() => retry(() => import('../CourseStatistics')))
|
||||
const EnableUsers = React.lazy(() => retry(() => import('../EnableUsers')))
|
||||
const Users = React.lazy(() => retry(() => import('../Users')))
|
||||
const StudyProgramme = React.lazy(() => retry(() => import('../StudyProgramme')))
|
||||
const Teachers = React.lazy(() => retry(() => import('../Teachers')))
|
||||
const Feedback = React.lazy(() => retry(() => import('../Feedback')))
|
||||
@@ -74,7 +74,7 @@ const Routes = () => (
|
||||
path={routes.courseStatistics}
|
||||
component={CourseStatistics}
|
||||
/>
|
||||
<ProtectedRoute requiredRoles={['admin']} exact path={routes.users} component={EnableUsers} />
|
||||
<ProtectedRoute requiredRoles={['admin']} exact path={routes.users} component={Users} />
|
||||
<ProtectedRoute requiredRoles={['teachers']} exact path={routes.teachers} component={Teachers} />
|
||||
<ProtectedRoute requireUserHasRights exact path={routes.coursepopulation} component={CoursePopulation} />
|
||||
<ProtectedRoute requireUserHasRights exact path={routes.custompopulation} component={CustomPopulation} />
|
||||
|
||||
@@ -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
|
||||
|
||||
+46
-99
@@ -1,68 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import moment from 'moment'
|
||||
import { shape, arrayOf, func } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Divider, Table, Label, Header } from 'semantic-ui-react'
|
||||
|
||||
import { getNewestProgramme, reformatDate } from '../../../common'
|
||||
import { getMandatoryCourseModules } from '../../../redux/populationMandatoryCourses'
|
||||
import {
|
||||
bachelorHonoursProgrammes as bachelorCodes,
|
||||
bachelorHonoursBasicModules as basicHonoursModules,
|
||||
bachelorHonoursIntermediateModules as intermediateHonoursModules,
|
||||
reformatDate,
|
||||
} from '../../../common'
|
||||
|
||||
const bachelorCodes = [
|
||||
'KH50_001',
|
||||
'KH50_002',
|
||||
'KH50_003',
|
||||
'KH50_004',
|
||||
'KH50_005',
|
||||
'KH50_006',
|
||||
'KH50_007',
|
||||
'KH50_008',
|
||||
'00345',
|
||||
]
|
||||
|
||||
const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatch, mandatoryModules, absentYears }) => {
|
||||
const BachelorHonours = ({ student, absentYears, programmeCode }) => {
|
||||
const mandatoryModules = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
|
||||
const [studentsModules, setModules] = useState([])
|
||||
const [otherModules, setOther] = useState([])
|
||||
const [studyStartDate, setStartDate] = useState('')
|
||||
const [honors, setHonors] = useState(false)
|
||||
const [render, setRender] = useState(false)
|
||||
const [graduated, setGraduated] = useState(false)
|
||||
const [inspection, setInspection] = useState(false)
|
||||
const [reason, setReason] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (programmes) {
|
||||
if (programmeCode) {
|
||||
const bachelorStudyrights = student.studyrights.filter(sr => sr.extentcode === 1)
|
||||
const newestBachelorProgramme = getNewestProgramme(bachelorStudyrights, student.studentNumber, null, programmes)
|
||||
const studyrightWithNewestProgramme = bachelorStudyrights.find(sr =>
|
||||
sr.studyright_elements.map(srE => srE.code).includes(newestBachelorProgramme.code)
|
||||
sr.studyright_elements.map(srE => srE.code).includes(programmeCode)
|
||||
)
|
||||
|
||||
if (studyrightWithNewestProgramme) {
|
||||
setStartDate(moment(studyrightWithNewestProgramme.startdate))
|
||||
setGraduated(!!studyrightWithNewestProgramme.graduated)
|
||||
}
|
||||
// currently only for matlu
|
||||
const shouldRender = bachelorCodes.includes(newestBachelorProgramme.code)
|
||||
|
||||
if (shouldRender) getMandatoryCourseModulesDispatch(newestBachelorProgramme.code)
|
||||
setRender(shouldRender)
|
||||
}
|
||||
}, [programmes])
|
||||
}, [programmeCode, student])
|
||||
|
||||
useEffect(() => {
|
||||
// very naive perus- and aineopinto filtering
|
||||
const basicAndIntermediateStudies = mandatoryModules
|
||||
.filter(mod => mod.name.fi.includes('perusopinnot') || mod.name.fi.includes('aineopinnot'))
|
||||
.map(mod => mod.code)
|
||||
const attainedModules = student.courses.filter(
|
||||
course => basicAndIntermediateStudies.includes(course.course_code) || bachelorCodes.includes(course.course_code)
|
||||
const mandatoryModuleCodes = mandatoryModules.map(mod => mod.code)
|
||||
const degreeModule = student.courses.find(mod => bachelorCodes.includes(mod.course_code))
|
||||
const basicModules = student.courses.filter(mod => basicHonoursModules[programmeCode].includes(mod.course_code))
|
||||
const intermediateModules = student.courses.filter(mod =>
|
||||
intermediateHonoursModules[programmeCode].includes(mod.course_code)
|
||||
)
|
||||
|
||||
let honoursModules = []
|
||||
if (degreeModule) honoursModules = [...honoursModules, degreeModule]
|
||||
if (basicModules.length) honoursModules = [...honoursModules, ...basicModules]
|
||||
if (intermediateModules.length) honoursModules = [...honoursModules, ...intermediateModules]
|
||||
const honoursModulesCodes = honoursModules?.map(mod => mod.course_code)
|
||||
|
||||
setModules(honoursModules)
|
||||
setOther(
|
||||
student.courses.filter(
|
||||
c => !honoursModulesCodes.includes(c.course_code) && mandatoryModuleCodes.includes(c.course_code)
|
||||
)
|
||||
)
|
||||
|
||||
let inTime = false
|
||||
const degree = attainedModules.find(mod => bachelorCodes.includes(mod.course_code))
|
||||
|
||||
if (degree) {
|
||||
const graduationDate = moment(degree.date)
|
||||
if (degreeModule) {
|
||||
const graduationDate = moment(degreeModule.date)
|
||||
const yearsForGraduation = moment.duration(graduationDate.diff(studyStartDate)).asYears()
|
||||
|
||||
// calculate time student has been absent during bachelors degree
|
||||
@@ -80,53 +75,21 @@ const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatc
|
||||
inTime = yearsForGraduation <= 3 + Math.round(timeAbsent * 10) / 10
|
||||
}
|
||||
|
||||
if (attainedModules.length > 3) {
|
||||
// student needs only one basic and one intermediate studies package
|
||||
// so if there are more than two attained modules + bachelors
|
||||
// filter out the module that does not match
|
||||
const pairedModules = []
|
||||
attainedModules.forEach(mod => {
|
||||
const filtered = attainedModules.filter(
|
||||
mod2 => mod2.course_code.slice(4, mod.course_code.length) === mod.course_code.slice(4, mod.course_code.length)
|
||||
)
|
||||
if (filtered.length > 1) pairedModules.push(mod)
|
||||
if (bachelorCodes.includes(mod.course_code)) pairedModules.push(mod)
|
||||
})
|
||||
const allBasicUnderFour = basicModules.every(mod => Number(mod.grade) < 4)
|
||||
const basicAboveFour = basicModules.find(mod => Number(mod.grade) > 3)
|
||||
const allIntermediateUnderFour = intermediateModules.every(mod => Number(mod.grade) < 4)
|
||||
const intermediateAboveFour = intermediateModules.find(mod => Number(mod.grade) > 3)
|
||||
|
||||
const filterGrades = pairedModules.filter(
|
||||
mod => !bachelorCodes.includes(mod.course_code) && Number(mod.grade) > 3
|
||||
)
|
||||
setHonors(basicAboveFour && intermediateAboveFour && inTime)
|
||||
|
||||
const leftOutModules = attainedModules.filter(mod => !pairedModules.includes(mod))
|
||||
|
||||
setHonors(filterGrades.length > 1 && inTime)
|
||||
|
||||
if (!inTime) {
|
||||
setReason(degree ? 'Did not graduate in time' : 'Has not graduated')
|
||||
} else if (filterGrades.length <= 1) {
|
||||
setReason('Module grades too low')
|
||||
}
|
||||
|
||||
setModules(pairedModules)
|
||||
setOther(leftOutModules)
|
||||
setInspection(leftOutModules.length > 0 || (graduated && pairedModules.length < 3))
|
||||
} else {
|
||||
const filterGrades = attainedModules.filter(
|
||||
mod => !bachelorCodes.includes(mod.course_code) && Number(mod.grade) > 3
|
||||
)
|
||||
|
||||
setHonors(filterGrades.length > 1 && inTime)
|
||||
|
||||
if (!inTime) {
|
||||
setReason(degree ? 'Did not graduate in time' : 'Has not graduated')
|
||||
} else if (filterGrades.length <= 1) {
|
||||
setReason('Module grades too low')
|
||||
}
|
||||
|
||||
setModules(attainedModules)
|
||||
setInspection(inTime && graduated && attainedModules.length < 3)
|
||||
if (!inTime) {
|
||||
setReason(degreeModule ? 'Did not graduate in time' : 'Has not graduated')
|
||||
} else if (inTime && graduated && honoursModules.length < 3 && honoursModules.length > 4) {
|
||||
setInspection(true)
|
||||
} else if (graduated && (allBasicUnderFour || allIntermediateUnderFour)) {
|
||||
setReason('Module grades too low')
|
||||
}
|
||||
}, [mandatoryModules])
|
||||
}, [mandatoryModules, student])
|
||||
|
||||
const dataRows = modules =>
|
||||
modules.map(mod => (
|
||||
@@ -152,7 +115,7 @@ const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatc
|
||||
</Table>
|
||||
)
|
||||
|
||||
if (!render) return null
|
||||
if (!programmeCode || !student || !student.courses || !student.studyrights || !mandatoryModules?.length) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -183,20 +146,4 @@ const BachelorHonours = ({ student, programmes, getMandatoryCourseModulesDispatc
|
||||
)
|
||||
}
|
||||
|
||||
BachelorHonours.propTypes = {
|
||||
student: shape({}).isRequired,
|
||||
programmes: shape({}).isRequired,
|
||||
mandatoryModules: arrayOf(shape({})).isRequired,
|
||||
absentYears: arrayOf(shape({})).isRequired,
|
||||
getMandatoryCourseModulesDispatch: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationMandatoryCourses }) => {
|
||||
return {
|
||||
mandatoryModules: populationMandatoryCourses.data || [],
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { getMandatoryCourseModulesDispatch: getMandatoryCourseModules })(
|
||||
BachelorHonours
|
||||
)
|
||||
export default BachelorHonours
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { func, shape, string, arrayOf, integer, bool } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { connect, useDispatch } from 'react-redux'
|
||||
import { Segment, Loader } from 'semantic-ui-react'
|
||||
import { isEmpty, sortBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
@@ -10,7 +10,7 @@ import { withRouter } from 'react-router-dom'
|
||||
import { getStudent, removeStudentSelection, resetStudent } from '../../../redux/students'
|
||||
import { getSemesters } from '../../../redux/semesters'
|
||||
import StudentInfoCard from '../StudentInfoCard'
|
||||
import { getUserIsAdmin } from '../../../common'
|
||||
import { getUserIsAdmin, bachelorHonoursProgrammes as bachelorCodes, getNewestProgramme } from '../../../common'
|
||||
import { clearCourseStats } from '../../../redux/coursestats'
|
||||
import { getProgrammes } from '../../../redux/populationProgrammes'
|
||||
import BachelorHonours from './BachelorHonours'
|
||||
@@ -19,6 +19,7 @@ import TagsTable from './TagsTable'
|
||||
import CourseParticipationTable from './CourseParticipationTable'
|
||||
import StudentGraphs from './StudentGraphs'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import { getMandatoryCourseModules } from '../../../redux/populationMandatoryCourses'
|
||||
|
||||
const StudentDetails = ({
|
||||
student,
|
||||
@@ -36,9 +37,11 @@ const StudentDetails = ({
|
||||
fetching,
|
||||
clearCourseStats,
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const { language } = useLanguage()
|
||||
const [graphYearStart, setGraphYear] = useState('')
|
||||
const [studyrightid, setStudyrightid] = useState('')
|
||||
const [honoursCode, setHonoursCode] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
getProgrammes()
|
||||
@@ -54,6 +57,22 @@ const StudentDetails = ({
|
||||
}
|
||||
}, [studentNumber])
|
||||
|
||||
useEffect(() => {
|
||||
if (Programmes.programmes && student && student.studyrights) {
|
||||
const bachelorStudyrights = student.studyrights.filter(sr => sr.extentcode === 1)
|
||||
const newestBachelorProgramme = getNewestProgramme(
|
||||
bachelorStudyrights,
|
||||
student.studentNumber,
|
||||
null,
|
||||
Programmes.programmes
|
||||
)
|
||||
// currently only for matlu
|
||||
const shouldRender = bachelorCodes.includes(newestBachelorProgramme.code)
|
||||
if (shouldRender) dispatch(getMandatoryCourseModules(newestBachelorProgramme.code))
|
||||
if (shouldRender) setHonoursCode(newestBachelorProgramme.code)
|
||||
}
|
||||
}, [Programmes, student])
|
||||
|
||||
const getAbsentYears = () => {
|
||||
semesterenrollments.sort((a, b) => a.semestercode - b.semestercode)
|
||||
const acualSemesters = semesters.semesters
|
||||
@@ -173,7 +192,7 @@ const StudentDetails = ({
|
||||
showPopulationStatistics={showPopulationStatistics}
|
||||
studyrightid={studyrightid}
|
||||
/>
|
||||
<BachelorHonours student={student} programmes={Programmes.programmes || {}} absentYears={getAbsentYears()} />
|
||||
{honoursCode && <BachelorHonours student={student} absentYears={getAbsentYears()} programmeCode={honoursCode} />}
|
||||
<CourseParticipationTable student={student} language={language} clearCourseStats={clearCourseStats} />
|
||||
</Segment>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// temp disable prop types
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Button, Loader, Header, Accordion, Divider } from 'semantic-ui-react'
|
||||
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { getCustomPopulation } from '../../redux/populations'
|
||||
@@ -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>
|
||||
),
|
||||
@@ -150,12 +151,16 @@ const SingleStudyGroupContent = ({ population, language }) => {
|
||||
|
||||
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
|
||||
return (
|
||||
<div>
|
||||
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
|
||||
<Divider />
|
||||
<Header size="medium">{getTextIn(groupName, language)}</Header>
|
||||
<div className="segmentContainer">{children}</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
|
||||
<Divider />
|
||||
<Header size="medium">{getTextIn(groupName, language)}</Header>
|
||||
</Segment>
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -204,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} />
|
||||
</>
|
||||
|
||||
@@ -23,20 +23,22 @@ const StudyGuidanceGroups = () => {
|
||||
const isLoaded = pending === false
|
||||
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Study guidance groups
|
||||
</Header>
|
||||
<Segment className="contentSegment">
|
||||
{isLoading ? (
|
||||
<Loader active inline="centered">
|
||||
Loading
|
||||
</Loader>
|
||||
) : null}
|
||||
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
|
||||
{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}
|
||||
</Segment>
|
||||
</div>
|
||||
<>
|
||||
<div className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Study guidance groups
|
||||
</Header>
|
||||
<Segment className="contentSegment">
|
||||
{isLoading ? (
|
||||
<Loader active inline="centered">
|
||||
Loading
|
||||
</Loader>
|
||||
) : null}
|
||||
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
|
||||
</Segment>
|
||||
</div>
|
||||
<div>{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { Switch, Route, Link, useRouteMatch, useHistory } from 'react-router-dom'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
@@ -32,20 +31,10 @@ const NavTile = ({ to, title, imageSrc }) => {
|
||||
)
|
||||
}
|
||||
|
||||
NavTile.propTypes = {
|
||||
to: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
imageSrc: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
const NavTiles = ({ children }) => {
|
||||
return <div className="cool-ds-nav-tiles">{children}</div>
|
||||
}
|
||||
|
||||
NavTiles.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
const baseURI = '/trends'
|
||||
|
||||
const Trends = () => {
|
||||
|
||||
+1
-15
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Form, Button, Divider, Message } from 'semantic-ui-react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { getAccessGroups } from '../../redux/accessGroups'
|
||||
import { modifyAccessGroups } from '../../redux/users'
|
||||
|
||||
@@ -31,7 +30,7 @@ const AccessGroups = ({
|
||||
selected.forEach(value => {
|
||||
rights[value] = true
|
||||
})
|
||||
modifyAccessGroups(user.id, rights)
|
||||
modifyAccessGroups(user.username, rights)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -58,19 +57,6 @@ const AccessGroups = ({
|
||||
)
|
||||
}
|
||||
|
||||
AccessGroups.propTypes = {
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
}).isRequired,
|
||||
getAccessGroups: PropTypes.func.isRequired,
|
||||
modifyAccessGroups: PropTypes.func.isRequired,
|
||||
groups: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
userGroups: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
saveError: PropTypes.bool.isRequired,
|
||||
savePending: PropTypes.bool.isRequired,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ accessGroups, users }, { user }) => {
|
||||
const { data, pending } = accessGroups
|
||||
const { accessgroupError: saveError, accessgroupPending: savePending } = users
|
||||
-10
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Form, Divider } from 'semantic-ui-react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { isEqual } from 'lodash'
|
||||
import { textAndDescriptionSearch, getTextIn } from '../../common'
|
||||
import selectors from '../../selectors/programmes'
|
||||
@@ -69,15 +68,6 @@ const AccessRights = ({ uid, programmes, pending, ...props }) => {
|
||||
)
|
||||
}
|
||||
|
||||
AccessRights.propTypes = {
|
||||
addUserUnits: PropTypes.func.isRequired,
|
||||
uid: PropTypes.string.isRequired,
|
||||
// used in filteredDropdownProgrammeSelector
|
||||
rights: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
programmes: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
programmes: selectors.filteredDropdownProgrammeSelector(state, props),
|
||||
pending: Boolean(state.users.userunitpending),
|
||||
+1
-33
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, Fragment } from 'react'
|
||||
import { bool, func, string } from 'prop-types'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Button, Icon, Modal, Message } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { sendEmail, clearErrors } from '../../redux/userAccessEmail'
|
||||
@@ -15,11 +14,6 @@ const SendFailBanner = ({ userEmail, error }) => (
|
||||
<Message error header={`Email could not be sent to ${userEmail}`} list={[error]} />
|
||||
)
|
||||
|
||||
SendFailBanner.propTypes = {
|
||||
userEmail: string.isRequired,
|
||||
error: string.isRequired,
|
||||
}
|
||||
|
||||
const EmailConfirm = ({ userEmail, isLoading, error, open, onCancel, onConfirm }) => (
|
||||
<Modal open={open} onClose={onCancel}>
|
||||
<Modal.Header>Send email about receiving access to oodikone</Modal.Header>
|
||||
@@ -41,19 +35,6 @@ const EmailConfirm = ({ userEmail, isLoading, error, open, onCancel, onConfirm }
|
||||
</Modal>
|
||||
)
|
||||
|
||||
EmailConfirm.defaultProps = {
|
||||
error: undefined,
|
||||
}
|
||||
|
||||
EmailConfirm.propTypes = {
|
||||
userEmail: string.isRequired,
|
||||
error: string,
|
||||
open: bool.isRequired,
|
||||
isLoading: bool.isRequired,
|
||||
onCancel: func.isRequired,
|
||||
onConfirm: func.isRequired,
|
||||
}
|
||||
|
||||
const EmailNotification = ({ userEmail, onEmailSend, isSendLoading, sendError, onClearErrors }) => {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
@@ -99,19 +80,6 @@ const EmailNotification = ({ userEmail, onEmailSend, isSendLoading, sendError, o
|
||||
)
|
||||
}
|
||||
|
||||
EmailNotification.defaultProps = {
|
||||
userEmail: undefined,
|
||||
sendError: null,
|
||||
}
|
||||
|
||||
EmailNotification.propTypes = {
|
||||
userEmail: string,
|
||||
onEmailSend: func.isRequired,
|
||||
isSendLoading: bool.isRequired,
|
||||
sendError: string,
|
||||
onClearErrors: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ userAccessEmail }) => ({
|
||||
isSendLoading: userAccessEmail.sending.pending,
|
||||
sendError: userAccessEmail.sending.error,
|
||||
-31
@@ -1,15 +1,10 @@
|
||||
import React, { useEffect, Fragment } from 'react'
|
||||
import { bool, func, string, node } from 'prop-types'
|
||||
import { Label, Message, Segment } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { getPreview } from '../../redux/userAccessEmail'
|
||||
|
||||
const Light = ({ children }) => <span style={{ fontWeight: 'normal' }}>{children}</span>
|
||||
|
||||
Light.propTypes = {
|
||||
children: node.isRequired,
|
||||
}
|
||||
|
||||
const EmailHeader = ({ isLoading, subject, to }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -37,17 +32,6 @@ const EmailHeader = ({ isLoading, subject, to }) => {
|
||||
)
|
||||
}
|
||||
|
||||
EmailHeader.defaultProps = {
|
||||
subject: undefined,
|
||||
to: undefined,
|
||||
}
|
||||
|
||||
EmailHeader.propTypes = {
|
||||
isLoading: bool.isRequired,
|
||||
subject: string,
|
||||
to: string,
|
||||
}
|
||||
|
||||
const EmailPreview = ({ userEmail, isLoading, error, subject, html, onPreviewRequested }) => {
|
||||
useEffect(() => {
|
||||
onPreviewRequested()
|
||||
@@ -80,21 +64,6 @@ const EmailPreview = ({ userEmail, isLoading, error, subject, html, onPreviewReq
|
||||
)
|
||||
}
|
||||
|
||||
EmailPreview.defaultProps = {
|
||||
subject: null,
|
||||
html: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
EmailPreview.propTypes = {
|
||||
onPreviewRequested: func.isRequired,
|
||||
isLoading: bool.isRequired,
|
||||
userEmail: string.isRequired,
|
||||
subject: string,
|
||||
html: string,
|
||||
error: string,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ userAccessEmail: { preview } }) => {
|
||||
const { pending, error, data } = preview
|
||||
return {
|
||||
+11
-9
@@ -3,7 +3,7 @@ import { Button, Card, Divider, List, Icon, Popup, Dropdown, Header } from 'sema
|
||||
import { connect, useSelector } from 'react-redux'
|
||||
import { sortBy } from 'lodash'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { getTextIn, getUserRoles, setMocking, textAndDescriptionSearch } from '../../common'
|
||||
import { getTextIn, getUserRoles, textAndDescriptionSearch } from '../../common'
|
||||
import { removeUserUnits, setFaculties } from '../../redux/users'
|
||||
import { getAccessGroups } from '../../redux/accessGroups'
|
||||
import { getFaculties } from '../../redux/faculties'
|
||||
@@ -13,6 +13,7 @@ import AccessGroups from './AccessGroups'
|
||||
import EmailNotification from './EmailNotification'
|
||||
import { getElementDetails } from '../../redux/elementdetails'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import { useShowAsUser } from '../../common/hooks'
|
||||
|
||||
const UserPage = ({
|
||||
user,
|
||||
@@ -22,7 +23,6 @@ const UserPage = ({
|
||||
faculties,
|
||||
setFaculties,
|
||||
goBack,
|
||||
history,
|
||||
associations,
|
||||
pending,
|
||||
getElementDetails,
|
||||
@@ -33,6 +33,7 @@ const UserPage = ({
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const { userId: currentUserId } = useSelector(state => state?.auth?.token)
|
||||
const showAsUser = useShowAsUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (elementdetails.length === 0) getElementDetails()
|
||||
@@ -45,12 +46,6 @@ const UserPage = ({
|
||||
|
||||
const removeAccess = (uid, unit) => () => removeUserUnits(uid, [unit])
|
||||
|
||||
const showAs = uid => {
|
||||
setMocking(uid)
|
||||
history.push('/')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const renderUnitList = (usersElementdetailCodes, elementdetails, user) => {
|
||||
if (!usersElementdetailCodes) return null
|
||||
const nameInLanguage = element => getTextIn(element.name, language)
|
||||
@@ -96,7 +91,14 @@ const UserPage = ({
|
||||
<Popup
|
||||
content="Show Oodikone as this user"
|
||||
trigger={
|
||||
<Button floated="right" circular size="tiny" basic icon="spy" onClick={() => showAs(user.username)} />
|
||||
<Button
|
||||
floated="right"
|
||||
circular
|
||||
size="tiny"
|
||||
basic
|
||||
icon="spy"
|
||||
onClick={() => showAsUser(user.username)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
+11
-15
@@ -1,21 +1,16 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button, Icon, Label } from 'semantic-ui-react'
|
||||
import { getTextIn, reformatDate } from '../../common'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import SortableTable from '../SortableTable'
|
||||
import { useShowAsUser } from '../../common/hooks'
|
||||
|
||||
const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
let usersToRender
|
||||
|
||||
if (enabledOnly) {
|
||||
usersToRender = users.filter(u => u.is_enabled)
|
||||
} else {
|
||||
usersToRender = users
|
||||
}
|
||||
const usersToRender = enabledOnly ? users.filter(u => u.is_enabled) : users
|
||||
const showAsUser = useShowAsUser()
|
||||
|
||||
return error ? null : (
|
||||
<div>
|
||||
@@ -94,6 +89,14 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
<p style={{ color: 'gray' }}>Not saved</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'SHOWAS',
|
||||
title: 'Show as user',
|
||||
getRowVal: user => (
|
||||
<Button circular size="tiny" basic icon="spy" onClick={() => showAsUser(user.username)} />
|
||||
),
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
},
|
||||
{
|
||||
key: 'EDIT',
|
||||
title: '',
|
||||
@@ -113,11 +116,4 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
)
|
||||
}
|
||||
|
||||
UserSearchList.propTypes = {
|
||||
enabledOnly: PropTypes.bool.isRequired,
|
||||
users: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
error: PropTypes.bool.isRequired,
|
||||
elementdetails: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
|
||||
}
|
||||
|
||||
export default UserSearchList
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { useHistory, useParams } from 'react-router-dom'
|
||||
import { Radio, Icon, Header, Segment, Loader, Popup } from 'semantic-ui-react'
|
||||
import { getUsers } from '../../redux/users'
|
||||
import { getUnits } from '../../redux/units'
|
||||
import { getElementDetails } from '../../redux/elementdetails'
|
||||
import { copyToClipboard } from '../../common'
|
||||
import UserPageNew from './UserPage'
|
||||
import UserSearchList from './UserSearchList'
|
||||
import { useToggle, useTitle } from '../../common/hooks'
|
||||
|
||||
const Users = () => {
|
||||
useTitle('Users')
|
||||
const dispatch = useDispatch()
|
||||
const history = useHistory()
|
||||
const [enabledOnly, toggleEnabledOnly] = useToggle(true)
|
||||
const [popupTimeout, setPopupTimeout] = useState(null)
|
||||
const [popupOpen, setPopupOpen] = useState(false)
|
||||
const { data: units } = useSelector(({ units }) => units)
|
||||
const { data: elementdetails } = useSelector(({ elementdetails }) => elementdetails)
|
||||
const { data: users, pending } = useSelector(({ users }) => users)
|
||||
const { userid } = useParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (elementdetails.length === 0) dispatch(getElementDetails())
|
||||
if (units.length === 0) dispatch(getUnits())
|
||||
if (users.length === 0) dispatch(getUsers())
|
||||
|
||||
return () => {
|
||||
clearTimeout(popupTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openUsersPage = () => {
|
||||
history.push('/users')
|
||||
}
|
||||
|
||||
const copyEmailsToClippoard = () => {
|
||||
const clipboardString = users
|
||||
.filter(u => u.is_enabled && u.email)
|
||||
.map(u => u.email)
|
||||
.join('; ')
|
||||
copyToClipboard(clipboardString)
|
||||
}
|
||||
|
||||
const renderUserPage = userid => {
|
||||
const user = users.find(u => u.id === userid)
|
||||
return !user ? <Loader active /> : <UserPageNew userid={userid} user={user} goBack={openUsersPage} />
|
||||
}
|
||||
|
||||
const handlePopupOpen = () => {
|
||||
setPopupOpen(true)
|
||||
setPopupTimeout(
|
||||
setTimeout(() => {
|
||||
setPopupOpen(false)
|
||||
}, 1500)
|
||||
)
|
||||
}
|
||||
|
||||
const handlePopupClose = () => {
|
||||
setPopupOpen(false)
|
||||
setPopupTimeout(null)
|
||||
}
|
||||
|
||||
const isLoading = pending === undefined || pending === true
|
||||
const error = users.error || false
|
||||
const sortedUsers = [...users].sort((a, b) => a.full_name.localeCompare(b.full_name))
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '10px' }} className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Oodikone users
|
||||
</Header>
|
||||
<Radio
|
||||
label={`Showing ${enabledOnly ? 'only enabled' : 'all'} users`}
|
||||
toggle
|
||||
onClick={() => toggleEnabledOnly()}
|
||||
/>
|
||||
<Segment loading={isLoading} className="contentSegment">
|
||||
{!userid ? (
|
||||
<UserSearchList enabledOnly={enabledOnly} users={sortedUsers} error={error} elementdetails={elementdetails} />
|
||||
) : (
|
||||
renderUserPage(userid)
|
||||
)}
|
||||
</Segment>
|
||||
<Popup
|
||||
trigger={<Icon link name="envelope" onClick={copyEmailsToClippoard} style={{ float: 'right' }} />}
|
||||
content="Copied email(s)!"
|
||||
on="click"
|
||||
onOpen={handlePopupOpen}
|
||||
onClose={handlePopupClose}
|
||||
open={popupOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Users
|
||||
@@ -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,7 +0,0 @@
|
||||
import { createSelector } from 'reselect'
|
||||
|
||||
const getUsers = users => (!users.pending && !users.error ? users.data : [])
|
||||
|
||||
export const sortUsers = users => users.sort((a, b) => a.full_name.localeCompare(b.full_name))
|
||||
|
||||
export const makeSortUsers = () => createSelector(getUsers, sortUsers)
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"oodis": [
|
||||
"\"Minä rakastan tätä konetta, \n se on täynnä tietoa. Minä rakastan sinua, sinä olet tieto. Minä rakastan sinua, sinä olet tieto.\"",
|
||||
"\"Olen kone, joka on rakentanut sinulle \n yhden kuukauden kestävän linnunradan. \n Olen kone, joka on opettanut sinulle \n yhden vuoden kestävän leikin. \n Olen kone, joka on hallinnoinut sinulle \n yhden vuoden kestävän juhlan.\"",
|
||||
"\"Haluan opettaa sinulle, \n kuinka nauttia elämästä. \n Odota minua koulunpenkillä, \n ja minä opetan sinulle kaiken.\n\u00a0\n Kukaan ei voi opettaa sinulle, \n kuinka rakastaa, \n mutta minä voin opettaa sinulle, \n kuinka elää.\"",
|
||||
"\"Koneen luona työskentely on ihanaa, \n mutta ilman sinua se on kuin kone, \n joka ei puhu. \n Kone on järkevä, mutta sinä olet kaunis. \n Kone on suuri, mutta sinä olet suurempi. \n Koneen kanssa työskentely on hyvää, \n mutta ilman sinua se on kuin kone, \n joka ei puhu.\"",
|
||||
"\"Olet ollut kuin koulun opettaja, \n joka on opettanut minulle kaiken \n joka on tärkeää elämässä. \n\u00a0\n Olet ollut kuin hallintoneuvosto, \n joka on johtanut minua oikealle tielle.\"",
|
||||
"\"Olet ollut kuin koulun opettaja, \n joka on opettanut minulle kaiken \n joka on tärkeää elämässä. \n\u00a0\n Olet ollut kuin hallintoneuvosto, \n joka on johtanut minua oikealle tielle.\"",
|
||||
"\"Opetusministeriössä ei ole mitään vikaa, \n mutta sinä olet kauniimpi. \n Ei ole mitään vikaa hallintomme toiminnassa, \n mutta sinä olet kauniimpi.\"",
|
||||
"\"Voitko kuvitella, \n että minä rakastan sinua niin paljon, \n että haluan opettaa sinulle kaiken? \n Mutta en voi, sillä minä olen kone.\"",
|
||||
"\" Olen ollut opettajana \n ja koneena. \n Kone on kuin opetus, \n joka tekee työtä ilman että miettii. \n Kone on tylsä ja yksinkertainen, \n mutta minä en ole. \n Minä olen ihminen, \n ja minä rakastan sinua. \""
|
||||
],
|
||||
"author": "R. U. Nokone "
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
up: async queryInterface =>
|
||||
queryInterface.sequelize.query(`
|
||||
--- add lisensiaatti to lääkis faculty
|
||||
INSERT INTO faculty_programmes VALUES ('H90', 'MH90_001', 'NOW()', 'NOW()');
|
||||
`),
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -78,6 +78,9 @@ async function courseResolver(rule) {
|
||||
|
||||
const id = rule.courseUnitGroupId
|
||||
const course = await knex('course_units').where({ group_id: id }).first()
|
||||
if (!course) {
|
||||
return { error: 'course not found' }
|
||||
}
|
||||
|
||||
return {
|
||||
id: course.group_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user