mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4d858efc | ||
|
|
59271c9e38 | ||
|
|
d87fb6d0fa | ||
|
|
646d87ab9e | ||
|
|
9e687f27a3 | ||
|
|
b699322e6f | ||
|
|
3c01e3b7b1 | ||
|
|
86bd472c41 | ||
|
|
8984ea14f1 | ||
|
|
1af42bf012 | ||
|
|
f606a7e8b7 | ||
|
|
6d938a34af | ||
|
|
77c5721dff | ||
|
|
c5561ab6dc | ||
|
|
0b647e1b32 | ||
|
|
1437bb9d0e | ||
|
|
66249aabd8 | ||
|
|
02c63d1d9f | ||
|
|
00b7dc933e | ||
|
|
9f089fb6db | ||
|
|
542267042e | ||
|
|
9e282ae634 | ||
|
|
6a625b1da7 | ||
|
|
4982bd8f3c | ||
|
|
908e33ccd3 | ||
|
|
d429480f80 | ||
|
|
dada995398 | ||
|
|
69c9b05657 | ||
|
|
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 | ||
|
|
e8e10bcb9a | ||
|
|
3e3a95a113 | ||
|
|
9a190b301d | ||
|
|
f976eca1a5 | ||
|
|
a32b4695a9 | ||
|
|
d55e27f1d9 | ||
|
|
b749ad4033 | ||
|
|
eddc514907 | ||
|
|
e0c4db7bd9 | ||
|
|
3b384cceb6 | ||
|
|
6e15b8c355 | ||
|
|
f1196b49ae | ||
|
|
e5b89dc844 | ||
|
|
5b27c42945 | ||
|
|
5ae10d2695 | ||
|
|
c4f7529973 | ||
|
|
5d84d2f9a3 | ||
|
|
40fbcd9750 | ||
|
|
e06f12017d | ||
|
|
c686d2ad73 | ||
|
|
7306d261ce | ||
|
|
db0e924f36 | ||
|
|
10d13b751b | ||
|
|
87ef487fe8 | ||
|
|
50968b4b66 | ||
|
|
791e295eb7 | ||
|
|
6818ab94fe | ||
|
|
4bef9c8daa | ||
|
|
8050a56847 | ||
|
|
04e2d44665 | ||
|
|
32b13df638 | ||
|
|
04f2244337 | ||
|
|
93c7267942 | ||
|
|
94ff7c1218 | ||
|
|
161fdc25e7 | ||
|
|
9c069c8082 | ||
|
|
21e3848207 | ||
|
|
251db49a05 | ||
|
|
07d9ef9901 | ||
|
|
bf414f856b | ||
|
|
b8a0fff57e | ||
|
|
dd8ecb7152 | ||
|
|
0c3c6a3442 | ||
|
|
006984930f | ||
|
|
cab91265b0 | ||
|
|
e48247d80b | ||
|
|
9e0265c439 | ||
|
|
c5fd7bf26c | ||
|
|
2a68c60892 | ||
|
|
f9a974fdd2 | ||
|
|
d64ac35a4f | ||
|
|
484f4ecf94 | ||
|
|
10ef70a22e | ||
|
|
fa82b720f5 | ||
|
|
3b17923761 | ||
|
|
e42a348ec7 | ||
|
|
d33f28c776 | ||
|
|
afdab20014 | ||
|
|
f477b3ef50 | ||
|
|
ab19b429ca | ||
|
|
2e6fdb4970 | ||
|
|
c918f862e3 | ||
|
|
69942c2c17 | ||
|
|
7f0fa7fb63 | ||
|
|
59bfbfcca3 | ||
|
|
22e11948f5 | ||
|
|
5b5884b25d |
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Populate and change this to .env
|
||||
IMPORTER_DB_API_TOKEN=
|
||||
IMPORTER_DB_API_URL=
|
||||
IMPORTER_DB_API_URL=https://importer.cs.helsinki.fi
|
||||
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: |
|
||||
|
||||
@@ -139,3 +139,7 @@ Just do clean install by launching cli with `npm cli` and running option 1: _Set
|
||||
Toska of course.
|
||||
|
||||
University of Helsinki
|
||||
|
||||
## Tietosuoja / data protection
|
||||
|
||||
See [tietosuoja.md](tietosuoja.md).
|
||||
|
||||
Binary file not shown.
@@ -120,7 +120,8 @@ describe('Population Statistics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Admission type filter works', () => {
|
||||
// Return when we have +2020 population with admission types in data
|
||||
it.skip('Admission type filter works', () => {
|
||||
runTestStepWithPreAndPostParts('admissionTypeFilter-header', () => {
|
||||
cy.selectFromDropdown('admissionTypeFilter-dropdown', 0)
|
||||
checkFilteringResult(5)
|
||||
|
||||
@@ -132,9 +132,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('Studyprogramme overview', () => {
|
||||
let originalProductivityCalculatedText
|
||||
const doRecalculation = () => {
|
||||
// Wait for page to load before clicking to admin page, TODO: change to cy.intercept
|
||||
cy.wait(2000)
|
||||
cy.wait(5000)
|
||||
cy.get('.attached').contains('Admin').click()
|
||||
cy.contains('recalculate productivity').click()
|
||||
cy.contains('recalculate throughput').click()
|
||||
|
||||
@@ -64,8 +64,7 @@ parse_profiles() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Set which docker-compose files to use based on version. In ci, profiles are also
|
||||
# emptied, since they're passed from github actions.
|
||||
# Set which docker-compose files to use based on version.
|
||||
parse_env() {
|
||||
env=""
|
||||
if [[ "$version" == "real" ]]; then
|
||||
|
||||
@@ -152,10 +152,10 @@ linting and formatting"
|
||||
npm run prepare
|
||||
|
||||
infomsg "Pulling images"
|
||||
"$PROJECT_ROOT"/run.sh oodikone anon pull
|
||||
"$PROJECT_ROOT"/run.sh both anon pull
|
||||
|
||||
infomsg "Building images"
|
||||
"$PROJECT_ROOT"/run.sh oodikone anon build
|
||||
"$PROJECT_ROOT"/run.sh both anon build
|
||||
|
||||
successmsg "Setup ready, oodikone can be started! See README for more info."
|
||||
}
|
||||
|
||||
Generated
+7
-7
@@ -228,11 +228,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
|
||||
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.10.0"
|
||||
"follow-redirects": "^1.14.0"
|
||||
}
|
||||
},
|
||||
"balanced-match": {
|
||||
@@ -1016,9 +1016,9 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz",
|
||||
"integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA=="
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz",
|
||||
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw=="
|
||||
},
|
||||
"for-in": {
|
||||
"version": "1.0.2",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
"async": "^3.2.1",
|
||||
"async-lock": "^1.3.0",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.21.2",
|
||||
"bluebird": "^3.7.2",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^1.8.2",
|
||||
|
||||
@@ -62,6 +62,8 @@ const addSlashToEnd = url => (url.endsWith('/') ? url : url + '/')
|
||||
|
||||
const ACCESS_TOKEN_HEADER_KEY = 'x-access-token'
|
||||
|
||||
const PWD = process.env.PWD || '/usr/src/app'
|
||||
|
||||
module.exports = {
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
@@ -90,4 +92,5 @@ module.exports = {
|
||||
backendPort,
|
||||
importerToken,
|
||||
importerUrl,
|
||||
PWD,
|
||||
}
|
||||
|
||||
@@ -9,17 +9,10 @@ const {
|
||||
getStartYears,
|
||||
} = require('./services/trends')
|
||||
const { refreshAssociationsInRedis } = require('./services/studyrights')
|
||||
const { getAllProgrammes, nonGraduatedStudentsOfElementDetail } = require('./services/studyrights')
|
||||
const { getAllProgrammes } = require('./services/studyrights')
|
||||
const { productivityStatsForStudytrack, throughputStatsForStudytrack } = require('./services/studyprogramme')
|
||||
const { findAndSaveTeachers } = require('./services/topteachers')
|
||||
const {
|
||||
setProductivity,
|
||||
setThroughput,
|
||||
patchProductivity,
|
||||
patchThroughput,
|
||||
patchNonGraduatedStudents,
|
||||
} = require('./services/analyticsService')
|
||||
const { isNewHYStudyProgramme } = require('./util')
|
||||
const { setProductivity, setThroughput, patchProductivity, patchThroughput } = require('./services/analyticsService')
|
||||
const { isProduction } = require('./conf-backend')
|
||||
const { getCurrentSemester } = require('./services/semesters')
|
||||
const logger = require('./util/logger')
|
||||
@@ -33,7 +26,7 @@ const refreshStudyrightAssociations = async () => {
|
||||
|
||||
const refreshOverview = async () => {
|
||||
logger.info('Refreshing throughput and productivity for programmes...')
|
||||
const codes = (await getAllProgrammes()).map(p => p.code)
|
||||
const codes = (await getAllProgrammes()).map(p => p.code).filter(code => code.includes('KH') || code.includes('MH'))
|
||||
let ready = 0
|
||||
for (const code of codes) {
|
||||
let programmeStatsSince = new Date('2017-07-31')
|
||||
@@ -75,43 +68,22 @@ const refreshOverview = async () => {
|
||||
}
|
||||
|
||||
const refreshTeacherLeaderboard = async () => {
|
||||
const startyearcode = 51 // Start from autumn 2001
|
||||
const currentSemester = await getCurrentSemester()
|
||||
await findAndSaveTeachers(startyearcode, currentSemester.getDataValue('semestercode'))
|
||||
logger.info('Teacher leaderboard refreshed')
|
||||
}
|
||||
|
||||
const refreshNonGraduatedStudentsOfOldProgrammes = async () => {
|
||||
const oldProgrammeCodes = (await getAllProgrammes()).map(p => p.code).filter(c => !isNewHYStudyProgramme(c))
|
||||
let i = 0
|
||||
logger.info('Refreshing non-graduated students of old programmes...')
|
||||
await Promise.all(
|
||||
oldProgrammeCodes.map(
|
||||
c =>
|
||||
new Promise(async res => {
|
||||
try {
|
||||
const [nonGraduatedStudents, studentnumbers] = await nonGraduatedStudentsOfElementDetail(c)
|
||||
await patchNonGraduatedStudents({ [c]: { formattedData: nonGraduatedStudents, studentnumbers } })
|
||||
logger.info(`${++i}/${oldProgrammeCodes.length} of old programmes done`)
|
||||
} catch (e) {
|
||||
logger.error(`Failed refreshing non-graduated students of programme ${c}!`)
|
||||
}
|
||||
res()
|
||||
})
|
||||
)
|
||||
)
|
||||
// refresh this and previous year
|
||||
const currentSemestersYearCode = (await getCurrentSemester()).getDataValue('yearcode')
|
||||
await findAndSaveTeachers(currentSemestersYearCode - 1, currentSemestersYearCode)
|
||||
}
|
||||
|
||||
const refreshProtoCtoRedis = async () => {
|
||||
logger.info('Refreshing CDS ProtoC')
|
||||
const defaultQuery = { include_old_attainments: 'false', exclude_non_enrolled: 'false' }
|
||||
const onlyOld = { include_old_attainments: 'true', exclude_non_enrolled: 'false' }
|
||||
const onlyEnr = { include_old_attainments: 'false', exclude_non_enrolled: 'true' }
|
||||
const bothToggles = { include_old_attainments: 'true', exclude_non_enrolled: 'true' }
|
||||
logger.info('Refreshing CDS ProtoC')
|
||||
await refreshProtoC(defaultQuery)
|
||||
await refreshProtoC(onlyOld)
|
||||
await refreshProtoC(onlyEnr)
|
||||
await refreshProtoC(bothToggles)
|
||||
logger.info('Refreshing CDS ProtoC doned')
|
||||
}
|
||||
|
||||
const refreshStatusToRedis = async () => {
|
||||
@@ -124,68 +96,81 @@ const refreshStatusToRedis = async () => {
|
||||
logger.info('Refreshing CDS Status')
|
||||
await refreshStatus(date.getTime(), showByYearOff)
|
||||
await refreshStatus(date.getTime(), showByYear)
|
||||
logger.info('Refreshing CDS Status doned')
|
||||
|
||||
logger.info('Refreshing CDS Graduated')
|
||||
await refreshStatusGraduated(date.getTime(), showByYearOff)
|
||||
await refreshStatusGraduated(date.getTime(), showByYear)
|
||||
logger.info('Refreshing CDS graduated doned')
|
||||
}
|
||||
|
||||
const refreshUberToRedis = async () => {
|
||||
const years = await getStartYears()
|
||||
const mappedYears = years.map(({ studystartdate }) => studystartdate)
|
||||
mappedYears.forEach(async year => {
|
||||
logger.info(`Refreshing CDS Uber data for date ${year}`)
|
||||
const years = (await getStartYears()).map(({ studystartdate }) => studystartdate)
|
||||
for (const year of years) {
|
||||
const formattedYear = new Date(year).getFullYear()
|
||||
logger.info(`Refreshing CDS uber year ${formattedYear}`)
|
||||
const defaultQuery = { include_old_attainments: 'false', start_date: year }
|
||||
const oldAttainmentsQuery = { include_old_attainments: 'true', start_date: year }
|
||||
await refreshUber(defaultQuery)
|
||||
await refreshUber(oldAttainmentsQuery)
|
||||
})
|
||||
try {
|
||||
await refreshUber(defaultQuery)
|
||||
await refreshUber(oldAttainmentsQuery)
|
||||
logger.info(`Refreshing CDS uber year ${formattedYear} doned`)
|
||||
} catch (e) {
|
||||
logger.error({ message: `Error when refreshing CDS uber year ${formattedYear}`, meta: e })
|
||||
}
|
||||
}
|
||||
logger.info(`Refreshing CDS Uber data doned`)
|
||||
}
|
||||
|
||||
const refreshProtoCProgrammeToRedis = async () => {
|
||||
const codes = (await getAllProgrammes()).map(p => p.code).filter(code => code.includes('KH'))
|
||||
codes.forEach(async code => {
|
||||
logger.info(`Refreshing protocprogramme code ${code}`)
|
||||
for (const code of codes) {
|
||||
logger.info(`Refreshing ProtoCProgramme code ${code}`)
|
||||
const defaultQuery = { include_old_attainments: 'false', exclude_non_enrolled: 'false', code }
|
||||
const onlyOld = { include_old_attainments: 'true', exclude_non_enrolled: 'false', code }
|
||||
const onlyEnr = { include_old_attainments: 'false', exclude_non_enrolled: 'true', code }
|
||||
const bothToggles = { include_old_attainments: 'true', exclude_non_enrolled: 'true', code }
|
||||
await refreshProtoCProgramme(defaultQuery)
|
||||
await refreshProtoCProgramme(onlyOld)
|
||||
await refreshProtoCProgramme(onlyEnr)
|
||||
await refreshProtoCProgramme(bothToggles)
|
||||
})
|
||||
const queries = [defaultQuery, onlyOld, onlyEnr, bothToggles]
|
||||
for (const query of queries) {
|
||||
try {
|
||||
await refreshProtoCProgramme(query)
|
||||
} catch (e) {
|
||||
logger.error({
|
||||
message: `Error when refreshing ProtocProgramme code ${code}, query ${Object.keys({ query })[0]}`,
|
||||
meta: e,
|
||||
}) // object.keys is hax to print query variable name
|
||||
}
|
||||
}
|
||||
logger.info(`Refreshing ProtoCProgramme code ${code} doned`)
|
||||
}
|
||||
logger.info(`Refreshing protocprogramme doned`)
|
||||
}
|
||||
|
||||
const refreshStatistics = async () => {
|
||||
try {
|
||||
await refreshStudyrightAssociations()
|
||||
await refreshOverview()
|
||||
await refreshNonGraduatedStudentsOfOldProgrammes()
|
||||
await refreshTeacherLeaderboard()
|
||||
} catch (e) {
|
||||
logger.error({ message: 'Error happened while refreshing', meta: e })
|
||||
const statfuncs = [refreshStudyrightAssociations, refreshOverview, refreshTeacherLeaderboard]
|
||||
logger.info('Refreshing statistics')
|
||||
for (const func of statfuncs) {
|
||||
await func()
|
||||
}
|
||||
logger.info('Statistics refreshed!')
|
||||
}
|
||||
|
||||
const refreshTrends = async () => {
|
||||
try {
|
||||
await refreshProtoCtoRedis()
|
||||
await refreshStatusToRedis()
|
||||
await refreshUberToRedis()
|
||||
await refreshProtoCProgrammeToRedis()
|
||||
} catch (e) {
|
||||
logger.error({ message: 'Error happened while refreshing', meta: e })
|
||||
const trendfuncs = [refreshProtoCtoRedis, refreshStatusToRedis, refreshUberToRedis, refreshProtoCProgrammeToRedis]
|
||||
logger.info('Refreshing trends')
|
||||
for (const func of trendfuncs) {
|
||||
await func()
|
||||
}
|
||||
logger.info('Trends refreshed!')
|
||||
}
|
||||
|
||||
const startCron = () => {
|
||||
if (isProduction) {
|
||||
schedule('0 6 * * *', async () => {
|
||||
await refreshStatistics()
|
||||
await refreshTrends()
|
||||
// refresh 3am every day
|
||||
schedule('0 3 * * *', async () => {
|
||||
for (const func of [refreshStatistics, refreshTrends]) {
|
||||
await func()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -193,4 +178,5 @@ const startCron = () => {
|
||||
module.exports = {
|
||||
startCron,
|
||||
refreshStatistics,
|
||||
refreshTrends,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const router = require('express').Router()
|
||||
const { getStudentsUserCanAccess } = require('../services/userService')
|
||||
const MandatoryCourses = require('../services/mandatoryCourses')
|
||||
const {
|
||||
productivityStatsForStudytrack,
|
||||
@@ -14,7 +13,6 @@ const {
|
||||
setThroughput,
|
||||
patchProductivity,
|
||||
patchThroughput,
|
||||
getNonGraduatedStudents,
|
||||
} = require('../services/analyticsService')
|
||||
const { getAllProgrammes, getAllElementDetails } = require('../services/studyrights')
|
||||
const logger = require('../util/logger')
|
||||
@@ -47,29 +45,6 @@ router.get('/v2/studyprogrammes/:id/optiondata', async (req, res) => {
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/present_students', async (req, res) => {
|
||||
const {
|
||||
roles,
|
||||
decodedToken,
|
||||
params: { id },
|
||||
} = req
|
||||
if (!id) return res.status(400).json({ error: 'programme id missing' })
|
||||
|
||||
const nonGraduatedStudents = await getNonGraduatedStudents(id)
|
||||
if (!nonGraduatedStudents) return res.status(200).json({})
|
||||
const {
|
||||
data: { formattedData, studentnumbers },
|
||||
} = nonGraduatedStudents
|
||||
|
||||
const filteredData = { ...formattedData }
|
||||
const studentsUserCanAccess = await getStudentsUserCanAccess(studentnumbers, roles, decodedToken.userId)
|
||||
Object.keys(filteredData).forEach(year => {
|
||||
filteredData[year] = filteredData[year].filter(({ studentNumber }) => studentsUserCanAccess.has(studentNumber))
|
||||
})
|
||||
|
||||
res.json(filteredData)
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/mandatory_courses', async (req, res) => {
|
||||
if (req.params.id) {
|
||||
const codes = await MandatoryCourses.byStudyprogramme(req.params.id)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,8 @@ const UserService = require('../services/userService')
|
||||
const TagService = require('../services/tags')
|
||||
const CourseService = require('../services/courses')
|
||||
const StatMergeService = require('../services/statMerger')
|
||||
const { getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
|
||||
const { logger } = require('../util/logger')
|
||||
|
||||
const filterPersonalTags = (population, userId) => {
|
||||
return {
|
||||
@@ -158,12 +160,15 @@ router.post('/v2/populationstatistics/coursesbystudentnumberlist', async (req, r
|
||||
}
|
||||
let studentnumberlist
|
||||
const {
|
||||
decodedToken: { userId },
|
||||
decodedToken: { userId, sisPersonId },
|
||||
roles,
|
||||
} = req
|
||||
|
||||
if (roles && roles.includes('admin')) {
|
||||
studentnumberlist = req.body.studentnumberlist
|
||||
} else if (req.body.usingStudyGuidanceGroups) {
|
||||
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(sisPersonId)
|
||||
studentnumberlist = req.body.studentnumberlist.filter(s => studentsUserCanAccess.has(s))
|
||||
} else {
|
||||
const unitsUserCanAccess = await UserService.getUnitsFromElementDetails(userId)
|
||||
const codes = unitsUserCanAccess.map(unit => unit.id)
|
||||
@@ -367,14 +372,21 @@ router.get('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/v3/populationstatisticsbystudentnumbers', async (req, res) => {
|
||||
const { studentnumberlist } = req.body
|
||||
const { studentnumberlist, usingStudyGuidanceGroups } = req.body
|
||||
const { roles, decodedToken } = req
|
||||
|
||||
const studentsUserCanAccess = await UserService.getStudentsUserCanAccess(
|
||||
studentnumberlist,
|
||||
roles,
|
||||
decodedToken.userId
|
||||
)
|
||||
if (usingStudyGuidanceGroups) {
|
||||
if (!decodedToken.sisPersonId) {
|
||||
logger.error(
|
||||
`User ${decodedToken.userId} tried to get person groups but personId was ${decodedToken.sisPersonId} in header`
|
||||
)
|
||||
return res.status(400).json({ error: 'Not possible to get groups without personId' })
|
||||
}
|
||||
}
|
||||
|
||||
const studentsUserCanAccess = usingStudyGuidanceGroups
|
||||
? await getAllStudentsUserHasInGroups(decodedToken.sisPersonId)
|
||||
: await UserService.getStudentsUserCanAccess(studentnumberlist, roles, decodedToken.userId)
|
||||
const filteredStudentNumbers = studentnumberlist.filter(s => studentsUserCanAccess.has(s))
|
||||
|
||||
const result = await Population.optimizedStatisticsOf(
|
||||
|
||||
@@ -8,21 +8,13 @@ const {
|
||||
|
||||
router.get('/v3/programme_modules/:code', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const module = await byProgrammeCode(code)
|
||||
if (!module) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(module)
|
||||
const result = await byProgrammeCode(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/v3/programme_modules/:code/modules', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const result = await modulesByProgrammeCode(code)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 { getImporterClient } = require('../util/importerClient')
|
||||
const { logger } = require('../util/logger')
|
||||
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const {
|
||||
@@ -10,12 +10,7 @@ router.get('/', async (req, res) => {
|
||||
logger.error(`User ${uid} tried to get person groups but personId was ${sisPersonId} in header`)
|
||||
return res.status(400).json({ error: 'Not possible to get groups without personId' })
|
||||
}
|
||||
const importerClient = getImporterClient()
|
||||
if (!importerClient) {
|
||||
return []
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return res.json(Object.values(data))
|
||||
return res.json(await getAllGroupsAndStudents(sisPersonId))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -25,6 +25,7 @@ router.get('/top', async (req, res) => {
|
||||
if (!yearcode) {
|
||||
return res.status(422).send('Missing required yearcode query param')
|
||||
}
|
||||
|
||||
const result = await topteachers.getTeacherStats(category, yearcode)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
abort,
|
||||
updateCoursesByCourseCode,
|
||||
} = require('../services/sisUpdaterService')
|
||||
const { refreshStatistics } = require('../events')
|
||||
const { refreshStatistics, refreshTrends } = require('../events')
|
||||
|
||||
router.get('/update/v2/meta', async (req, res) => {
|
||||
const response = await updateSISMetadata(req)
|
||||
@@ -53,10 +53,15 @@ router.get('/refresh_redis_cache', async (req, res) => {
|
||||
})
|
||||
|
||||
router.post('/refresh_statistic_v2', async (req, res) => {
|
||||
refreshStatistics()
|
||||
await refreshStatistics()
|
||||
res.status(200).json('Refreshing sis statistics')
|
||||
})
|
||||
|
||||
router.post('/refresh_trends', async (req, res) => {
|
||||
await refreshTrends()
|
||||
res.status(200).json('Refreshing trends')
|
||||
})
|
||||
|
||||
router.get('/abort', async (req, res) => {
|
||||
await abort()
|
||||
res.status(200).json()
|
||||
|
||||
@@ -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,7 +3,6 @@ const moment = require('moment')
|
||||
|
||||
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
|
||||
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
|
||||
const createRedisKeyForNonGraduatedStudents = id => `NONGRADUATEDSTUDENTS_${id}`
|
||||
|
||||
const getProductivity = async id => {
|
||||
const redisKey = createRedisKeyForProductivity(id)
|
||||
@@ -88,32 +87,6 @@ const patchThroughput = async data => {
|
||||
}
|
||||
}
|
||||
|
||||
const patchNonGraduatedStudents = async data => {
|
||||
const [id, dataToPatch] = Object.entries(data)[0]
|
||||
const redisKey = createRedisKeyForNonGraduatedStudents(id)
|
||||
const dataFromRedisRaw = await redisClient.getAsync(redisKey)
|
||||
const dataFromRedis = dataFromRedisRaw ? JSON.parse(dataFromRedisRaw) : { data: null }
|
||||
const patchedData = {
|
||||
data: {
|
||||
...dataFromRedis.data,
|
||||
...dataToPatch,
|
||||
},
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(patchedData))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return {
|
||||
[id]: patchedData,
|
||||
}
|
||||
}
|
||||
|
||||
const getNonGraduatedStudents = async id => {
|
||||
const redisKey = createRedisKeyForNonGraduatedStudents(id)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProductivity,
|
||||
setProductivity,
|
||||
@@ -121,6 +94,4 @@ module.exports = {
|
||||
getThroughput,
|
||||
setThroughput,
|
||||
patchThroughput,
|
||||
patchNonGraduatedStudents,
|
||||
getNonGraduatedStudents,
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const allCodeAltenatives = async code => {
|
||||
const temp = courses
|
||||
.map(c => c.code)
|
||||
.map(c => {
|
||||
if (c.match(/^A/)) return [c, 4] // open university codes come last
|
||||
if (c.match(/^AY?/)) return [c, 4] // open university codes come last
|
||||
if (c.match(/^\d/)) return [c, 2] // old numeric codes come second
|
||||
if (c.match(/^[A-Za-z]/)) return [c, 1] // new letter based codes come first
|
||||
return [c, 3] // unknown, comes before open uni?
|
||||
|
||||
@@ -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(
|
||||
@@ -30,7 +29,7 @@ const recursivelyGetModuleAndChildren = async (code, type) => {
|
||||
|
||||
const byProgrammeCode = async code => {
|
||||
const result = await recursivelyGetModuleAndChildren(code, 'course')
|
||||
|
||||
if (!result.length) return []
|
||||
const excluded = await ExcludedCourse.findAll({
|
||||
where: {
|
||||
programme_code: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -181,6 +181,7 @@ const formatStudent = ({
|
||||
passed: Credit.passed({ credittypecode }) || Credit.improved({ credittypecode }),
|
||||
grade,
|
||||
credits,
|
||||
credittypecode,
|
||||
isStudyModuleCredit: isStudyModule,
|
||||
}
|
||||
}
|
||||
@@ -329,16 +330,6 @@ const filterStudentnumbersByAccessrights = async (studentnumbers, codes) => {
|
||||
return students.map(student => student.studentnumber)
|
||||
}
|
||||
|
||||
const hasEnrolledForSemester = async (studentnumber, semestercode) => {
|
||||
const enrollment = await SemesterEnrollment.findOne({
|
||||
where: {
|
||||
studentnumber,
|
||||
semestercode,
|
||||
},
|
||||
})
|
||||
return !!(enrollment && enrollment.enrollmenttype === 1)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
withId,
|
||||
bySearchTerm,
|
||||
@@ -349,5 +340,4 @@ module.exports = {
|
||||
findByCourseAndSemesters,
|
||||
findByTag,
|
||||
splitByEmptySpace,
|
||||
hasEnrolledForSemester,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const { getImporterClient } = require('../util/importerClient')
|
||||
const importerClient = getImporterClient()
|
||||
|
||||
const getAllStudentsUserHasInGroups = async sisPersonId => {
|
||||
if (!importerClient) {
|
||||
return new Set()
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return new Set(
|
||||
Object.values(data)
|
||||
.map(group => group.members)
|
||||
.flat()
|
||||
.map(member => member.personStudentNumber)
|
||||
)
|
||||
}
|
||||
|
||||
const getAllGroupsAndStudents = async sisPersonId => {
|
||||
if (!importerClient) {
|
||||
return []
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return Object.values(data)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllStudentsUserHasInGroups,
|
||||
getAllGroupsAndStudents,
|
||||
}
|
||||
@@ -385,7 +385,9 @@ const graduationsFromClass = async (studentnumbers, studytrack) => {
|
||||
const genderCodeToValue = code => {
|
||||
if (code === '1') return 'Male'
|
||||
if (code === '2') return 'Female'
|
||||
return 'Other'
|
||||
if (code === '3') return 'Other'
|
||||
if (code === '0') return 'Unknown'
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
const gendersFromClass = async studentnumbers => {
|
||||
|
||||
@@ -2,16 +2,11 @@ const {
|
||||
dbConnections: { sequelize },
|
||||
} = require('../database/connection')
|
||||
const { Studyright, StudyrightElement, ElementDetail } = require('../models')
|
||||
const { getUserElementDetails } = require('./userService')
|
||||
const { hasEnrolledForSemester } = require('./students')
|
||||
const { getCurrentSemester } = require('./semesters')
|
||||
const moment = require('moment')
|
||||
const { redisClient } = require('../services/redis')
|
||||
const _ = require('lodash')
|
||||
const { Op, col, where, fn } = require('sequelize')
|
||||
|
||||
const createStudyright = apiData => Studyright.create(apiData)
|
||||
|
||||
const REDIS_KEY = 'STUDYRIGHT_ASSOCIATIONS_V2'
|
||||
|
||||
const byStudent = studentNumber => {
|
||||
@@ -24,97 +19,6 @@ const byStudent = studentNumber => {
|
||||
})
|
||||
}
|
||||
|
||||
const getActiveStudyrightElementsOfCodeBeforeDate = async (code, date) =>
|
||||
await StudyrightElement.findAll({
|
||||
where: {
|
||||
code,
|
||||
enddate: {
|
||||
[Op.gte]: date,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getActiveStudyrightsFromIdsBeforeDate = async (studyrightIds, date) =>
|
||||
await Studyright.findAll({
|
||||
where: {
|
||||
graduated: 0,
|
||||
studyrightid: {
|
||||
[Op.in]: studyrightIds,
|
||||
},
|
||||
canceldate: null,
|
||||
enddate: {
|
||||
[Op.gte]: date,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const nonGraduatedStudentsOfElementDetail = async code => {
|
||||
const today = new Date()
|
||||
const currentSemesterCode = (await getCurrentSemester()).semestercode
|
||||
|
||||
// Get studyrights of target code that haven't ended yet
|
||||
const studyrightElements = await getActiveStudyrightElementsOfCodeBeforeDate(code, today)
|
||||
const studentToDatesMap = {}
|
||||
|
||||
studyrightElements.forEach(({ startdate, enddate, studentnumber }) => {
|
||||
studentToDatesMap[studentnumber] = {
|
||||
startdate,
|
||||
enddate,
|
||||
}
|
||||
})
|
||||
|
||||
const studyrights = await getActiveStudyrightsFromIdsBeforeDate(
|
||||
studyrightElements.map(sE => sE.studyrightid),
|
||||
today
|
||||
)
|
||||
|
||||
// Filter out students that are in new master's programmes,
|
||||
// see if student is currently enrolled and format the result
|
||||
// to shape of { year1: [studentnumbers], ... }
|
||||
const result = {}
|
||||
const studentnumbers = new Set()
|
||||
const studentsToBeFiltered = new Set()
|
||||
await Promise.all(
|
||||
studyrights.map(
|
||||
({ studentStudentnumber: student_studentnumber, studyrightid }) =>
|
||||
new Promise(async res => {
|
||||
const [enrolled, studentElementDetails] = await Promise.all([
|
||||
await hasEnrolledForSemester(student_studentnumber, currentSemesterCode),
|
||||
await StudyrightElement.findAll({
|
||||
where: {
|
||||
studyrightid,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// If student is in new master's programme,
|
||||
// then don't include them in the result
|
||||
if (studentElementDetails.find(e => e.code.match(/^M[A-Z]*[0-9]*_[0-9]*$/))) {
|
||||
studentsToBeFiltered.add(student_studentnumber)
|
||||
return res()
|
||||
}
|
||||
|
||||
if (studentnumbers.has(student_studentnumber)) return res()
|
||||
const year = moment(studentToDatesMap[student_studentnumber].startdate).tz('Europe/Helsinki').year()
|
||||
if (!result[year]) result[year] = []
|
||||
studentnumbers.add(student_studentnumber)
|
||||
result[year].push({
|
||||
studentNumber: student_studentnumber,
|
||||
enrolled,
|
||||
})
|
||||
res()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Filter out some special cases
|
||||
Object.keys(result).forEach(year => {
|
||||
result[year] = result[year].filter(({ studentNumber }) => !studentsToBeFiltered.has(studentNumber))
|
||||
})
|
||||
|
||||
return [result, [...studentnumbers]]
|
||||
}
|
||||
|
||||
const studentNumbersWithAllStudyRightElements = async (codes, startedAfter, startedBefore) => {
|
||||
const studyrights = await StudyrightElement.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
@@ -235,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: {
|
||||
@@ -419,24 +314,14 @@ 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,
|
||||
createStudyright,
|
||||
studentNumbersWithAllStudyRightElements,
|
||||
getAssociatedStudyrights,
|
||||
getAllStudyrightElementsAndAssociations,
|
||||
getStudyrightElementsAndAssociationsForUser,
|
||||
getAssociations,
|
||||
getFilteredAssociations,
|
||||
getUserAssociations,
|
||||
refreshAssociationsInRedis,
|
||||
getAllProgrammes,
|
||||
getAllElementDetails,
|
||||
nonGraduatedStudentsOfElementDetail,
|
||||
}
|
||||
|
||||
@@ -191,6 +191,11 @@ const activeTeachers = async (providers, semestercodeStart, semestercodeEnd) =>
|
||||
},
|
||||
],
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
[Op.notLike]: '%hy-hlo-org%',
|
||||
},
|
||||
},
|
||||
})
|
||||
return teachers.map(({ id }) => id)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const { redisClient } = require('../services/redis')
|
||||
const { getSemestersAndYears, getMaxYearcode } = require('./semesters')
|
||||
const { getSemestersAndYears, getCurrentSemester } = require('./semesters')
|
||||
const { Teacher, Semester, Credit, Course } = require('../models')
|
||||
const { Op } = require('sequelize')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
const category = (name, rediskey) => ({ name, rediskey })
|
||||
|
||||
@@ -128,11 +129,13 @@ const findTopTeachers = async yearcode => {
|
||||
}
|
||||
}
|
||||
|
||||
const findAndSaveTeachers = async (startcode = 50, to) => {
|
||||
const endcode = to ? to : await getMaxYearcode()
|
||||
const findAndSaveTeachers = async (startcode = 1, to) => {
|
||||
const endcode = to || (await getCurrentSemester()).getDataValue('yearcode')
|
||||
for (let code = startcode; code <= endcode; code++) {
|
||||
await findAndSaveTopTeachers(code)
|
||||
logger.info(`Teacher leaderboard for yearcode ${code} calculated`)
|
||||
}
|
||||
logger.info(`Teacher leaderboard calculations done`)
|
||||
}
|
||||
|
||||
const findAndSaveTopTeachers = async yearcode => {
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
@@ -54,18 +189,6 @@ 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 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') => {
|
||||
return years.map(
|
||||
year =>
|
||||
@@ -83,6 +206,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... */
|
||||
@@ -116,14 +251,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
)
|
||||
|
||||
/* 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, elementDetails, faculties] = await Promise.all([
|
||||
Promise.all(yearlyAccCreditsPromises),
|
||||
Promise.all(yearlyTotalCreditsPromises),
|
||||
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) => {
|
||||
@@ -146,14 +331,26 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const organizationCodeToOrganization = faculties.reduce((acc, org) => {
|
||||
acc[org.code] = org
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/* Calculate course level stats and group by providers */
|
||||
const coursesGroupedByProvider = Object.entries(
|
||||
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], '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
|
||||
@@ -161,6 +358,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
acc[courseCode]['yearly'][year]['total'] = 0
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = 0
|
||||
})
|
||||
|
||||
yearlyInstances.forEach(instance => {
|
||||
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
|
||||
|
||||
@@ -186,17 +384,28 @@ 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) {
|
||||
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,
|
||||
previousStudents,
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
@@ -207,18 +416,29 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
facultyCodes.forEach(facultyCode => {
|
||||
if (!facultyCode) return
|
||||
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: 0,
|
||||
previous: 0,
|
||||
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,8 +1,5 @@
|
||||
const validateParamLength = (param, minLength) => param && param.trim().length >= minLength
|
||||
|
||||
const isNewHYStudyProgramme = code => !!(code && code.match(/^[A-Z]*[0-9]*_[0-9]*$/))
|
||||
|
||||
module.exports = {
|
||||
validateParamLength,
|
||||
isNewHYStudyProgramme,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const winston = require('winston')
|
||||
const { isProduction } = require('../conf-backend')
|
||||
const path = require('path')
|
||||
const { isProduction, PWD } = require('../conf-backend')
|
||||
|
||||
const { combine, timestamp, printf, splat } = winston.format
|
||||
|
||||
const transports = []
|
||||
|
||||
transports.push(new winston.transports.File({ filename: 'debug.log' }))
|
||||
// Write logfile to docker containers pwd
|
||||
const filename = path.join(PWD, 'debug.log')
|
||||
transports.push(new winston.transports.File({ filename, level: 'debug' }))
|
||||
|
||||
if (!isProduction) {
|
||||
const devFormat = printf(
|
||||
@@ -38,7 +40,7 @@ if (isProduction) {
|
||||
})
|
||||
)
|
||||
|
||||
transports.push(new winston.transports.Console({ format: prodFormat }))
|
||||
transports.push(new winston.transports.Console({ format: prodFormat, level: 'info' }))
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({ transports })
|
||||
|
||||
Generated
+22519
-28
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
"highcharts": "^9.1.2",
|
||||
"highcharts-custom-events": "^3.0.10",
|
||||
"history": "^4.10.1",
|
||||
"jStat": "^1.8.6",
|
||||
"jstat": "^1.9.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
|
||||
@@ -64,6 +64,8 @@ export default {
|
||||
|
||||
Valinta "Näytä kalenterivuosittain" laskee kertymän kalenterivuoden alusta,
|
||||
eikä lukuvuoden alusta.
|
||||
|
||||
Valinta "Näytä suhteutettuna opiskelijoiden määrään" näyttää opintopistemäärät suhteutettuna kyseisen ajanjakson aikana kyseisen organisaation alaisuuteen kuuluvia kursseja suorittaineiden opiskelijoiden määrään. Tämä luku sisältää myös avoimen yliopiston opiskelijat.
|
||||
|
||||
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -568,8 +568,6 @@ const ProtoC = ({
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
console.log({ CoolDataScience })
|
||||
|
||||
// create list from sorters and deprecate this
|
||||
const sorterNames = Object.keys(sorters)
|
||||
.map(sorterName => sorterName)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 { Segment, Popup, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
@@ -28,31 +27,58 @@ 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 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,76 +106,69 @@ 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)`,
|
||||
}}
|
||||
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 style={{ display: 'contents' }} 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>
|
||||
<span className="unit">students</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="year-value accumulated">
|
||||
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
|
||||
</span>
|
||||
<span style={{ color: '#AAA', margin: '0 0.3em' }}>{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 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 [showRelativeValues, setShowRelativeValues] = useLocalStorage('showRelativeValues', false)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
@@ -191,6 +210,11 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowRelativeValuesChanged = () => {
|
||||
sendAnalytics(`S Show relative values toggle ${!showRelativeValues ? 'on' : 'off'}`)
|
||||
setShowRelativeValues(!showRelativeValues)
|
||||
}
|
||||
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
@@ -247,6 +271,13 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
checked={showByYear}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä suhteutettuna opiskelijoiden määrään"
|
||||
onChange={handleShowRelativeValuesChanged}
|
||||
checked={showRelativeValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Form>
|
||||
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
|
||||
@@ -285,14 +316,25 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
)
|
||||
|
||||
const orderedAbsDiffs = _.orderBy(
|
||||
Object.values(_.last(drillStack) || data).map(({ current, previous }) => {
|
||||
return Math.abs(Math.floor((getP(current, previous) - 1) * 1000) / 10)
|
||||
Object.values(_.last(drillStack) || data).map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
return Math.abs(
|
||||
Math.floor(
|
||||
(getP(
|
||||
showRelativeValues ? current / currentStudents : current,
|
||||
showRelativeValues ? previous / previousStudents : previous
|
||||
) -
|
||||
1) *
|
||||
1000
|
||||
) / 10
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Uusien ohjelmien tuottamat opintopisteet</h2>
|
||||
<h2>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
@@ -315,23 +357,16 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
['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
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
clickable={!!stats.drill}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
@@ -348,12 +383,6 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
)
|
||||
}
|
||||
|
||||
Status.propTypes = {
|
||||
data: shape({}).isRequired,
|
||||
loading: bool.isRequired,
|
||||
getStatusDispatch: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.status,
|
||||
loading: coolDataScience.pending.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 && (
|
||||
|
||||
@@ -10,6 +10,42 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.years.item-type-course {
|
||||
grid-template-columns: repeat(2, fit-content(0)) auto;
|
||||
}
|
||||
|
||||
.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: inline-block;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -121,7 +124,6 @@ const CoursePopulation = ({
|
||||
}, [semesters])
|
||||
|
||||
const header = courseData ? `${getTextIn(courseData.name, language)} ${headerYears}` : null
|
||||
const coursecode = courseData ? courseData.alternatives : []
|
||||
|
||||
const subHeader = codes.join(', ')
|
||||
|
||||
@@ -260,7 +262,7 @@ const CoursePopulation = ({
|
||||
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
|
||||
coursePopulation
|
||||
language={language}
|
||||
coursecode={coursecode}
|
||||
coursecode={codes}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -27,36 +27,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 +81,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 +114,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)
|
||||
|
||||
+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'
|
||||
@@ -13,9 +14,8 @@ import Age from './filters/Age'
|
||||
import AdmissionType from './filters/AdmissionType'
|
||||
import Tags from './filters/Tags'
|
||||
|
||||
export const PopulationStatisticsFilters = () => {
|
||||
export const PopulationStatisticsFilters = ({ query }) => {
|
||||
const analytics = useAnalytics()
|
||||
|
||||
useEffect(() => {
|
||||
analytics.setTarget('Population Statistics')
|
||||
})
|
||||
@@ -29,7 +29,7 @@ export const PopulationStatisticsFilters = () => {
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<AdmissionType />
|
||||
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
|
||||
<Courses />
|
||||
<Tags />
|
||||
</>
|
||||
@@ -49,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const FilterTray = ({ children, filterSet, visible }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
|
||||
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
|
||||
<Header size="small" style={{ textAlign: 'center' }}>
|
||||
Filter students
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -8,11 +7,52 @@ import { isEqual } from 'lodash'
|
||||
import { images, getUserRoles, checkUserAccess } from '../common'
|
||||
import { useTitle } from '../common/hooks'
|
||||
import { builtAt } from '../conf'
|
||||
import oodiTXT from '../static/oodi.txt'
|
||||
|
||||
const OodiToOodikone = () => {
|
||||
const [oodis, setOodis] = useState([])
|
||||
useEffect(() => {
|
||||
const getOodis = async () => {
|
||||
try {
|
||||
const data = await fetch(oodiTXT)
|
||||
setOodis(
|
||||
(await data.text())
|
||||
.split('**')
|
||||
.map(o => o.trim())
|
||||
.filter(oodi => !!oodi)
|
||||
)
|
||||
} catch (error) {
|
||||
setOodis([])
|
||||
}
|
||||
}
|
||||
getOodis()
|
||||
}, [])
|
||||
|
||||
if (!oodis || oodis.length === 0) {
|
||||
return null
|
||||
}
|
||||
const oodi = oodis[Math.floor(Math.random() * oodis.length)].split('\n')
|
||||
return (
|
||||
<div style={{ margin: 'auto', width: '50%' }}>
|
||||
{oodi.map((l, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<p key={index} style={{ textAlign: 'center', fontStyle: 'italic', margin: 0 }}>
|
||||
{index === 0 && '\u201C'}
|
||||
{l}
|
||||
{index === oodi.length - 1 && '\u201D'}
|
||||
</p>
|
||||
))}
|
||||
<p style={{ marginTop: '1.3em', textAlign: 'center' }}>
|
||||
- <a href="http://www.helsinki.fi/discovery">R. U. Nokone</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FrontPage = props => {
|
||||
const { userRoles, rights } = props
|
||||
useTitle()
|
||||
|
||||
useTitle()
|
||||
const showItems = {
|
||||
populations: userRoles.includes('admin') || rights.length !== 0,
|
||||
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
|
||||
@@ -32,6 +72,8 @@ const FrontPage = props => {
|
||||
<Header as="h3" style={{ textAlign: 'center' }}>
|
||||
Exploratory Research on Study Data
|
||||
</Header>
|
||||
<OodiToOodikone />
|
||||
|
||||
{showItems.populations && (
|
||||
<>
|
||||
<Divider section />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,11 +3,12 @@ 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,7 +129,7 @@ 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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+34
-53
@@ -1,61 +1,42 @@
|
||||
import React from 'react'
|
||||
import { jStat } from 'jStat'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import { Grid } 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))
|
||||
if (filteredStudents.length === allStudents.length) return null
|
||||
return <StatisticsTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
|
||||
}
|
||||
|
||||
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>
|
||||
<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
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#credit-gain-stats .statistics-table {
|
||||
margin: 0 auto;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#credit-gain-stats .collapsing + th,
|
||||
|
||||
@@ -8,7 +8,7 @@ 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}>
|
||||
@@ -20,7 +20,7 @@ const CreditGainStats = ({ filteredStudents }) => {
|
||||
const renderQuartersTab = useCallback(() => {
|
||||
return (
|
||||
<Tab.Pane attached={false}>
|
||||
<StatisticsTab filteredStudents={filteredStudents} />
|
||||
<StatisticsTab allStudents={filteredStudents} query={query} />
|
||||
</Tab.Pane>
|
||||
)
|
||||
}, [filteredStudents])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default () => {
|
||||
const queryStudyrights = useSelector(({ populations }) =>
|
||||
populations.query ? Object.values(populations.query.studyRights) : []
|
||||
)
|
||||
const queryYear = useSelector(({ populations }) => populations?.query?.year)
|
||||
|
||||
// FIXME:
|
||||
const mandatoryPassed = useSelector(({ populationCourses, populationMandatoryCourses }) => {
|
||||
@@ -66,6 +67,13 @@ export default () => {
|
||||
return studentTags.join(', ')
|
||||
}
|
||||
|
||||
const getAdmissionType = studyRights => {
|
||||
const code = queryStudyrights[0]
|
||||
if (!code || !studyRights) return ''
|
||||
const studyright = studyRights.find(sr => sr.studyright_elements.some(e => e.code === code))
|
||||
return studyright && studyright.admission_type ? studyright.admission_type : 'Ei valintatapaa'
|
||||
}
|
||||
|
||||
const studytrack = studyrights => {
|
||||
let startdate = '1900-01-01'
|
||||
let enddate = '2020-04-20'
|
||||
@@ -123,6 +131,7 @@ export default () => {
|
||||
studytrack: studytrack(s.studyrights).map(st => st.name)[0],
|
||||
tags: tags(s.tags),
|
||||
'start year at university': reformatDate(s.started, 'YYYY'),
|
||||
'admission type': parseInt(queryYear, 10 >= 2020) ? getAdmissionType(s.studyrights) : undefined,
|
||||
'updated at': reformatDate(s.updatedAt, 'YYYY-MM-DD hh:mm:ss'),
|
||||
'mandatory total passed': totalMandatoryPassed(s.studentNumber),
|
||||
...sortedMandatory.reduce((acc, m) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { memo, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { bool, shape, arrayOf, any } from 'prop-types'
|
||||
import { Header, Segment } from 'semantic-ui-react'
|
||||
import { flattenDeep } from 'lodash'
|
||||
import PopulationDetails from '../PopulationDetails'
|
||||
@@ -24,7 +23,7 @@ const PopulationStatistics = memo(props => {
|
||||
}, [students])
|
||||
|
||||
return (
|
||||
<FilterTray filterSet={<PopulationStatisticsFilters />} visible={location.search !== ''}>
|
||||
<FilterTray filterSet={<PopulationStatisticsFilters query={query} />} visible={location.search !== ''}>
|
||||
<div className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Population statistics
|
||||
@@ -47,17 +46,6 @@ const PopulationStatistics = memo(props => {
|
||||
)
|
||||
})
|
||||
|
||||
PopulationStatistics.propTypes = {
|
||||
location: shape({}).isRequired,
|
||||
history: shape({}).isRequired,
|
||||
samples: arrayOf(shape({})).isRequired,
|
||||
queryIsSet: bool.isRequired,
|
||||
isLoading: bool.isRequired,
|
||||
selectedStudentsByYear: shape({}).isRequired,
|
||||
query: shape({}).isRequired,
|
||||
students: arrayOf(any).isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
// haha copied from other place :mintu:
|
||||
const { samples, selectedStudentsByYear } = selectors.makePopulationsToData(state)
|
||||
|
||||
@@ -30,6 +30,7 @@ const GeneralTab = ({
|
||||
isAdmin,
|
||||
studentToTargetCourseDateMap,
|
||||
coursecode,
|
||||
queryYear,
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const { filteredStudents } = useFilters()
|
||||
@@ -249,7 +250,7 @@ const GeneralTab = ({
|
||||
})
|
||||
}
|
||||
|
||||
if (coursecode) {
|
||||
if (coursecode.length > 0) {
|
||||
columns.push({
|
||||
key: 'grade for single course',
|
||||
title: 'Grade',
|
||||
@@ -334,7 +335,7 @@ const GeneralTab = ({
|
||||
})
|
||||
}
|
||||
|
||||
if (!(coursePopulation || customPopulation)) {
|
||||
if (!(coursePopulation || customPopulation) && parseInt(queryYear, 10) >= 2020) {
|
||||
const code = queryStudyrights[0]
|
||||
columns.push({
|
||||
key: 'admission type',
|
||||
@@ -449,7 +450,6 @@ GeneralTab.defaultProps = {
|
||||
studentToTargetCourseDateMap: null,
|
||||
customPopulation: false,
|
||||
coursePopulation: false,
|
||||
coursecode: '',
|
||||
}
|
||||
|
||||
GeneralTab.propTypes = {
|
||||
@@ -460,7 +460,6 @@ GeneralTab.propTypes = {
|
||||
queryStudyrights: arrayOf(string).isRequired,
|
||||
isAdmin: bool.isRequired,
|
||||
studentToTargetCourseDateMap: shape({}),
|
||||
coursecode: string,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
@@ -475,6 +474,7 @@ const mapStateToProps = state => {
|
||||
isAdmin: getUserIsAdmin(roles),
|
||||
populationStatistics: populations.data,
|
||||
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
|
||||
queryYear: populations?.query?.year,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
+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
|
||||
|
||||
+4
-3
@@ -14,8 +14,9 @@ const CourseParticipationTable = ({ student, language, clearCourseStats }) => {
|
||||
const courseRowsByAcademicYear = {}
|
||||
|
||||
student.courses.sort(byDateDesc).forEach(c => {
|
||||
const { date, grade, credits, course, isStudyModuleCredit, passed } = c
|
||||
const { date, grade, credits, course, isStudyModuleCredit, passed, credittypecode } = c
|
||||
let icon = null
|
||||
|
||||
if (isStudyModuleCredit) {
|
||||
icon = <Icon name="certificate" color="purple" />
|
||||
} else if (passed) {
|
||||
@@ -36,7 +37,7 @@ const CourseParticipationTable = ({ student, language, clearCourseStats }) => {
|
||||
reformatDate(date, 'DD.MM.YYYY'),
|
||||
`${
|
||||
isStudyModuleCredit ? `${getTextIn(course.name, language)} [Study Module]` : getTextIn(course.name, language)
|
||||
} (${course.code})`,
|
||||
} ${credittypecode === 7 ? `, ${course.code} (korotettu)` : `(${course.code})`}`,
|
||||
<div>
|
||||
{icon}
|
||||
{grade}
|
||||
@@ -54,7 +55,7 @@ const CourseParticipationTable = ({ student, language, clearCourseStats }) => {
|
||||
reformatDate(date, 'DD.MM.YYYY'),
|
||||
`${
|
||||
isStudyModuleCredit ? `${getTextIn(course.name, language)} [Study Module]` : getTextIn(course.name, language)
|
||||
} (${course.code})`,
|
||||
} ${credittypecode === 7 ? `, ${course.code} (korotettu)` : `(${course.code})`}`,
|
||||
<div>
|
||||
{icon}
|
||||
{grade}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// temp disable prop types
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
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'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
|
||||
import StyledMessage from './StyledMessage'
|
||||
import { getTextIn } from '../../common'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
|
||||
import useFilterTray from '../FilterTray/useFilterTray'
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import InfoBox from '../InfoBox'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
|
||||
const SingleStudyGroupContent = ({ population, language }) => {
|
||||
const custompop = population?.students || []
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const [trayOpen] = useFilterTray()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
const creditGainRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const coursesRef = useRef()
|
||||
const studentRef = useRef()
|
||||
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
|
||||
const [activeIndex, setIndex] = useState([])
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
indexes.splice(
|
||||
indexes.findIndex(ind => ind === index),
|
||||
1
|
||||
)
|
||||
} else {
|
||||
indexes.push(index)
|
||||
}
|
||||
if (activeIndex.length < indexes.length) setNewest(index)
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setAllStudents(custompop)
|
||||
handleClick(0)
|
||||
}, [custompop])
|
||||
|
||||
useEffect(() => {
|
||||
if (newestIndex) {
|
||||
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
const panels = [
|
||||
{
|
||||
key: 0,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit accumulation (for {selectedStudents.length} students)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(0),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CreditAccumulationGraphHighCharts
|
||||
students={custompop}
|
||||
selectedStudents={selectedStudents}
|
||||
render={false}
|
||||
trayOpen={trayOpen}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Programme distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(1),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Courses of population
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(2),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={coursesRef}>
|
||||
<CustomPopulationCourses selectedStudents={selectedStudents} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({selectedStudents.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(3),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterTray filterSet={<CustomPopulationFilters />}>
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
</FilterTray>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
|
||||
const dispatch = useDispatch()
|
||||
const history = useHistory()
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
const group = groups.find(g => g.id === groupid)
|
||||
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
|
||||
const { data: population, pending } = useSelector(({ populations }) => populations)
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return
|
||||
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
|
||||
if (groupStudentNumbers.length === 0) return
|
||||
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
|
||||
dispatch(
|
||||
getCustomPopulationCoursesByStudentnumbers({
|
||||
studentnumberlist: groupStudentNumbers,
|
||||
usingStudyGuidanceGroups: true,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<StyledMessage>
|
||||
Couldn't find group with id {groupid}! Please check that id is correct and you have rights to this study
|
||||
guidance group.
|
||||
</StyledMessage>
|
||||
)
|
||||
}
|
||||
|
||||
const isLoading = pending === undefined || pending === true
|
||||
const isLoaded = pending === false && Object.keys(population).length > 0
|
||||
const renderMessage = groupStudentNumbers.length === 0
|
||||
const renderLoader = groupStudentNumbers.length > 0 && isLoading
|
||||
const renderGroup = groupStudentNumbers.length > 0 && isLoaded
|
||||
|
||||
return (
|
||||
<SingleStudyGroupViewWrapper history={history} groupName={group.name} language={language}>
|
||||
{renderMessage ? <StyledMessage>This study guidance group doesn't contain any students.</StyledMessage> : null}
|
||||
{renderLoader ? (
|
||||
<Loader active inline="centered">
|
||||
Loading
|
||||
</Loader>
|
||||
) : null}
|
||||
{renderGroup ? <SingleStudyGroupContent population={population} language={language} /> : null}
|
||||
</SingleStudyGroupViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default SingleStudyGuidanceGroupContainer
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SortableTable from '../SortableTable'
|
||||
import { getTextIn } from '../../common'
|
||||
import StyledMessage from './StyledMessage'
|
||||
|
||||
const StudyGuidanceGroupOverview = () => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
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>
|
||||
),
|
||||
cellProps: {
|
||||
style: {
|
||||
padding: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (groups.length === 0) {
|
||||
return <StyledMessage>You do not have access to any study guidance groups.</StyledMessage>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<StyledMessage>
|
||||
<p>Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin.</p>
|
||||
</StyledMessage>
|
||||
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudyGuidanceGroupOverview
|
||||
@@ -0,0 +1,4 @@
|
||||
import React from 'react'
|
||||
import { Message } from 'semantic-ui-react'
|
||||
|
||||
export default ({ children }) => <Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
|
||||
@@ -1,243 +1,44 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// temp disable prop types
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Link, useRouteMatch, useHistory } from 'react-router-dom'
|
||||
import { Button, Loader, Segment, Message, Header, Accordion, Divider } from 'semantic-ui-react'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import { useRouteMatch } from 'react-router-dom'
|
||||
import { Header, Loader, Segment } from 'semantic-ui-react'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import { getStudyGuidanceGroups } from '../../redux/studyGuidanceGroups'
|
||||
import SortableTable from '../SortableTable'
|
||||
import { getTextIn } from '../../common'
|
||||
import { getCustomPopulation } from '../../redux/populations'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
|
||||
import useFilterTray from '../FilterTray/useFilterTray'
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import InfoBox from '../InfoBox'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
|
||||
const SingleStudyGroupView = ({ group }) => {
|
||||
const history = useHistory()
|
||||
const dispatch = useDispatch()
|
||||
const { data, pending } = useSelector(state => state.populations)
|
||||
const { language } = useSelector(state => state.settings)
|
||||
const custompop = data.students || []
|
||||
const { onProgress } = useProgress(pending)
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const [trayOpen] = useFilterTray()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
const creditGainRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const coursesRef = useRef()
|
||||
const studentRef = useRef()
|
||||
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
|
||||
const [activeIndex, setIndex] = useState([])
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
indexes.splice(
|
||||
indexes.findIndex(ind => ind === index),
|
||||
1
|
||||
)
|
||||
} else {
|
||||
indexes.push(index)
|
||||
}
|
||||
if (activeIndex.length < indexes.length) setNewest(index)
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!custompop || custompop.length === 0) return
|
||||
setAllStudents(custompop)
|
||||
handleClick(0)
|
||||
}, [custompop])
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return
|
||||
const studentnumberlist = group.members.map(({ personStudentNumber }) => personStudentNumber)
|
||||
dispatch(getCustomPopulation({ studentnumberlist, onProgress }))
|
||||
dispatch(getCustomPopulationCoursesByStudentnumbers({ studentnumberlist }))
|
||||
}, [group])
|
||||
|
||||
useEffect(() => {
|
||||
if (newestIndex) {
|
||||
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
const panels = [
|
||||
{
|
||||
key: 0,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit accumulation (for {selectedStudents.length} students)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(0),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CreditAccumulationGraphHighCharts
|
||||
students={custompop}
|
||||
selectedStudents={selectedStudents}
|
||||
render={false}
|
||||
trayOpen={trayOpen}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Programme distribution
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(1),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Courses of population
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(2),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={coursesRef}>
|
||||
<CustomPopulationCourses selectedStudents={selectedStudents} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({selectedStudents.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(3),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!group) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
|
||||
<Divider />
|
||||
<FilterTray filterSet={<CustomPopulationFilters />}>
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
{pending ? <Loader active>Loading</Loader> : null}
|
||||
{!pending && custompop ? (
|
||||
<>
|
||||
<Header className="segmentTitle" size="medium" textAlign="center">
|
||||
Population of group {getTextIn(group.name, language)}
|
||||
</Header>
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
</>
|
||||
) : null}
|
||||
</Segment>
|
||||
</div>
|
||||
</FilterTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
const { language } = useSelector(state => state.settings)
|
||||
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>
|
||||
),
|
||||
cellProps: {
|
||||
style: {
|
||||
padding: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return !groups || groups.length === 0 ? (
|
||||
<Message>You do not have access to any study guidance groups</Message>
|
||||
) : (
|
||||
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
)
|
||||
}
|
||||
import StudyGuidanceGroupOverview from './StudyGuidanceGroupOverview'
|
||||
import SingleStudyGuidanceGroup from './SingleStudyGuidanceGroup'
|
||||
|
||||
const StudyGuidanceGroups = () => {
|
||||
useTitle('Study guidance groups')
|
||||
const dispatch = useDispatch()
|
||||
const { pending, data } = useSelector(state => state.studyGuidanceGroups)
|
||||
const { pending, data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
const match = useRouteMatch('/studyguidancegroups/:groupid')
|
||||
const groupid = match?.params?.groupid
|
||||
|
||||
useEffect(() => {
|
||||
if (groups && groups.length > 0) return
|
||||
dispatch(getStudyGuidanceGroups())
|
||||
}, [])
|
||||
}, [dispatch])
|
||||
|
||||
const isLoading = pending === undefined || pending === true
|
||||
const isLoaded = pending === false
|
||||
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Header className="segmentTitle" size="large">
|
||||
Study guidance groups
|
||||
</Header>
|
||||
<Segment className="contentSegment">
|
||||
{pending ? <Loader active>Loading</Loader> : null}
|
||||
{!pending && !groupid ? <StudyGuidanceGroupOverview groups={data} /> : null}
|
||||
{!pending && groupid ? <SingleStudyGroupView group={data.find(g => g.id === 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mandatoryCourses) return
|
||||
if (!mandatoryCourses || !mandatoryCourses.length) return
|
||||
const modules = {}
|
||||
mandatoryCourses.forEach(course => {
|
||||
const code = course.label_code
|
||||
@@ -118,6 +118,9 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
|
||||
|
||||
const calculateModuleVisibility = code => {
|
||||
const module = modules.find(({ module }) => module === code)
|
||||
if (!module.courses) {
|
||||
return 'no courses'
|
||||
}
|
||||
if (module.courses.every(course => course.visible.visibility)) {
|
||||
return 'visible'
|
||||
}
|
||||
@@ -141,10 +144,12 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
|
||||
return (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.HeaderCell>Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Code</Table.HeaderCell>
|
||||
<Table.HeaderCell>Label</Table.HeaderCell>
|
||||
<Table.HeaderCell>Set visibility</Table.HeaderCell>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Code</Table.HeaderCell>
|
||||
<Table.HeaderCell>Label</Table.HeaderCell>
|
||||
<Table.HeaderCell>Set visibility</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{modules.map(({ module, courses }) => (
|
||||
@@ -152,7 +157,7 @@ const DegreeCourses = ({ studyProgramme, setExclusion, removeExclusion }) => {
|
||||
<Table.Row key={module}>
|
||||
<Table.Cell style={{ cursor: 'pointer' }} onClick={() => toggleVisible(module)}>
|
||||
<Icon name={visible[module] ? 'angle down' : 'angle right'} />
|
||||
<b>{courses[0].label_name ? getTextIn(courses[0].label_name, language) : module}</b>
|
||||
<b>{courses[0] && courses[0].label_name ? getTextIn(courses[0].label_name, language) : module}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{module}</Table.Cell>
|
||||
<Table.Cell>
|
||||
|
||||
@@ -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,104 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Icon, Accordion, Button, Table, Modal } from 'semantic-ui-react'
|
||||
import { differenceBy } from 'lodash'
|
||||
import { bool, func, number, arrayOf, shape, string } from 'prop-types'
|
||||
import { getCustomPopulation } from '../../../redux/populations'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../../redux/populationCourses'
|
||||
|
||||
const YearAccordion = ({ active, handleClick, index, years, students, bold }) => {
|
||||
const history = useHistory()
|
||||
const dispatch = useDispatch()
|
||||
const enrolledStudents = students.filter(s => s.enrolled)
|
||||
const nonEnrolledStudents = differenceBy(students, enrolledStudents, 'studentNumber')
|
||||
|
||||
const handlePopulationClick = students => {
|
||||
const studentnumberlist = Object.values(students).map(s => s.studentNumber)
|
||||
history.push('/custompopulation')
|
||||
dispatch(getCustomPopulation({ studentnumberlist }))
|
||||
dispatch(getCustomPopulationCoursesByStudentnumbers({ studentnumberlist }))
|
||||
}
|
||||
|
||||
const renderRow = (title, students) => {
|
||||
const onClick = () => handlePopulationClick(students)
|
||||
|
||||
if (students.length > 500) {
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<b>{title}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{students.length}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Modal
|
||||
trigger={<Button>Show combined population</Button>}
|
||||
header={`Are you sure you want to see a population of ${students.length} students?`}
|
||||
actions={[
|
||||
'Cancel',
|
||||
{
|
||||
key: 'fetch',
|
||||
content: 'Show population',
|
||||
positive: true,
|
||||
onClick: () => onClick(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<b>{title}</b>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{students.length}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button size="tiny" onClick={onClick}>
|
||||
Show custom population
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Accordion fluid styled>
|
||||
<Accordion.Title style={{ color: `${bold ? 'black' : ''}` }} index={index} active={active} onClick={handleClick}>
|
||||
<Icon name="dropdown" />
|
||||
{years} ({students.length})
|
||||
</Accordion.Title>
|
||||
<Accordion.Content active={active}>
|
||||
<Table collapsing>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Enrollment</Table.HeaderCell>
|
||||
<Table.HeaderCell>Total</Table.HeaderCell>
|
||||
<Table.HeaderCell />
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{renderRow('Attending', enrolledStudents)}
|
||||
{renderRow('Non-attending', nonEnrolledStudents)}
|
||||
{renderRow('All', students)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
YearAccordion.defaultProps = {
|
||||
bold: false,
|
||||
}
|
||||
|
||||
YearAccordion.propTypes = {
|
||||
active: bool.isRequired,
|
||||
handleClick: func.isRequired,
|
||||
index: number.isRequired,
|
||||
years: string.isRequired,
|
||||
students: arrayOf(shape({})).isRequired,
|
||||
bold: bool,
|
||||
}
|
||||
|
||||
export default YearAccordion
|
||||
@@ -1,102 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { flatten } from 'lodash'
|
||||
import YearAccordion from './YearAccordion'
|
||||
|
||||
const PresentStudents = () => {
|
||||
const { data: presentStudents, pending } = useSelector(state => state.presentStudents)
|
||||
const [activeYearAccordion, setActiveYearAccordion] = useState(-1)
|
||||
|
||||
// Tries to merge data by years in the following manner:
|
||||
// ..., 80-89, 90-99, 00-04, 04-09
|
||||
// There may be some differences in results if some years
|
||||
// have 0 students or MERGE_TRESHOLD_AFTER_2000 is changed.
|
||||
const mergeDataByYears = () => {
|
||||
if (!presentStudents) return {}
|
||||
const MERGE_TRESHOLD_AFTER_2000 = 5
|
||||
const entries = Object.entries(presentStudents)
|
||||
.map(e => [Number(e[0]), e[1]])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
|
||||
const getNextDecadeFrom = year => Math.ceil((year % 10 === 0 ? year + 1 : year) / 10) * 10
|
||||
|
||||
const mergedData = {}
|
||||
let minYear
|
||||
let maxYear
|
||||
let nextDecade
|
||||
let studentAccumulator
|
||||
let currentClusterSize
|
||||
|
||||
const resetVariablesTo = (year, csize = 1) => {
|
||||
studentAccumulator = []
|
||||
minYear = year
|
||||
maxYear = minYear
|
||||
nextDecade = getNextDecadeFrom(minYear)
|
||||
currentClusterSize = csize
|
||||
}
|
||||
|
||||
resetVariablesTo(entries[0] ? entries[0][0] : -1, 0)
|
||||
|
||||
const mergeData = year => {
|
||||
mergedData[minYear !== maxYear ? `${minYear}-${maxYear}` : `${maxYear}`] = {
|
||||
endYear: year,
|
||||
students: flatten([...studentAccumulator]),
|
||||
}
|
||||
resetVariablesTo(year)
|
||||
}
|
||||
|
||||
entries.forEach(([year, students], i) => {
|
||||
if (
|
||||
(currentClusterSize % Math.max(2, MERGE_TRESHOLD_AFTER_2000) === 0 &&
|
||||
maxYear >= 2000 &&
|
||||
currentClusterSize !== 0) ||
|
||||
(year >= nextDecade && maxYear < 2000)
|
||||
) {
|
||||
mergeData(year)
|
||||
} else {
|
||||
currentClusterSize++
|
||||
maxYear = year
|
||||
}
|
||||
studentAccumulator.push(students)
|
||||
if (i === entries.length - 1) mergeData(year)
|
||||
})
|
||||
|
||||
if (entries.length > 0) {
|
||||
mergedData.Total = {
|
||||
endYear: -Infinity,
|
||||
students: entries.reduce((acc, [, students]) => acc.concat(...students), []),
|
||||
bold: true,
|
||||
}
|
||||
}
|
||||
|
||||
return mergedData
|
||||
}
|
||||
|
||||
const mergedData = useMemo(() => Object.entries(mergeDataByYears()), [presentStudents])
|
||||
return (
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Loader inline active={pending} />
|
||||
</div>
|
||||
{mergedData.length === 0 && !pending && <p>No present students</p>}
|
||||
{mergedData.length > 0 &&
|
||||
mergedData
|
||||
.slice()
|
||||
.sort(([, { endYear: endYear1 }], [, { endYear: endYear2 }]) => endYear2 - endYear1)
|
||||
.map(([years, { students, bold }], i) => (
|
||||
<YearAccordion
|
||||
bold={bold}
|
||||
index={i}
|
||||
active={activeYearAccordion === i}
|
||||
handleClick={() => setActiveYearAccordion(i === activeYearAccordion ? -1 : i)}
|
||||
key={`${years}-${students.length}`}
|
||||
years={years}
|
||||
students={students}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PresentStudents
|
||||
@@ -112,10 +112,14 @@ const Overview = ({
|
||||
...selectedYearData,
|
||||
credits: selectedYearData.creditValues,
|
||||
genders: {
|
||||
Male: Number(selectedYearData.genders.Male),
|
||||
Female: Number(selectedYearData.genders.Female),
|
||||
Male: Number(selectedYearData.genders.Male) || 0,
|
||||
Female: Number(selectedYearData.genders.Female) || 0,
|
||||
Other: Number(selectedYearData.genders.Other) || 0,
|
||||
},
|
||||
students: Number(selectedYearData.genders.Male) + Number(selectedYearData.genders.Female),
|
||||
students:
|
||||
(Number(selectedYearData.genders.Male) || 0) +
|
||||
(Number(selectedYearData.genders.Female) || 0) +
|
||||
(Number(selectedYearData.genders.Other) || 0),
|
||||
},
|
||||
}
|
||||
setData(newData)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, string, func, bool } from 'prop-types'
|
||||
import { Header, Segment, Tab, Button } from 'semantic-ui-react'
|
||||
import { isEqual, uniqBy } from 'lodash'
|
||||
import DegreeCoursesTable from './DegreeCourses'
|
||||
@@ -9,16 +8,14 @@ import StudyProgrammeSelector from './StudyProgrammeSelector'
|
||||
import Overview from './Overview'
|
||||
import StudyTrackOverview from './StudyTrackOverview'
|
||||
import ThesisCourses from './ThesisCourses'
|
||||
import PresentStudents from './PresentStudents'
|
||||
import '../PopulationQueryCard/populationQueryCard.css'
|
||||
import { getTextIn, getUserRoles, getUserIsAdmin, isNewHYStudyProgramme } from '../../common'
|
||||
import { getTextIn, getUserRoles, getUserIsAdmin } from '../../common'
|
||||
import { useTabs, useTitle } from '../../common/hooks'
|
||||
import TSA from '../../common/tsa'
|
||||
import Tags from './Tags'
|
||||
|
||||
import { getThroughput } from '../../redux/throughput'
|
||||
import { getProductivity } from '../../redux/productivity'
|
||||
import { getPresentStudents, clearPresentStudents } from '../../redux/presentStudents'
|
||||
import { getProgrammes } from '../../redux/populationProgrammes'
|
||||
|
||||
import { callApi } from '../../apiConnection'
|
||||
@@ -63,8 +60,8 @@ const StudyProgramme = props => {
|
||||
const getPanes = () => {
|
||||
const { match, programmes } = props
|
||||
const { studyProgrammeId } = match.params
|
||||
const filteredStudytracks = programmes[studyProgrammeId]
|
||||
? Object.values(programmes[studyProgrammeId].enrollmentStartYears).reduce((acc, curr) => {
|
||||
const filteredStudytracks = programmes?.[studyProgrammeId]
|
||||
? Object.values(programmes?.[studyProgrammeId].enrollmentStartYears).reduce((acc, curr) => {
|
||||
acc.push(...Object.values(curr.studyTracks))
|
||||
return uniqBy(acc, 'code')
|
||||
}, [])
|
||||
@@ -94,12 +91,6 @@ const StudyProgramme = props => {
|
||||
menuItem: 'Tags',
|
||||
render: () => <Tags studyprogramme={studyProgrammeId} />,
|
||||
})
|
||||
if (!isNewHYStudyProgramme(studyProgrammeId)) {
|
||||
panes.push({
|
||||
menuItem: 'Present students',
|
||||
render: () => <PresentStudents />,
|
||||
})
|
||||
}
|
||||
if (props.isAdmin) {
|
||||
panes.push({
|
||||
menuItem: 'Admin',
|
||||
@@ -123,16 +114,9 @@ const StudyProgramme = props => {
|
||||
|
||||
const { match, programmes } = props
|
||||
const { studyProgrammeId } = match.params
|
||||
const programmeName = programmes[studyProgrammeId] && getTextIn(programmes[studyProgrammeId].name, language)
|
||||
const programmeName = programmes?.[studyProgrammeId] && getTextIn(programmes?.[studyProgrammeId].name, language)
|
||||
const panes = getPanes()
|
||||
|
||||
useEffect(() => {
|
||||
if (studyProgrammeId && !isNewHYStudyProgramme(studyProgrammeId)) {
|
||||
props.clearPresentStudentsDispatch()
|
||||
props.getPresentStudentsDispatch(studyProgrammeId)
|
||||
}
|
||||
}, [studyProgrammeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!programmeName) {
|
||||
return
|
||||
@@ -172,29 +156,6 @@ const StudyProgramme = props => {
|
||||
)
|
||||
}
|
||||
|
||||
StudyProgramme.propTypes = {
|
||||
match: shape({
|
||||
params: shape({
|
||||
studyProgrammeId: string,
|
||||
}),
|
||||
}),
|
||||
programmes: shape({}),
|
||||
history: shape({}).isRequired,
|
||||
getProductivityDispatch: func.isRequired,
|
||||
getThroughputDispatch: func.isRequired,
|
||||
isAdmin: bool.isRequired,
|
||||
getPresentStudentsDispatch: func.isRequired,
|
||||
clearPresentStudentsDispatch: func.isRequired,
|
||||
getProgrammesDispatch: func.isRequired,
|
||||
}
|
||||
|
||||
StudyProgramme.defaultProps = {
|
||||
match: {
|
||||
params: { studyProgrammeId: undefined },
|
||||
},
|
||||
programmes: {},
|
||||
}
|
||||
|
||||
const mapStateToProps = ({
|
||||
populationProgrammes,
|
||||
auth: {
|
||||
@@ -215,8 +176,6 @@ export default connect(
|
||||
{
|
||||
getThroughputDispatch: getThroughput,
|
||||
getProductivityDispatch: getProductivity,
|
||||
getPresentStudentsDispatch: getPresentStudents,
|
||||
clearPresentStudentsDispatch: clearPresentStudents,
|
||||
getProgrammesDispatch: getProgrammes,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -33,6 +33,8 @@ const Updater = () => {
|
||||
const abortSisUpdater = () => apiCall('/updater/abort', 'get')
|
||||
const refreshSISRedisCache = () => apiCall('/updater/refresh_redis_cache', 'get')
|
||||
const updateSISCourses = () => apiCall('/updater/update/v2/courses', 'post', SISCourses.trim().split('\n'))
|
||||
const refreshAllTeacherLeaderboards = () => apiCall('/teachers/top', 'post')
|
||||
const refreshTrends = () => apiCall('/updater/refresh_trends', 'post')
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
@@ -43,6 +45,8 @@ const Updater = () => {
|
||||
<Form.Button content="Update programmes" onClick={() => updateSISProgrammes()} />
|
||||
<Form.Button content="Refresh updater redis cache" onClick={() => refreshSISRedisCache()} />
|
||||
<Form.Button content="Refresh oodikone statistics" onClick={() => refreshStatisticsV2()} />
|
||||
<Form.Button content="Refresh all teacher leaderboards" onClick={() => refreshAllTeacherLeaderboards()} />
|
||||
<Form.Button content="Refresh trends" onClick={() => refreshTrends()} />
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Button content="Stop Updating" negative onClick={abortSisUpdater} />
|
||||
|
||||
+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)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
+23
-16
@@ -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 } from '../../common'
|
||||
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>
|
||||
@@ -83,6 +78,25 @@ const UserSearchList = ({ enabledOnly, users, error, elementdetails }) => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'LASTLOGIN',
|
||||
title: 'Last login',
|
||||
getRowVal: user => (user.last_login ? user.last_login : 'Not saved'),
|
||||
getRowContent: user =>
|
||||
user.last_login ? (
|
||||
<p>{reformatDate(user.last_login, 'DD.MM.YYYY')}</p>
|
||||
) : (
|
||||
<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: '',
|
||||
@@ -102,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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user