diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index e4b7c333a..0a235f10d 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -89,6 +89,7 @@ def current_user_teaching_staff_of_assignment?(assignment_id) ( current_user_instructs_assignment?(assignment) || current_user_has_ta_mapping_for_assignment?(assignment) + # TODO: include a check to allow admins or superadmins access to assignments their child instructors can access ) end diff --git a/app/controllers/grades_controller.rb b/app/controllers/grades_controller.rb index 287a64996..67b89a439 100644 --- a/app/controllers/grades_controller.rb +++ b/app/controllers/grades_controller.rb @@ -6,7 +6,7 @@ def action_allowed? when 'view_our_scores','view_my_scores' set_participant_and_team_via_assignment current_user_is_assignment_participant?(params[:assignment_id]) - when 'view_all_scores' + when 'view_all_scores', 'get_review_tableau_data' current_user_teaching_staff_of_assignment?(params[:assignment_id]) when 'edit', 'assign_grade', 'instructor_review' set_team_and_assignment_via_participant @@ -54,6 +54,105 @@ def view_my_scores end + # (GET /api/v1/grades/:assignment_id/:participant_id/get_review_tableau_data) + # Given an AssignmentParticipant ID, gather and return all reviews completed by that participant for the corresponding assignment. + def get_review_tableau_data + responses_by_round = {} + begin + # Determine all questionnaires used as part of this assignment, grouped by the round in which they are used. + AssignmentQuestionnaire.where("assignment_id = " + params[:assignment_id]).find_each do |pairing| + round_id = pairing[:used_in_round] + rubric_id = pairing[:questionnaire_id] + + # If this round has not been recorded yet, record it. + if !responses_by_round.key?(round_id) + responses_by_round[round_id] = {} + end + # If this questionnaire has not been recorded yet, record it. + if !responses_by_round[round_id].key?(rubric_id) + # Items (the "questions") are always the same across responses of the same rubric. + # Initialize them into a hash using a helper function. + responses_by_round[round_id] = get_items_from_questionnaire(rubric_id) + end + end + + response_mapping_condition = "reviewed_object_id = " + params[:assignment_id] + " AND reviewer_id = " + params[:participant_id] + ReviewResponseMap.where(response_mapping_condition).find_each do |mapping| + Response.where("map_id = " + mapping[:id].to_s).find_each do |response| + + # response = Response.find_by(map_id: mapping[:id]) + + if response == nil + # If, for some reason, there is no response with this mapping id, move on to the next mapping id. + next + end + + response_id = response[:id] + round_id = response[:round] + + if !responses_by_round.key?(round_id) + # If, for some reason, there is no questionnaire associated with the given round, move on to the next mapping id. + next + end + + # Record this response's values and comments, one pair for each item in the corresponding questionnaire. + responses_by_round[round_id].each_key do |item_id| + response_answer = Answer.find_by(item_id: item_id, response_id: response_id) + responses_by_round[round_id][item_id][:answers][:values].append(response_answer[:answer]) + responses_by_round[round_id][item_id][:answers][:comments].append(response_answer[:comments]) + end + end + end + + # Get participant and user information for the response + participant = AssignmentParticipant.find(params[:participant_id]) + assignment = Assignment.find(params[:assignment_id]) + + # Return JSON containing all answer values and comments associated with this reviewer and for this assignment. + render json: { + responses_by_round: responses_by_round, + participant: { + id: participant.id, + user_id: participant.user_id, + user_name: participant.user.name, + full_name: participant.user.full_name, + handle: participant.handle + }, + assignment: { + id: assignment.id, + name: assignment.name + } + } + rescue ActiveRecord::RecordNotFound + render json: { error: "Participant or assignment not found" }, status: :not_found + rescue StandardError => e + render json: { error: "Internal server error" }, status: :internal_server_error + end + end + + # A helper function which, given a questionnaire id, returns a hash keyed by the ids of that questionnaire's items. + # The values of the hash include the description (usually a question) of the item, and an empty hash for including responses. + def get_items_from_questionnaire(questionnaire_id) + questionnaire = Questionnaire.find_by(id: questionnaire_id) + item_data = { + min_answer_value: questionnaire[:min_question_score], + max_answer_value: questionnaire[:max_question_score], + items: {} + } + Item.where("questionnaire_id = " + questionnaire_id.to_s).find_each do |item| + item_data[:items][item[:id]] = { + description: item[:txt], + question_type: item[:question_type], + answers: { + values: [], + comments: [] + } + } + end + return item_data + end + + # edit (GET /api/v1/grades/:participant_id/edit) # provides data for the grade-assignment interface. # Given an AssignmentParticipant ID, it looks up the participant and its assignment, gathers the full list of items diff --git a/config/routes.rb b/config/routes.rb index 25642363c..eff7d3b04 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -144,6 +144,7 @@ get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' patch '/:participant_id/assign_grade', to: 'grades#assign_grade' get '/:participant_id/edit', to: 'grades#edit' + get '/:assignment_id/:participant_id/get_review_tableau_data', to: 'grades#get_review_tableau_data' get '/:assignment_id/view_our_scores', to: 'grades#view_our_scores' get '/:assignment_id/view_my_scores', to: 'grades#view_my_scores' get '/:participant_id/instructor_review', to: 'grades#instructor_review' diff --git a/spec/controllers/grades_controller_get_review_tableau_data_spec.rb b/spec/controllers/grades_controller_get_review_tableau_data_spec.rb new file mode 100644 index 000000000..951e9c36b --- /dev/null +++ b/spec/controllers/grades_controller_get_review_tableau_data_spec.rb @@ -0,0 +1,176 @@ +require 'rails_helper' + +RSpec.describe GradesController, type: :controller do + let(:assignment_id) { "10" } + let(:participant_id) { "20" } + + # Fake User + let(:user) { instance_double(User, id: 99, name: "ReviewerUser", full_name: "Reviewer User") } + + # Fake AssignmentParticipant + let(:fake_participant) do + double("AssignmentParticipant", id: participant_id.to_i, user_id: user.id, user: user, handle: "reviewer_handle").tap do |p| + allow(p).to receive(:[]).with(:id).and_return(p.id) + allow(p).to receive(:[]).with(:user_id).and_return(p.user_id) + allow(p).to receive(:[]).with(:handle).and_return(p.handle) + end + end + + # Fake Assignment + let(:assignment) { instance_double(Assignment, id: assignment_id.to_i, name: "Test Assignment") } + + # Fake AssignmentQuestionnaire + let(:fake_questionnaire) do + double("AssignmentQuestionnaire", used_in_round: 1, questionnaire_id: 999).tap do |q| + allow(q).to receive(:[]).with(:used_in_round).and_return(1) + allow(q).to receive(:[]).with(:questionnaire_id).and_return(999) + end + end + + # Fake Item + let(:fake_item) do + double("Item", id: 1, txt: "Criterion 1", question_type: "Scale").tap do |item| + allow(item).to receive(:[]).with(:id).and_return(item.id) + allow(item).to receive(:[]).with(:txt).and_return(item.txt) + allow(item).to receive(:[]).with(:question_type).and_return(item.question_type) + end + end + + # Fake ReviewResponseMap + let(:fake_response_map) { instance_double(ReviewResponseMap, id: 555) } + + # Fake Response + let(:fake_response) { instance_double(Response, id: 777, round: 1) } + + # Fake Answer + let(:fake_answer) { instance_double(Answer, answer: 4, comments: "Good work") } + + # Expected JSON keys + let(:expected_json_keys) { %w[responses_by_round participant assignment] } + + before do + # Stub ActiveRecord finders + allow(Assignment).to receive(:find).with(assignment_id).and_return(assignment) + allow(AssignmentParticipant).to receive(:find).with(participant_id).and_return(fake_participant) + + # Stub AssignmentQuestionnaire query + fake_relation = double("ActiveRecord::Relation") + allow(fake_relation).to receive(:find_each).and_yield(fake_questionnaire) + allow(AssignmentQuestionnaire).to receive(:where) + .with("assignment_id = #{assignment_id}") + .and_return(fake_relation) + + # Stub Item query + fake_item_relation = double("ActiveRecord::Relation") + allow(fake_item_relation).to receive(:find_each).and_yield(fake_item) + allow(Item).to receive(:where).with("questionnaire_id = 999").and_return(fake_item_relation) + + # Use simple hashes instead of instance_double for hash-style access + fake_response_map = { id: 555 } + fake_response = { id: 777, round: 1 } + fake_item = { id: 1, txt: "Criterion 1", question_type: "Scale" } + fake_answer = { answer: 4, comments: "Good work" } + + # Stub ReviewResponseMap.where(...).find_each + fake_review_map_relation = double("ActiveRecord::Relation") + allow(fake_review_map_relation).to receive(:find_each).and_yield(fake_response_map) + allow(ReviewResponseMap).to receive(:where) + .with("reviewed_object_id = #{assignment_id} AND reviewer_id = #{participant_id}") + .and_return(fake_review_map_relation) + + # Stub Response.where(...).find_each + fake_response_relation = double("ActiveRecord::Relation") + allow(fake_response_relation).to receive(:find_each).and_yield(fake_response) + allow(Response).to receive(:where) + .with("map_id = #{fake_response_map[:id]}") + .and_return(fake_response_relation) + + # Stub Answer.find_by(...) + allow(Answer).to receive(:find_by) + .with(item_id: fake_item[:id], response_id: fake_response[:id]) + .and_return(fake_answer) + + # Controller authorization stubs + allow(controller).to receive(:authorize).and_return(true) + allow(controller).to receive(:current_user).and_return(user) + allow(controller).to receive(:has_role?).and_return(true) + allow(controller).to receive(:action_allowed?).and_return(true) + + # Stub JWT decoding (if using token auth) + request.headers['Authorization'] = 'Bearer faketoken' + allow(JsonWebToken).to receive(:decode).and_return({ id: user.id }) + allow(User).to receive(:find).with(user.id).and_return(user) + end + + describe "GET #get_review_tableau_data" do + it "responds with valid JSON containing required keys" do + get :get_review_tableau_data, params: { assignment_id: assignment_id, participant_id: participant_id } + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json.keys).to include(*expected_json_keys) + end + + it "returns 404 if participant is not found" do + allow(AssignmentParticipant).to receive(:find).with(participant_id).and_raise(ActiveRecord::RecordNotFound) + + get :get_review_tableau_data, params: { assignment_id: assignment_id, participant_id: participant_id } + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json["error"]).to match(/Participant or assignment not found/) + end + + it "returns 404 if assignment is not found" do + allow(Assignment).to receive(:find).with(assignment_id).and_raise(ActiveRecord::RecordNotFound) + + get :get_review_tableau_data, params: { assignment_id: assignment_id, participant_id: participant_id } + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json["error"]).to match(/Participant or assignment not found/) + end + + it "returns participant info correctly" do + get :get_review_tableau_data, params: { assignment_id: assignment_id, participant_id: participant_id } + + json = JSON.parse(response.body) + participant_json = json["participant"] + expect(participant_json["id"]).to eq(fake_participant.id) + expect(participant_json["user_id"]).to eq(fake_participant.user_id) + expect(participant_json["user_name"]).to eq(fake_participant.user.name) + expect(participant_json["full_name"]).to eq(fake_participant.user.full_name) + expect(participant_json["handle"]).to eq(fake_participant.handle) + end + + it "returns assignment info correctly" do + get :get_review_tableau_data, params: { assignment_id: assignment_id, participant_id: participant_id } + + json = JSON.parse(response.body) + assignment_json = json["assignment"] + expect(assignment_json["id"]).to eq(assignment.id) + expect(assignment_json["name"]).to eq(assignment.name) + end + + context "when user is a student (forbidden)" do + let(:student_user) { instance_double(User, id: 50, name: "StudentUser", full_name: "Student User", role_id: 1) } + + before do + allow(controller).to receive(:current_user).and_return(student_user) + # allow(controller).to receive(:action_allowed?).and_return(false) # unauthorized + allow(controller).to receive(:authorize).and_call_original + allow(controller).to receive(:all_actions_allowed?).and_return(false) + end + + it "returns 403 Unauthorized" do + get :get_review_tableau_data, params: { assignment_id: assignment_id, participant_id: participant_id } + + expect(response).to have_http_status(:forbidden) + + json = JSON.parse(response.body) + expect(json["error"]).to match(/You are not authorized to get_review_tableau_data this grades/) + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 4e51c9933..a0794fd1b 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -78,7 +78,12 @@ RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures # config.fixture_path = Rails.root.join('spec/fixtures') - + if config.respond_to?(:fixture_paths=) + config.fixture_paths = [Rails.root.join('spec/fixtures').to_s] + else + # fallback for older Rails / rspec-rails + config.fixture_path = Rails.root.join('spec/fixtures') + end # Since we're using Factory Bot instead of fixtures, we don't need fixture_path # config.fixture_path is deprecated in newer RSpec versions anyway