Skip to content

Commit

Permalink
Merge pull request #174 from crux-bphc/update-section-and-timetable-t…
Browse files Browse the repository at this point in the history
…able

"Update section and timetable tables when course timings change"
  • Loading branch information
soumitradev authored Jun 1, 2024
2 parents 6828b75 + c19ca17 commit 7fc428e
Show file tree
Hide file tree
Showing 5 changed files with 309 additions and 3 deletions.
232 changes: 232 additions & 0 deletions backend/src/controllers/timetable/updateChangedTimetable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import type { Request, Response } from "express";
import { z } from "zod";
import {
courseWithSectionsType,
sectionTypeList,
} from "../../../../lib/src/index.js";
import { AppDataSource } from "../../db.js";
import { Course, Section, Timetable } from "../../entity/entities.js";
import { validate } from "../../middleware/zodValidateRequest.js";
import { checkForExamTimingsChange } from "../../utils/checkForChange.js";
import {
checkForClassHoursClash,
checkForExamHoursClash,
} from "../../utils/checkForClashes.js";
import { addExamTimings, removeSection } from "../../utils/updateSection.js";
import { updateSectionWarnings } from "../../utils/updateWarnings.js";

const dataSchema = z.object({
body: z.object({
course: courseWithSectionsType,
}),
});

export const updateChangedTimetableValidator = validate(dataSchema);

export const updateChangedTimetable = async (req: Request, res: Response) => {
try {
// Use a transaction because we will run many dependent mutations
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

// Update the course's exam timings, also make sure that the course is not archived
const course: Course = req.body.course;
try {
await queryRunner.manager
.createQueryBuilder()
.update(Course)
.set({
midsemStartTime: course?.midsemStartTime,
midsemEndTime: course?.midsemEndTime,
compreStartTime: course?.compreStartTime,
compreEndTime: course?.compreEndTime,
})
.where("id = :id", { id: course?.id })
.andWhere("archived = :archived", { archived: false })
.execute();
} catch (err: any) {
console.log("Error while querying for course: ", err.message);
return res.status(500).json({ message: "Internal Server Error" });
}

// Fetch the total types of sections of that course (required later to update warnings)
let requiredSectionTypes: sectionTypeList = [];
try {
const sectionTypeHolders = await queryRunner.manager
.createQueryBuilder(Section, "section")
.select("section.type")
.where("section.courseId = :courseId", { courseId: course.id })
.distinctOn(["section.type"])
.getMany();
requiredSectionTypes = sectionTypeHolders.map((section) => section.type);
} catch (err: any) {
// will replace the console.log with a logger when we have one
console.log(
"Error while querying for course's section types: ",
err.message,
);
}

let timetables: Timetable[] | null = null;

// Fetch the timetables that are affected, archived timetables cannot be affected
try {
timetables = await queryRunner.manager
.createQueryBuilder(Timetable, "timetable")
.leftJoinAndSelect("timetable.sections", "section")
.where("section.courseId = :id", { id: course?.id })
.andWhere("timetable.archived = :archived", { archived: false })
.getMany();
} catch (err: any) {
console.log("Error while querying for timetable: ", err.message);
return res.status(500).json({ message: "Internal Server Error" });
}

for (const timetable of timetables) {
// For each timetable, check if the exam times have changed
if (checkForExamTimingsChange(timetable, course)) {
// If they have, remove the course's exams from the timings
timetable.examTimes = timetable.examTimes.filter((examTime) => {
return examTime.split("|")[0] !== course?.code;
});

// Convert exam dates to actual dates from strings, since the
// checkForExamHoursClash() function compares them with dates

// This is needed because the course exam dates are not from DB,
// but are from the JSON body of the request.
// A similar procedure is used during ingestion as well.
course.compreStartTime = new Date(course.compreStartTime);
course.compreEndTime = new Date(course.compreEndTime);
course.midsemStartTime = new Date(course.midsemStartTime);
course.midsemEndTime = new Date(course.midsemEndTime);

// Check if the new timings clash with any other timings of other courses
if (checkForExamHoursClash(timetable, course).clash) {
// If they do, then remove all sections of the course with updated timings
for (const sec of timetable.sections) {
await queryRunner.manager
.createQueryBuilder()
.relation(Timetable, "sections")
.of(timetable)
.remove(sec);
removeSection(timetable, sec);
}
// Since the timetable has been changed, make it a draft
timetable.draft = true;
timetable.private = true;
} else {
// If there is no clash, simply add the new timings to the timetable
const newExamTimes = timetable.examTimes;
addExamTimings(newExamTimes, course);
timetable.examTimes = newExamTimes;
}
}
for (const section of timetable.sections) {
// For each section of the course previously in the timetable, find its corresponding replacement
const newSection = course.sections.find((el) => el.id === section.id);

if (newSection !== undefined) {
// Start off by removing the existing section, both in DB and the timings column of the timetable
removeSection(timetable, section);
await queryRunner.manager
.createQueryBuilder()
.relation(Timetable, "sections")
.of(timetable)
.remove(section);

if (checkForClassHoursClash(timetable, newSection).clash) {
// If the updated section will cause a clash, then keep the section removed
// Also make the timetable a draft, and update its warnings since that
// section is now gone
timetable.draft = true;
timetable.private = true;
timetable.warnings = updateSectionWarnings(
course.code,
section,
requiredSectionTypes,
false,
timetable.warnings,
);
} else {
// If there is no clash, add the new section timings to the timetable
const newTimes: string[] = newSection.roomTime.map(
(time) =>
`${course?.code}:${time.split(":")[2]}${time.split(":")[3]}`,
);
// Add the section back to the timetable
await queryRunner.manager
.createQueryBuilder()
.relation(Timetable, "sections")
.of(timetable)
.add(section);

// Update the timings and the sections in the timetable object
timetable.timings = [...timetable.timings, ...newTimes];
timetable.sections = [...timetable.sections, newSection];
}
}
}

// After all that, if the timetable now has 0 sections of that course,
// remove its exam timings as well, removing it fully from the timetable
const sameCourseSections: Section[] = timetable.sections.filter(
(currentSection) => {
return currentSection.courseId === course.id;
},
);
if (sameCourseSections.length === 0) {
timetable.examTimes = timetable.examTimes.filter((examTime) => {
return examTime.split("|")[0] !== course?.code;
});
}
}

// Remove sections from the timetable before saving in db

// Since we are fetching only this course's sections in the
// timetable query (due to the left join), none of the other sections
// end up in timetable.sections. Saving this to db causes all the other
// sections of other courses to be wiped. This is why, using some typescript
// magic, we redefine the timetable type, and then set sections = undefined.
// Since sections = undefined, TypeORM sees that the field isn't present,
// and doesn't make any additional changes to timetable sections.

// If we do want to remove the sections, those db calls have already been
// made above. This db call is only here to update timetable timings and examTimes
type timetableWithoutSections = Omit<Timetable, "sections"> & {
sections: Section[] | undefined;
};
const timetablesWithoutSections: timetableWithoutSections[] = timetables;
await queryRunner.manager.save(
timetablesWithoutSections.map((x) => {
x.sections = undefined;
return x;
}),
);

// Regardless of whether or not a section was present in a timetable,
// update the section's timings
try {
for (const section of course.sections) {
await queryRunner.manager
.createQueryBuilder()
.update(Section, { roomTime: section.roomTime })
.where("section.id = :id", { id: section?.id })
.execute();
}
} catch (err: any) {
console.log("Error while querying for course: ", err.message);
return res.status(500).json({ message: "Internal Server Error" });
}

// After everything passes fine, commit the transaction
await queryRunner.commitTransaction();
queryRunner.release();
return res.json({ message: "Timetable successfully updated" });
} catch (err: any) {
console.log(err);
return res.status(500).json({ message: "Internal Server Error" });
}
};
9 changes: 9 additions & 0 deletions backend/src/routers/courseRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ import {
} from "../controllers/course/getAllCourses.js";
import { getCourseById } from "../controllers/course/getCourseById.js";
import { getCourseByIdValidator } from "../controllers/course/getCourseById.js";
import {
updateChangedTimetable,
updateChangedTimetableValidator,
} from "../controllers/timetable/updateChangedTimetable.js";

