Source

API/src/controllers/exercise.controllers.js

/**
 * @fileoverview Exercise controller
 * 
 * @category Backend API
 * @subcategory Controllers
 * 
 * @module Exercise Controller
 * @requires ../models/course.models
 * @requires ../utils/errors
 * 
 * @description This module is responsible for handling all exercise related requests <br>
 * 
 * The following routes are handled by this module:: <br>
 * 
 * </br>
 * 
 * <b>POST</b> /exercise/new <i> - Create a new exercise </i> </br>
 * <b>GET</b> /exercise/ <i> - Get all exercises </i> </br>
 * <b>GET</b> /exercise/:id <i> - Get a particular exercise </i> </br>
 * <b>PATCH</b> /exercise/update/:id <i> - Update a particular exercise </i> </br>
 * <b>DELETE</b> /exercise/delete/:id <i> - Delete a particular exercise </i> </br>
 * <b>POST</b> /exercise/score <i> - Grade or score a particular exercise </i> </br>
 * <b>GET</b> /exercise/submission/:id <i> - Get a particular exercise submission </i> </br>
 * <b>GET</b> /exercise/submission/prev/:exerciseId <i> - Get previous submissions for a particular exercise </i> </br>
 */

const { Question, Exercise, ExerciseSubmission, CourseReport, CourseSection, ExerciseReport } = require("../models/course.models");
const { translateDoc } = require("../utils/crowdin");
const { BadRequestError, NotFoundError, ForbiddenError } = require("../utils/errors");
const { issueCertificate } = require("./certificate.controllers");

// Create a new exercise
/**
 * Create new exercise
 * 
 * @param {string} title - Exercise title
 * @param {string} description - Exercise description
 * @param {string} course_id - Course id
 * @param {string} course_section_id - Course section id
 * @param {string} duration - Course duration in time
 * 
 * @returns {MongooseObject} saved_exercise
 * 
 * @throws {error} if an error occured
 * @throws {NotFoundError} if course_id provided and it doesn't match any course in DB
 */
exports.createExercise = async (req, res, next) => {
    const { title, description, duration, course_id, course_section_id } = req.body

    // Check if all required fields are provided
    if (!title || !description || !duration || !course_id || !course_section_id) {
        return next(new BadRequestError('Please provide all required fields'))
    }

    let course_section = await CourseSection.findById(course_section_id).populate('course')
    if (!course_section) {
        return next(new NotFoundError('Course section not found'))
    }

    // Check if course section belongs to the course provided
    if (course_section.course._id.toString() !== course_id) {
        return next(new ForbiddenError('Course section does not belong to the course provided'))
    }

    // Check if course is available
    if (!course_section.course.isAvailable) {
        return next(new ForbiddenError('Course is not available'))
    }

    const saved_exercise = await Exercise.create({
        title, description, duration, course: course_section.course._id,
        course_section: course_section._id
    });

    return res.status(200).json({
        success: true,
        data: {
            exercise: await saved_exercise.populate({
                path: 'course_section',
                populate: {
                    path: 'course exercises videos'
                }
            })
        }
    });
}


// Get exercises for a particular course - req.body.course_id = the id of the course you want to get exercises for
// Get exercises for all courses - req.body = {} // empty
// Get data for a particular exercise - req.body._id = exercise._id
/**
 * Get Exercises
 * 
 * @description 
 * By default it gets all available exercises, 
 * if req.body is provided it'll be used as query params
 * to make a more streamlined query result
 * 
 * @param {string} course_id - Course id
 * @param {string} _id - Exercise id
 * @param {string} title - Exercise title
 * @param {string} description - Exercise description
 * @param {string} duration - Exercise duration
 * 
 * @returns {ArrayObject} exercises
 * 
 * @throws {error} if an error occured
 */
exports.getExercises = async (req, res, next) => {
    let exercises;
    // If any specific query was added 
    if (req.body && Object.keys(req.body).length > 0) {
        console.log('req.body', req.body)
        exercises = await Exercise.find(req.body)
    }

    // Sort the exercises according to how they where added
    exercises = !exercises ? await Exercise.find().populate('questions') : exercises

    // Get only the available courses
    const available_exercises = exercises.filter((exercise) => exercise.toJSON() )

    return res.status(200).json({
        success: true,
        data: {
            exercises: available_exercises
        }
    });
}

/**
 * Get exercise data
 * 
 * @param {string} id - id of the exercise 
 * 
 * @returns {Object} exercise 
 * 
 * @throws {BadRequestError} if missing required param in request
 * @throws {NotFoundError} if exercise not found
 * 
 * @see {@link module:CourseModel~exerciseSchema}
 */
