mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[Backend][Course search] make course search use new course units and substitutions
This commit is contained in:
committed by
Aaro Koivukangas
parent
5bd7fb6792
commit
c908a61c0f
@@ -9,7 +9,6 @@ import {
|
||||
CourseYearlyStatsReqBody,
|
||||
CourseYearlyStatsQuery,
|
||||
} from '@oodikone/shared/routes/courses'
|
||||
import type { CourseWithSubsDetails } from '@oodikone/shared/types/course'
|
||||
import { getCourseDetails, getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
|
||||
import {
|
||||
@@ -24,30 +23,13 @@ const router = Router()
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, includeSpecial } = req.query
|
||||
const { name, code } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
|
||||
|
||||
const mergedCourses = new Map<string, CourseWithSubsDetails>()
|
||||
|
||||
for (const course of courses) {
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) continue
|
||||
|
||||
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
|
||||
const mergedCourse = mergedCourses.get(course.code)!
|
||||
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Array.from(mergedCourses.values()) })
|
||||
const courses = await getCoursesByNameAndOrCode(name, code)
|
||||
res.json({ courses }).end()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,70 +1,115 @@
|
||||
import { Op } from 'sequelize'
|
||||
import { col, fn, Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import { CourseWithSubsDetails } from '@oodikone/shared/types/course'
|
||||
import { CourseModel } from '../../models'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
|
||||
const nameLikeTerm = (name: string) => {
|
||||
if (!name) {
|
||||
return undefined
|
||||
}
|
||||
const term = `%${name.trim()}%`
|
||||
return {
|
||||
name: {
|
||||
[Op.or]: {
|
||||
fi: {
|
||||
[Op.iLike]: term,
|
||||
const likeTerm = (userInput: string, type: 'name' | 'code') => {
|
||||
const searchTerm = userInput.trim().replace(/[\\%_]/g, '')
|
||||
if (!searchTerm) return undefined
|
||||
|
||||
return type === 'name'
|
||||
? {
|
||||
name: {
|
||||
[Op.or]: {
|
||||
fi: {
|
||||
[Op.iLike]: `%${searchTerm}%`,
|
||||
},
|
||||
sv: {
|
||||
[Op.iLike]: `%${searchTerm}%`,
|
||||
},
|
||||
en: {
|
||||
[Op.iLike]: `%${searchTerm}%`,
|
||||
},
|
||||
},
|
||||
},
|
||||
sv: {
|
||||
[Op.iLike]: term,
|
||||
},
|
||||
en: {
|
||||
[Op.iLike]: term,
|
||||
}
|
||||
: {
|
||||
code: {
|
||||
[Op.iLike]: `${searchTerm}%`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const getCoursesByNameAndOrCode = async (name: string, code: string): Promise<CourseWithSubsDetails[]> => {
|
||||
// Get groupIds for the course units (CU) the user is querying
|
||||
// This step allows users to search with old or upcoming course codes and names
|
||||
// Example: searching for GEOK_2011 (old code) returns results for GEOK2011 (new code)
|
||||
const groupIds = (
|
||||
await CourseModel.findAll({
|
||||
attributes: [[fn('DISTINCT', col('group_id')), 'groupId']],
|
||||
where: {
|
||||
...likeTerm(name, 'name'),
|
||||
...likeTerm(code, 'code'),
|
||||
groupId: { [Op.ne]: null },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const codeLikeTerm = (code: string) => {
|
||||
if (!code) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
code: {
|
||||
[Op.iRegexp]: `^(AY|A)?${escapeRegExp(code)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const getCoursesByNameAndOrCode = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const courses = await CourseModel.findAll({
|
||||
where: {
|
||||
...(includeSpecial ? {} : { mainCourseCode: { [Op.ne]: null } }),
|
||||
...nameLikeTerm(name),
|
||||
...codeLikeTerm(code),
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const coursesWithSubstitutionGroupDetails = await Promise.all(
|
||||
courses.map<Promise<CourseWithSubsDetails>>(async course => {
|
||||
const groupDetails = await Promise.all(
|
||||
course?.substitution_groups?.map(async group => {
|
||||
return await CourseModel.findAll({
|
||||
attributes: ['name', 'code'],
|
||||
where: { code: { [Op.in]: group } },
|
||||
raw: true,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return { ...course, substitution_groups: groupDetails }
|
||||
raw: true,
|
||||
})
|
||||
).map(({ groupId }) => groupId)
|
||||
|
||||
if (!groupIds.length) return []
|
||||
|
||||
// Gets name/code from the "primary" CU, and attainment dates from all CUs with the same groupId
|
||||
const primaryCourses = await dbConnections.sequelize.query<CourseModel>(
|
||||
`
|
||||
WITH filtered AS (
|
||||
SELECT *
|
||||
FROM course
|
||||
WHERE group_id IN (:groupIds)
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
group_id,
|
||||
MIN(min_attainment_date) AS min_attainment_date,
|
||||
MAX(max_attainment_date) AS max_attainment_date
|
||||
FROM filtered
|
||||
GROUP BY group_id
|
||||
),
|
||||
primary_course AS (
|
||||
SELECT code, name, group_id, substitution_groups
|
||||
FROM filtered
|
||||
where is_primary = true
|
||||
)
|
||||
SELECT
|
||||
g.group_id AS "groupId",
|
||||
g.min_attainment_date AS "minAttainmentDate",
|
||||
g.max_attainment_date AS "maxAttainmentDate",
|
||||
p.name,
|
||||
p.code,
|
||||
p.substitution_groups AS "substitutionGroups"
|
||||
FROM grouped g
|
||||
JOIN primary_course p
|
||||
ON p.group_id = g.group_id
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
groupIds,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
}
|
||||
)
|
||||
|
||||
return coursesWithSubstitutionGroupDetails
|
||||
const substitutionGroupIds = [...new Set(primaryCourses.map(course => course.substitutionGroups).flat(2))]
|
||||
const substitutionCourses = substitutionGroupIds.length
|
||||
? Object.groupBy(
|
||||
await CourseModel.findAll({
|
||||
attributes: ['name', 'groupId', 'code'],
|
||||
where: { groupId: { [Op.in]: substitutionGroupIds }, isPrimary: true },
|
||||
raw: true,
|
||||
}),
|
||||
({ groupId }) => groupId
|
||||
)
|
||||
: {}
|
||||
|
||||
const coursesWithSubstitutionDetails = primaryCourses.map(course => ({
|
||||
...course,
|
||||
// NOTE: There are some substitutions that just don't exist in importer -> don't send undefined to client
|
||||
substitutionGroups: course.substitutionGroups
|
||||
.map(groupIds => groupIds.flatMap(groupId => substitutionCourses[groupId]).filter(sub => !!sub))
|
||||
.filter(group => group.length),
|
||||
}))
|
||||
|
||||
return coursesWithSubstitutionDetails
|
||||
}
|
||||
|
||||
export const getCoursesByCodes = (codes: string[]) => {
|
||||
|
||||
@@ -79,7 +79,7 @@ export const getOptionsForStudents = (
|
||||
|
||||
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
|
||||
CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitution_groups', 'is_study_module'],
|
||||
attributes: ['code', 'name', 'substitutionGroups', 'isStudyModule'],
|
||||
where: {
|
||||
code: { [Op.in]: courses },
|
||||
},
|
||||
|
||||
@@ -39,8 +39,8 @@ const CourseRow = ({
|
||||
data-cy={`course-${course.code}`}
|
||||
hover
|
||||
key={course.id}
|
||||
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
|
||||
onClick={() => (course.minAttainmentDate ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: course.minAttainmentDate ? 'pointer' : 'default' }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
|
||||
@@ -54,7 +54,7 @@ const CourseRow = ({
|
||||
<TableCell>
|
||||
{combineSubstitutions ? (
|
||||
<Stack>
|
||||
{course?.substitution_groups.map(group => (
|
||||
{course?.substitutionGroups.map(group => (
|
||||
<GroupChip getTextIn={getTextIn} group={group} key={group.map(({ code }) => code).join(':')} />
|
||||
)) ?? <Typography fontSize="0.9rem">Equivalent groups not available!</Typography>}
|
||||
</Stack>
|
||||
|
||||
@@ -135,12 +135,12 @@ export const SearchForm = () => {
|
||||
const courses = matchingCourses
|
||||
.filter(course => !selectedCourses[course.code])
|
||||
.sort((a, b) => {
|
||||
const yearA = dayjs(a.max_attainment_date).year()
|
||||
const yearB = dayjs(b.max_attainment_date).year()
|
||||
const yearA = dayjs(a.maxAttainmentDate).year()
|
||||
const yearB = dayjs(b.maxAttainmentDate).year()
|
||||
if (yearA !== yearB) return yearB - yearA
|
||||
|
||||
const isFallA = dayjs(a.max_attainment_date).month() >= 7 ? 1 : 0
|
||||
const isFallB = dayjs(b.max_attainment_date).month() >= 7 ? 1 : 0
|
||||
const isFallA = dayjs(a.maxAttainmentDate).month() >= 7 ? 1 : 0
|
||||
const isFallB = dayjs(b.maxAttainmentDate).month() >= 7 ? 1 : 0
|
||||
if (isFallA !== isFallB) return isFallB - isFallA
|
||||
|
||||
return a.code.localeCompare(b.code)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { SearchResultCourse } from '@/types/api/courses'
|
||||
import { AvailableStats, CourseStat, Realisation } from '@/types/courseStat'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { isSpring } from '@oodikone/shared/util'
|
||||
@@ -35,16 +36,16 @@ const MAX_YEAR = 2112
|
||||
const isPre2016Course = course => !Number.isNaN(Number(course.code.charAt(0)))
|
||||
const getYearText = (year: number, spring: boolean) => (spring ? `Spring ${year}` : `Fall ${year}`)
|
||||
|
||||
export const getActiveYears = course => {
|
||||
if (!course.min_attainment_date && !course.max_attainment_date) return 'No attainments yet'
|
||||
export const getActiveYears = (course: SearchResultCourse) => {
|
||||
if (!(course.minAttainmentDate && course.maxAttainmentDate)) return 'No attainments yet'
|
||||
|
||||
const min_attainment_date = new Date(course.min_attainment_date)
|
||||
const max_attainment_date = new Date(course.max_attainment_date)
|
||||
const minAttainmentDate = new Date(course.minAttainmentDate)
|
||||
const maxAttainmentDate = new Date(course.maxAttainmentDate)
|
||||
|
||||
const [startYear, endYear] = [min_attainment_date.getFullYear(), max_attainment_date.getFullYear()]
|
||||
const [startYear, endYear] = [minAttainmentDate.getFullYear(), maxAttainmentDate.getFullYear()]
|
||||
|
||||
const startYearText = getYearText(startYear, isSpring(min_attainment_date))
|
||||
const endYearText = getYearText(endYear, isSpring(max_attainment_date))
|
||||
const startYearText = getYearText(startYear, isSpring(minAttainmentDate))
|
||||
const endYearText = getYearText(endYear, isSpring(maxAttainmentDate))
|
||||
|
||||
if (endYear === MAX_YEAR && isPre2016Course(course)) return `— ${getYearText(2016, false)}`
|
||||
else if (startYear === MIN_YEAR) return `— ${endYearText}`
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../types'
|
||||
import { FormattedStudent } from '../types/studentData'
|
||||
|
||||
export type CourseStats = Pick<Course, 'code' | 'name' | 'is_study_module' | 'substitutions' | 'substitution_groups'>
|
||||
export type CourseStats = Pick<Course, 'code' | 'name' | 'isStudyModule' | 'substitutionGroups'>
|
||||
|
||||
export type AttainmentDates = {
|
||||
latestTotal?: Date
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Course } from '../models'
|
||||
|
||||
export type CourseWithSubsDetails = Omit<Course, 'substitution_groups'> & { substitution_groups: Course[][] }
|
||||
type CourseSubstitution = Pick<Course, 'code' | 'groupId' | 'name'>
|
||||
export type CourseWithSubsDetails = Omit<Course, 'substitutionGroups'> & { substitutionGroups: CourseSubstitution[][] }
|
||||
|
||||
Reference in New Issue
Block a user