diff --git a/app/controllers/api/v1/student_quizzes_controller.rb b/app/controllers/api/v1/student_quizzes_controller.rb new file mode 100644 index 000000000..b10184b44 --- /dev/null +++ b/app/controllers/api/v1/student_quizzes_controller.rb @@ -0,0 +1,83 @@ +class Api::V1::StudentQuizzesController < ApplicationController + include AuthorizationHelper + + # Checks if the current user is authorized to perform the requested action. + # For students, it verifies if they have the necessary roles for the 'index' action. + # For TAs, it checks if they have TA privileges and are assigned to the current course. + def action_allowed? + if current_user_is_a? 'Student' + if action_name.eql? 'index' + are_needed_authorizations_present?(params[:id], 'reviewer', 'submitter') + else + true + end + else + current_user_has_ta_privileges? && user_ta_for_current_course? + end + end + + # Fetches and lists all quiz response mappings for a specific participant in an assignment. + def index + @participant = AssignmentParticipant.find(params[:id]) + # Ensures the current user is authorized to view the participant's quiz mappings. + return unless current_user_id?(@participant.user_id) + + @assignment = Assignment.find(@participant.parent_id) + @quiz_mappings = ResponseMap.mappings_for_reviewer(@participant.id) + end + + # Displays the questions and participant responses for a completed quiz + def show_quiz_responses + @response = Response.find(params[:response_id]) + @response_map = ResponseMap.find(params[:map_id]) + @questions = Question.where(questionnaire_id: @response_map.reviewed_object_id) + @participant = AssignmentTeam.find(@response_map.reviewee_id).participants.first + @quiz_score = @response.aggregate_questionnaire_score # Use the score calculated by Response model + end + +# Fetches quizzes for a given assignment that a reviewer has not yet started. +def fetch_available_quizzes_for_reviewer(assignment_id, reviewer_id) + reviewer = Participant.find_by(user_id: reviewer_id, parent_id: assignment_id) + return [] unless reviewer + + # Find quiz questionnaires created by teams in the assignment + quiz_questionnaires = Questionnaire.where(instructor_id: Team.where(parent_id: assignment_id).pluck(:id)) + + # Filter out quizzes already started by the reviewer + available_quizzes = quiz_questionnaires.reject do |quiz| + quiz.started_by?(reviewer) + end + + available_quizzes +end + + # Submits the quiz response and calculates the score. + def submit_quiz + map = ResponseMap.find(params[:map_id]) + # Check if there is any response for this map_id. This is to prevent student from taking the same quiz twice. + if map.response.empty? + response = Response.create(map_id: params[:map_id], created_at: DateTime.current, updated_at: DateTime.current) + if response.calculate_score(params) + redirect_to controller: 'student_quizzes', action: 'show_finished_quiz', map_id: map.id + else + flash[:error] = 'Please answer every question.' + redirect_to action: :fetch_available_quizzes_for_reviewer, assignment_id: params[:assignment_id], questionnaire_id: response.response_map.reviewed_object_id, map_id: map.id + end + else + flash[:error] = 'You have already taken this quiz, below is the record of your responses.' + redirect_to controller: 'student_quizzes', action: 'show_finished_quiz', map_id: map.id + end + end + + # Provides a list of quiz questionnaires for a given assignment. + # This method is called when instructors click "view quiz questions" on the pop-up panel. + def view_questions + @assignment_id = params[:id] + @quiz_questionnaires = [] + Team.where(parent_id: params[:id]).each do |quiz_creator| + Questionnaire.where(instructor_id: quiz_creator.id).each do |questionnaire| + @quiz_questionnaires.push questionnaire + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4c8c36ece..188f8b6b9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,12 @@ class ApplicationController < ActionController::API include JwtToken + + # Check if a participant has given authorizations + def are_needed_authorizations_present?(id, *authorizations) + participant = Participant.find_by(id: id) + return false if participant.nil? + + authorization = participant.authorization + !authorizations.include?(authorization) + end end diff --git a/app/helpers/authorization_helper.rb b/app/helpers/authorization_helper.rb new file mode 100644 index 000000000..06b2d3972 --- /dev/null +++ b/app/helpers/authorization_helper.rb @@ -0,0 +1,24 @@ +module AuthorizationHelper + + # Determine if the currently logged-in user has the privileges of a TA (or higher) + def current_user_has_ta_privileges? + current_user_has_privileges_of?('Teaching Assistant') + end + + # Determine if the currently logged-in user has the privileges of a Student (or higher) + def current_user_has_student_privileges? + current_user_has_privileges_of?('Student') + end + + private + + # Determine if the currently logged-in user has the privileges of the given role name (or higher privileges) + # If there is no currently logged-in user return false + def current_user_has_privileges_of?(role_name) + current_user_and_role_exist? && session[:user].role.has_all_privileges_of?(Role.find_by(name: role_name)) + end + + # Check whether user is logged-in and user role exists + def current_user_and_role_exist? + user_logged_in? && !session[:user].role.nil? + end diff --git a/app/helpers/response_helper.rb b/app/helpers/response_helper.rb new file mode 100644 index 000000000..6e290f491 --- /dev/null +++ b/app/helpers/response_helper.rb @@ -0,0 +1,24 @@ +module ResponseHelper + + # Assigns total contribution for cake question across all reviewers to a hash map + # Key : question_id, Value : total score for cake question + def store_total_cake_score + reviewee = ResponseMap.select(:reviewee_id, :type).where(id: @response.map_id.to_s).first + @total_score = scores_per_question(reviewee.type, + @review_questions, + @participant.id, + @assignment.id, + reviewee.reviewee_id) + end + + # Calculates the total score for each of the questions for a given participant and assignment. + # Total scores per question, with question_id as the key and total score as the value. + def scores_per_question(review_type, questions, participant_id, assignment_id, reviewee_id) + questions.each_with_object({}) do |question, scores| + next unless question.is_a?(Cake) + + score = question.running_total(review_type, question.id, participant_id, assignment_id, reviewee_id) + scores[question.id] = score || 0 + end + end +end diff --git a/app/models/cake.rb b/app/models/cake.rb new file mode 100644 index 000000000..6fbc24932 --- /dev/null +++ b/app/models/cake.rb @@ -0,0 +1,45 @@ +class Cake < ScoredQuestion + include ActionView::Helpers + validates :size, presence: true + + # Retrieves and calculates the total score for a specific question. + def running_total(review_type, question_id, participant_id, assignment_id, reviewee_id) + team_id = Team.joins(teams_users: :participant) + .where('participants.id = ? AND teams.parent_id = ?', participant_id, assignment_id) + .pluck(:id) + .first + return 0 unless team_id + + if review_type == 'TeammateReviewResponseMap' + answers = Participant.joins(user: :teams_users) + .where('teams_users.team_id = ? AND participants.parent_id = ?', team_id, assignment_id) + .pluck(:id) + .flat_map do |team_member_id| + Answer.joins(response: :response_map) + .where("response_maps.reviewee_id = ? AND response_maps.reviewed_object_id = ? + AND response_maps.reviewer_id = ? AND answers.question_id = ? + AND response_maps.reviewee_id != ? AND answers.answer IS NOT NULL", + team_member_id, assignment_id, participant_id, question_id, reviewee_id) + end + answers.compact.sum(&:answer) + else + 0 + end + end + + # The score a user gave to a specific question. + def score_given_by_user(participant_id, question_id, assignment_id, reviewee_id) + Answer.joins(response: :response_map) + .where("response_maps.reviewer_id = ? AND response_maps.reviewee_id = ? AND response_maps.reviewed_object_id = ? AND answers.question_id = ?", + participant_id, reviewee_id, assignment_id, question_id) + .pluck(:answer) + .first || 0 + end + + # The score a user has left to give for a question. + def score_remaining_for_user(review_type, question_id, participant_id, assignment_id, reviewee_id) + total_score = running_total(review_type, question_id, participant_id, assignment_id, reviewee_id) + remaining_score = 100 - total_score + remaining_score.negative? ? 0 : remaining_score + end +end diff --git a/app/models/question.rb b/app/models/question.rb index 4dc76ae10..b4b8211df 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -2,7 +2,8 @@ class Question < ApplicationRecord before_create :set_seq belongs_to :questionnaire # each question belongs to a specific questionnaire has_many :answers, dependent: :destroy - + has_many :quiz_question_choices, class_name: 'QuizQuestionChoice', foreign_key: 'question_id' + validates :seq, presence: true, numericality: true # sequence must be numeric validates :txt, length: { minimum: 0, allow_nil: false, message: "can't be nil" } # user must define text content for a question validates :question_type, presence: true # user must define type for a question @@ -11,7 +12,7 @@ class Question < ApplicationRecord def scorable? false end - + def set_seq self.seq = questionnaire.questions.size + 1 end diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index d576bc421..f28e0823e 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -6,7 +6,7 @@ class Questionnaire < ApplicationRecord validate :validate_questionnaire validates :name, presence: true - validates :max_question_score, :min_question_score, numericality: true + validates :max_question_score, :min_question_score, numericality: true # clones the contents of a questionnaire, including the questions and associated advice def self.copy_questionnaire_details(params) @@ -52,4 +52,16 @@ def as_json(options = {}) hash['instructor'] ||= { id: nil, name: nil } end end + + # Check if the questionnaire has been started by any participant + # "Started" means there is at least one ResponseMap record associated with the Questionnaire. This indicates that a participant has begun to respond to the questionnaire, but it does not necessarily mean the response is complete. + def started_by_anyone? + !ResponseMap.where(reviewed_object_id: id).empty? + end + + # Check if the questionnaire has been started by a specific participant + # "Started" means there is at least one ResponseMap record associated with the Questionnaire and the participant. + def started_by?(participant) + !ResponseMap.where(reviewed_object_id: id, reviewer_id: participant.id).empty? + end end diff --git a/app/models/quiz_question_choice.rb b/app/models/quiz_question_choice.rb new file mode 100644 index 000000000..564974971 --- /dev/null +++ b/app/models/quiz_question_choice.rb @@ -0,0 +1,3 @@ +class QuizQuestionChoice < ApplicationRecord + belongs_to :question, dependent: :destroy +end diff --git a/app/models/response.rb b/app/models/response.rb index c63bdb5fd..9e38d67ff 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -56,4 +56,65 @@ def aggregate_questionnaire_score end sum end + + # Calculate score based on provided answers + def calculate_score(params) + questionnaire = Questionnaire.find(map.reviewed_object_id) + questions = Question.where(questionnaire_id: questionnaire.id) + valid = true + scores = [] + + questions.each do |question| + score = score(question, params) + new_score = Answer.new( + comments: params[question.id.to_s], + question_id: question.id, + response_id: id, + answer: score + ) + + valid = false unless new_score.valid? + scores.push(new_score) + end + + if valid + scores.each(&:save) + true + else + false + end + end + + # Calculates the score for a question based on the type and user answers. + def score(question, user_answers) + correct_answers = question.quiz_question_choices.where(iscorrect: true) + + case question.question_type + when 'MultipleChoiceCheckbox' + checkbox_score(correct_answers, user_answers) + when 'TrueFalse', 'MultipleChoiceRadio' + calculate_score_for_truefalse_question(correct_answers.first, user_answers) + else + 0 # Default score for unsupported question types + end + end + + private + + # Calculates score for Checkbox type questions. + def checkbox_score(correct_answers, user_answers) + return 0 if user_answers.nil? + + score = 0 + correct_answers.each do |correct| + score += 1 if user_answers.include?(correct.txt) + end + + score == correct_answers.count && score == user_answers.count ? 1 : 0 + end + + # Calculates score for TrueFalse and MultipleChoice type questions. + def truefalse_score(correct_answer, user_answer) + correct_answer.txt == user_answer ? 1 : 0 + end end diff --git a/app/models/response_map.rb b/app/models/response_map.rb index d27124d76..a1ec0f9ba 100644 --- a/app/models/response_map.rb +++ b/app/models/response_map.rb @@ -3,17 +3,20 @@ class ResponseMap < ApplicationRecord belongs_to :reviewer, class_name: 'Participant', foreign_key: 'reviewer_id', inverse_of: false belongs_to :reviewee, class_name: 'Participant', foreign_key: 'reviewee_id', inverse_of: false belongs_to :assignment, class_name: 'Assignment', foreign_key: 'reviewed_object_id', inverse_of: false + belongs_to :questionnaire, class_name: 'Questionnaire', foreign_key: 'reviewed_object_id', optional: true alias map_id id - # returns the assignment related to the response map + # Returns the assignment related to the response map. def response_assignment - return Participant.find(self.reviewer_id).assignment + Participant.find(self.reviewer_id).assignment end + # Retrieves all the responses for a given team. + # This method sorts the responses and returns the latest response for each map. def self.assessments_for(team) responses = [] - # stime = Time.now + if team array_sort = [] sort_to = [] @@ -34,8 +37,20 @@ def self.assessments_for(team) array_sort.clear sort_to.clear end + # Sort responses by the reviewer's full name. responses = responses.sort { |a, b| a.map.reviewer.fullname <=> b.map.reviewer.fullname } end responses end + + # Deletes the associated response and then destroys the response map itself. + def delete + response.delete unless response.nil? + destroy + end + + # Retrieves all mappings for a given reviewer (participant). + def self.mappings_for_reviewer(participant_id) + where(reviewer_id: participant_id) + end end