exports.getExerciseData = async (req, res, next) => {
    const exercise_id = req.params.id

    if (!exercise_id || exercise_id == ':id') {
        return next(new BadRequestError('Missing param `id` in request params'))
    }

    let exercise = await Exercise.findById(exercise_id).populate('questions')

    if (!exercise) {
        return next(new NotFoundError("Exercise not found"));
    }

    exercise = exercise.toObject()
    console.log(req.user)
    const exercise_report = await ExerciseReport.findOne({ exercise: exercise._id, user: req.user.id })
    console.log(exercise_report)
    exercise.percentage_passed = exercise_report ? exercise_report.percentage_passed : undefined

    return res.status(200).send({
        success: true,
        data: {
            exercise
        }
    })
}

// Update data for a particular exercise
/**
 * Update exercise data
 * 
 * @description This function updates the exercise data,
 * it doesn't update the questions, to update the questions
 * 
 * use {@link module:QuestionController~Questions}
 * @see {@link module:CourseController~updateExerciseQuestions}
 * 
 * @param {string} id - id of exercise
 * 
 * @returns {string} message
 * @returns {object} exercise
 * 
 * @throws {error} if an error occured
 * @throws {NotFoundError} if exercise not found
 */
exports.updateExercise = async (req, res, next) => {
    const exercise_id = req.params.id

    if (!exercise_id || exercise_id == ':id') {
        return next(new BadRequestError('Missing param `id` in request params'))
    }

    const exercise = await Exercise.findByIdAndUpdate(
        exercise_id,
        { $set: req.body },
        { new: true }
    );

    if (!exercise) {
        return next(new NotFoundError("Exercise not found"));
    }

    const updated_exercise = await translateDoc(exercise)

    return res.status(200).json({
        success: true,
        data: {
            message: "Exercise Updated",
            exercise: updated_exercise
        }
    });
}


// Delete a particular exercise
/**
 * Delete exercise
 * 
 * Doesn't literally delete the exercise, it only
 * makes it unavailable
 * 
 * @param {string} id - id of exercise
 * 
 * @throws {error} if an error occured
 * @throws {NotFoundError} if exercise not found
 * */
exports.deleteExercise = async (req, res, next) => {
    const exercise_id = req.params.id

    if (!exercise_id || exercise_id == ':id') {
        return next(new BadRequestError('Missing param `id` in request params'))
    }

    // Make exercise unavailable
    await Exercise.findByIdAndUpdate(exercise_id, { isAvailable: false })

    return res.status(200).send({
        success: true,
        data: {
            message: "Exercise deleted successfully"
        }
    })
}


// Add a question to an exercise
/**
 * Add question to exercise
 * 
 * @description This function adds a question to an exercise.
 * 
 * @param {string} exercise_id
 * @param {string} question_id
 * 
 * @returns {string} message
 * 
 * @throws {error} if an error occured
 * @throws {NotFoundError} if Exercise not found
 * @throws {NotFoundError} if Question not found
 * */
exports.addQuestionToExercise = async (req, res, next) => {
    const { exercise_id, question_id } = req.body

    if (!exercise_id || !question_id) {
        return next(new BadRequestError('Missing required param in request body'))
    }

    const exercise = await Exercise.findById(exercise_id)

    if (!exercise) {
        return next(new NotFoundError("Exercise not found"))
    }

    const question = await Question.findByIdAndUpdate(question_id, { exercise: exercise_id })
    if (!question) {
        return next(new NotFoundError("Question not found"))
    }

    return res.status(200).send({
        success: true,
        data: {
            message: "Question has been added to exercise",
            question
        }
    })
}


// Remove a question from an exercise
/**
 * Remove question from exercise
 * 
 * @param {string} question_id
 * 
 * @returns {string} message
 * 
 * @throws {NotFoundError} if Questin not found
 * @throws {error} if an error occured
 * */
exports.removeQuestionFromExercise = async (req, res, next) => {
    const { question_id } = req.body

    const question = await Question.findByIdAndUpdate(question_id, { exercise: null })
    if (!question) {
        return next(new NotFoundError("Question not found"))
    }

    return res.status(200).send({
        success: true,
        data: {
            message: "Question has been removed from exercise",
            question
        }
    })
}


/**
 * Score anwers
 * 
 * @description Score answers for a particular exercise,
 * this function is called when a student submits an exercise for grading,
 * it returns the score and the report for the exercise, the report contains
 * the exercise id, the user id, the score and the submission.
 * 
 * <br>
 * <br>
 * 
 * The submission is saved to the database so the user can view all his submissions 
 * for a particular exercise.
 * 
 * @param {string} id - exercise id
 * @param {Object} submission Object where keys are question_id's and values are selected option
 * 
 * @returns {Object} report
 * @returns {string} report.exercise report.user report.score submission
 * 
 * @throws {error} if an error occured
 */
