mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c367f5b93 | ||
|
|
ee2240f457 | ||
|
|
fb4ecc7785 | ||
|
|
594ffb460b | ||
|
|
d9d7f2ae80 | ||
|
|
8425643639 | ||
|
|
33d5a04dc8 | ||
|
|
abbecf412e | ||
|
|
6809c668e0 | ||
|
|
0db4db47ac | ||
|
|
ab420572eb | ||
|
|
657a96abc0 | ||
|
|
1760a165e4 | ||
|
|
3f311dee69 | ||
|
|
eb6b683296 | ||
|
|
b2153ec3dd | ||
|
|
8949e7baf0 | ||
|
|
1fa10f7812 | ||
|
|
fbe5db8a43 | ||
|
|
d4c7a4014b | ||
|
|
1177c2f848 | ||
|
|
0f8d72fd1d | ||
|
|
47e3e2e26e | ||
|
|
a312a14944 |
@@ -12,7 +12,7 @@ const save = () => {
|
||||
}
|
||||
|
||||
const selectSavedPopulation = name => {
|
||||
cy.cs('history-search').click().type(`${name}{enter}`)
|
||||
cy.cs('history-search').type(`${name}{enter}`)
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
|
||||
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.get('#checkstudentsresults').within(() => {
|
||||
cy.cs('found-title').click()
|
||||
|
||||
+3
-2
@@ -28,9 +28,10 @@
|
||||
"prepare": "husky",
|
||||
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"i:both": "npm run install:both",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone-sis-updater-worker npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone-sis-updater-worker npm ci",
|
||||
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
|
||||
Generated
+6
-38
@@ -11,7 +11,6 @@
|
||||
"@sentry/profiling-node": "^10.43.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/pg": "^8.18.0",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
@@ -2388,19 +2387,9 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -2658,6 +2647,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -2818,6 +2808,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -2958,6 +2949,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3160,30 +3152,11 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
@@ -3448,6 +3421,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -4440,12 +4414,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc -p .",
|
||||
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -11,7 +11,6 @@
|
||||
"@sentry/profiling-node": "^10.43.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/pg": "^8.18.0",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { Release } from '@oodikone/shared/types'
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { isDev, isTest } from '../config'
|
||||
|
||||
const router = Router()
|
||||
@@ -39,16 +39,24 @@ router.get<never, Release[]>('/', async (_, res) => {
|
||||
return res.status(200).json(fakeRelease)
|
||||
}
|
||||
|
||||
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
|
||||
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
const { data } = await Fetchios.get<Record<string, any>[]>(
|
||||
'https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases',
|
||||
{}
|
||||
)
|
||||
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
if (data) {
|
||||
const releasesFromAPI: Release[] = data.map(release => ({
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
}
|
||||
|
||||
res.status(500).send()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -46,7 +46,7 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
|
||||
// the students have enrolled to their courses in last 8 months
|
||||
// (acual logic a bit different, see Importer)
|
||||
const { data: teacherRightsToStudents, error } = importerClient
|
||||
? await tryCatch<{ data: string[] }>(importerClient.post('/teacher-rights/', { teacherId, studentNumbers }))
|
||||
? await tryCatch(importerClient.post<string[]>('/teacher-rights/', { teacherId, studentNumbers }, {}))
|
||||
: { data: null, error: null }
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -57,7 +57,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
|
||||
programmes: refreshProgrammesByList,
|
||||
}
|
||||
if (typeof typeToJob[type] !== 'function') {
|
||||
return res.status(400).json('Bad job type')
|
||||
return res.status(400).json('Updater does not recognize this job type: ' + type)
|
||||
}
|
||||
const response = await typeToJob[type](list)
|
||||
if (response) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { isProduction, pateToken } from '../config'
|
||||
import { FormattedUser } from '../types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const pateClient = axios.create({
|
||||
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
|
||||
const pateClient = Fetchios.create({
|
||||
baseUrl: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
|
||||
params: {
|
||||
token: pateToken,
|
||||
},
|
||||
@@ -24,7 +23,7 @@ const baseSettings = {
|
||||
const sendEmail = async (options = {}) => {
|
||||
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
|
||||
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
|
||||
return await pateClient.post('/', options)
|
||||
return await pateClient.post('', options, {})
|
||||
}
|
||||
|
||||
export const sendFeedback = async ({ feedbackContent, user }: { feedbackContent: string; user: FormattedUser }) => {
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
const axios = require('axios')
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
|
||||
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
|
||||
const { ApplicationError } = require('../util/customErrors')
|
||||
|
||||
const getSisuAccessToken = async eppn => {
|
||||
export const getSisuAccessToken = async eppn => {
|
||||
const oriAppAuthPath = `${sisUrl}/ori/application-auth`
|
||||
let sisuResponse
|
||||
try {
|
||||
sisuResponse = await axios.post(
|
||||
oriAppAuthPath,
|
||||
{
|
||||
username: sisGrapqlAppAccount,
|
||||
key: sisGrapqlAppKey,
|
||||
executingEppn: eppn,
|
||||
},
|
||||
{ 'content-type': 'application/json' }
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
const { data: sisuResponse, error } = await Fetchios.post(
|
||||
oriAppAuthPath,
|
||||
{
|
||||
username: sisGrapqlAppAccount,
|
||||
key: sisGrapqlAppKey,
|
||||
executingEppn: eppn,
|
||||
},
|
||||
{ 'content-type': 'application/json' }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new ApplicationError(
|
||||
'Could not find the user in Sisu, either with the eppn of the user or the new person.',
|
||||
404
|
||||
)
|
||||
}
|
||||
return sisuResponse.data.authToken
|
||||
|
||||
return sisuResponse.authToken
|
||||
}
|
||||
|
||||
const decodeJwtTokenPayloadToObject = token => {
|
||||
export const decodeJwtTokenPayloadToObject = token => {
|
||||
const payload = token.split('.')[1]
|
||||
const paddedPayload = `${payload}==`
|
||||
const bufferObj = Buffer.from(paddedPayload, 'base64')
|
||||
return JSON.parse(bufferObj.toString('utf-8'))
|
||||
}
|
||||
|
||||
const getSisuAuthData = async eppn => {
|
||||
export const getSisuAuthData = async eppn => {
|
||||
const accessToken = await getSisuAccessToken(eppn)
|
||||
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
|
||||
return { accessToken, tokenData }
|
||||
}
|
||||
|
||||
const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
export const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
private_person(id: $subjectUserId) {
|
||||
id
|
||||
first_name:firstNames
|
||||
@@ -52,32 +53,17 @@ const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
|
||||
}
|
||||
}`
|
||||
|
||||
const getGraphqlData = async (accessToken, queryObject) => {
|
||||
const graphQlUrl = `${sisUrl}/api`
|
||||
const authHeader = `Application ${accessToken}`
|
||||
const config = {
|
||||
url: graphQlUrl,
|
||||
method: 'post',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Accept-Charset': 'UTF-8',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: queryObject,
|
||||
}
|
||||
let graphQlResponse
|
||||
export const getGraphqlData = async (accessToken, queryObject) => {
|
||||
try {
|
||||
graphQlResponse = await axios(config)
|
||||
const graphQlResponse = await Fetchios.post(`${sisUrl}/api`, queryObject, {
|
||||
headers: {
|
||||
Authorization: `Application ${accessToken}`,
|
||||
'Accept-Charset': 'UTF-8',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
return graphQlResponse.data.data.private_person
|
||||
} catch (error) {
|
||||
throw new ApplicationError('Graphql request failed for unclear reasons.', 500)
|
||||
}
|
||||
return graphQlResponse.data.data.private_person
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSisuAccessToken,
|
||||
decodeJwtTokenPayloadToObject,
|
||||
getSisuAuthData,
|
||||
personSearchQuery,
|
||||
getGraphqlData,
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const getOptionsForStudents = (
|
||||
|
||||
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
|
||||
CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitutions', 'is_study_module'],
|
||||
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
|
||||
where: {
|
||||
code: { [Op.in]: courses },
|
||||
},
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
import axios from 'axios'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { SECRET_TOKEN, SIS_UPDATER_URL } from '../config'
|
||||
import { StudyplanModel } from '../models'
|
||||
|
||||
const client = axios.create({ baseURL: SIS_UPDATER_URL })
|
||||
const params = { params: { token: SECRET_TOKEN } }
|
||||
const client = Fetchios.create({
|
||||
baseUrl: SIS_UPDATER_URL,
|
||||
params: { token: SECRET_TOKEN ?? '' },
|
||||
})
|
||||
|
||||
export const abortUpdate = async () => {
|
||||
const response = await client.get('/v1/abort', params)
|
||||
const response = await client.get<{ message: string }>('/v1/abort', {})
|
||||
return response.data?.message
|
||||
}
|
||||
|
||||
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
|
||||
const data = { coursecodes }
|
||||
const response = await client.post('/v1/courses', data, params)
|
||||
const response = await client.post('/v1/courses', data, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISMetadata = async () => {
|
||||
const response = await client.get('/v1/meta', params)
|
||||
const response = await client.get('/v1/meta', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISProgrammes = async () => {
|
||||
const response = await client.get('/v1/programmes', params)
|
||||
const response = await client.get('/v1/programmes', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISRedisCache = async () => {
|
||||
const response = await client.get('/v1/rediscache', params)
|
||||
const response = await client.get('/v1/rediscache', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISStudents = async () => {
|
||||
const response = await client.get('/v1/students', params)
|
||||
const response = await client.get('/v1/students', {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSISStudentsByStudentNumber = async (studentnumbers: string[]) => {
|
||||
const data = { studentnumbers }
|
||||
const response = await client.post('/v1/students', data, params)
|
||||
const response = await client.post('/v1/students', data, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -54,6 +56,6 @@ export const updateSISStudyPlans = async (days: number) => {
|
||||
limit: 1000,
|
||||
})
|
||||
const studentnumbers = result.map(r => r.studentnumber)
|
||||
const response = await client.post('v1/studyplans', { studentnumbers }, params)
|
||||
const response = await client.post('v1/studyplans', { studentnumbers }, {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { AxiosError } from 'axios'
|
||||
import { uniq } from 'lodash-es'
|
||||
|
||||
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
|
||||
@@ -12,22 +11,23 @@ const importerClient = getImporterClient()
|
||||
const getGroupsFromImporter = async (sisPersonId: string): Promise<StudyGuidanceGroup[]> => {
|
||||
if (!importerClient || !sisPersonId) return []
|
||||
|
||||
try {
|
||||
const response: { data: Record<string, StudyGuidanceGroup> } = await importerClient.get(
|
||||
`/person-groups/person/${sisPersonId}`
|
||||
)
|
||||
const { data, error } = await importerClient.get<Record<string, StudyGuidanceGroup>>(
|
||||
`/person-groups/person/${sisPersonId}`,
|
||||
{}
|
||||
)
|
||||
|
||||
return response?.data ? Object.values(response.data) : []
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
logger.error('Could not fetch study guidance groups!')
|
||||
if (error instanceof AxiosError) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.stack)
|
||||
logger.error(JSON.stringify(error.response?.data))
|
||||
logger.error(JSON.stringify(error.message))
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
return []
|
||||
return data ? Object.values(data) : []
|
||||
}
|
||||
|
||||
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { importerToken, importerUrl } from '../config'
|
||||
import logger from './logger'
|
||||
|
||||
const importerClient = axios.create({
|
||||
const importerClient = Fetchios.create({
|
||||
headers: { token: importerToken },
|
||||
baseURL: importerUrl,
|
||||
baseUrl: importerUrl,
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import axios from 'axios'
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
|
||||
import { importerToken, isStaging, jamiUrl } from '../config'
|
||||
import { IamAccess } from '../types'
|
||||
import logger from './logger'
|
||||
|
||||
const jamiClient = axios.create({
|
||||
baseURL: jamiUrl,
|
||||
const jamiClient = Fetchios.create({
|
||||
baseUrl: jamiUrl,
|
||||
params: {
|
||||
token: importerToken,
|
||||
},
|
||||
@@ -24,29 +24,34 @@ export const getUserIamAccess = async (
|
||||
): Promise<UserIamAccess> => {
|
||||
if (iamGroups.length === 0) return {}
|
||||
|
||||
try {
|
||||
const { data } = await jamiClient.post('/', {
|
||||
const { data, error } = await jamiClient.post<Pick<UserIamAccess, 'specialGroup'>>(
|
||||
'',
|
||||
{
|
||||
userId: sisPersonId,
|
||||
getSisuAccess: !isStaging && !!sisPersonId,
|
||||
iamGroups,
|
||||
})
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
if (error === null) {
|
||||
const { specialGroup, ...resetOfIamAccess } = data
|
||||
return { iamAccess: resetOfIamAccess, specialGroup }
|
||||
} catch (error) {
|
||||
if (attempt > 3) {
|
||||
logger.error('[Jami] error: ', error)
|
||||
Sentry.captureException(error)
|
||||
return {}
|
||||
}
|
||||
|
||||
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
|
||||
}
|
||||
|
||||
if (attempt > 3) {
|
||||
logger.error('[Jami] error: ', error)
|
||||
Sentry.captureException(error)
|
||||
return {}
|
||||
}
|
||||
|
||||
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
|
||||
}
|
||||
|
||||
export const getUserIams = async (userId: string) => {
|
||||
try {
|
||||
const { data } = await jamiClient.get(`/${userId}`)
|
||||
const { data } = await jamiClient.get<any>(userId, {})
|
||||
|
||||
return data.iamGroups as string[]
|
||||
} catch (error: any) {
|
||||
if (error.response.status !== 404) {
|
||||
@@ -65,12 +70,12 @@ type Access = {
|
||||
}
|
||||
|
||||
export const getAllUserAccess = async (userIds: string[]) => {
|
||||
const { data } = await jamiClient.post('access-and-special-groups', { userIds })
|
||||
const { data } = await jamiClient.post('access-and-special-groups', { userIds }, {})
|
||||
return data as Access[]
|
||||
}
|
||||
|
||||
jamiClient
|
||||
.get('/ping', { timeout: 4000 })
|
||||
.get('ping', { timeout: 4000 })
|
||||
.then(() => {
|
||||
logger.info('JAMI connected')
|
||||
})
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"sourceMap": true,
|
||||
"sourceRoot": "/",
|
||||
"inlineSources": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["index.ts", "src", "tests"],
|
||||
"exclude": ["../frontend"]
|
||||
|
||||
Generated
-259
@@ -17,7 +17,6 @@
|
||||
"@sentry/browser": "^10.43.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"lodash-es": "^4.17.23",
|
||||
@@ -2257,23 +2256,6 @@
|
||||
"vite": "^4 || ^5 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
@@ -2299,19 +2281,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -2380,18 +2349,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
@@ -2484,15 +2441,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2525,20 +2473,6 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
@@ -2548,51 +2482,6 @@
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -2687,42 +2576,6 @@
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2747,82 +2600,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -3144,15 +2921,6 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
|
||||
@@ -3748,27 +3516,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3948,12 +3695,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@sentry/browser": "^10.43.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"lodash-es": "^4.17.23",
|
||||
|
||||
+33
-50
@@ -1,14 +1,12 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import axios from 'axios'
|
||||
|
||||
import { showAsUserKey } from '@/common'
|
||||
import { apiBasePath, isDev } from '@/conf'
|
||||
import { actionHistory } from '@/redux/actionHistory'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
const baseHeaders = isDev
|
||||
const baseHeaders: Record<string, string> = isDev
|
||||
? {
|
||||
uid: 'mluukkai',
|
||||
displayName: 'Matti Luukkainen',
|
||||
@@ -31,68 +29,53 @@ const getHeaders = () => {
|
||||
return headers
|
||||
}
|
||||
|
||||
const api = axios.create({ baseURL: apiBasePath, headers: getHeaders(), timeout: 120_000 })
|
||||
const api = Fetchios.create({ baseUrl: apiBasePath })
|
||||
|
||||
const actionTypes = prefix => ({
|
||||
attempt: `${prefix}ATTEMPT`,
|
||||
failure: `${prefix}FAILURE`,
|
||||
success: `${prefix}SUCCESS`,
|
||||
})
|
||||
type ApiMethods = 'get' | 'post' | 'put' | 'delete'
|
||||
|
||||
export const callApi = async (url, method = 'get', data, params, timeout = 0, progressCallback = null) => {
|
||||
const options = { headers: getHeaders(), timeout }
|
||||
/**
|
||||
* Calls API with minimal preprocessing.
|
||||
* NOTE! `data` -attribute has to be JSON.stringified in-order for the predefined
|
||||
* 'Content-Type' to work and the receiving Express instance to parse the body correctly.
|
||||
*/
|
||||
export const callApi = async (
|
||||
url: string,
|
||||
method: ApiMethods = 'get',
|
||||
data?: any,
|
||||
params: Record<string, any> = {},
|
||||
timeout = 30_000
|
||||
) => {
|
||||
const apiRequestController = new AbortController()
|
||||
const signal = timeout !== 0 ? apiRequestController.signal : undefined
|
||||
|
||||
const onDownloadProgress = ({ loaded, total }) => {
|
||||
if (progressCallback) progressCallback(Math.round((loaded / total) * 100))
|
||||
const options = {
|
||||
params,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getHeaders(),
|
||||
},
|
||||
}
|
||||
|
||||
// const buildUrl = () => e{
|
||||
// const urlParams = new URLSearchParams(params)
|
||||
// return apiBasePath + url + urlParams.toString()
|
||||
// }
|
||||
|
||||
switch (method) {
|
||||
case 'get':
|
||||
return api.get(url, { ...options, params, onDownloadProgress })
|
||||
return api.get(url, options)
|
||||
case 'post':
|
||||
return api.post(url, data, options)
|
||||
case 'put':
|
||||
return api.put(url, data, options)
|
||||
case 'delete':
|
||||
return api.delete(url, { headers: options.headers, data })
|
||||
return api.delete(url, options)
|
||||
default:
|
||||
return Promise.reject(new Error('Invalid http method'))
|
||||
throw new Error('Invalid method used for API call.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (error, actionHistory = []) => {
|
||||
const { config, request, response } = error
|
||||
if (response && response.status) {
|
||||
Sentry.withScope(scope => {
|
||||
scope.setExtra('config', config)
|
||||
scope.setExtra('request', request)
|
||||
scope.setExtra('response', response)
|
||||
scope.setExtra('actionHistory', JSON.stringify(actionHistory))
|
||||
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const handleRequest = store => next => async action => {
|
||||
if (action.requestSettings) {
|
||||
const { success, failure } = actionTypes(prefix)
|
||||
const { route, method, data, prefix, query, params, onProgress } = action.requestSettings
|
||||
|
||||
// Based no the previous config. We don't actually care when this finishes.
|
||||
void callApi(route, method, data, params, 0, onProgress)
|
||||
.then(res => store.dispatch({ type: success, response: res.data, query }))
|
||||
.catch(error => {
|
||||
// Handle error first to avoid redux minified error #3 in production.
|
||||
// We don't need to add the error to the actionHistory as it is already passed to Sentry.
|
||||
handleError(error, actionHistory)
|
||||
store.dispatch({ type: failure, response: error, query })
|
||||
})
|
||||
}
|
||||
|
||||
next(action)
|
||||
}
|
||||
|
||||
// Redux-toolkit query based API
|
||||
// Api can be extended with enhanceEndpoints method
|
||||
// All tags used for invalidating cache must be defined here
|
||||
@@ -1,19 +1,34 @@
|
||||
import TablePagination from '@mui/material/TablePagination'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> }) => {
|
||||
const tableRows = table.getRowCount()
|
||||
const pageRowOptions = [100, 200, 500, 1000, tableRows].filter(count => count <= tableRows)
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.min(200, tableRows),
|
||||
})
|
||||
|
||||
useEffect(() => table.setPageSize(pagination.pageSize), [pagination.pageSize])
|
||||
useEffect(() => table.setPageIndex(pagination.pageIndex), [pagination.pageIndex])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.max(pagination.pageSize, pageRowOptions[0]),
|
||||
}),
|
||||
[table.getCoreRowModel().rows.length]
|
||||
)
|
||||
|
||||
return (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={table.getFilteredRowModel().rows.length}
|
||||
onPageChange={(_, newPage) => table.setPageIndex(newPage)}
|
||||
onRowsPerPageChange={event => {
|
||||
table.setPageSize(Number(event.target.value))
|
||||
table.setPageIndex(0)
|
||||
}}
|
||||
onPageChange={(_, newPage) => setPagination({ ...pagination, pageIndex: newPage })}
|
||||
onRowsPerPageChange={event => setPagination({ pageIndex: 0, pageSize: Number(event.target.value) })}
|
||||
page={table.getState().pagination.pageIndex}
|
||||
rowsPerPage={table.getState().pagination.pageSize}
|
||||
rowsPerPageOptions={pageRowOptions}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnDef, TableOptions } from '@tanstack/react-table'
|
||||
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AggregationRowFeature, VerticalHeaderFeature, ZebrastripesFeature } from './features'
|
||||
import { OodiTableContainer } from './OodiTable'
|
||||
@@ -34,15 +34,8 @@ export const OodiTable = <TData,>({
|
||||
toolbarContent?: ReactNode
|
||||
cy?: string
|
||||
}) => {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.min(200, data.length),
|
||||
})
|
||||
|
||||
// should maybe use a deep merging tool or write own
|
||||
const config: Partial<TableOptions<TData>> = {
|
||||
...options,
|
||||
}
|
||||
const { ...config }: Partial<TableOptions<TData>> = options
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
_features: [VerticalHeaderFeature, AggregationRowFeature, ZebrastripesFeature],
|
||||
@@ -51,9 +44,7 @@ export const OodiTable = <TData,>({
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
...config,
|
||||
state: { pagination, ...config.state },
|
||||
})
|
||||
return <OodiTableContainer cy={cy} isExportView={isExportView} table={table} toolbarContent={toolbarContent} />
|
||||
}
|
||||
|
||||
+46
-28
@@ -26,6 +26,11 @@ const getSortingValue = (course: Courses['number'] | undefined) => {
|
||||
return 1
|
||||
}
|
||||
|
||||
const isCourseCodes = (courses: unknown): courses is string[] => {
|
||||
if (Array.isArray(courses) && courses.length && typeof courses.at(0) === 'string') return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>): ColumnDef<CourseTabStudent, any>[] => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const theme = useTheme()
|
||||
@@ -153,35 +158,14 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
|
||||
if (!correctCourse) return null
|
||||
|
||||
const sub = correctCourse.substitutedBy
|
||||
const substitutePrefix = sub ? `Substituting course: ${sub}\n` : ''
|
||||
|
||||
if (correctCourse.grade) {
|
||||
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
|
||||
const title =
|
||||
substitutePrefix +
|
||||
(isTransferred
|
||||
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`)
|
||||
if (correctCourse.enrollmentDate) {
|
||||
const subPrefix =
|
||||
sub && !isCourseCodes(sub)
|
||||
? `Substituted by: ${sub?.map(course => course.course_code)?.join(', ')}\n`
|
||||
: ''
|
||||
return (
|
||||
<div title={title}>
|
||||
{isTransferred ? (
|
||||
<SwapHorizIcon
|
||||
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
|
||||
/>
|
||||
) : (
|
||||
<CheckIcon
|
||||
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.enrollmentDate) {
|
||||
return (
|
||||
<div
|
||||
title={
|
||||
substitutePrefix + `Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`
|
||||
}
|
||||
>
|
||||
<div title={`${subPrefix}Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`}>
|
||||
<RemoveIcon
|
||||
sx={{
|
||||
color: isWithinSixMonths(correctCourse.enrollmentDate)
|
||||
@@ -192,11 +176,45 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.inHops) {
|
||||
const subString = sub && isCourseCodes(sub) ? `Substituted by: ${sub?.join(', ')}\n\n` : ''
|
||||
return (
|
||||
<div title={substitutePrefix + 'In primary studyplan'}>
|
||||
<div title={`${subString}In primary studyplan`}>
|
||||
<CropSquareIcon sx={{ color: theme.palette.ooditable.hops }} />
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.grade) {
|
||||
// NOTE: Contains only normally-passed-courses because substitutions don't have a grade field
|
||||
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
|
||||
const title = isTransferred
|
||||
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
|
||||
return (
|
||||
<div title={title}>
|
||||
{isTransferred ? (
|
||||
<SwapHorizIcon sx={{ color: theme.palette.ooditable.success }} />
|
||||
) : (
|
||||
<CheckIcon sx={{ color: theme.palette.ooditable.success }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else if (sub) {
|
||||
const subStringPrefix = `Substituted by:\n\n`
|
||||
const subString = isCourseCodes(sub)
|
||||
? subStringPrefix + sub.join(', ')
|
||||
: subStringPrefix +
|
||||
sub
|
||||
.map(
|
||||
course =>
|
||||
`${course.course_code}\nGrade: ${course.grade}\nCompleted on: ${formatDate(course.date, dateFormat)}`
|
||||
)
|
||||
.join('\n\n')
|
||||
|
||||
return (
|
||||
<div title={subString}>
|
||||
<CheckIcon sx={{ color: theme.palette.ooditable.hops }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
+152
-80
@@ -7,7 +7,7 @@ import { useToggle } from '@/hooks/toggle'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { CreditTypeCode, Name, ProgrammeCourse } from '@oodikone/shared/types'
|
||||
import { FormattedStudent } from '@oodikone/shared/types/studentData'
|
||||
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
import { IncludeSubstitutionsToggle } from '../../IncludeSubstitutionsToggle'
|
||||
import { useGetColumnDefinitions } from './columnDefinitions'
|
||||
|
||||
@@ -22,7 +22,7 @@ export type Courses = Record<
|
||||
{
|
||||
grade?: string
|
||||
completionDate?: string
|
||||
substitutedBy?: string
|
||||
substitutedBy?: StudentCourse[] | string[]
|
||||
inHops?: boolean
|
||||
enrollmentDate?: Date
|
||||
passed?: boolean
|
||||
@@ -48,11 +48,12 @@ const studentMapper = (
|
||||
student: FormattedStudent,
|
||||
includeSubstitutions: boolean,
|
||||
curriculumCourseCodes: string[],
|
||||
substituteToPrimaryCodes: Record<string, string[]>
|
||||
substitutionsForCourseCode: Record<string, string[][]>
|
||||
) => {
|
||||
const courseMap = {}
|
||||
|
||||
// NB: there can be many attainments/enrollments for each course code
|
||||
// All passed courses that are included in curriculum
|
||||
const passedCourses = student.courses.filter(
|
||||
course => curriculumCourseCodes.includes(course.course_code) && course.passed
|
||||
)
|
||||
@@ -61,74 +62,152 @@ const studentMapper = (
|
||||
studyPlan.included_courses.filter(code => curriculumCourseCodes.includes(code))
|
||||
)
|
||||
|
||||
const passedSubstituteCourses = student.courses.filter(course => !!substituteToPrimaryCodes[course.course_code])
|
||||
const substituteEnrollments = student.enrollments.filter(
|
||||
enrollment => !!substituteToPrimaryCodes[enrollment.course_code]
|
||||
)
|
||||
const substituteHopsItems = student.studyplans.flatMap(studyPlan =>
|
||||
studyPlan.included_courses.filter(code => !!substituteToPrimaryCodes[code])
|
||||
// All passed courses including random AY-codes etc
|
||||
const allPassedCourseCodes = student.courses.filter(course => course.passed).map(course => course.course_code)
|
||||
const substitutionsToCurriculumCourses = curriculumCourseCodes.reduce<Record<string, string[][]>>((acc, code) => {
|
||||
const substitutionsToCurriculumCourse = substitutionsForCourseCode[code]
|
||||
if (substitutionsToCurriculumCourse) {
|
||||
acc[code] = substitutionsToCurriculumCourse
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// curriculumCourseCode -> passedSubstGroup
|
||||
const passedSubstitutionsToCurriculumCourses = Object.keys(substitutionsToCurriculumCourses).reduce<
|
||||
Record<string, StudentCourse[]>
|
||||
>((acc, code) => {
|
||||
const passedSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
|
||||
substGroup.every(sgCode => allPassedCourseCodes.includes(sgCode))
|
||||
)
|
||||
// TODO: Implement better logic to select the most optimal substitution_groups, now we select shortest and first group
|
||||
// Also this .find (and at(0)!) should never be undefined because the codes are student's completed courses => they exist under student.courses
|
||||
const passedSubstitutionGroupCourses = passedSubstitutionGroups
|
||||
.map(sg => sg.map(code => student.courses.find(course => course.course_code === code)!))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
if (passedSubstitutionGroups.length) {
|
||||
acc[code] = passedSubstitutionGroupCourses
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const enrollmentCodes = enrollments.map(e => e.course_code)
|
||||
const enrollmentsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<
|
||||
Record<string, FormattedStudent['enrollments']>
|
||||
>((acc, code) => {
|
||||
const enrolledSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
|
||||
substGroup.every(sgCode => enrollmentCodes.includes(sgCode))
|
||||
)
|
||||
// TODO: Same as above
|
||||
const enrolledSubstitutionGroupCourses = enrolledSubstitutionGroups
|
||||
.map(sg => sg.map(code => student.enrollments.find(course => course.course_code === code)!))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
if (enrolledSubstitutionGroupCourses?.length) {
|
||||
acc[code] = enrolledSubstitutionGroupCourses
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const hopsItemsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<Record<string, string[]>>(
|
||||
(acc, code) => {
|
||||
// TODO: Same as above
|
||||
const hopsSubstitutionGroups = substitutionsToCurriculumCourses[code]
|
||||
.filter(substGroup => substGroup.every(sgCode => hopsItems.includes(sgCode)))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)!
|
||||
if (hopsSubstitutionGroups?.length) {
|
||||
acc[code] = hopsSubstitutionGroups
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const mapCourses = (coursesToAdd: typeof passedCourses, substitutions: boolean) => {
|
||||
const mapSubstitutionCourses = (coursesToAdd: typeof passedSubstitutionsToCurriculumCourses) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(coursesToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
substitutedBy: substitutionGroup,
|
||||
exportValue: `Substitutes ${code}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapCourses = (coursesToAdd: typeof passedCourses) => {
|
||||
for (const course of coursesToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[course.course_code] : [course.course_code]
|
||||
codes.forEach(code => {
|
||||
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
|
||||
courseMap[code] = {
|
||||
grade: course.grade,
|
||||
completionDate: course.date,
|
||||
passed: course.passed,
|
||||
substitutedBy: substitutions ? course.course_code : undefined,
|
||||
credittypecode: course.credittypecode,
|
||||
exportValue: course.grade,
|
||||
}
|
||||
const code = course.course_code
|
||||
|
||||
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
|
||||
courseMap[code] = {
|
||||
grade: course.grade,
|
||||
completionDate: course.date,
|
||||
passed: course.passed,
|
||||
substitutedBy: undefined,
|
||||
credittypecode: course.credittypecode,
|
||||
exportValue: course.grade,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapEnrollments = (enrollmentsToAdd: typeof enrollments, substitutions: boolean) => {
|
||||
const mapSubstitutionEnrollments = (enrollmentsToAdd: typeof enrollmentsWithSubstitutions) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(enrollmentsToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
substitutedBy: substitutionGroup,
|
||||
enrollmentDate: substitutionGroup.at(0)!.enrollment_date_time,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapEnrollments = (enrollmentsToAdd: typeof enrollments) => {
|
||||
for (const enrollment of enrollmentsToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[enrollment.course_code] : [enrollment.course_code]
|
||||
codes.forEach(code => {
|
||||
if (
|
||||
!courseMap[code] ||
|
||||
(!!courseMap[code].enrollmentDate &&
|
||||
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
|
||||
)
|
||||
courseMap[code] = {
|
||||
enrollmentDate: enrollment.enrollment_date_time,
|
||||
substitutedBy: substitutions ? enrollment.course_code : undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mapHopsSelections = (selectionsToAdd: typeof hopsItems, substitutions: boolean) => {
|
||||
for (const selection of selectionsToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[selection] : [selection]
|
||||
codes.forEach(code => {
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: substitutions ? selection : undefined,
|
||||
const code = enrollment.course_code
|
||||
if (
|
||||
!courseMap[code] ||
|
||||
(!!courseMap[code].enrollmentDate &&
|
||||
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
|
||||
)
|
||||
courseMap[code] = {
|
||||
enrollmentDate: enrollment.enrollment_date_time,
|
||||
substitutedBy: undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mapCourses(passedCourses, false)
|
||||
if (includeSubstitutions) mapCourses(passedSubstituteCourses, true)
|
||||
const mapHopsSelections = (selectionsToAdd: typeof hopsItems) => {
|
||||
for (const selection of selectionsToAdd) {
|
||||
const code = selection
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapSubstitutionHopsSelections = (selectionsToAdd: typeof hopsItemsWithSubstitutions) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(selectionsToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: substitutionGroup,
|
||||
exporValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapCourses(passedCourses)
|
||||
if (includeSubstitutions) mapSubstitutionCourses(passedSubstitutionsToCurriculumCourses)
|
||||
|
||||
mapEnrollments(enrollments)
|
||||
if (includeSubstitutions) mapSubstitutionEnrollments(enrollmentsWithSubstitutions)
|
||||
|
||||
mapHopsSelections(hopsItems)
|
||||
if (includeSubstitutions) mapSubstitutionHopsSelections(hopsItemsWithSubstitutions)
|
||||
|
||||
const totalPassed = Object.keys(courseMap).length
|
||||
|
||||
mapEnrollments(enrollments, false)
|
||||
if (includeSubstitutions) mapEnrollments(substituteEnrollments, true)
|
||||
|
||||
mapHopsSelections(hopsItems, false)
|
||||
if (includeSubstitutions) mapHopsSelections(substituteHopsItems, true)
|
||||
|
||||
const totalPlanned = Object.keys(courseMap).length - totalPassed
|
||||
|
||||
return {
|
||||
@@ -163,7 +242,7 @@ const gradeOrdering = ['0', 'Hyl.', 'TT', 'HT', '1', '2', '3', '4', '5', 'Hyv.']
|
||||
/**
|
||||
* @returns true if current course (the latter) has the same or better grade than old course
|
||||
*/
|
||||
const compareCourseGrades = (previous, current) =>
|
||||
const compareCourseGrades = (previous: StudentCourse, current: StudentCourse) =>
|
||||
gradeOrdering.indexOf(previous.grade) <= gradeOrdering.indexOf(current.grade)
|
||||
|
||||
export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTabContainerProps) => {
|
||||
@@ -182,34 +261,27 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
|
||||
const curriculumCourseCodes = useMemo(() => curriculumCourses.map(course => course.code), [curriculumCourses])
|
||||
|
||||
const substitutionCourseCodes = useMemo(
|
||||
// All substitution_groups entries as codes
|
||||
const substitutionGroupsCourseCodes = useMemo(
|
||||
() =>
|
||||
courses
|
||||
.filter(course => curriculumCourseCodes.includes(course.course.code))
|
||||
.flatMap(course => course.course.substitutions),
|
||||
.flatMap(course => course.course.substitution_groups?.map(sbGr => sbGr) ?? []),
|
||||
[courses, curriculumCourseCodes]
|
||||
)
|
||||
|
||||
/**
|
||||
* Justification for using array:
|
||||
* there are instances where a course substitutes multiple different courses currently present in curriculum
|
||||
* g.g. course code TKT20015 -> BSCS2002/TKT10001 in curriculum 2020-2023
|
||||
* but most of the time there is only one main code
|
||||
*/
|
||||
const substituteToPrimaryCodes: Record<string, string[]> = useMemo(
|
||||
// All substitutionGroups for a given course code => course.substitution_groups
|
||||
const substitutionsForCourseCode: Record<string, string[][]> = useMemo(
|
||||
() =>
|
||||
courses
|
||||
.filter(course => substitutionCourseCodes.includes(course.course.code))
|
||||
.reduce((acc, course) => {
|
||||
const mainCodes = course.course.substitutions.filter(sub => curriculumCourseCodes.includes(sub))
|
||||
// If no mainCode in curriculum, leave blank as nothing would be shown in course tab anyway
|
||||
if (mainCodes.length) {
|
||||
acc[course.course.code] ??= []
|
||||
acc[course.course.code].push(...mainCodes)
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
[curriculumCourseCodes, substitutionCourseCodes, courses]
|
||||
courses.reduce((acc, course) => {
|
||||
const substitutionGroups = course.course.substitution_groups ?? []
|
||||
if (substitutionGroups.length) {
|
||||
acc[course.course.code] ??= []
|
||||
acc[course.course.code].push(...substitutionGroups.filter(group => group.length))
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
[substitutionGroupsCourseCodes, courses]
|
||||
)
|
||||
|
||||
const coursesByParentModule = useMemo(() => {
|
||||
@@ -232,9 +304,9 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
const formattedStudents = useMemo(
|
||||
() =>
|
||||
students.map(student =>
|
||||
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substituteToPrimaryCodes)
|
||||
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substitutionsForCourseCode)
|
||||
),
|
||||
[students, studentMapper]
|
||||
[students, studentMapper, includeSubstitutions]
|
||||
)
|
||||
|
||||
const columns = useGetColumnDefinitions(coursesByParentModule)
|
||||
|
||||
@@ -6,7 +6,8 @@ import Table from '@mui/material/Table'
|
||||
* To be paired with StyledCell
|
||||
*/
|
||||
export const StyledTable = styled(Table, {
|
||||
shouldForwardProp: prop => prop !== 'showCellBorders' && prop !== 'zebraStriped',
|
||||
shouldForwardProp: prop =>
|
||||
prop !== 'showCellBorders' && prop !== 'zebraStriped' && prop !== 'slimHeader' && prop !== 'slimBody',
|
||||
})<{ showCellBorders?: true; zebraStriped?: true; slimHeader?: true; slimBody?: true }>(({
|
||||
theme,
|
||||
showCellBorders,
|
||||
|
||||
-1
@@ -160,7 +160,6 @@ export const AttemptsTable = ({
|
||||
)
|
||||
|
||||
const ooditable_table: Partial<TableOptions<TableData>> = {
|
||||
manualPagination: true,
|
||||
enableSortingRemoval: false,
|
||||
initialState: {
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
|
||||
-1
@@ -160,7 +160,6 @@ export const StudentsTable = ({
|
||||
)
|
||||
|
||||
const ooditable_table: Partial<TableOptions<TableData>> = {
|
||||
manualPagination: true,
|
||||
enableSortingRemoval: false,
|
||||
initialState: {
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setupListeners } from '@reduxjs/toolkit/query/react'
|
||||
import { configureStore } from '@reduxjs/toolkit/react'
|
||||
|
||||
import { handleRequest, RTKApi } from '@/apiConnection'
|
||||
import { RTKApi } from '@/apiConnection'
|
||||
import { isDev } from '@/conf'
|
||||
import { actionHistoryMiddleware } from './actionHistory'
|
||||
import { reducer as filters } from './filters'
|
||||
@@ -22,7 +22,6 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }).concat(
|
||||
RTKApi.middleware,
|
||||
handleRequest,
|
||||
actionHistoryMiddleware
|
||||
),
|
||||
})
|
||||
|
||||
Generated
+18
@@ -6,9 +6,20 @@
|
||||
"": {
|
||||
"name": "@oodikone/shared",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
@@ -22,6 +33,13 @@
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../types'
|
||||
import { FormattedStudent } from '../types/studentData'
|
||||
|
||||
export type CourseStats = Pick<Course, 'code' | 'name' | 'substitutions' | 'is_study_module'>
|
||||
export type CourseStats = Pick<Course, 'code' | 'name' | 'is_study_module' | 'substitution_groups'>
|
||||
|
||||
export type AttainmentDates = {
|
||||
latestTotal?: Date
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"sourceMap": true,
|
||||
"sourceRoot": "/",
|
||||
"inlineSources": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Result } from '../util'
|
||||
|
||||
type Params = Record<string, string> | string
|
||||
|
||||
// eslint-disable-next-line import-x/no-unused-modules
|
||||
export type BaseConfig = { baseUrl?: string; params?: Params; timeout?: number } & RequestInit
|
||||
|
||||
// eslint-disable-next-line import-x/no-unused-modules
|
||||
export type RequestConfig = { params?: Params; timeout?: number } & RequestInit
|
||||
|
||||
const buildBaseUrl = (baseUrl: string) => {
|
||||
try {
|
||||
return new URL(baseUrl)
|
||||
} catch (_) {
|
||||
// Invalid URLs will be caught here if the baseUrl cannot be extended on top of page origin.
|
||||
return new URL(baseUrl, globalThis?.location.origin)
|
||||
}
|
||||
}
|
||||
|
||||
const buildUrl = (
|
||||
baseUrl: string | undefined = '',
|
||||
url: string | undefined = '',
|
||||
baseParams?: Params,
|
||||
params?: Params
|
||||
) => {
|
||||
const base = baseUrl ? buildBaseUrl(url ? baseUrl.replace(/\/?$/, '/') : baseUrl) : ''
|
||||
const path = new URL(url.replace(/^\//, ''), base)
|
||||
|
||||
const baseUrlParams = new URLSearchParams(baseParams)
|
||||
const urlParams = new URLSearchParams(params)
|
||||
|
||||
Array.from(baseUrlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
|
||||
Array.from(urlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
|
||||
|
||||
return path.href
|
||||
}
|
||||
|
||||
const buildSignal = (timeout: number | undefined): AbortSignal | undefined =>
|
||||
timeout !== undefined ? AbortSignal.timeout(timeout) : undefined
|
||||
|
||||
const prepData = <T>(data: T) => JSON.stringify(data)
|
||||
|
||||
// We only really use JSON in Oodikone, so I didn't fell like implementing this function.
|
||||
const getMimeType = <T>(_: T) => {
|
||||
return 'application/json'
|
||||
}
|
||||
|
||||
const convertDataBasedOnCT = <R>(contentType: string, res: Response): Promise<R> => {
|
||||
// Should be the most common type, as express and JAMI return JSON
|
||||
if (contentType.startsWith('application/json')) return res.json() as Promise<R>
|
||||
else if (contentType.startsWith('application/octet-stream')) return res.arrayBuffer() as Promise<R>
|
||||
else if (contentType.startsWith('multipart/form-data')) return res.formData() as Promise<R>
|
||||
else if (contentType.startsWith('text/')) return res.text() as Promise<R>
|
||||
else return res.blob() as Promise<R>
|
||||
}
|
||||
|
||||
const handleRequestData = <R>(url: string, options: RequestInit): Promise<Result<R>> =>
|
||||
globalThis
|
||||
.fetch(url, options)
|
||||
.then(async res => {
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
|
||||
return {
|
||||
...res,
|
||||
data: await convertDataBasedOnCT<R>(ct, res),
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
.catch(error => ({
|
||||
data: null,
|
||||
error,
|
||||
}))
|
||||
|
||||
const fetcher = <T>({
|
||||
baseUrl,
|
||||
params: baseParams,
|
||||
timeout: baseTimeout,
|
||||
headers: baseHeaders,
|
||||
...baseRest
|
||||
}: BaseConfig) => ({
|
||||
get: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'GET',
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
post: <R>(
|
||||
url: string | undefined,
|
||||
data: T,
|
||||
{ params, headers, timeout, ...rest }: RequestConfig
|
||||
): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
'Content-Type': getMimeType(data),
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'POST',
|
||||
body: prepData(data),
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
put: <R>(
|
||||
url: string | undefined,
|
||||
data: T,
|
||||
{ params, headers, timeout, ...rest }: RequestConfig
|
||||
): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
'Content-Type': getMimeType(data),
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'PUT',
|
||||
body: prepData(data),
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
delete: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
|
||||
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
|
||||
signal: buildSignal(timeout ?? baseTimeout),
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...headers,
|
||||
},
|
||||
method: 'DELETE',
|
||||
...baseRest,
|
||||
...rest,
|
||||
}),
|
||||
})
|
||||
|
||||
export const Fetchios = {
|
||||
create: <R>(config: BaseConfig) => fetcher<R>(config),
|
||||
get: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<R>({ baseUrl: url }).get(undefined, config),
|
||||
post: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<T>({ baseUrl: url }).post<R>(undefined, data, config),
|
||||
put: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<T>({ baseUrl: url }).put<R>(undefined, data, config),
|
||||
delete: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
|
||||
fetcher<R>({ baseUrl: url }).delete(undefined, config),
|
||||
}
|
||||
@@ -41,7 +41,7 @@ type Err<E> = {
|
||||
error: E
|
||||
}
|
||||
|
||||
type Result<T, E = Error> = Ok<T> | Err<E>
|
||||
export type Result<T, E = Error> = Ok<T> | Err<E>
|
||||
|
||||
/**
|
||||
* @returns data, error object with distinct values.
|
||||
|
||||
Reference in New Issue
Block a user