mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[Completed courses] fix regression + add types
This commit is contained in:
@@ -2,8 +2,9 @@ import { Router } from 'express'
|
||||
import { difference, intersection } from 'lodash-es'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import type { CompletedCoursesStudent, CompletedCoursesCourse } from '@oodikone/shared/types'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import { getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import {
|
||||
getUserSearches,
|
||||
createNewUserSearch,
|
||||
@@ -19,11 +20,14 @@ const router = Router()
|
||||
|
||||
const importerClient = getImporterClient()
|
||||
|
||||
export type SearchResBody = CanError<
|
||||
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
|
||||
type SearchResBody = CanError<
|
||||
{ discardedStudentNumbers: string[] } & {
|
||||
students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]
|
||||
courses: CompletedCoursesCourse[]
|
||||
}
|
||||
>
|
||||
export type SearchReqBody = never
|
||||
export type SearchQuery = {
|
||||
type SearchReqBody = never
|
||||
type SearchQuery = {
|
||||
studentlist: string
|
||||
courselist: string
|
||||
}
|
||||
|
||||
@@ -1,62 +1,28 @@
|
||||
import { col, fn, Op, where } from 'sequelize'
|
||||
|
||||
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
|
||||
import {
|
||||
CompletedCoursesCourse,
|
||||
CompletedCoursesStudent,
|
||||
CreditTypeCode,
|
||||
EnrollmentState,
|
||||
StudentCredits,
|
||||
} from '@oodikone/shared/types'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
|
||||
|
||||
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitution_groups'>>
|
||||
|
||||
interface StudentCredit {
|
||||
date: Date
|
||||
courseCode: string
|
||||
creditType: CreditTypeCode
|
||||
substitution: string[] | null
|
||||
}
|
||||
|
||||
interface StudentEnrollment {
|
||||
date: Date
|
||||
courseCode: string
|
||||
substitution: string[] | null
|
||||
}
|
||||
|
||||
interface StudentInfo {
|
||||
firstnames: string
|
||||
lastname: string
|
||||
email: string
|
||||
sis_person_id: string
|
||||
coursesInStudyPlan: string[]
|
||||
secondaryEmail: string | null
|
||||
credits: StudentCredit[]
|
||||
enrollments: Record<string, StudentEnrollment>
|
||||
}
|
||||
|
||||
type StudentCredits = Record<
|
||||
string,
|
||||
Omit<StudentInfo, 'credits' | 'enrollments'> & {
|
||||
credits: StudentCredit[]
|
||||
enrollments: StudentEnrollment[]
|
||||
}
|
||||
>
|
||||
|
||||
export interface FormattedStudent extends StudentInfo {
|
||||
studentNumber: string
|
||||
allEnrollments: StudentEnrollment[]
|
||||
}
|
||||
|
||||
interface StudentWithStudyplanNested
|
||||
extends Pick<
|
||||
StudentModel,
|
||||
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
|
||||
> {
|
||||
type StudentWithStudyplanNested = Pick<
|
||||
StudentModel,
|
||||
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
|
||||
> & {
|
||||
studyplans: { included_courses: string[] }[]
|
||||
}
|
||||
|
||||
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
|
||||
type StudentWithCourses = Omit<StudentWithStudyplanNested, 'studyplans'> & {
|
||||
coursesInStudyPlan: string[]
|
||||
}
|
||||
|
||||
const getCourses = async (courseCodes: string[]) => {
|
||||
const courses: Courses = (
|
||||
const courses: CompletedCoursesCourse[] = (
|
||||
await CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitution_groups'],
|
||||
where: where(fn('LOWER', col('code')), {
|
||||
@@ -67,25 +33,28 @@ const getCourses = async (courseCodes: string[]) => {
|
||||
return courses
|
||||
}
|
||||
|
||||
const getPassedCredits = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
|
||||
const credits: Array<
|
||||
Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>
|
||||
> = await CreditModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
|
||||
order: [['attainment_date', 'DESC']],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
const getPassedCredits = async (
|
||||
courses: CompletedCoursesCourse[],
|
||||
fullCourseCodes: string[],
|
||||
studentNumbers: string[]
|
||||
) => {
|
||||
const credits: Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>[] =
|
||||
await CreditModel.findAll({
|
||||
raw: true,
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
|
||||
order: [['attainment_date', 'DESC']],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
},
|
||||
student_studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
credittypecode: {
|
||||
[Op.not]: CreditTypeCode.FAILED,
|
||||
},
|
||||
},
|
||||
student_studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
credittypecode: {
|
||||
[Op.not]: CreditTypeCode.FAILED,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const creditsByStudentNumber = Object.groupBy(credits, ({ student_studentnumber }) => student_studentnumber)
|
||||
|
||||
@@ -125,7 +94,11 @@ const getPassedCredits = async (courses: Courses, fullCourseCodes: string[], stu
|
||||
return formattedCredits
|
||||
}
|
||||
|
||||
const getEnrollments = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
|
||||
const getEnrollments = async (
|
||||
courses: CompletedCoursesCourse[],
|
||||
fullCourseCodes: string[],
|
||||
studentNumbers: string[]
|
||||
) => {
|
||||
const enrollments: Array<Pick<EnrollmentModel, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
|
||||
await EnrollmentModel.findAll({
|
||||
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
|
||||
@@ -208,7 +181,7 @@ const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses
|
||||
export const getCompletedCourses = async (
|
||||
studentNumbers: string[],
|
||||
courseCodes: string[]
|
||||
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
|
||||
): Promise<{ students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]; courses: CompletedCoursesCourse[] }> => {
|
||||
const courses = await getCourses(courseCodes)
|
||||
const courseCodesSet = new Set(courseCodes)
|
||||
|
||||
@@ -280,22 +253,25 @@ export const getCompletedCourses = async (
|
||||
})
|
||||
})
|
||||
|
||||
const students = Object.keys(studentCredits).reduce((acc: FormattedStudent[], studentNumber) => {
|
||||
const student = studentCredits[studentNumber]
|
||||
acc.push({
|
||||
studentNumber,
|
||||
sis_person_id: student.sis_person_id,
|
||||
credits: student.credits,
|
||||
enrollments: {},
|
||||
allEnrollments: student.enrollments,
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
})
|
||||
return acc
|
||||
}, [] as FormattedStudent[])
|
||||
const students = Object.keys(studentCredits).reduce<CompletedCoursesStudent[]>(
|
||||
(acc: CompletedCoursesStudent[], studentNumber) => {
|
||||
const student = studentCredits[studentNumber]
|
||||
acc.push({
|
||||
studentNumber,
|
||||
sis_person_id: student.sis_person_id,
|
||||
credits: student.credits,
|
||||
enrollments: {},
|
||||
allEnrollments: student.enrollments,
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
students.forEach(student => {
|
||||
courseCodes.forEach(courseCode => {
|
||||
|
||||
@@ -17,20 +17,18 @@ import { useGetCompletedCoursesQuery } from '@/redux/completedCoursesSearch'
|
||||
import { CropSquareIcon, DoneIcon, RemoveIcon } from '@/theme'
|
||||
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
|
||||
import { formatDate, isWithinSixMonths } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
import { CompletedCoursesStudent, CreditTypeCode } from '@oodikone/shared/types'
|
||||
import { SearchValues } from '.'
|
||||
|
||||
const isPassed = credit => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED, CreditTypeCode.IMPROVED].includes(credit)
|
||||
const isPassed = (creditType: CreditTypeCode) =>
|
||||
[CreditTypeCode.PASSED, CreditTypeCode.APPROVED, CreditTypeCode.IMPROVED].includes(creditType)
|
||||
|
||||
const getTotalPassed = student => student.credits.filter(credit => isPassed(credit)).length
|
||||
|
||||
const getTotalUnfinished = student => Object.values(student.enrollments).length
|
||||
|
||||
const getCompletion = (student, courseCode, { icon }) => {
|
||||
const getCompletion = (student: CompletedCoursesStudent, courseCode: string, icon: boolean) => {
|
||||
const completion = student.credits.find(credit => credit.courseCode === courseCode && isPassed(credit.creditType))
|
||||
const enrollment = student.enrollments[courseCode]
|
||||
const isInStudyPlan = student.coursesInStudyPlan.includes(courseCode)
|
||||
|
||||
if (completion === undefined) {
|
||||
if (!completion) {
|
||||
if (!enrollment) {
|
||||
if (isInStudyPlan) {
|
||||
return <CropSquareIcon fontSize="small" style={{ color: grey[500] }} />
|
||||
@@ -47,9 +45,8 @@ const getCompletion = (student, courseCode, { icon }) => {
|
||||
return `Latest enrollment: ${formatDate(enrollment.date, DateFormat.ISO_DATE)}`
|
||||
}
|
||||
|
||||
const substituted = !!completion.substitution?.length
|
||||
const substitutionString = substituted ? ` as ${completion.substitution.join(', ')}` : ''
|
||||
|
||||
const substituted = Array.isArray(completion.substitution) && !!completion.substitution.length
|
||||
const substitutionString = substituted ? ` as ${completion.substitution!.join(', ')}` : ''
|
||||
return icon ? (
|
||||
<DoneIcon fontSize="small" style={{ color: substituted ? grey[700] : green[700] }} />
|
||||
) : (
|
||||
@@ -57,13 +54,13 @@ const getCompletion = (student, courseCode, { icon }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const getCellTitle = (student, courseCode) => {
|
||||
const getCellTitle = (student: CompletedCoursesStudent, courseCode: string) => {
|
||||
const credit = student.credits.find(credit => credit.courseCode === courseCode)
|
||||
const enrollment = student.enrollments[courseCode]
|
||||
if (!credit && !enrollment) {
|
||||
return 'Student has the course in their primary study plan'
|
||||
}
|
||||
let title
|
||||
let title: string
|
||||
if (credit) {
|
||||
const substitutionString = credit.substitution?.length ? `Substituted by: ${credit.substitution?.join(', ')}` : ''
|
||||
title = `Passed on ${formatDate(credit.date, DateFormat.ISO_DATE)}\n${substitutionString}`
|
||||
@@ -98,7 +95,7 @@ const RightsNotification = ({ discardedStudentNumbers }: { discardedStudentNumbe
|
||||
)
|
||||
}
|
||||
|
||||
export const SearchResults = ({ searchValues }) => {
|
||||
export const SearchResults = ({ searchValues }: { searchValues: SearchValues }) => {
|
||||
const { courseList, studentList } = searchValues
|
||||
const { visible: namesVisible } = useStudentNameVisibility()
|
||||
const { data, isFetching } = useGetCompletedCoursesQuery({ courseList, studentList })
|
||||
@@ -116,8 +113,12 @@ export const SearchResults = ({ searchValues }) => {
|
||||
})
|
||||
}, [namesVisible])
|
||||
|
||||
const staticColumns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
() => [
|
||||
const staticColumns = useMemo<MRT_ColumnDef<CompletedCoursesStudent>[]>(() => {
|
||||
const getTotalPassed = (student: CompletedCoursesStudent) =>
|
||||
student.credits.filter(credit => isPassed(credit.creditType)).length
|
||||
const getTotalUnfinished = (student: CompletedCoursesStudent) =>
|
||||
(data?.courses?.length ?? 0) - student.credits.length
|
||||
return [
|
||||
{
|
||||
accessorKey: 'lastname',
|
||||
header: 'Last name',
|
||||
@@ -139,27 +140,27 @@ export const SearchResults = ({ searchValues }) => {
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
accessorFn: student => getTotalPassed(student),
|
||||
accessorFn: getTotalPassed,
|
||||
id: 'passed',
|
||||
header: 'Passed',
|
||||
},
|
||||
{
|
||||
accessorFn: student => getTotalUnfinished(student),
|
||||
accessorFn: getTotalUnfinished,
|
||||
id: 'unfinished',
|
||||
header: 'Unfinished',
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
]
|
||||
}, [data])
|
||||
|
||||
const dynamicColumns = useMemo(() => {
|
||||
const dynamicColumns = useMemo<MRT_ColumnDef<CompletedCoursesStudent>[]>(() => {
|
||||
if (!data) return []
|
||||
const courseOrder = courseList ?? []
|
||||
|
||||
return (
|
||||
data?.courses
|
||||
.toSorted(({ code: a }, { code: b }) => courseOrder.indexOf(b) < courseOrder.indexOf(a))
|
||||
data.courses
|
||||
.toSorted((a, b) => courseOrder.indexOf(a.code) - courseOrder.indexOf(b.code))
|
||||
.map(course => ({
|
||||
accessorFn: student => getCompletion(student, course.code, { icon: false }),
|
||||
accessorFn: (student: CompletedCoursesStudent) => getCompletion(student, course.code, false),
|
||||
id: course.code,
|
||||
header: `${course.code} – ${getTextIn(course.name)}`,
|
||||
Header: () => (
|
||||
@@ -173,14 +174,17 @@ export const SearchResults = ({ searchValues }) => {
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
title={getCellTitle(cell.row.original, course.code)}
|
||||
>
|
||||
{getCompletion(cell.row.original, course.code, { icon: true })}
|
||||
{getCompletion(cell.row.original, course.code, true)}
|
||||
</Box>
|
||||
),
|
||||
})) ?? []
|
||||
)
|
||||
}, [data, getTextIn])
|
||||
|
||||
const columns = useMemo(() => [...staticColumns, ...dynamicColumns], [staticColumns, dynamicColumns])
|
||||
const columns: any[] /* MRT getting replaced anyway */ = useMemo(
|
||||
() => [...staticColumns, ...dynamicColumns],
|
||||
[staticColumns, dynamicColumns]
|
||||
)
|
||||
|
||||
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
|
||||
|
||||
@@ -204,7 +208,7 @@ export const SearchResults = ({ searchValues }) => {
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
open={exportModalOpen}
|
||||
/>
|
||||
{data?.discardedStudentNumbers?.length > 0 && (
|
||||
{!!data?.discardedStudentNumbers?.length && (
|
||||
<div style={{ width: '75%' }}>
|
||||
<RightsNotification discardedStudentNumbers={data.discardedStudentNumbers} />
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@ import { CropSquareIcon, DoneIcon, RemoveIcon } from '@/theme'
|
||||
import { SearchModal } from './SearchModal'
|
||||
import { SearchResults } from './SearchResults'
|
||||
|
||||
export type SearchValues = { courseList: string[]; studentList: string[] }
|
||||
|
||||
export const CompletedCourses = () => {
|
||||
useTitle('Completed courses of students')
|
||||
const [searchValues, setValues] = useState({ courseList: [], studentList: [] })
|
||||
const [searchValues, setValues] = useState<SearchValues>({ courseList: [], studentList: [] })
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { RTKApi } from '@/apiConnection'
|
||||
import type { CompletedCoursesRes } from '@oodikone/shared/routes/completedCoursesOfStudents'
|
||||
|
||||
const completedCoursesSearchApi = RTKApi.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
getCompletedCourses: builder.query<any, { courseList: string[]; studentList: string[] }>({
|
||||
getCompletedCourses: builder.query<CompletedCoursesRes, { courseList: string[]; studentList: string[] }>({
|
||||
query: ({ courseList, studentList }) =>
|
||||
`completedcoursessearch?courselist=${JSON.stringify(courseList)}&studentlist=${JSON.stringify(studentList)}`,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { CompletedCoursesCourse, CompletedCoursesStudent } from '../types'
|
||||
|
||||
export type CompletedCoursesRes = {
|
||||
discardedStudentNumbers: string[]
|
||||
students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]
|
||||
courses: CompletedCoursesCourse[]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Course } from '../models'
|
||||
import { CreditTypeCode } from '../types'
|
||||
|
||||
type StudentCredit = {
|
||||
date: Date
|
||||
courseCode: string
|
||||
creditType: CreditTypeCode
|
||||
substitution: string[] | null
|
||||
}
|
||||
|
||||
type StudentEnrollment = {
|
||||
date: Date
|
||||
courseCode: string
|
||||
substitution: string[] | null
|
||||
}
|
||||
|
||||
type StudentInfo = {
|
||||
firstnames: string
|
||||
lastname: string
|
||||
email: string
|
||||
sis_person_id: string
|
||||
coursesInStudyPlan: string[]
|
||||
secondaryEmail: string | null
|
||||
credits: StudentCredit[]
|
||||
enrollments: Record<string, StudentEnrollment>
|
||||
}
|
||||
|
||||
export type StudentCredits = Record<
|
||||
string,
|
||||
Omit<StudentInfo, 'credits' | 'enrollments'> & {
|
||||
credits: StudentCredit[]
|
||||
enrollments: StudentEnrollment[]
|
||||
}
|
||||
>
|
||||
|
||||
export type CompletedCoursesStudent = StudentInfo & {
|
||||
studentNumber: string
|
||||
allEnrollments: StudentEnrollment[]
|
||||
}
|
||||
|
||||
export type CompletedCoursesCourse = Pick<Course, 'code' | 'name' | 'substitution_groups'>
|
||||
@@ -61,3 +61,4 @@ export { ExtentCode } from './extentCode'
|
||||
export { DegreeProgrammeType } from './degreeProgrammeType'
|
||||
export { Phase } from './phase'
|
||||
export { Unification } from './unification'
|
||||
export type { StudentCredits, CompletedCoursesStudent, CompletedCoursesCourse } from './completedCourses'
|
||||
|
||||
Reference in New Issue
Block a user