diff --git a/src/associations.ts b/src/associations.ts index d093cf04..8a770eb7 100644 --- a/src/associations.ts +++ b/src/associations.ts @@ -1,5 +1,6 @@ import { - APIKeyRole, + APIKeyRole, + ActiveClass, Class, ClassStories, IgnoreClass, @@ -16,6 +17,25 @@ import { APIKey } from "./models/api_key"; export function setUpAssociations() { + Student.belongsToMany(ActiveClass, { + through: StudentsClasses, + sourceKey: "id", + targetKey: "id", + foreignKey: "student_id", + otherKey: "class_id", + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + ActiveClass.belongsToMany(Student, { + through: StudentsClasses, + sourceKey: "id", + targetKey: "id", + foreignKey: "class_id", + otherKey: "student_id", + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + Student.belongsToMany(Class, { through: StudentsClasses, sourceKey: "id", diff --git a/src/database.ts b/src/database.ts index f253fe04..3f2c36ab 100644 --- a/src/database.ts +++ b/src/database.ts @@ -5,6 +5,7 @@ import { createNamespace } from "cls-hooked"; import * as S from "@effect/schema/Schema"; import { + ActiveClass, Class, Educator, ClassStories, @@ -650,16 +651,18 @@ export async function deleteStageState(studentID: number, storyName: string, sta }); } -export async function getClassesForEducator(educatorID: number): Promise { - return Class.findAll({ +export async function getClassesForEducator(educatorID: number, activeOnly=true): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findAll({ where: { educator_id: educatorID } }); } -export async function getClassesForStudent(studentID: number): Promise { - return Class.findAll({ +export async function getClassesForStudent(studentID: number, activeOnly=true): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findAll({ include: [{ model: Student, where: { @@ -680,30 +683,41 @@ export async function getStudentsForClass(classID: number): Promise { }); } +export async function deactivateClass(cls: Class): Promise { + return cls.update({ active: false }); +} + +export async function deactiveClassByID(id: number): Promise { + return Class.update({ active: false }, { where: { id } }) + .then(result => result[0] > 0); +} + export async function deleteClass(id: number): Promise { return Class.destroy({ where: { id } }); } -export async function findClassByCode(code: string): Promise { - return Class.findOne({ +export async function findClassByCode(code: string, activeOnly=false): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findOne({ where: { code } }); } -export async function findClassById(id: number): Promise { - return Class.findOne({ +export async function findClassById(id: number, activeOnly=false): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findOne({ where: { id } }); } -export async function findClassByIdOrCode(identifier: string): Promise { +export async function findClassByIdOrCode(identifier: string, activeOnly=false): Promise { const id = Number(identifier); if (isNaN(id)) { - return findClassByCode(identifier); + return findClassByCode(identifier, activeOnly); } else { - return findClassById(id); + return findClassById(id, activeOnly); } } @@ -915,7 +929,7 @@ export async function isClassStoryActive(classID: number, storyName: string): Pr export async function setClassStoryActive(classID: number, storyName: string, active: boolean): Promise { const result = await ClassStories.update( - { active }, + { active }, { where: { class_id: classID, diff --git a/src/models/active_class.ts b/src/models/active_class.ts new file mode 100644 index 00000000..5d3026c9 --- /dev/null +++ b/src/models/active_class.ts @@ -0,0 +1,10 @@ +import { Class, CLASS_ATTRIBUTES } from "./class"; +import { Sequelize } from "sequelize"; + +// NB: This model is actually a view defined on the Classes table +// where `active = 1` +export class ActiveClass extends Class {} + +export function initializeActiveClassModel(sequelize: Sequelize) { + ActiveClass.init(CLASS_ATTRIBUTES, { sequelize }); +} diff --git a/src/models/class.ts b/src/models/class.ts index 4bbc5034..63ec0333 100644 --- a/src/models/class.ts +++ b/src/models/class.ts @@ -1,7 +1,7 @@ import { Educator } from "./educator"; import { Sequelize, DataTypes, Model, InferAttributes, InferCreationAttributes, CreationOptional } from "sequelize"; -const CLASS_STATUS_VALUES = ["seed", "real_good_data", "real_bad_data", "test_good_data", "test_bad_data"] as const; +export const CLASS_STATUS_VALUES = ["seed", "real_good_data", "real_bad_data", "test_good_data", "test_bad_data"] as const; type ClassStatus = typeof CLASS_STATUS_VALUES[number]; export class Class extends Model, InferCreationAttributes> { @@ -16,81 +16,83 @@ export class Class extends Model, InferCreationAttributes declare test: CreationOptional; declare seed: CreationOptional; declare expected_size: CreationOptional; - declare small_class: CreationOptional; + declare readonly small_class: CreationOptional; declare status: CreationOptional; } +export const CLASS_ATTRIBUTES = { + id: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + autoIncrement: true, + primaryKey: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + educator_id: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + references: { + model: Educator, + key: "id" + } + }, + created: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP") + }, + updated: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null + }, + active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + code: { + type: DataTypes.STRING, + allowNull: false + }, + asynchronous: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + test: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: 0 + }, + seed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: 0, + }, + expected_size: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + }, + small_class: { + type: DataTypes.VIRTUAL, + // type: "tinyint(1) GENERATED ALWAYS AS (expected_size < 15) VIRTUAL", + get(this: Class): boolean { + return this.expected_size < 15; + } + }, + status: { + type: DataTypes.ENUM(...CLASS_STATUS_VALUES), + allowNull: true, + defaultValue: null, + }, +}; + export function initializeClassModel(sequelize: Sequelize) { - Class.init({ - id: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - autoIncrement: true, - primaryKey: true - }, - name: { - type: DataTypes.STRING, - allowNull: false - }, - educator_id: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - references: { - model: Educator, - key: "id" - } - }, - created: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: Sequelize.literal("CURRENT_TIMESTAMP") - }, - updated: { - type: DataTypes.DATE, - allowNull: true, - defaultValue: null - }, - active: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false - }, - code: { - type: DataTypes.STRING, - allowNull: false - }, - asynchronous: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false - }, - test: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: 0 - }, - seed: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: 0, - }, - expected_size: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - }, - small_class: { - type: DataTypes.VIRTUAL, - // type: "tinyint(1) GENERATED ALWAYS AS (expected_size < 15) VIRTUAL", - get() { - return this.expected_size < 15; - } - }, - status: { - type: DataTypes.ENUM(...CLASS_STATUS_VALUES), - allowNull: true, - defaultValue: null, - }, - }, { + Class.init(CLASS_ATTRIBUTES, { sequelize, indexes: [ { diff --git a/src/models/index.ts b/src/models/index.ts index 9dd19ca5..38f0d630 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,5 +1,6 @@ import { APIKeyRole, initializeAPIKeyRoleModel } from "./api_key_role"; import { Class, initializeClassModel } from "./class"; +import { ActiveClass, initializeActiveClassModel } from "./active_class"; import { DashboardClassGroup, initializeDashboardClassGroupModel } from "./dashboard_class_group"; import { DummyClass, initializeDummyClassModel } from "./dummy_class"; import { Educator, initializeEducatorModel } from "./educator"; @@ -26,6 +27,7 @@ import { initializeUserExperienceRatingModel } from "./user_experience"; export { APIKeyRole, Class, + ActiveClass, ClassStories, CosmicDSSession, DashboardClassGroup, @@ -54,6 +56,7 @@ export function initializeModels(db: Sequelize) { initializeSessionModel(db); initializeEducatorModel(db); initializeClassModel(db); + initializeActiveClassModel(db); initializeStudentModel(db); initializeStoryModel(db); initializeClassStoryModel(db); diff --git a/src/server.ts b/src/server.ts index 08d552a0..65c0ac10 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,6 +34,7 @@ import { updateStageState, deleteStageState, findClassById, + deactivateClass, getStages, getStory, getStageStates, @@ -1225,7 +1226,7 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { return; } - cls.destroy() + deactivateClass(cls) .then(() => res.status(204).end()) .catch(error => { console.log(error); @@ -2177,6 +2178,10 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { * required: true * schema: * type: integer + * - name: active_only + * in: query + * schema: + * type: boolean * responses: * 200: * description: The given educator exists. Returns an object containing the educator ID and a list of Class objects @@ -2200,6 +2205,8 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { */ app.get("/educator-classes/:educatorID", async (req, res) => { const params = req.params; + const activeOnlyString = req.query.active_only as string | undefined; + const activeOnly = activeOnlyString?.toLowerCase() !== "false"; const educatorID = Number(params.educatorID); const educator = await findEducatorById(educatorID); if (educator === null) { @@ -2208,7 +2215,7 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { }); return; } - const classes = await getClassesForEducator(educatorID); + const classes = await getClassesForEducator(educatorID, activeOnly); res.json({ educator_id: educatorID, classes, @@ -2229,6 +2236,10 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { * required: true * schema: * type: integer + * - name: active_only + * in: query + * schema: + * type: boolean * responses: * 200: * description: Returns an object containing the student ID, as well as a list of Class items, one for each of the student's classes @@ -2252,8 +2263,10 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { */ app.get("/student-classes/:studentID", async (req, res) => { const params = req.params; + const activeOnlyString = req.query.active_only as string | undefined; + const activeOnly = activeOnlyString?.toLowerCase() !== "false"; const studentID = Number(params.studentID); - const classes = await getClassesForStudent(studentID); + const classes = await getClassesForStudent(studentID, activeOnly); res.json({ student_id: studentID, classes: classes diff --git a/src/sql/create_active_class_view.sql b/src/sql/create_active_class_view.sql new file mode 100644 index 00000000..bd630845 --- /dev/null +++ b/src/sql/create_active_class_view.sql @@ -0,0 +1,4 @@ +CREATE VIEW ActiveClasses AS +SELECT * +FROM Classes +WHERE active = 1; diff --git a/tests/utils.ts b/tests/utils.ts index a60bcf14..45f8dfc4 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -76,7 +76,7 @@ export async function setupTestDatabase(): Promise { // See https://github.com/sequelize/sequelize/issues/7953 // and https://stackoverflow.com/a/45114507 // db.sync({ force: true, match: /test/ }).finally(() => db.close()); - await syncTables(); + await syncTables(db); return db; } @@ -86,7 +86,7 @@ export async function teardownTestDatabase(): Promise { await connection.query("DROP DATABASE test;"); } -export async function syncTables(force=false): Promise { +export async function syncTables(db: Sequelize, force=false): Promise { const options = { force, alter: false }; await APIKey.sync(options); await Student.sync(options); @@ -103,6 +103,15 @@ export async function syncTables(force=false): Promise { await StageState.sync(options); await IgnoreStudent.sync(options); await IgnoreClass.sync(options); + + // ActiveClasses is a view, which Sequelize is not great at handling + // so we just create it ourselves here + await db.query(` + CREATE VIEW ActiveClasses AS + SELECT * + FROM Classes + WHERE active = 1; + `); } export async function addAdminAPIKey(): Promise {