[META] Remove open uni search

This commit is contained in:
Santeri Rajamäki
2026-05-29 11:54:02 +03:00
parent f4ac2b3806
commit e069c315a5
20 changed files with 14 additions and 1025 deletions
-2
View File
@@ -77,8 +77,6 @@ jobs:
cypress/e2e/Users.js
- name: 'Test faculty statistics'
spec: cypress/e2e/Faculty_statistics.js
- name: 'Test open uni search'
spec: cypress/e2e/Openuni.js
- name: 'Test language center view'
spec: cypress/e2e/Language_center.js
- name: 'Test study guidance groups'
-2
View File
@@ -334,8 +334,6 @@ jobs:
cypress/e2e/Users.js
- name: 'Test faculty statistics'
spec: cypress/e2e/Faculty_statistics.js
- name: 'Test open uni search'
spec: cypress/e2e/Openuni.js
- name: 'Test language center view'
spec: cypress/e2e/Language_center.js
- name: 'Test study guidance groups'
-126
View File
@@ -1,126 +0,0 @@
/// <reference types="cypress" />
const deleteAllSearches = () => {
cy.cs('open-uni-search-button').click()
cy.contains('Saved populations').parent().parent().parent().parent().get('.search').as('search')
cy.get('@search').then($el => {
const searchItems = $el.find('div[role=option] > span[class=text]')
for (let i = searchItems.length - 1; 0 <= i; i--) {
if (searchItems[i].textContent.includes('TEST-')) {
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
cy.get('button').contains('Delete').click()
}
}
})
}
// Skipping the whole test suite as it is unclear whether the feature has any use or will be
// removed alltogether.
describe.skip('Open uni population tests', () => {
describe('Open uni form can be used', () => {
// When openUniUser is created, change it here
beforeEach(() => {
cy.init('/openunipopulation', 'admin')
})
after(() => {
cy.init('/openunipopulation', 'admin')
cy.contains('Open uni student population')
deleteAllSearches()
})
// TODO: Rewrite the whole component
it.skip('Finds a proper population', () => {
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.contains('Insert course code(s)').siblings().get('textarea').as('textarea')
cy.get('@textarea').type(courseCodesSet1.join('\n'))
cy.cs('begin-of-search').click()
cy.cs('begin-of-search').clear()
cy.cs('begin-of-search').type('01.01.2015')
cy.cs('end-of-search').click()
cy.cs('end-of-search').clear()
cy.cs('end-of-search').type('01.01.2020')
cy.get('button').contains('Search population').click()
cy.contains('Beginning of the search for all fields:')
cy.contains('01.01.2015')
cy.contains('End of the search for enrollments:')
cy.contains('01.01.2020')
cy.location('pathname').should('eq', '/openunipopulation')
cy.location('search').then(search => {
const params = new URLSearchParams(search)
const courseCodes = params.getAll('courseCode')
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
expect(params.get('startdate')).to.eq('01-01-2015')
expect(params.get('enddate')).to.eq('01-01-2020')
})
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
})
})
it('Textarea can handle different kinds of input', () => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.contains('Insert course code(s)')
.siblings()
.get('textarea')
.type('tkt10001 tkt10002,tkt10003\n TKT10004,,TKt10005\nTKT-1234')
cy.get('button').contains('Search population').click()
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10001')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10002')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10003')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10004')
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', 'TKT10005')
cy.get('[data-cy="open-uni-table-div"]').should('not.contain', 'TKT-1234')
})
it('Saves a custom population search', () => {
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
cy.get('[data-cy="save-search"]').click()
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
cy.contains('TEST-Avoimen-populaatio')
cy.get('button').contains('Search population').as('btn')
cy.get('@btn').click()
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
})
})
it('Updates a custom population search', () => {
cy.fixture('openUniPopulation').then(({ courseCodesSet1, courseCodesSet2 }) => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
cy.get('[data-cy="save-search"]').click()
cy.get('button').contains('Search population').click()
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
cy.get('[data-cy="open-uni-search-button"]').click()
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
cy.contains('Insert course code(s)').siblings().get('textarea').should('contain', courseCodesSet1.join(', '))
cy.contains('Insert course code(s)').siblings().get('textarea').type('\n')
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet2.join('\n'))
cy.get('[data-cy="save-search"]').click()
cy.get('button').contains('Search population').click()
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[0])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[1])
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet2[2])
})
})
it('View does not crash with non-existing courses', () => {
cy.get('[data-cy="open-uni-search-button"]').click()
cy.contains('Insert course code(s)').siblings().get('textarea').type('cypress-fun')
cy.get('button').contains('Search population').click()
cy.get('[data-cy="open-uni-table-div"]').should('not.contain', 'cypress-fun')
})
})
})
-11
View File
@@ -58,17 +58,6 @@ const onlyStudyGuidanceGroupsUser = {
hypersonsisuid: 'hy-hlo-2222222',
}
// TODO add user into anon-DB (check the need for changing hypersonid)
// REMEMBER TO ADD ROLE openUniSearch
// const openUniUser = {
// uid: 'openuniuser',
// displayname: 'Open Uni User',
// 'shib-session-id': 'mock-cypress-session',
// hygroupcn: 'grp-oodikone-basics-users;hy-ypa-opa-dojo',
// mail: 'grp-toska+mockopenuniuser@helsinki.fi',
// hypersonsisuid: 'hy-hlo-9999999',
// }
const userHeaders = [
adminUserHeaders,
basicUserHeaders,
+1 -4
View File
@@ -8,11 +8,10 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
- courseStatistics
- facultyStatistics
- fullSisuAccess
- openUniSearch
- studyGuidanceGroups
- teachers
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
The roles `facultyStatistics` and `studyGuidanceGroups` simply enable certain views (_Faculties_ and _Guidance groups_ respectively).
### courseStatistics
@@ -43,7 +42,6 @@ The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply
| Custom population | | ✅ | ✅ |
| Completed courses | ✅ | ✅ | ✅ |
| Close to graduation | | ✅ | ✅ |
| Open uni population | | ✅ | |
| Courses | | ✅ | ✅ |
\* = anyone who can log in to Oodikone, check the [IAM groups](#iam-groups) section for more information
@@ -63,7 +61,6 @@ A user must be a member of either the `grp-oodikone-basic-users` or `grp-oodikon
| grp-oodikone-basic-users | courseStatistics |
| grp-oodikone-users | facultyStatistics |
| grp-toska | admin |
| hy-ypa-opa-dojo | openUniSearch |
`grp-oodikone-basic-users`: basically the whole staff (hy-\*-allstaff)
-1
View File
@@ -5,7 +5,6 @@ export const roles = [
'courseStatistics',
'facultyStatistics',
'fullSisuAccess',
'openUniSearch',
'studyGuidanceGroups',
'teachers',
] as const
-2
View File
@@ -15,7 +15,6 @@ import completedCoursesSearch from './routes/completedCoursesSearch'
import courseExclusions from './routes/courseExclusions'
import courses from './routes/courses'
import curriculumPeriods from './routes/curriculumPeriods'
import customOpenUniSearch from './routes/customOpenUniSearch'
import customPopulationSearch from './routes/customPopulationSearch'
import faculties from './routes/faculties'
import feedback from './routes/feedback'
@@ -55,7 +54,6 @@ const routes = (app: Express, url: string) => {
app.use(`${url}/course-exclusions`, courseExclusions)
app.use(`${url}/studyprogrammes`, studyProgramme)
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
app.use(`${url}/changelog`, changelog)
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
if (languageCenterViewEnabled) {
@@ -5,10 +5,10 @@ import { CanError } from '@oodikone/shared/routes'
import { tryCatch } from '@oodikone/shared/util'
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
import {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
getUserSearches,
createNewUserSearch,
deleteUserSearch,
updateUserSearch,
FoundSearch,
} from '../services/openUni/openUniManageSearches'
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
@@ -72,7 +72,7 @@ export type GetSearchResBody = FoundSearch[]
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const userId = req.user.id
const foundSearches = await getOpenUniSearches(userId)
const foundSearches = await getUserSearches(userId)
return res.json(foundSearches)
})
@@ -98,7 +98,7 @@ router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async
if (courseCodes && !Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Courselist must be type of array' })
}
const createdSearch = await createNewSearch(userId, name, courseCodes)
const createdSearch = await createNewUserSearch(userId, name, courseCodes)
if (!createdSearch) {
return res.status(400).json({ error: 'Failed to create courselist' })
}
@@ -130,7 +130,7 @@ router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searc
return res.status(422).end()
}
const updatedSearch = await updateSearch(userId, id, courseCodes)
const updatedSearch = await updateUserSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
@@ -155,7 +155,7 @@ router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (r
return res.status(422).end()
}
const deletedSearch = await deleteSearch(userId, id)
const deletedSearch = await deleteUserSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Courselist could not be found' })
}
@@ -1,148 +0,0 @@
import { Router } from 'express'
import { CanError } from '@oodikone/shared/routes'
import {
getOpenUniSearches,
createNewSearch,
deleteSearch,
updateSearch,
} from '../services/openUni/openUniManageSearches'
import { getCustomOpenUniCourses } from '../services/openUni/openUniStats'
import { safeJSONParse } from '../util'
const router = Router()
type SearchResBody = CanError<Awaited<ReturnType<typeof getCustomOpenUniCourses>>>
type SearchReqBody = never
type SearchQuery = {
courselist: string
startdate: string
enddate: string
}
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
const { courselist, startdate, enddate } = req.query
const courseCodes = (await safeJSONParse(courselist)) ?? []
if (!Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Courses must be of type array' })
}
const startDate = startdate !== 'null' ? new Date(startdate) : new Date(2017, 7, 1, 0, 0, 0)
const endDate = enddate !== 'null' ? new Date(enddate) : new Date()
if (enddate === 'null') {
endDate.setHours(23, 59, 59, 999)
}
const customOpenUniSearches = await getCustomOpenUniCourses(courseCodes, startDate, endDate)
return res.json(customOpenUniSearches)
})
type GetSearchResBody = Awaited<ReturnType<typeof getOpenUniSearches>>
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
const { id: userId } = req.user
const foundSearches = await getOpenUniSearches(userId)
return res.json(foundSearches)
})
type CreateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type CreateSearchReqBody = {
courselist: string[]
name: string
}
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
const { courselist, name } = req.body
const { id: userId } = req.user
const courseCodes = courselist || []
if (!name) {
return res.status(400).json({ error: 'Name missing' })
}
if (courseCodes && !Array.isArray(courseCodes)) {
return res.status(400).json({ error: 'Course codes must be type of array' })
}
const createdSearch = await createNewSearch(userId, name, courseCodes)
if (!createdSearch) {
return res.status(400).json({ error: 'Failed to create search' })
}
return res.status(201).json({
id: createdSearch.id,
userId: createdSearch.userId,
name: createdSearch.name,
courseList: createdSearch.courseCodes,
updatedAt: createdSearch.updatedAt,
})
})
type UpdateSearchParams = {
id: string
}
type UpdateSearchResBody = CanError<{
id: string
userId: string
name: string
courseList: string[]
updatedAt: string
}>
type UpdateSearchReqBody = {
courselist: string[]
}
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { courselist } = req.body
const { id: userId } = req.user
const courseCodes = courselist || []
if (!id || !userId) {
return res.status(422).end()
}
const updatedSearch = await updateSearch(userId, id, courseCodes)
if (!updatedSearch) {
return res.status(404).json({ error: 'Open uni search could not be found' })
}
return res.json({
id: updatedSearch.id,
userId: updatedSearch.userId,
name: updatedSearch.name,
courseList: updatedSearch.courseCodes,
updatedAt: updatedSearch.updatedAt,
})
})
type DeleteSearchParams = {
id: string
}
type DeleteSearchResBody = CanError<string>
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
const { id } = req.params
const { id: userId } = req.user
if (!id || !userId) {
return res.status(422).end()
}
const deletedSearch = await deleteSearch(userId, id)
if (!deletedSearch) {
return res.status(404).json({ error: 'Open uni search could not be found' })
}
return res.json(id)
})
export default router
@@ -14,7 +14,7 @@ export type FoundSearch = {
updatedAt: Date
}
export const getOpenUniSearches = async (userId: string) => {
export const getUserSearches = async (userId: string) => {
const savedSearches = await getOpenUniSearchesByUser(userId)
if (savedSearches === undefined) {
return []
@@ -34,7 +34,7 @@ export const getOpenUniSearches = async (userId: string) => {
return foundSearches
}
export const createNewSearch = async (userId: string, name: string, courseCodes: string[]) => {
export const createNewUserSearch = async (userId: string, name: string, courseCodes: string[]) => {
const savedSearches = await getOpenUniSearchesByUser(userId)
if (savedSearches?.some(search => search.name === name)) {
return null
@@ -51,7 +51,7 @@ export const createNewSearch = async (userId: string, name: string, courseCodes:
}
}
export const deleteSearch = async (userId: string, id: string) => {
export const deleteUserSearch = async (userId: string, id: string) => {
try {
const deleted = await deleteOpenUniSearch(userId, id)
if (!deleted) {
@@ -64,7 +64,7 @@ export const deleteSearch = async (userId: string, id: string) => {
}
}
export const updateSearch = async (userId: string, id: string, courseCodes: string[]) => {
export const updateUserSearch = async (userId: string, id: string, courseCodes: string[]) => {
try {
const updated = await updateOpenUniPopulationSearch(userId, id, courseCodes)
if (!updated) {
@@ -1,149 +0,0 @@
import dayjs from 'dayjs'
import { SISStudyRightModel } from '../../models'
import { getCourseNames, getCredits, getEnrollments, getStudentInfo, getStudyRights } from './openUniSearches'
type CourseInfo = {
status: {
passed: Date | null
failed: Date | null
unfinished: Date | null
}
}
type StudentStats = Record<
string,
{
courseInfo: Record<string, CourseInfo>
email: string
secondaryEmail: string
totals: {
passed: number
failed: number
unfinished: number
}
}
>
const uniq = objects => [...new Set(objects)]
const calculateTotalsForStudent = (studentStats: StudentStats, studentNumber: string) => {
Object.keys(studentStats[studentNumber].courseInfo).forEach(course => {
const { status } = studentStats[studentNumber].courseInfo[course]
if (status.passed) {
studentStats[studentNumber].totals.passed += 1
} else if (status.failed) {
studentStats[studentNumber].totals.failed += 1
} else if (status.unfinished) {
studentStats[studentNumber].totals.unfinished += 1
}
})
}
const getAllCourseCodes = (courseCodes: string[]) => {
const ayCourseCodes = courseCodes.map(courseCode => `AY${courseCode}`)
return courseCodes.concat(ayCourseCodes)
}
const isStartDateOutsideInterval = (studyRight: SISStudyRightModel, startDate: Date) => {
return startDate <= studyRight.startDate && studyRight.startDate <= new Date()
}
const isStartDateInsideAndEndDateOutside = (studyRight: SISStudyRightModel, startDate: Date) => {
return studyRight.startDate <= startDate && new Date() <= studyRight.endDate
}
const isEndDateBeforeNow = (studyRight: SISStudyRightModel) => studyRight.endDate <= new Date()
const getEmptyCourseInfo = (): CourseInfo => ({
status: {
passed: null,
failed: null,
unfinished: null,
},
})
const updatePassedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
if (!courseInfo.status.passed || dayjs(courseInfo.status.passed).isBefore(attainmentDate, 'day')) {
courseInfo.status.passed = attainmentDate
}
}
const updateFailedStatus = (courseInfo: CourseInfo, attainmentDate: Date) => {
if (
!courseInfo.status.passed &&
(!courseInfo.status.failed || dayjs(courseInfo.status.failed).isBefore(attainmentDate, 'day'))
) {
courseInfo.status.failed = attainmentDate
}
}
const updateUnfinishedStatus = (courseInfo: CourseInfo, enrollmentDateTime: Date) => {
if (
!courseInfo.status.passed &&
!courseInfo.status.failed &&
(!courseInfo.status.unfinished || dayjs(courseInfo.status.unfinished).isBefore(enrollmentDateTime, 'day'))
) {
courseInfo.status.unfinished = enrollmentDateTime
}
}
export const getCustomOpenUniCourses = async (courseCodes: string[], startDate: Date, endDate: Date) => {
const allCourseCodes = getAllCourseCodes(courseCodes)
const allCredits = await getCredits(allCourseCodes, startDate)
const allEnrollments = await getEnrollments(allCourseCodes, startDate, endDate)
const courses = await getCourseNames(courseCodes)
const studentNumbers = uniq(allEnrollments.map(enrollment => enrollment.enrollmentStudentNumber)) as string[]
const allStudyRights = await getStudyRights(studentNumbers)
const studentInfo = await getStudentInfo(studentNumbers)
const passedGrades = ['1', '2', '3', '4', '5', 'Hyv.', 'hyv.', 'HT', 'TT']
const failedGrades = ['Hyl.', 'HYL', '0']
const studentsWithCurrentStudyRight = allStudyRights
.filter(
studyRight =>
isStartDateOutsideInterval(studyRight, startDate) ||
isStartDateInsideAndEndDateOutside(studyRight, startDate) ||
isEndDateBeforeNow(studyRight)
)
.map(studyRight => studyRight.studentNumber)
const uniqueStudentsWithCurrentStudyRight = uniq(studentsWithCurrentStudyRight)
const studentStats: StudentStats = {}
for (const { studentNumber, email, secondaryEmail } of studentInfo) {
if (uniqueStudentsWithCurrentStudyRight.includes(studentNumber)) {
continue
}
if (!(studentNumber in studentStats)) {
studentStats[studentNumber] = {
courseInfo: courseCodes.reduce((acc, code) => ({ ...acc, [code.replace('AY', '')]: getEmptyCourseInfo() }), {}),
email,
secondaryEmail,
totals: { passed: 0, failed: 0, unfinished: 0 },
}
}
const studentCourses = studentStats[studentNumber].courseInfo
for (const { attainmentCourseCode, attainmentDate, attainmentStudentNumber, attainmentGrade } of allCredits) {
if (attainmentStudentNumber === studentNumber) {
const courseCode = attainmentCourseCode.replace('AY', '')
if (passedGrades.includes(attainmentGrade)) {
updatePassedStatus(studentCourses[courseCode], attainmentDate)
} else if (failedGrades.includes(attainmentGrade)) {
updateFailedStatus(studentCourses[courseCode], attainmentDate)
}
}
}
for (const { enrollmentStudentNumber, enrollmentCourseCode, enrollmentDateTime } of allEnrollments) {
if (enrollmentStudentNumber === studentNumber) {
const courseCode = enrollmentCourseCode.replace('AY', '')
updateUnfinishedStatus(studentCourses[courseCode], enrollmentDateTime)
}
}
calculateTotalsForStudent(studentStats, studentNumber)
}
const openUniStats = { students: studentStats, courses }
return openUniStats
}
+1 -2
View File
@@ -152,7 +152,7 @@ const updateAccessGroups = async (
userFromDb: UserModel
) => {
const { jory, superAdmin, openUni, fullSisuAccess } = specialGroup
const { jory, superAdmin, fullSisuAccess } = specialGroup
const currentAccessGroups = userFromDb?.roles ?? []
const newAccessGroups = [
@@ -163,7 +163,6 @@ const updateAccessGroups = async (
...(jory || iamGroups.includes(facultyStatisticsGroup) ? ['facultyStatistics'] : []),
...(currentAccessGroups.includes('teachers') ? ['teachers'] : []),
...(superAdmin || currentAccessGroups.includes('admin') ? ['admin'] : []),
...(openUni ? ['openUniSearch'] : []),
...(fullSisuAccess ? ['fullSisuAccess'] : []),
] as Role[]
@@ -1,247 +0,0 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { DateRangeSelector } from '@/components/common/DateRangeSelector'
import { SearchHistory } from '@/components/common/SearchHistory'
import {
useCreateOpenUniCourseSearchMutation,
useDeleteOpenUniCourseSearchMutation,
useUpdateOpenUniCourseSearchMutation,
} from '@/redux/openUniPopulations'
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
import { formatISODate } from '@/util/timeAndDate'
import { formatToArray } from '@oodikone/shared/util'
export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
const location = useLocation()
const navigate = useNavigate()
const [modal, setModal] = useState(false)
const [input, setInput] = useState('')
const [searchList, setSearches] = useState(savedSearches)
const [name, setName] = useState('')
const [startdate, setStartdate] = useState(dayjs('01-08-2017 00:00:00', 'DD-MM-YYYY'))
const [enddate, setEnddate] = useState(dayjs().endOf('day'))
const [selectedSearchId, setSelectedSearchId] = useState('')
const [updateOpenUniCourseSearch, { isLoading: updateIsLoading, data: updatedData }] =
useUpdateOpenUniCourseSearchMutation()
const [createOpenUniCourseSearch, { isLoading: createIsLoading, data: createdData }] =
useCreateOpenUniCourseSearchMutation()
const [deleteOpenUniCourseSearch, { isLoading: deleteIsLoading, data: deletedData }] =
useDeleteOpenUniCourseSearchMutation()
useEffect(() => {
if (updatedData) {
const updatedSearches = searchList.map(s => (s.id === updatedData.id ? updatedData : s))
setSearches(updatedSearches)
}
}, [updatedData])
useEffect(() => {
if (createdData && !createdData.error) {
const newList = searchList.concat(createdData)
setSearches(newList)
}
}, [createdData])
useEffect(() => {
if (deletedData) {
const filteredSearches = searchList.filter(s => s.id !== deletedData)
setSearches(filteredSearches)
}
}, [deletedData])
const pushQueryToUrl = query => {
setTimeout(() => {
const searchString = queryParamsToString(query)
void navigate({ search: searchString }, { replace: true })
}, 0)
}
const parseQueryFromUrl = () => {
const { courseCode, startdate, enddate } = parseQueryParams(location.search)
const courseCodes = formatToArray(courseCode)
const query = {
courseList: courseCodes,
startdate: dayjs(startdate, 'DD-MM-YYYY').toISOString(),
enddate: dayjs(enddate, 'DD-MM-YYYY').endOf('day').toISOString(),
}
return query
}
useEffect(() => {
const timer = setTimeout(() => {
if (!location.search) {
setValues({})
} else {
const query = parseQueryFromUrl()
setValues(query)
}
}, 0)
// Cleanup function to clear the timeout
return () => clearTimeout(timer)
}, [location.search])
const clearForm = () => {
setInput('')
setName('')
setStartdate(dayjs('01-08-2017 00-00-00', 'DD-MM-YYYY'))
setEnddate(dayjs().endOf('day'))
setSelectedSearchId('')
}
const handleClose = () => {
setModal(false)
clearForm()
}
const onClicker = event => {
event.preventDefault()
const courseList = input.split(/[\s,]+/).map(code => code.trim().toUpperCase())
const query = {
courseCode: courseList,
startdate: dayjs(startdate).isValid() ? formatISODate(startdate) : formatISODate(new Date('01-08-2017')),
enddate: dayjs(enddate).isValid() ? formatISODate(enddate) : formatISODate(new Date()),
}
setValues({
courseList,
startdate: dayjs(startdate).isValid()
? dayjs(startdate).toISOString()
: dayjs('01-08-2017', 'DD-MM-YYYY').toISOString(),
enddate: dayjs(enddate).isValid() ? dayjs(enddate).toISOString() : dayjs().toISOString(),
})
pushQueryToUrl(query)
handleClose()
}
const onDelete = () => {
void deleteOpenUniCourseSearch({ id: selectedSearchId })
clearForm()
}
const onSelectSearch = selectedId => {
if (!selectedId) {
clearForm()
return
}
const { id: selectedSearchId } = selectedId
const selectedSearch = searchList.find(search => search.id === selectedSearchId)
if (selectedSearch) {
setInput(selectedSearch.courseList.join(', '))
setName(selectedSearch.name)
setSelectedSearchId(selectedSearch.id)
}
}
const onSave = () => {
const courseList = input.split(/[\s,]+/).map(code => code.trim().toUpperCase())
if (selectedSearchId !== '') {
void updateOpenUniCourseSearch({ id: selectedSearchId, courseList })
} else {
void createOpenUniCourseSearch({ courseList, name })
}
}
return (
<>
<Button color="primary" data-cy="open-uni-search-button" onClick={() => setModal(true)} variant="outlined">
Fetch Open Uni Students
</Button>
<Dialog fullWidth onClose={handleClose} open={modal}>
<Paper sx={{ padding: 2 }}>
<h2>Fetch open uni course population</h2>
<Stack>
<em>Insert name for this population if you wish to save it:</em>
<TextField
data-cy="search-name"
disabled={selectedSearchId !== ''}
onChange={event => setName(event.target.value)}
placeholder="name"
value={name}
/>
</Stack>
<Stack>
<em>Insert course code(s):</em>
<TextField
minRows={2}
multiline
onChange={event => setInput(event.target.value)}
placeholder="TKT12345, PSYK-123"
value={input}
/>
</Stack>
<SearchHistory
handleSearch={onSelectSearch}
header="Saved populations"
items={searchList?.map(s => ({
...s,
text: s.name,
timestamp: new Date(s.updatedAt),
params: { id: s.id },
}))}
updateItem={() => null}
/>
<Typography>Select beginnings and ending dates for enrollemnts:</Typography>
<DateRangeSelector
onChange={([startDate, endDate]) => {
setStartdate(startDate)
setEnddate(endDate)
}}
value={[startdate, enddate]}
/>
<Box sx={{ py: 2 }}>
<Stack flexDirection="row" sx={{ justifyContent: 'space-between' }}>
<Box>
<Button
color="success"
data-cy="save-search"
disabled={!name || updateIsLoading || createIsLoading}
loading={updateIsLoading || createIsLoading}
onClick={onSave}
variant="outlined"
>
Save
</Button>
<Button
color="error"
disabled={!selectedSearchId || deleteIsLoading}
onClick={onDelete}
sx={{ ml: 0.5 }}
variant="outlined"
>
Delete
</Button>
</Box>
<Box>
<Button color="error" onClick={handleClose} variant="outlined">
Cancel
</Button>
<Button
color="primary"
data-cy="search-button"
onClick={event => onClicker(event)}
sx={{ ml: 0.5 }}
variant="outlined"
>
Search population
</Button>
</Box>
</Stack>
</Box>
</Paper>
</Dialog>
</>
)
}
@@ -1,188 +0,0 @@
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { createColumnHelper } from '@tanstack/react-table'
import { useMemo } from 'react'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { Loading } from '@/components/Loading'
import { OodiTable } from '@/components/OodiTable'
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
import { DateFormat } from '@/constants/date'
import { useGetOpenUniCourseStudentsQuery } from '@/redux/openUniPopulations'
import { CheckIcon, CloseIcon, RemoveIcon } from '@/theme'
import { formatDate } from '@/util/timeAndDate'
const columnHelper = createColumnHelper()
const getTableData = studentsData => {
return Object.keys(studentsData).reduce(
(acc, student) => [
...acc,
{
studentnumber: student,
courseInfo: { ...studentsData[student].courseInfo },
email: studentsData[student].email,
secondaryEmail: studentsData[student].secondaryEmail,
totals: studentsData[student].totals,
},
],
[]
)
}
const findRowContent = (student, courseCode) => {
if (student.courseInfo[courseCode] === undefined) return null
if (student.courseInfo[courseCode].status.passed) return <CheckIcon color="success" />
if (student.courseInfo[courseCode].status.failed) return <CloseIcon color="error" />
if (student.courseInfo[courseCode].status.unfinished) return <RemoveIcon color="disabled" />
return null
}
const findRowValue = (student, courseCode) => {
if (student.courseInfo[courseCode]?.status.passed) {
return `Passed: ${formatDate(student.courseInfo[courseCode].status.passed, DateFormat.ISO_DATE)}`
}
if (student.courseInfo[courseCode]?.status.failed) {
return `Failed: ${formatDate(student.courseInfo[courseCode].status.failed, DateFormat.ISO_DATE)}`
}
if (student.courseInfo[courseCode]?.status.unfinished) {
return `Enrollment: ${formatDate(student.courseInfo[courseCode].status.unfinished, DateFormat.ISO_DATE)}`
}
return ''
}
const getColumns = (labelsToCourses, getTextIn) => [
columnHelper.group({
key: 'general',
header: 'Labels',
columns: [
columnHelper.accessor('studentnumber', {
key: 'studentnumber-child',
header: 'Student number',
cell: ({ row }) => row.original.studentnumber,
}),
],
}),
columnHelper.group({
key: 'information',
header: 'Information',
columns: [
columnHelper.accessor('email', {
key: 'email-child',
header: 'Email',
cell: ({ row }) => row.original.email ?? '',
}),
columnHelper.accessor('secondaryEmail', {
key: 'secondary_email-child',
header: 'Secondary email',
cell: ({ row }) => row.original.secondaryEmail ?? '',
}),
],
}),
columnHelper.group({
key: 'courses',
header: 'Courses',
columns: labelsToCourses.map(course =>
columnHelper.display({
key: `${course.label}-${getTextIn(course.name)}`,
header: course.label,
// cellProps: student => findProp(student, course.label),
cell: ({ row }) => (
<Tooltip title={findRowValue(row.original, course.label)}>
<Typography component="div" sx={{ m: 'auto', width: 'fit-content' }}>
{findRowContent(row.original, course.label)}
</Typography>
</Tooltip>
),
})
),
}),
columnHelper.group({
key: 'statistics',
header: 'Total of',
columns: [
columnHelper.display({
key: 'passed',
header: 'Passed',
cell: ({ row }) => row.original.totals.passed,
}),
columnHelper.display({
key: 'failed',
header: 'Failed',
cell: ({ row }) => row.original.totals.failed,
}),
columnHelper.display({
key: 'unfinished',
header: 'Unfinished',
cell: ({ row }) => row.original.totals.unfinished,
}),
],
}),
]
export const OpenUniPopulationResults = ({ fieldValues }) => {
const { courseList, startdate, enddate } = fieldValues
const { getTextIn } = useLanguage()
const { data, isLoading, isFetching, isSuccess, isError } = useGetOpenUniCourseStudentsQuery(
{ courseList, startdate, enddate },
{ skip: !courseList.length }
)
const isFetchingOrLoading = isLoading || isFetching
const hasFailed = isError || (isSuccess && !data)
const [columns, tableData, excelData] = useMemo(() => {
if (isFetchingOrLoading || hasFailed) return []
const labelsToCourses = data.courses
const columns = getColumns(
data.courses.toSorted((a, b) => a.label.localeCompare(b.label)),
getTextIn
)
const tableData = getTableData(data.students)
const excelData = tableData.map(student => ({
'Student number': student.studentnumber,
Email: student.email,
'Secondary email': student.secondaryEmail,
...Object.fromEntries(labelsToCourses.map(course => [course.label, findRowValue(student, course.label)])),
Passed: student.totals.passed,
Failed: student.totals.failed,
Unfinished: student.totals.unfinished,
}))
return [columns, tableData, excelData]
}, [data])
const accessorKeys = useMemo(
() => [
'Student number',
'Email',
'Secondary email',
...(data?.courses.map(course => course.label).toSorted((a, b) => a.localeCompare(b)) ?? []),
'Passed',
'Failed',
'Unfinished',
],
[data?.courses]
)
if (!courseList.length) return <h3>No courses selected.</h3>
if (isFetchingOrLoading) return <Loading />
if (hasFailed) return <h3>Something went wrong, please try refreshing the page.</h3>
const tableOptions = {}
return (
<>
{`Open uni student population (${Object.keys(data.students).length} students)`}
<OodiTable
columns={columns}
data={tableData}
options={tableOptions}
toolbarContent={<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />}
/>
</>
)
}
@@ -1,73 +0,0 @@
import Alert from '@mui/material/Alert'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useState } from 'react'
import { Loading } from '@/components/Loading'
import { DateFormat } from '@/constants/date'
import { useTitle } from '@/hooks/title'
import { useGetSavedSearchesQuery } from '@/redux/openUniPopulations'
import { CheckIcon, CloseIcon, RemoveIcon } from '@/theme'
import { formatDate } from '@/util/timeAndDate'
import { CustomOpenUniSearch } from './CustomOpenUniSearch'
import { OpenUniPopulationResults } from './OpenUniPopulationResults'
export const CustomOpenUniPopulation = () => {
useTitle('Custom open uni population')
const [fieldValues, setValues] = useState({})
const savedSearches = useGetSavedSearchesQuery()
const isFetchingOrLoading = savedSearches.isLoading || savedSearches.isFetching
const isError = savedSearches.isError || (savedSearches.isSuccess && !savedSearches.data)
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
if (isFetchingOrLoading) return <Loading active style={{ marginTop: '15em' }} />
return (
<Stack
sx={{
display: 'flex',
mx: 'auto',
alignItems: 'center',
width: '100%',
maxWidth: 'md',
}}
>
<Alert icon={false} severity="info" style={{ maxWidth: '800px' }} variant="outlined">
<Typography variant="h6">Open uni student population</Typography>
Here you can create custom population using a list of courses. Clicking the blue button will open a modal where
you can enter a list of courses. <br />
<Typography sx={{ display: 'flex' }}>
<CheckIcon color="success" />: Student has passed the course. <br />
</Typography>
<Typography sx={{ display: 'flex' }}>
<CloseIcon color="error" />: Student has failed the course. <br />
</Typography>
<Typography sx={{ display: 'flex' }}>
<RemoveIcon color="disabled" />: Student has enrolled, but has not received any grade from the
</Typography>
course. <br />
<b>Empty cell</b>: Student has no enrollments for the course. <br />
</Alert>
<CustomOpenUniSearch savedSearches={savedSearches.data} setValues={setValues} />
<div style={{ paddingTop: '25px', paddingBottom: '10px', fontSize: '20px' }}>
{fieldValues && fieldValues.courseList?.length > 0 ? (
<p>
<span style={{ color: '#484848' }}>Beginning of the search for all fields:</span>
<span style={{ paddingLeft: '10px', fontWeight: 'bold' }}>
{formatDate(fieldValues.startdate, DateFormat.DISPLAY_DATE)}
</span>
<br />
<span style={{ color: '#484848' }}>End of the search for enrollments:</span>
<span style={{ paddingLeft: '30px', fontWeight: 'bold' }}>
{formatDate(fieldValues.enddate, DateFormat.DISPLAY_DATE)}
</span>
</p>
) : null}
</div>
<div data-cy="open-uni-table-div" style={{ width: '100%' }}>
{fieldValues && fieldValues.courseList?.length > 0 ? (
<OpenUniPopulationResults fieldValues={fieldValues} />
) : null}
</div>
</Stack>
)
}
@@ -25,10 +25,6 @@ export const NavigationButton = ({ item }: { item: NavigationItem }) => {
return true
}
if (checkUserAccess(['admin', 'openUniSearch'], roles) && subItemKey === 'openUniSearch') {
return true
}
if (
(checkUserAccess(['admin', 'fullSisuAccess', 'studyGuidanceGroups'], roles) ||
fullStudyProgrammeRights.length > 0) &&
@@ -31,7 +31,6 @@ export const navigationItems: Record<string, NavigationItem> = {
label: 'Special populations',
items: [
{ key: 'customSearch', label: 'Custom population', path: '/custompopulation' },
{ key: 'openUniSearch', label: 'Fetch open uni students by courses', path: '/openunipopulation' },
{ key: 'completedCoursesSearch', label: 'Completed courses of students', path: '/completedcoursessearch' },
{ key: 'languageCenterView', label: 'Language center view', path: '/languagecenterview' },
{ key: 'closeToGraduation', label: 'Students close to graduation', path: '/close-to-graduation' },
@@ -2,7 +2,6 @@ import { Suspense } from 'react'
import { Navigate, Route, Routes as RouterRoutes } from 'react-router'
import { CoursePopulation } from '@/components/CoursePopulation'
import { CustomOpenUniPopulation } from '@/components/CustomOpenUniPopulation'
import { CustomPopulation } from '@/components/CustomPopulation'
import { LanguageCenterView } from '@/components/LanguageCenterView'
import { PopulationStatistics } from '@/components/PopulationStatistics'
@@ -78,10 +77,6 @@ export const Routes = () => (
<Route element={<CloseToGraduation />} path="/close-to-graduation" />
</Route>
<Route element={<ProtectedRoute requiredRoles={['openUniSearch']} />}>
<Route element={<CustomOpenUniPopulation />} path="/openunipopulation" />
</Route>
{/* Access control inside ProtectedRoute */}
<Route element={<ProtectedRoute />}>
{languageCenterViewEnabled ? <Route element={<LanguageCenterView />} path="/languagecenterview" /> : null}
@@ -1,47 +0,0 @@
import { RTKApi } from '@/apiConnection'
const openUniPopulationApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getOpenUniCourseStudents: builder.query({
query: ({ courseList, startdate, enddate }) =>
`/openunisearch?courselist=${JSON.stringify(courseList)}&startdate=${startdate}&enddate=${enddate}`,
}),
getSavedSearches: builder.query({
query: () => '/openunisearch/searches',
}),
createOpenUniCourseSearch: builder.mutation({
query: ({ courseList, name }) => ({
url: '/openunisearch/searches',
method: 'POST',
body: {
courselist: courseList,
name,
},
}),
}),
updateOpenUniCourseSearch: builder.mutation({
query: ({ id, courseList }) => ({
url: `/openunisearch/searches/${id}`,
method: 'PUT',
body: {
courselist: courseList,
},
}),
}),
deleteOpenUniCourseSearch: builder.mutation({
query: ({ id }) => ({
url: `/openunisearch/searches/${id}`,
method: 'DELETE',
}),
}),
}),
overrideExisting: false,
})
export const {
useGetOpenUniCourseStudentsQuery,
useGetSavedSearchesQuery,
useCreateOpenUniCourseSearchMutation,
useDeleteOpenUniCourseSearchMutation,
useUpdateOpenUniCourseSearchMutation,
} = openUniPopulationApi
-1
View File
@@ -3,6 +3,5 @@ export type Role =
| 'courseStatistics'
| 'facultyStatistics'
| 'fullSisuAccess'
| 'openUniSearch'
| 'studyGuidanceGroups'
| 'teachers'