exports.scoreExercise = async (req, res, next) => {
    const exercise_id = req.params.id;

    if (!exercise_id || exercise_id == ":id") {
        return next(new BadRequestError("Missing param `id` in request params"));
    }

    const students_submission = req.body.submission;
    if (!students_submission) {
        return next(
            new BadRequestError("Missing required param `submission` in request body")
        );
    }

    // Check if exercise exists
    const exercise_doc = await Exercise.findById(exercise_id).populate({
        path: "questions",
        select: "correct_option",
    });
    if (!exercise_doc) {
        return next(new NotFoundError("Exercise not found"));
    }

    // Check if user has enrolled for course
    const { course } = (await exercise_doc.populate({
        path: 'course',
        populate: {
            path: "exercises"
        }
    }));
    if (!course.enrolled_users.includes(req.user.id)) {
        return next(new ForbiddenError("User hasn't enrolled for course"));
    }

    const exercise = exercise_doc.toJSON();
    let score = 0;
    let exercise_submission = new ExerciseSubmission({
        user: req.user.id,
        exercise: exercise._id,
    });

    // Grade users submission
    exercise.questions.forEach((question) => {
        const submitted_option = students_submission[question._id.toString()];

        // Check if submitted option is correct. If yes, increment score
        if (question.correct_option == submitted_option) score++;
        exercise_submission.submission.push({
            question: question._id,
            submitted_option: submitted_option,
        });
    });

    let course_report_query = CourseReport.findOne(
        { user: req.user.id, course: course._id },
    )
    let course_report = await course_report_query.exec();

    let exercise_report = await ExerciseReport.findOneAndUpdate(
        { user: req.user.id, exercise: exercise_doc._id },
        { course_report: course_report._id },
        { new: true, upsert: true })

    exercise_report.best_score = Math.max(exercise_report.best_score, score)
    exercise_report = await exercise_report.save()

    exercise_submission.score = score;
    exercise_submission.report = exercise_report._id;
    exercise_submission = await exercise_submission.save();
    exercise_submission = await exercise_submission.populate(
        "submission.question"
    );

    // Update best score in course report
    course_report = await course_report.updateBestScore()

    // Issue certificate if user has completed course
    let certificate = course_report.isCompleted
        ? await issueCertificate(course_report._id)
        : null;

    return res.status(200).send({
        success: true,
        data: {
            report: {
                ...exercise_submission.toObject(),
                percentage_passed: exercise_submission.percentage_passed,
                best_score: exercise_report.best_score,
                best_percentage_passed: exercise_report.percentage_passed,
                course_progress: course_report.percentage_passed
            },
            certificate,
        },
    });
}

/**
 * Get previous submissions for exercise
 * 
 * @description Get result for previously submitted exercises,
 * it all the previously submissions for a particular exercise.
 * 
 * @param {string} exerciseId - id of the exercise
 * 
 * @throws {NotFoundError} if Exercise not found
 * @throws {BadRequestError} if exerciseId not provided in request params
 * 
 * @returns {MongooseObject} submission
 */
exports.getPreviousSubmissionsForExercise = async (req, res, next) => {
    const exercise_id = req.params.exerciseId

    if (!exercise_id || exercise_id == ':exerciseId') {
        return next(new BadRequestError('Missing param `id` in request params'))
    }

    const exercise = await Exercise.findById(exercise_id)
    if (!exercise) {
        return next(new NotFoundError('Exercise not found'))
    }

    const exercise_submissions = await ExerciseSubmission.find(
        { exercise: exercise._id, user: req.user.id }).populate('submission.question')

    return res.status(200).send({
        success: true,
        data: {
            submissions: exercise_submissions
        }
    })
}

/**
 * Get submission data
 * 
 * @description get data for ealier submitted quiz 
 * 
 * @param {string} id - id of exercise submission
 * 
 * @throws {BadRequestError} if submission id not in request param
 * @throws {NotFoundError} if Submission not found
 * @throws {ForbiddenError} if user didn't make submission earlier
 * 
 * @return {Object} submission
 */
exports.getSubmissionData = async (req, res, next) => {
    const submitted_quiz_id = req.params.id;

    // Check for required parameters
    if (!submitted_quiz_id || submitted_quiz_id == ":id") {
        return next(new BadRequestError("Missing param `id` in request params"));
    }

    const submission = await ExerciseSubmission.findById(
        submitted_quiz_id
    ).populate("submission.question");

    // Check if submission record exists
    if (!submission) {
        return next(new NotFoundError('Submission not found'))
    }

    // Check if initial exercise submission was made by user
    if (submission.user.toString() != req.user.id) {
        return next(new ForbiddenError("Submission doesn't belong to user"))
    }

    return res.status(200).send({
        success: true,
        data: {
            submission
        }
    })
}