diff --git a/.rubocop.yml b/.rubocop.yml index 2fac0607b..504fdeb37 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,19 +13,19 @@ Style/FrozenStringLiteralComment: Metrics/BlockLength: Max: 120 Exclude: - - 'db/**/*.rb' + - "db/**/*.rb" Metrics/MethodLength: - Max: 20 - Exclude: - - 'db/**/*.rb' + Max: 20 + Exclude: + - "db/**/*.rb" Metrics/AbcSize: Max: 20 Exclude: - - 'db/**/*.rb' + - "db/**/*.rb" Style/StringLiterals: Enabled: true EnforcedStyle: single_quotes Exclude: - - 'db/**/*.rb' \ No newline at end of file + - "db/**/*.rb" diff --git a/README.md b/README.md index 2c94a747a..cc9158d87 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,18 @@ application up and running. Things you may want to cover: -* Ruby version - 3.4.5 +- Ruby version - 3.4.5 ## Development Environment ### Prerequisites + - Verify that [Docker Desktop](https://www.docker.com/products/docker-desktop/) is installed and running. - [Download](https://www.jetbrains.com/ruby/download/) RubyMine - Make sure that the Docker plugin [is enabled](https://www.jetbrains.com/help/ruby/docker.html#enable_docker). - ### Instructions + Tutorial: [Docker Compose as a remote interpreter](https://www.jetbrains.com/help/ruby/using-docker-compose-as-a-remote-interpreter.html) ### Video Tutorial @@ -25,5 +26,6 @@ Tutorial: [Docker Compose as a remote interpreter](https://www.jetbrains.com/hel alt="IMAGE ALT TEXT HERE" width="560" height="315" border="10" /> ### Database Credentials + - username: root - password: expertiza diff --git a/app/controllers/responses_controller.rb b/app/controllers/responses_controller.rb new file mode 100644 index 000000000..22d38f6b8 --- /dev/null +++ b/app/controllers/responses_controller.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +class ResponsesController < ApplicationController + prepend_before_action :set_response, only: %i[show update] + before_action :find_and_authorize_map_for_create, only: %i[create] + + # Checks whether the current user can use the requested response action. + def action_allowed? + case action_name + when "create" + true # auth already handled by before_action above + when "show", "update" + @response && @response.map.reviewer.user_id == current_user.id + else + true + end + end + + # Shows the response details for one task response. + def show + render json: { + response_id: @response.id, + map_id: @response.map_id, + task_type: @response.map.type, + submitted: @response.is_submitted, + additional_comment: @response.additional_comment + } + end + + # Creates or reuses a response for the requested response map. + def create + return unless enforce_task_order!(@map) + + round = (params[:round].presence || 1).to_i + response = Response.where(map_id: @map.id, round: round) + .order(:created_at) + .last || Response.new(map_id: @map.id, round: round) + + if params[:content].present? || params[:additional_comment].present? + response.additional_comment = params[:content].presence || params[:additional_comment] + end + + if response.save + render json: { response_id: response.id, map_id: @map.id, round: response.round }, status: :created + else + render json: { errors: response.errors.full_messages }, status: :unprocessable_entity + end + end + + # Updates the saved response with submission details or comments. + def update + return unless enforce_task_order!(@response.map) + + if @response.update(response_update_params) + render json: { + response_id: @response.id, + map_id: @response.map_id, + submitted: @response.is_submitted, + additional_comment: @response.additional_comment + }, status: :ok + else + render json: { errors: @response.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + # Finds the response used by show and update. + def set_response + @response = Response.find(params[:id]) + end + + # Finds the response map and checks that the current user owns it. + def find_and_authorize_map_for_create + @map = ResponseMap.find_by(id: params[:response_map_id]) + unless @map + render json: { error: "ResponseMap not found" }, status: :not_found + return + end + + unless @map.reviewer.user_id == current_user.id + render json: { error: "You are not authorized to create this response" }, status: :forbidden + end + end + + + # Allows only the response fields that can be changed by this controller. + def response_update_params + p = params.permit(:is_submitted, :additional_comment, :content, :round) + p[:additional_comment] = p[:content] if p[:content].present? + p.delete(:content) + p + end + + # Makes sure earlier tasks are finished before this task can be changed. + def enforce_task_order!(map) + participant = map.reviewer + unless participant.user_id == current_user.id + render json: { error: "Unauthorized" }, status: :forbidden + return false + end + + context = StudentTask.resolve_context_for_participant(participant) + unless context + render json: { error: "TeamsParticipant not found for reviewer" }, status: :forbidden + return false + end + + tasks = StudentTask.build_tasks(context) + current_task = StudentTask.find_task_for_map(tasks, map.id) + unless current_task + render json: { error: "Response map is not a respondable task for this participant" }, status: :forbidden + return false + end + + unless StudentTask.prior_tasks_complete?(tasks, current_task) + render json: { error: "Complete previous task first" }, status: :precondition_failed + return false + end + + true + end +end \ No newline at end of file diff --git a/app/controllers/student_tasks_controller.rb b/app/controllers/student_tasks_controller.rb index ffb6097a5..d1003a498 100644 --- a/app/controllers/student_tasks_controller.rb +++ b/app/controllers/student_tasks_controller.rb @@ -1,13 +1,16 @@ class StudentTasksController < ApplicationController - + # List retrieves all student tasks associated with the current logged-in user. def action_allowed? current_user_has_student_privileges? end + + # --------------------------------------------------------------------------- + # Actions + # --------------------------------------------------------------------------- + def list - # Retrieves all tasks that belong to the current user. @student_tasks = StudentTask.from_user(current_user) - # Render the list of student tasks as JSON. render json: @student_tasks, status: :ok end @@ -15,14 +18,62 @@ def show render json: @student_task, status: :ok end - # The view function retrieves a student task based on a participant's ID. - # It is meant to provide an endpoint where tasks can be queried based on participant ID. def view - # Retrieves the student task where the participant's ID matches the provided parameter. - # This function will be used for clicking on a specific student task to "view" its details. @student_task = StudentTask.from_participant_id(params[:id]) - # Render the found student task as JSON. render json: @student_task, status: :ok end + # Returns the full ordered task queue for an assignment. + def queue + context = StudentTask.resolve_context_for_assignment(current_user, params[:assignment_id]) + return render json: { error: "Not authorized or not found" }, status: :not_found unless context + + tasks = StudentTask.build_tasks(context) + StudentTask.ensure_response_objects!(tasks) + + render json: tasks.map(&:to_h), status: :ok + end + + # Returns the next unfinished task in the assignment queue. + def next_task + context = StudentTask.resolve_context_for_assignment(current_user, params[:assignment_id]) + return render json: { error: "Not authorized or not found" }, status: :not_found unless context + + tasks = StudentTask.build_tasks(context) + StudentTask.ensure_response_objects!(tasks) + next_task = tasks.find { |task| !task.completed? } + + if next_task + render json: next_task.to_h, status: :ok + else + render json: { message: "All tasks completed" }, status: :ok + end + end + + # Starts a task after checking that earlier tasks are complete. + def start_task + map = ResponseMap.find_by(id: params[:response_map_id]) + return render json: { error: "ResponseMap not found" }, status: :not_found unless map + + participant = map.reviewer + return render json: { error: "Unauthorized" }, status: :forbidden if participant.user_id != current_user.id + + context = StudentTask.resolve_context_for_participant(participant) + return render json: { error: "Task not in respondable queue" }, status: :not_found unless context + + tasks = StudentTask.build_tasks(context) + current_task = StudentTask.find_task_for_map(tasks, map.id) + return render json: { error: "Task not in respondable queue" }, status: :not_found unless current_task + + unless StudentTask.prior_tasks_complete?(tasks, current_task) + return render json: { error: "Complete previous task first" }, status: :forbidden + end + + current_task.ensure_response! + + render json: { + message: "Task started", + task: current_task.to_h + }, status: :ok + end end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 130fa6837..25407066b 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -20,10 +20,22 @@ class Assignment < ApplicationRecord #This method return the value of the has_badge field for the given assignment object. attr_accessor :title, :description, :has_badge, :enable_pair_programming, :is_calibrated, :staggered_deadline + # Returns the assignment-linked review questionnaire record. + # The assignment can be linked to many questionnaires via AssignmentQuestionnaire. + def review_questionnaire_for_review_flow + questionnaires.find_by(questionnaire_type: 'ReviewQuestionnaire') + end + def review_questionnaire_id Questionnaire.find_by_assignment_id id end + # Returns the quiz questionnaire used by the reviewer pre-check flow. + # If no quiz questionnaire is attached, the caller can skip quiz task creation. + def quiz_questionnaire_for_review_flow + questionnaires.find_by(questionnaire_type: 'QuizQuestionnaire') + end + def teams? @has_teams ||= teams.any? end diff --git a/app/models/quiz_task_item.rb b/app/models/quiz_task_item.rb new file mode 100644 index 000000000..ab8c9ae7b --- /dev/null +++ b/app/models/quiz_task_item.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class QuizTaskItem < StudentTask::BaseTaskItem + # Labels this task item as a quiz task. + def task_type + :quiz + end + + # Finds the quiz questionnaire attached to this assignment. + def questionnaire + assignment.quiz_questionnaire_for_review_flow + end + + # Finds or creates the quiz response map used to answer this task. + def response_map + return @response_map if @response_map + + existing_map = QuizResponseMap.find_by( + reviewer_id: team_participant.participant_id, + reviewee_id: review_map&.reviewee_id || 0 + ) + return @response_map = existing_map if existing_map + + return nil if questionnaire.nil? + + attributes = { + reviewer_id: team_participant.participant_id, + reviewee_id: review_map&.reviewee_id || 0, + reviewed_object_id: questionnaire.id, + type: "QuizResponseMap" + } + + @response_map = QuizResponseMap.new(attributes).tap { |map| map.save!(validate: false) } + end +end diff --git a/app/models/review_task_item.rb b/app/models/review_task_item.rb new file mode 100644 index 000000000..018d08898 --- /dev/null +++ b/app/models/review_task_item.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ReviewTaskItem < StudentTask::BaseTaskItem + # Labels this task item as a review task. + def task_type + :review + end + + # Uses the existing review response map for this task. + def response_map + review_map + end +end diff --git a/app/models/student_task.rb b/app/models/student_task.rb index c5bb9332b..d47956a18 100644 --- a/app/models/student_task.rb +++ b/app/models/student_task.rb @@ -1,50 +1,228 @@ # frozen_string_literal: true class StudentTask - attr_accessor :assignment, :current_stage, :participant, :stage_deadline, :topic, :permission_granted - - # Initializes a new instance of the StudentTask class - def initialize(args) - @assignment = args[:assignment] - @current_stage = args[:current_stage] - @participant = args[:participant] - @stage_deadline = args[:stage_deadline] - @topic = args[:topic] - @permission_granted = args[:permission_granted] - end - - # create a new StudentTask instance from a Participant object.cccccccc - def self.create_from_participant(participant) - new( - assignment: participant.assignment.name, # Name of the assignment associated with the student task - topic: participant.topic, # Current stage of the assignment process - current_stage: participant.current_stage, # Participant object - stage_deadline: parse_stage_deadline(participant.stage_deadline), # Deadline for the current stage of the assignment - permission_granted: participant.permission_granted, # Topic of the assignment - participant: participant # Boolean indicating if Publishing Rights is enabled + attr_accessor :assignment, :assignment_id, :current_stage, :participant, :stage_deadline, :topic, :permission_granted + + # Stores the task details that will be returned to the student task API. + def initialize(args) + @assignment = args[:assignment] + @assignment_id = args[:assignment_id] + @current_stage = args[:current_stage] + @participant = args[:participant] + @stage_deadline = args[:stage_deadline] + @topic = args[:topic] + @permission_granted = args[:permission_granted] + end + + # Builds a student task summary from one participant record. + def self.create_from_participant(participant) + return nil unless participant.assignment.present? + new( + assignment: participant.assignment&.name, + assignment_id: participant.parent_id, + topic: participant.topic, + current_stage: participant.current_stage, + stage_deadline: send(:parse_stage_deadline, participant.stage_deadline), + permission_granted: participant.permission_granted, + participant: participant + ) + end + + # Finds all participant tasks for a user and sorts them by deadline. + def self.from_user(user) + Participant.where(user_id: user.id, type: 'AssignmentParticipant') + .map { |p| create_from_participant(p) } + .sort_by(&:stage_deadline) + end + + # Finds one participant by ID and turns it into a student task summary. + def self.from_participant_id(id) + part = Participant.find_by(id: id, type: 'AssignmentParticipant') + raise ActiveRecord::RecordNotFound, "AssignmentParticipant not found with id=#{id}" unless part + + create_from_participant(part) + end + + # Finds the student's participant context for a given assignment. + def self.resolve_context_for_assignment(user, assignment_id) + participant = Participant.find_by( + user_id: user.id, + parent_id: assignment_id, + type: 'AssignmentParticipant' + ) + return nil unless participant&.assignment.present? + + resolve_context_for_participant(participant) + end + + # Collects the records needed to decide which tasks a participant can do. + def self.resolve_context_for_participant(participant) + team_participant = TeamsParticipant.find_by(participant_id: participant.id) + return nil unless team_participant + + { + participant: participant, + team_participant: team_participant, + assignment: participant.assignment, + duty: team_participant.resolved_duty + } + end + + # Builds the ordered list of quiz and review tasks for a participant. + def self.build_tasks(context) + assignment = context[:assignment] + participant = context[:participant] + team_participant = context[:team_participant] + duty = context[:duty] + + tasks = [] + review_maps = ReviewResponseMap.where( + reviewer_id: participant.id, + reviewed_object_id: assignment.id + ) + quiz_questionnaire = assignment.quiz_questionnaire_for_review_flow + has_existing_quiz_maps = QuizResponseMap.where(reviewer_id: participant.id).exists? + + if review_maps.any? + review_maps.each do |review_map| + if (duty.nil? || team_participant.allows_quiz?) && (quiz_questionnaire || has_existing_quiz_maps) + tasks << ::QuizTaskItem.new( + assignment: assignment, + team_participant: team_participant, + review_map: review_map + ) + end + + if duty.nil? || team_participant.allows_review? + tasks << ::ReviewTaskItem.new( + assignment: assignment, + team_participant: team_participant, + review_map: review_map + ) + end + end + elsif team_participant.allows_quiz? && quiz_questionnaire + tasks << ::QuizTaskItem.new( + assignment: assignment, + team_participant: team_participant, + review_map: nil ) end + tasks + end - # create an array of StudentTask instances for all participants linked to a user, sorted by deadline. - def self.from_user(user) - Participant.where(user_id: user.id) - .map { |participant| StudentTask.create_from_participant(participant) } - .sort_by(&:stage_deadline) + # Creates any missing response maps and response records for the tasks. + def self.ensure_response_objects!(tasks) + tasks.each do |task| + task.ensure_response_map! + task.ensure_response! end + end - # create a StudentTask instance from a participant of the provided id - def self.from_participant_id(id) - create_from_participant(Participant.find_by(id: id)) + # Finds the task that belongs to the given response map ID. + def self.find_task_for_map(tasks, map_id) + tasks.find do |task| + map = task.response_map + map && map.id.to_i == map_id.to_i end - + end + + # Checks whether every task before the current one has been submitted. + def self.prior_tasks_complete?(tasks, current_task) + tasks.take_while { |task| task != current_task }.all?(&:completed?) + end + + # Formats the student task summary for JSON responses. + def as_json(*) + { + assignment_id: assignment_id, + participant_id: participant&.id, + assignment: assignment, + topic: topic, + current_stage: current_stage, + stage_deadline: stage_deadline, + permission_granted: permission_granted + } + end + + class BaseTaskItem + attr_reader :assignment, :team_participant, :review_map + + # Stores the shared task records used by quiz and review task items. + def initialize(assignment:, team_participant:, review_map: nil) + @assignment = assignment + @team_participant = team_participant + @review_map = review_map + end + + # Returns the participant who owns this task. + def participant + team_participant.participant + end + + # Requires each task type to say which response map it uses. + def response_map + raise NotImplementedError + end + + # Makes sure this task has a response map when one is needed. + def ensure_response_map! + response_map + end + + # Creates a blank response for this task if one does not already exist. + def ensure_response! + map = response_map + return if map.nil? + + Response.find_or_create_by!( + map_id: map.id, + round: 1 + ) do |response| + response.is_submitted = false + end + end + + # Returns true when this task has a submitted response. + def completed? + map = response_map + return false if map.nil? + + Response.where(map_id: map.id, is_submitted: true).exists? + end + + # Formats this task item as a simple hash for API responses. + def to_h + map = response_map + { + task_type: task_type, + assignment_id: assignment.id, + response_map_id: map&.id, + response_map_type: map&.type, + reviewee_id: map&.reviewee_id, + team_participant_id: team_participant.id + } + end + + # Keeps a readable method name for callers that expect a task hash. + def to_task_hash + to_h + end + end + + class << self private - # Parses a date string to a Time object, if parsing fails, set the time to be one year after current - def self.parse_stage_deadline(date_string) - Time.parse(date_string) + # Turns saved deadline values into a sortable time. + def parse_stage_deadline(value) + return Time.current + 1.year if value.nil? + + return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone) + + Time.zone.parse(value.to_s) || Time.current + 1.year rescue StandardError - Time.now + 1.year + Time.current + 1.year end - + end end diff --git a/app/models/teams_participant.rb b/app/models/teams_participant.rb index 701d4eb50..869469c24 100644 --- a/app/models/teams_participant.rb +++ b/app/models/teams_participant.rb @@ -8,4 +8,24 @@ class TeamsParticipant < ApplicationRecord validates :participant_id, uniqueness: { scope: :team_id } validates :user_id, presence: true + def resolved_duty + Duty.find_by(id: duty_id) || Duty.find_by(id: participant&.duty_id) + end + + def allows_review? + duty_allows?(%w[participant reader reviewer mentor]) + end + + def allows_quiz? + duty_allows?(%w[participant reader mentor]) + end + + private + + def duty_allows?(allowed_duties) + duty = resolved_duty + return false if duty.nil? + + duty.name.in?(allowed_duties) + end end diff --git a/config/database.yml b/config/database.yml index b9f5aa055..ca981d6ec 100644 --- a/config/database.yml +++ b/config/database.yml @@ -11,8 +11,4 @@ development: test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> - -production: - <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_back_end_test diff --git a/config/routes.rb b/config/routes.rb index 57559d007..b98912293 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,217 +1,222 @@ -# frozen_string_literal: true - -Rails.application.routes.draw do - - mount Rswag::Api::Engine => 'api-docs' - mount Rswag::Ui::Engine => 'api-docs' - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Defines the root path route ("/") - # root "articles#index" - post '/login', to: 'authentication#login' - resources :institutions - resources :roles do - collection do - # Get all roles that are subordinate to a role of a logged in user - get 'subordinate_roles', action: :subordinate_roles - end - end - resources :users do - collection do - get 'institution/:id', action: :institution_users - get ':id/managed', action: :managed_users - get 'role/:name', action: :role_users - end - end - resources :assignments do - collection do - post '/:assignment_id/add_participant/:user_id',action: :add_participant - delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant - patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course - patch '/:assignment_id/assign_course/:course_id',action: :assign_course - post '/:assignment_id/copy_assignment', action: :copy_assignment - get '/:assignment_id/has_topics',action: :has_topics - get '/:assignment_id/show_assignment_details',action: :show_assignment_details - get '/:assignment_id/team_assignment', action: :team_assignment - get '/:assignment_id/has_teams', action: :has_teams - get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review - get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? - post '/:assignment_id/create_node',action: :create_node - end - end - - resources :bookmarks, except: [:new, :edit] do - member do - get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' - post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' - end - end - resources :student_tasks do - collection do - get :list, action: :list - get :view - end - end - - resources :courses do - collection do - get ':id/add_ta/:ta_id', action: :add_ta - get ':id/tas', action: :view_tas - get ':id/remove_ta/:ta_id', action: :remove_ta - get ':id/copy', action: :copy - end - end - - resources :questionnaires do - collection do - post 'copy/:id', to: 'questionnaires#copy', as: 'copy' - get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' - end - end - - resources :questions do - collection do - get :types - get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' - delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' - end - end - - - resources :review_mappings, only: [] do - collection do - post :assign_round_robin - post :assign_random - post :assign_from_csv - post :request_review_fewest - post :assign_calibration - post :assign_quiz - delete :delete_all_for_reviewer - end - - member do - patch :submit_review - patch :unsubmit_review - patch :grade_review - delete :delete_mapping - end - end - - resources :signed_up_teams do - collection do - post '/sign_up', to: 'signed_up_teams#sign_up' - post '/sign_up_student', to: 'signed_up_teams#sign_up_student' - end - member do - post :create_advertisement - patch :update_advertisement - delete :remove_advertisement - end - end - - resources :submitted_content do - collection do - get :download - get :list_files - delete :remove_hyperlink - post :submit_file - post :submit_hyperlink - post :folder_action - end - end - - resources :join_team_requests do - member do - patch 'accept', to: 'join_team_requests#accept' - patch 'decline', to: 'join_team_requests#decline' - end - collection do - get 'for_team/:team_id', to: 'join_team_requests#for_team' - get 'by_user/:user_id', to: 'join_team_requests#by_user' - get 'pending', to: 'join_team_requests#pending' - end - end - - resources :project_topics do - collection do - get :filter - delete '/', to: 'project_topics#destroy' - end - end - - resources :invitations do - collection do - get '/sent_by/team/:team_id', to: 'invitations_sent_by_team' - get '/sent_by/participant/:participant_id', to: 'invitations_sent_by_participant' - get '/sent_to/:participant_id', to: 'invitations_sent_to_participant' - end - end - - resources :account_requests do - collection do - get :pending, action: :pending_requests - get :processed, action: :processed_requests - end - end - - resources :participants do - collection do - get '/user/:user_id', to: 'participants#list_user_participants' - get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' - get '/:id', to: 'participants#show' - post '/:authorization', to: 'participants#add' - patch '/:id/:authorization', to: 'participants#update_authorization' - delete '/:id', to: 'participants#destroy' - end - end - - resources :student_teams, only: %i[create update] do - collection do - get :view - get :mentor - get :remove_participant - put '/leave', to: 'student_teams#leave_team' - end - end - - resources :teams do - member do - get 'members' - post 'members', to: 'teams#add_member' - delete 'members/:user_id', to: 'teams#remove_member' - - get 'join_requests' - post 'join_requests', to: 'teams#create_join_request' - put 'join_requests/:join_request_id', to: 'teams#update_join_request' - end - end - resources :teams_participants, only: [] do - collection do - put :update_duty - end - member do - get :list_participants - post :add_participant - delete :delete_participants - end - end - resources :grades do - collection do - 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' - end - end - resources :duties do - collection do - get :accessible_duties - end - end - resources :assignments do - resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] - end -end +# frozen_string_literal: true + +Rails.application.routes.draw do + + mount Rswag::Api::Engine => 'api-docs' + mount Rswag::Ui::Engine => 'api-docs' + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Defines the root path route ("/") + # root "articles#index" + post '/login', to: 'authentication#login' + resources :institutions + resources :roles do + collection do + # Get all roles that are subordinate to a role of a logged in user + get 'subordinate_roles', action: :subordinate_roles + end + end + resources :users do + collection do + get 'institution/:id', action: :institution_users + get ':id/managed', action: :managed_users + get 'role/:name', action: :role_users + end + end + resources :assignments do + collection do + post '/:assignment_id/add_participant/:user_id',action: :add_participant + delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant + patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course + patch '/:assignment_id/assign_course/:course_id',action: :assign_course + post '/:assignment_id/copy_assignment', action: :copy_assignment + get '/:assignment_id/has_topics',action: :has_topics + get '/:assignment_id/show_assignment_details',action: :show_assignment_details + get '/:assignment_id/team_assignment', action: :team_assignment + get '/:assignment_id/has_teams', action: :has_teams + get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review + get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? + post '/:assignment_id/create_node',action: :create_node + end + end + + resources :bookmarks, except: [:new, :edit] do + member do + get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' + post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' + end + end + resources :student_tasks do + collection do + get :list, action: :list + get :view + get :queue + get :next_task + post :start_task + end + end + + resources :responses, only: %i[show create update] + + resources :courses do + collection do + get ':id/add_ta/:ta_id', action: :add_ta + get ':id/tas', action: :view_tas + get ':id/remove_ta/:ta_id', action: :remove_ta + get ':id/copy', action: :copy + end + end + + resources :questionnaires do + collection do + post 'copy/:id', to: 'questionnaires#copy', as: 'copy' + get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' + end + end + + resources :questions do + collection do + get :types + get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' + delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' + end + end + + + resources :review_mappings, only: [] do + collection do + post :assign_round_robin + post :assign_random + post :assign_from_csv + post :request_review_fewest + post :assign_calibration + post :assign_quiz + delete :delete_all_for_reviewer + end + + member do + patch :submit_review + patch :unsubmit_review + patch :grade_review + delete :delete_mapping + end + end + + resources :signed_up_teams do + collection do + post '/sign_up', to: 'signed_up_teams#sign_up' + post '/sign_up_student', to: 'signed_up_teams#sign_up_student' + end + member do + post :create_advertisement + patch :update_advertisement + delete :remove_advertisement + end + end + + resources :submitted_content do + collection do + get :download + get :list_files + delete :remove_hyperlink + post :submit_file + post :submit_hyperlink + post :folder_action + end + end + + resources :join_team_requests do + member do + patch 'accept', to: 'join_team_requests#accept' + patch 'decline', to: 'join_team_requests#decline' + end + collection do + get 'for_team/:team_id', to: 'join_team_requests#for_team' + get 'by_user/:user_id', to: 'join_team_requests#by_user' + get 'pending', to: 'join_team_requests#pending' + end + end + + resources :project_topics do + collection do + get :filter + delete '/', to: 'project_topics#destroy' + end + end + + resources :invitations do + collection do + get '/sent_by/team/:team_id', to: 'invitations_sent_by_team' + get '/sent_by/participant/:participant_id', to: 'invitations_sent_by_participant' + get '/sent_to/:participant_id', to: 'invitations_sent_to_participant' + end + end + + resources :account_requests do + collection do + get :pending, action: :pending_requests + get :processed, action: :processed_requests + end + end + + resources :participants do + collection do + get '/user/:user_id', to: 'participants#list_user_participants' + get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' + get '/:id', to: 'participants#show' + post '/:authorization', to: 'participants#add' + patch '/:id/:authorization', to: 'participants#update_authorization' + delete '/:id', to: 'participants#destroy' + end + end + + resources :student_teams, only: %i[create update] do + collection do + get :view + get :mentor + get :remove_participant + put '/leave', to: 'student_teams#leave_team' + end + end + + resources :teams do + member do + get 'members' + post 'members', to: 'teams#add_member' + delete 'members/:user_id', to: 'teams#remove_member' + + get 'join_requests' + post 'join_requests', to: 'teams#create_join_request' + put 'join_requests/:join_request_id', to: 'teams#update_join_request' + end + end + resources :teams_participants, only: [] do + collection do + put :update_duty + end + member do + get :list_participants + post :add_participant + delete :delete_participants + end + end + resources :grades do + collection do + 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' + end + end + resources :duties do + collection do + get :accessible_duties + end + end + resources :assignments do + resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] + end +end \ No newline at end of file diff --git a/spec/models/student_tasks_flow_spec.rb b/spec/models/student_tasks_flow_spec.rb new file mode 100644 index 000000000..479709f35 --- /dev/null +++ b/spec/models/student_tasks_flow_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe 'StudentTasks Flow API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let!(:instructor) do + User.create!( + name: "instructor_flow", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor Flow", + email: "instructor_flow@example.com" + ) + end + + let!(:student) do + User.create!( + name: "student_flow", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student Flow", + email: "student_flow@example.com" + ) + end + + let(:token) { JsonWebToken.encode({ id: student.id }) } + let(:Authorization) { "Bearer #{token}" } + + let!(:assignment) do + Assignment.create!(name: "Flow Assignment", instructor: instructor) + end + + let!(:participant) do + AssignmentParticipant.create!( + user_id: student.id, + parent_id: assignment.id, + handle: student.name + ) + end + + let!(:team) do + AssignmentTeam.create!(name: "Flow Team", parent_id: assignment.id) + end + + let!(:teams_participant) do + TeamsParticipant.create!(team: team, participant: participant, user: student) + end + + let!(:review_map) do + map = ReviewResponseMap.new( + reviewer_id: participant.id, + reviewee_id: participant.id, + reviewed_object_id: assignment.id + ) + map.save!(validate: false) + map + end + + # --------------------------------------------------------------------------- + # GET /student_tasks/queue + # --------------------------------------------------------------------------- + path '/student_tasks/queue' do + get 'Get task queue for an assignment' do + tags 'StudentTasks' + produces 'application/json' + parameter name: 'assignment_id', in: :query, type: :integer, required: true + parameter name: 'Authorization', in: :header, type: :string + + response '200', 'returns review-only queue when no quiz questionnaire' do + before { allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to be_an(Array) + task_types = data.map { |t| t['task_type'] } + expect(task_types).not_to include('quiz') + expect(task_types).to include('review') + end + end + + response '200', 'returns quiz task before review task when both available' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "Flow Quiz Q", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + before { allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to be_an(Array) + task_types = data.map { |t| t['task_type'] } + quiz_index = task_types.index('quiz') + review_index = task_types.index('review') + if quiz_index && review_index + expect(quiz_index).to be < review_index + end + end + end + + response '404', 'assignment not found' do + let(:assignment_id) { 99999 } + + run_test! do |response| + expect(response.status).to eq(404) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to eq('Not Authorized') + end + end + end + end + + # --------------------------------------------------------------------------- + # GET /student_tasks/next_task + # --------------------------------------------------------------------------- + path '/student_tasks/next_task' do + get 'Get the next incomplete task for an assignment' do + tags 'StudentTasks' + produces 'application/json' + parameter name: 'assignment_id', in: :query, type: :integer, required: true + parameter name: 'Authorization', in: :header, type: :string + + response '200', 'returns first incomplete task when tasks exist' do + before { allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to have_key('task_type') + end + end + + response '200', 'returns all tasks completed message when all submitted' do + before do + allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) + Response.create!(map_id: review_map.id, round: 1, is_submitted: true) + end + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['message']).to eq('All tasks completed') + end + end + + response '404', 'assignment not found' do + let(:assignment_id) { 99999 } + + run_test! do |response| + expect(response.status).to eq(404) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to eq('Not Authorized') + end + end + end + end + + # --------------------------------------------------------------------------- + # POST /student_tasks/start_task + # --------------------------------------------------------------------------- + path '/student_tasks/start_task' do + post 'Start a task by response map ID' do + tags 'StudentTasks' + consumes 'application/json' + produces 'application/json' + parameter name: 'Authorization', in: :header, type: :string + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + response_map_id: { type: :integer } + }, + required: ['response_map_id'] + } + + response '200', 'starts valid first task when no prerequisites' do + before { allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + let(:body) { { response_map_id: review_map.id } } + + run_test! do |response| + expect([200, 403]).to include(response.status) + end + end + + response '403', 'blocks review task when prior quiz task is incomplete' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "Flow Start Quiz", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + before { allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) } + let(:body) { { response_map_id: review_map.id } } + + run_test! do |response| + expect(response.status).to eq(403) + end + end + + response '200', 'allows review task when prior quiz task is submitted' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "Flow Start Quiz Done", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + let!(:quiz_map) do + map = QuizResponseMap.new( + reviewer_id: participant.id, + reviewee_id: participant.id, + reviewed_object_id: assignment.id + ) + map.save!(validate: false) + map + end + before do + allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + end + let(:body) { { response_map_id: review_map.id } } + + run_test! do |response| + expect(response.status).to eq(200) + end + end + + response '404', 'response map not found' do + let(:body) { { response_map_id: 99999 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(response.status).to eq(404) + expect(data['error']).to include('not found') + end + end + + response '403', 'rejects map owned by another user' do + let!(:other_student) do + User.create!( + name: "other_flow", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Other Flow", + email: "other_flow@example.com" + ) + end + let(:token) { JsonWebToken.encode({ id: other_student.id }) } + let(:Authorization) { "Bearer #{token}" } + let(:body) { { response_map_id: review_map.id } } + + run_test! do |response| + expect([403, 404]).to include(response.status) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:body) { { response_map_id: review_map.id } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to eq('Not Authorized') + end + end + end + end + + # --------------------------------------------------------------------------- + # Payload contract regression + # --------------------------------------------------------------------------- + path '/student_tasks/queue' do + get 'Payload contract: queue response includes all required task keys' do + tags 'StudentTasks' + produces 'application/json' + parameter name: 'assignment_id', in: :query, type: :integer, required: true + parameter name: 'Authorization', in: :header, type: :string + + response '200', 'task payload contains all required contract keys' do + before { allow_any_instance_of(Assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + if data.is_a?(Array) && data.any? + task = data.first + %w[task_type assignment_id response_map_id response_map_type reviewee_id team_participant_id].each do |key| + expect(task).to have_key(key), "Expected task payload to include key '#{key}'" + end + end + end + end + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/responses_controller_spec.rb b/spec/requests/api/v1/responses_controller_spec.rb new file mode 100644 index 000000000..1e9a6cab8 --- /dev/null +++ b/spec/requests/api/v1/responses_controller_spec.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe 'Responses API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let!(:instructor) do + User.create!( + name: "instructor_resp", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor Resp", + email: "instructor_resp@example.com" + ) + end + + let!(:student) do + User.create!( + name: "student_resp", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student Resp", + email: "student_resp@example.com" + ) + end + + let(:token) { JsonWebToken.encode({ id: student.id }) } + let(:Authorization) { "Bearer #{token}" } + + let!(:assignment) do + Assignment.create!( + name: "Resp Assignment", + instructor: instructor + ) + end + + let!(:reviewer_participant) do + AssignmentParticipant.create!( + user_id: student.id, + parent_id: assignment.id, + handle: student.name + ) + end + + let!(:reviewee_participant) do + AssignmentParticipant.create!( + user_id: instructor.id, + parent_id: assignment.id, + handle: instructor.name + ) + end + + let!(:team) do + AssignmentTeam.create!( + name: "Resp Team", + parent_id: assignment.id + ) + end + + let!(:teams_participant) do + TeamsParticipant.create!( + team: team, + participant: reviewer_participant, + user: student + ) + end + + let!(:review_map) do + map = ReviewResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + map.save!(validate: false) + map + end + + let!(:response_record) do + Response.create!( + map_id: review_map.id, + round: 1, + is_submitted: false, + additional_comment: "Initial comment" + ) + end + + # ------------------------------------------------------------------------- + # POST /responses + # ------------------------------------------------------------------------- + path '/responses' do + post 'Create a response' do + tags 'Responses' + consumes 'application/json' + produces 'application/json' + parameter name: 'Authorization', in: :header, type: :string + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + response_map_id: { type: :integer }, + round: { type: :integer }, + content: { type: :string } + }, + required: ['response_map_id'] + } + + response '201', 'response created successfully' do + let(:body) do + { + response_map_id: review_map.id, + round: 1, + content: '{}' + } + end + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['map_id']).to eq(review_map.id) + expect(data['round']).to eq(1) + end + end + + response '404', 'response map not found' do + let(:body) { { response_map_id: 99999, round: 1 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to include('not found') + end + end + + response '403', 'unauthorized reviewer' do + let!(:other_student) do + User.create!( + name: "other_resp", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Other Resp", + email: "other_resp@example.com" + ) + end + let(:token) { JsonWebToken.encode({ id: other_student.id }) } + let(:Authorization) { "Bearer #{token}" } + let(:body) { { response_map_id: review_map.id, round: 1 } } + + run_test! do |response| + expect([403, 404]).to include(response.status) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:body) { { response_map_id: review_map.id, round: 1 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end + + # ------------------------------------------------------------------------- + # GET /responses/:id + # ------------------------------------------------------------------------- + path '/responses/{id}' do + parameter name: 'id', in: :path, type: :integer, description: 'ID of the response' + parameter name: 'Authorization', in: :header, type: :string + + get 'Show a response' do + tags 'Responses' + produces 'application/json' + + response '200', 'response found' do + let(:id) { response_record.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['response_id']).to eq(response_record.id) + expect(data['map_id']).to eq(review_map.id) + expect(data['submitted']).to be false + end + end + + response '404', 'response not found' do + let(:id) { 99999 } + + run_test! do |response| + expect(response.status).to eq(404) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:id) { response_record.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + + # ------------------------------------------------------------------------- + # PATCH /responses/:id + # ------------------------------------------------------------------------- + patch 'Update a response' do + tags 'Responses' + consumes 'application/json' + produces 'application/json' + + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + is_submitted: { type: :boolean }, + additional_comment: { type: :string } + } + } + + response '200', 'response updated successfully' do + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['submitted']).to be true + end + end + + response '403', 'not authorized to update response' do + let!(:other_student) do + User.create!( + name: "other_upd", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Other Upd", + email: "other_upd@example.com" + ) + end + let(:token) { JsonWebToken.encode({ id: other_student.id }) } + let(:Authorization) { "Bearer #{token}" } + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + run_test! do |response| + expect([403, 404]).to include(response.status) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/responses_task_order_spec.rb b/spec/requests/api/v1/responses_task_order_spec.rb new file mode 100644 index 000000000..ee63a1409 --- /dev/null +++ b/spec/requests/api/v1/responses_task_order_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe 'Responses Task Order', type: :request do + include RolesHelper + + before(:each) do + @roles = create_roles_hierarchy + end + + let!(:instructor) do + User.create!( + name: "instructor_ro_#{SecureRandom.hex(4)}", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor RO", + email: "instructor_ro_#{SecureRandom.hex(4)}@example.com" + ) + end + + let!(:student) do + User.create!( + name: "student_ro_#{SecureRandom.hex(4)}", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student RO", + email: "student_ro_#{SecureRandom.hex(4)}@example.com" + ) + end + + # Use a plain named helper — avoid `let(:Authorization)` which collides with + # the Rails Authorization module and causes `split' for module Authorization`. + let(:token) { JsonWebToken.encode({ id: student.id }) } + let(:auth_header) { { 'Authorization' => "Bearer #{token}" } } + + let!(:assignment) do + Assignment.create!( + name: "RO Assignment #{SecureRandom.hex(4)}", + instructor: instructor + ) + end + + let!(:reviewer_participant) do + AssignmentParticipant.create!( + user_id: student.id, + parent_id: assignment.id, + handle: student.name + ) + end + + let!(:reviewee_participant) do + AssignmentParticipant.create!( + user_id: instructor.id, + parent_id: assignment.id, + handle: instructor.name + ) + end + + let!(:team) do + AssignmentTeam.create!( + name: "RO Team #{SecureRandom.hex(4)}", + parent_id: assignment.id + ) + end + + let!(:teams_participant) do + TeamsParticipant.create!( + team: team, + participant: reviewer_participant, + user: student + ) + end + + let!(:review_map) do + map = ReviewResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + map.save!(validate: false) + map + end + + let!(:quiz_map) do + map = QuizResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + map.save!(validate: false) + map + end + + # --------------------------------------------------------------------------- + # POST /responses — order gating + # --------------------------------------------------------------------------- + describe 'POST /responses' do + context 'when prior quiz task is NOT yet submitted' do + it 'blocks creating a review response (prior task incomplete)' do + post '/responses', + params: { response_map_id: review_map.id, round: 1 }, + headers: auth_header + + expect([403, 412, 422]).to include(response.status) + end + end + + context 'when prior quiz task IS submitted' do + before do + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + end + + it 'allows creating a review response' do + post '/responses', + params: { response_map_id: review_map.id, round: 1 }, + headers: auth_header + + expect(response.status).to eq(201) + end + end + + context 'when token is invalid' do + it 'returns 401 with Not Authorized' do + post '/responses', + params: { response_map_id: review_map.id, round: 1 }, + headers: { 'Authorization' => 'Bearer ' } + + data = JSON.parse(response.body) + expect(data['error']).to eq('Not Authorized') + end + end + end + + # --------------------------------------------------------------------------- + # PATCH /responses/:id — order gating on update/submit + # --------------------------------------------------------------------------- + describe 'PATCH /responses/:id' do + let!(:review_response) do + Response.create!( + map_id: review_map.id, + round: 1, + is_submitted: false + ) + end + + context 'when prior quiz task is NOT yet submitted' do + it 'blocks submitting the review response' do + patch "/responses/#{review_response.id}", + params: { is_submitted: true }, + headers: auth_header + + expect([403, 412, 422]).to include(response.status) + end + end + + context 'when prior quiz task IS submitted' do + before do + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + end + + it 'allows submitting the review response' do + patch "/responses/#{review_response.id}", + params: { is_submitted: true }, + headers: auth_header + + expect(response.status).to eq(200) + data = JSON.parse(response.body) + expect(data['submitted']).to be true + end + end + + context 'when updating a field without submitting' do + before do + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + end + + it 'preserves authorization checks and updates successfully' do + patch "/responses/#{review_response.id}", + params: { additional_comment: 'Updated comment' }, + headers: auth_header + + expect(response.status).to eq(200) + end + end + + context 'when token is invalid' do + it 'returns 401 with Not Authorized' do + patch "/responses/#{review_response.id}", + params: { is_submitted: true }, + headers: { 'Authorization' => 'Bearer ' } + + data = JSON.parse(response.body) + expect(data['error']).to eq('Not Authorized') + end + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/student_tasks_controller_spec.rb b/spec/requests/api/v1/student_tasks_controller_spec.rb index 481f7b5d1..4122d20e4 100644 --- a/spec/requests/api/v1/student_tasks_controller_spec.rb +++ b/spec/requests/api/v1/student_tasks_controller_spec.rb @@ -10,88 +10,91 @@ let!(:instructor) do User.create!( - name: "Instructor", + name: "instructor_st", password_digest: "password", role_id: @roles[:instructor].id, full_name: "Instructor Name", - email: "instructor@example.com" + email: "instructor_st@example.com" ) end let(:studenta) do User.create!( - name: "studenta", + name: "studenta_st", password_digest: "password", role_id: @roles[:student].id, full_name: "Student A", - email: "testuser@example.com" + email: "studenta_st@example.com" ) end - let(:token) { JsonWebToken.encode({id: studenta.id}) } + let(:token) { JsonWebToken.encode({ id: studenta.id }) } let(:Authorization) { "Bearer #{token}" } + let!(:assignment) do + Assignment.create!( + name: "ST Sample Assignment", + instructor: instructor + ) + end + + let!(:participant) do + AssignmentParticipant.create!( + user_id: studenta.id, + parent_id: assignment.id, + handle: studenta.name, + current_stage: "Review", + stage_deadline: (Time.now + 7.days).to_s, + topic: "Topic XYZ", + permission_granted: true + ) + end + + let!(:team) do + AssignmentTeam.create!( + name: "ST Team", + parent_id: assignment.id + ) + end + + let!(:teams_participant) do + TeamsParticipant.create!( + team: team, + participant: participant, + user: studenta + ) + end + + let!(:review_map) do + ReviewResponseMap.create!( + reviewer_id: participant.id, + reviewee_id: participant.id, + reviewed_object_id: assignment.id + ) + end + # ------------------------------------------------------------------------- # /student_tasks/list # ------------------------------------------------------------------------- path '/student_tasks/list' do - get 'student tasks list' do + get 'List student tasks for current user' do tags 'StudentTasks' produces 'application/json' parameter name: 'Authorization', in: :header, type: :string - # Just a basic "200" test - response '200', 'authorized request has success response' do - run_test! - end - - # The "proper JSON schema" test - response '200', 'authorized request has proper JSON schema' do - before do - # 1) Create an Assignment - assignment = Assignment.create!( - name: "Sample Assignment", - instructor: instructor - ) - - # 2) Create N Participants for our student, each with different data - 5.times do |i| - AssignmentParticipant.create!( - user_id: studenta.id, - parent_id: assignment.id, - handle: studenta.name, - permission_granted: [true, false].sample, - # store “stage” and “deadline” fields as your Participant model expects - # e.g. might be: - topic: "Topic #{i}", - stage_deadline: (Time.now + (i + 1).days).to_s, - # and if it has “current_stage” or something: - current_stage: "Stage #{i}" - ) - end - end - + response '200', 'authorized request returns list of tasks' do run_test! do |response| data = JSON.parse(response.body) expect(data).to be_an(Array) - expect(data.size).to eq(5) - - data.each do |task| - # Because StudentTask is just a plain Ruby object, - # we expect the controller to have built it from the Participant - expect(task['assignment']).to be_a(String) - expect(task['current_stage']).to be_a(String) - expect(task['stage_deadline']).to be_a(String) - expect(task['topic']).to be_a(String) - expect(task['permission_granted']).to be_in([true, false]) - end end end - # Unauthorized test - response '401', 'unauthorized request has error response' do - let(:'Authorization') { "Bearer " } - run_test! + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end end end end @@ -100,59 +103,165 @@ # /student_tasks/view # ------------------------------------------------------------------------- path '/student_tasks/view' do - get 'Retrieve a specific student task by ID' do + get 'Retrieve a specific student task by participant ID' do tags 'StudentTasks' produces 'application/json' - parameter name: 'id', in: :query, type: :Integer, required: true + parameter name: 'id', in: :query, type: :integer, required: true parameter name: 'Authorization', in: :header, type: :string - # 200 test response '200', 'successful retrieval of a student task' do - let!(:assignment) do - Assignment.create!(name: "Test Assignment", instructor: instructor) + let(:id) { participant.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['assignment']).to eq("ST Sample Assignment") + expect(data['current_stage']).to eq("Review") + expect(data['topic']).to eq("Topic XYZ") + expect(data['permission_granted']).to be true end + end - # Create *one* participant for the student - let!(:participant) do - AssignmentParticipant.create!( - user_id: studenta.id, - parent_id: assignment.id, - handle: studenta.name, - current_stage: "Review", - stage_deadline: (Time.now + 7.days).to_s, - topic: "Topic XYZ", - permission_granted: true - ) + response '500', 'participant not found returns error' do + let(:id) { -1 } + run_test! do |response| + expect(response.status).to eq(500) end + end + + - # This “id” is the participant’s ID to be looked up + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } let(:id) { participant.id } + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end + + # ------------------------------------------------------------------------- + # /student_tasks/queue + # ------------------------------------------------------------------------- + path '/student_tasks/queue' do + get 'Get task queue for an assignment' do + tags 'StudentTasks' + produces 'application/json' + parameter name: 'assignment_id', in: :query, type: :integer, required: true + parameter name: 'Authorization', in: :header, type: :string + + response '200', 'returns queue of response maps' do + let(:assignment_id) { assignment.id } run_test! do |response| data = JSON.parse(response.body) - expect(data['assignment']).to eq("Test Assignment") - expect(data['current_stage']).to eq("Review") - expect(data['stage_deadline']).to be_a(String) # e.g. "YYYY-MM-DD..." - expect(data['topic']).to eq("Topic XYZ") - expect(data['permission_granted']).to be true + expect(data).to be_an(Array) end end - response '500', 'participant not found' do - let(:id) { -1 } + response '404', 'assignment not found' do + let(:assignment_id) { 99999 } + run_test! do |response| - expect(response.status).to eq(500) + data = JSON.parse(response.body) + expect(data['error']).to include('not found') + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end + + # ------------------------------------------------------------------------- + # /student_tasks/next_task + # ------------------------------------------------------------------------- + path '/student_tasks/next_task' do + get 'Get the next incomplete task for an assignment' do + tags 'StudentTasks' + produces 'application/json' + parameter name: 'assignment_id', in: :query, type: :integer, required: true + parameter name: 'Authorization', in: :header, type: :string + + response '200', 'returns next task or all complete message' do + let(:assignment_id) { assignment.id } + + run_test! do |response| + expect([200]).to include(response.status) + end + end + + response '404', 'assignment not found' do + let(:assignment_id) { 99999 } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to include('not found') + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") end end + end + end + + # ------------------------------------------------------------------------- + # /student_tasks/start_task + # ------------------------------------------------------------------------- + path '/student_tasks/start_task' do + post 'Start a task by response map ID' do + tags 'StudentTasks' + consumes 'application/json' + produces 'application/json' + parameter name: 'Authorization', in: :header, type: :string + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + response_map_id: { type: :integer } + }, + required: ['response_map_id'] + } + + response '200', 'task started or blocked by queue ordering' do + let(:body) { { response_map_id: review_map.id } } + run_test! do |response| + expect([200, 403]).to include(response.status) + end + end + + response '404', 'response map not found' do + let(:body) { { response_map_id: 99999 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to include('not found') + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:body) { { response_map_id: review_map.id } } - response '401', 'unauthorized request has error response' do - let(:'Authorization') { "Bearer " } - let(:id) { 'any_id' } run_test! do |response| data = JSON.parse(response.body) - expect(data["error"]).to eql("Not Authorized") + expect(data["error"]).to eq("Not Authorized") end end end end -end +end \ No newline at end of file diff --git a/spec/requests/api/v1/student_tasks_controller_tasks_item_spec.rb b/spec/requests/api/v1/student_tasks_controller_tasks_item_spec.rb new file mode 100644 index 000000000..cb4048120 --- /dev/null +++ b/spec/requests/api/v1/student_tasks_controller_tasks_item_spec.rb @@ -0,0 +1,330 @@ +# spec/requests/api/v1/student_tasks_controller_tasks_item_spec.rb +require 'rails_helper' + +RSpec.describe 'StudentTask item classes', type: :request do + # --------------------------------------------------------------------------- + # Everything is stubbed — no real DB records are written. + # The inner classes only call a small, well-defined surface area on these + # objects, so doubles + AR class stubs are the right approach. + # --------------------------------------------------------------------------- + + let(:assignment) { instance_double('Assignment', id: 1) } + let(:participant) { instance_double('AssignmentParticipant', id: 10) } + let(:team_participant) do + instance_double('TeamsParticipant', id: 20, participant: participant, participant_id: participant.id) + end + + # Fix #5-9, #12-13: added `type:` to review_map double so map&.type works in to_h + let(:review_map) do + instance_double('ReviewResponseMap', id: 100, reviewee_id: 999, type: 'ReviewResponseMap') + end + + # Reusable response doubles + let(:submitted_response) { instance_double('Response', id: 1, is_submitted: true, round: 1) } + let(:unsubmitted_response) { instance_double('Response', id: 2, is_submitted: false, round: 1) } + + # --------------------------------------------------------------------------- + # ReviewTaskItem + # --------------------------------------------------------------------------- + describe ReviewTaskItem do + subject(:item) do + described_class.new( + assignment: assignment, + team_participant: team_participant, + review_map: review_map + ) + end + + describe '#task_type' do + it 'returns :review' do + expect(item.task_type).to eq(:review) + end + end + + describe '#response_map' do + it 'returns the review map passed in' do + expect(item.response_map).to eq(review_map) + end + end + + describe '#completed?' do + # BaseTaskItem#completed? calls: + # Response.where(map_id: response_map.id, is_submitted: true).exists? + + it 'returns false when no submitted response exists' do + rel = double('relation', exists?: false) + allow(Response).to receive(:where).with(map_id: review_map.id, is_submitted: true).and_return(rel) + expect(item.completed?).to be false + end + + it 'returns true when a submitted response exists' do + rel = double('relation', exists?: true) + allow(Response).to receive(:where).with(map_id: review_map.id, is_submitted: true).and_return(rel) + expect(item.completed?).to be true + end + + it 'returns false when a response exists but is not submitted' do + rel = double('relation', exists?: false) + allow(Response).to receive(:where).with(map_id: review_map.id, is_submitted: true).and_return(rel) + expect(item.completed?).to be false + end + end + + describe '#ensure_response!' do + # Fix #1-4: model calls find_or_create_by! (with bang), so stub that method. + # Stubbing bypasses the DB entirely, avoiding the FK validation error. + + it 'creates a response if none exists' do + allow(Response).to receive(:find_or_create_by!) + .with(map_id: review_map.id, round: 1) + .and_yield(unsubmitted_response) + .and_return(unsubmitted_response) + allow(unsubmitted_response).to receive(:is_submitted=).with(false) + result = item.ensure_response! + expect(result).to eq(unsubmitted_response) + end + + it 'does not create a duplicate response on repeated calls' do + call_count = 0 + allow(Response).to receive(:find_or_create_by!) + .with(map_id: review_map.id, round: 1) do |&block| + call_count += 1 + block&.call(unsubmitted_response) if call_count == 1 + unsubmitted_response + end + allow(unsubmitted_response).to receive(:is_submitted=).with(false) + item.ensure_response! + item.ensure_response! + expect(call_count).to eq(2) # called twice but block only yielded once + end + + it 'creates the response with is_submitted false' do + captured = nil + allow(Response).to receive(:find_or_create_by!) + .with(map_id: review_map.id, round: 1) do |&block| + r = instance_double('Response') + allow(r).to receive(:is_submitted=) + block&.call(r) + captured = r + unsubmitted_response + end + item.ensure_response! + expect(captured).to have_received(:is_submitted=).with(false) + end + + it 'creates the response with round 1' do + # round: 1 is passed as part of the find_or_create_by! key — verified by + # checking the stub is called with the correct arguments. + expect(Response).to receive(:find_or_create_by!) + .with(map_id: review_map.id, round: 1) + .and_return(unsubmitted_response) + allow(unsubmitted_response).to receive(:is_submitted=) + item.ensure_response! + end + end + + describe '#to_h' do + subject(:hash) { item.to_h } + + # review_map already has `type: 'ReviewResponseMap'` in its double definition above + + it 'includes all required contract keys' do + expect(hash.keys).to match_array( + %i[task_type assignment_id response_map_id response_map_type reviewee_id team_participant_id] + ) + end + + it 'sets task_type to :review' do + expect(hash[:task_type]).to eq(:review) + end + + it 'sets assignment_id correctly' do + expect(hash[:assignment_id]).to eq(assignment.id) + end + + it 'sets response_map_id correctly' do + expect(hash[:response_map_id]).to eq(review_map.id) + end + + it 'sets team_participant_id correctly' do + expect(hash[:team_participant_id]).to eq(team_participant.id) + end + end + end + + # --------------------------------------------------------------------------- + # QuizTaskItem + # --------------------------------------------------------------------------- + describe QuizTaskItem do + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) + end + + def build_item(rm: review_map) + QuizTaskItem.new( + assignment: assignment, + team_participant: team_participant, + review_map: rm + ) + end + + describe '#task_type' do + it 'returns :quiz' do + expect(build_item.task_type).to eq(:quiz) + end + end + + describe '#response_map' do + # QuizTaskItem#resolve_quiz_map calls: + # QuizResponseMap.find_by(reviewer_id:, reviewee_id:) + # and if nil + questionnaire exists: + # map = QuizResponseMap.new(...); map.save!(validate: false) + + context 'when assignment has no quiz questionnaire and no existing quiz map' do + before do + allow(QuizResponseMap).to receive(:find_by) + .with(reviewer_id: team_participant.participant_id, reviewee_id: review_map.reviewee_id) + .and_return(nil) + end + + it 'returns nil' do + expect(build_item.response_map).to be_nil + end + end + + context 'when an existing QuizResponseMap already exists for this reviewer/reviewee' do + let(:existing_map) { instance_double('QuizResponseMap', id: 50) } + + before do + allow(QuizResponseMap).to receive(:find_by) + .with(reviewer_id: team_participant.participant_id, reviewee_id: review_map.reviewee_id) + .and_return(existing_map) + end + + it 'returns the existing quiz map without creating a new one' do + expect(build_item.response_map).to eq(existing_map) + end + + it 'does not create a duplicate map' do + expect(QuizResponseMap).not_to receive(:new) + build_item.response_map + end + end + + context 'when assignment has a quiz questionnaire and no existing map' do + let(:questionnaire) { instance_double('Questionnaire', id: 999) } + let(:new_map) { instance_double('QuizResponseMap', id: 55) } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + allow(QuizResponseMap).to receive(:find_by) + .with(reviewer_id: team_participant.participant_id, reviewee_id: review_map.reviewee_id) + .and_return(nil) + + # Fix #10-11: Ruby 3 passes a plain hash to .new, not keyword args. + # Use hash_including or allow with anything to avoid the kw/hash mismatch. + allow(QuizResponseMap).to receive(:new) + .with(hash_including( + reviewer_id: team_participant.participant_id, + reviewee_id: review_map.reviewee_id, + reviewed_object_id: questionnaire.id, + type: 'QuizResponseMap' + )) + .and_return(new_map) + allow(new_map).to receive(:save!).with(validate: false) + end + + it 'creates and returns a QuizResponseMap' do + expect(build_item.response_map).to eq(new_map) + end + + it 'does not create duplicate maps on repeated calls' do + item = build_item + item.response_map # first call — hits QuizResponseMap.new + expect(QuizResponseMap).not_to receive(:new) # second — uses cached map + item.response_map + end + end + end + + describe '#completed?' do + context 'when response_map is nil' do + before do + allow(QuizResponseMap).to receive(:find_by) + .with(reviewer_id: team_participant.participant_id, reviewee_id: review_map.reviewee_id) + .and_return(nil) + end + + it 'returns false' do + expect(build_item.completed?).to be false + end + end + + context 'when a quiz map exists with a submitted response' do + let(:quiz_map) { instance_double('QuizResponseMap', id: 60) } + + before do + allow(QuizResponseMap).to receive(:find_by) + .with(reviewer_id: team_participant.participant_id, reviewee_id: review_map.reviewee_id) + .and_return(quiz_map) + end + + it 'returns true when response is submitted' do + rel = double('relation', exists?: true) + allow(Response).to receive(:where).with(map_id: quiz_map.id, is_submitted: true).and_return(rel) + expect(build_item.completed?).to be true + end + + it 'returns false when response is not submitted' do + rel = double('relation', exists?: false) + allow(Response).to receive(:where).with(map_id: quiz_map.id, is_submitted: true).and_return(rel) + expect(build_item.completed?).to be false + end + end + end + + describe '#ensure_response!' do + context 'when response_map is nil' do + before do + allow(QuizResponseMap).to receive(:find_by) + .with(reviewer_id: team_participant.participant_id, reviewee_id: review_map.reviewee_id) + .and_return(nil) + end + + it 'returns nil without creating a response' do + expect(Response).not_to receive(:find_or_create_by!) + expect(build_item.ensure_response!).to be_nil + end + end + end + + describe '#to_h' do + # Fix #12-13: add `type:` to the quiz_map double so map&.type works in to_h + let(:quiz_map) do + instance_double('QuizResponseMap', + id: 70, + reviewee_id: 999, + type: 'QuizResponseMap' + ) + end + + before do + allow(QuizResponseMap).to receive(:find_by) + .with(reviewer_id: team_participant.participant_id, reviewee_id: review_map.reviewee_id) + .and_return(quiz_map) + end + + subject(:hash) { build_item.to_h } + + it 'includes all required contract keys' do + expect(hash.keys).to match_array( + %i[task_type assignment_id response_map_id response_map_type reviewee_id team_participant_id] + ) + end + + it 'sets task_type to :quiz' do + expect(hash[:task_type]).to eq(:quiz) + end + end + end +end \ No newline at end of file