const courseRouter = express.Router();

courseRouter.get("/", getAllCoursesValidator, getAllCourses);
courseRouter.get("/:id", getCourseByIdValidator, getCourseById);
courseRouter.post(
"/update",
updateChangedTimetableValidator,
updateChangedTimetable,
);

export default courseRouter;
35 changes: 35 additions & 0 deletions backend/src/utils/checkForChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Course, Timetable } from "../entity/entities.js";

export const checkForExamTimingsChange = (
timetable: Timetable,
course: Course,
) => {
const midsemTimes = timetable.examTimes.filter((examTime) => {
return (
examTime?.split("|")[0] === course?.code &&
examTime?.split("|")[1] === "MIDSEM"
);
})[0];

const compreTimes = timetable.examTimes.filter((examTime) => {
return (
examTime?.split("|")[0] === course?.code &&
examTime?.split("|")[1] === "COMPRE"
);
})[0];

if (
midsemTimes?.split("|")[2] !== `${course.midsemStartTime}` ||
midsemTimes?.split("|")[3] !== `${course.midsemEndTime}`
) {
return true;
}
if (
compreTimes?.split("|")[2] !== `${course.compreStartTime}` ||
compreTimes?.split("|")[3] !== `${course.compreEndTime}`
) {
return true;
}

return false;
};
5 changes: 2 additions & 3 deletions backend/src/utils/checkForClashes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,8 @@ export const checkForExamHoursClash = (
}

if (newCourse.midsemStartTime !== null && newCourse.midsemEndTime !== null) {
const newMidsemStartTime = newCourse.midsemStartTime;
const newMidsemEndTime = newCourse.midsemEndTime;

const newMidsemStartTime = new Date(newCourse.midsemStartTime);
const newMidsemEndTime = new Date(newCourse.midsemEndTime);
for (const [key, value] of examTimesMap) {
const { courseCode, end } = value;
const start = key;
Expand Down
31 changes: 31 additions & 0 deletions backend/src/utils/updateSection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Course, Section, Timetable } from "../entity/entities.js";

export const removeSection = (timetable: Timetable, section: Section) => {
const classTimings = section.roomTime.map((time) => {
return time.split(":")[2] + time.split(":")[3];
});

timetable.timings = timetable.timings.filter((time) => {
return !classTimings.includes(time.split(":")[1]);
});
timetable.sections = timetable.sections.filter((currentSection) => {
return currentSection.id !== section?.id;
});
};

export const addExamTimings = (newExamTimes: Array<string>, course: Course) => {
if (course.midsemStartTime !== null && course.midsemEndTime !== null) {
newExamTimes.push(
`${
course.code
}|MIDSEM|${course.midsemStartTime.toISOString()}|${course.midsemEndTime.toISOString()}`,
);
}
if (course.compreStartTime !== null && course.compreEndTime !== null) {
newExamTimes.push(
`${
course.code
}|COMPRE|${course.compreStartTime.toISOString()}|${course.compreEndTime.toISOString()}`,
);
}
};

0 comments on commit 7fc428e

Please sign in to comment.