diff --git a/app/controllers/api/v1/assignments_controller.rb b/app/controllers/api/v1/assignments_controller.rb index e28ad573f..98c8eea3a 100644 --- a/app/controllers/api/v1/assignments_controller.rb +++ b/app/controllers/api/v1/assignments_controller.rb @@ -45,7 +45,7 @@ def destroy render json: { error: "Assignment not found" }, status: :not_found end end - + #add participant to assignment def add_participant assignment = Assignment.find_by(id: params[:assignment_id]) @@ -92,7 +92,7 @@ def remove_assignment_from_course render json: assignment.errors, status: :unprocessable_entity end end - + end #update course id of an assignment/ assign the assign to some together course @@ -146,7 +146,7 @@ def show_assignment_details end # check if assignment has topics - # has_topics is set to true if there is SignUpTopic corresponding to the input assignment id + # has_topics is set to true if there is SignUpTopic corresponding to the input assignment id def has_topics assignment = Assignment.find_by(id: params[:assignment_id]) if assignment.nil? @@ -156,7 +156,7 @@ def has_topics end end - # check if assignment is a team assignment + # check if assignment is a team assignment # true if assignment's max team size is greater than 1 def team_assignment assignment = Assignment.find_by(id: params[:assignment_id]) @@ -204,11 +204,12 @@ def varying_rubrics_by_round? end end end - + private # Only allow a list of trusted parameters through. def assignment_params - params.require(:assignment).permit(:title, :description) + params.require(:assignment).permit(:name, :directory_path, :course_id, :instructor_id, + :require_quiz, :num_quiz_questions, :description) end # Helper method to determine staggered_and_no_topic for the assignment diff --git a/app/controllers/api/v1/participants_controller.rb b/app/controllers/api/v1/participants_controller.rb new file mode 100644 index 000000000..8d84ed189 --- /dev/null +++ b/app/controllers/api/v1/participants_controller.rb @@ -0,0 +1,35 @@ +class Api::V1::ParticipantsController < ApplicationController + before_action :find_user, only: :create + before_action :find_assignment, only: :create + # POST /participants + def create + participant = Participant.new(participant_params) + + if participant.save + render json: participant, status: :created + else + render json: participant.errors, status: :unprocessable_entity + end + end + + private + + # to fetch user + def find_user + unless User.exists?(params[:participant][:user_id]) + render json: { error: 'User does not exist' }, status: :not_found and return + end + end + + #to find assignment in the db + def find_assignment + unless Assignment.exists?(params[:participant][:assignment_id]) + render json: { error: 'Assignment does not exist' }, status: :not_found and return + end + end + + #to check params of a participant + def participant_params + params.require(:participant).permit(:user_id, :assignment_id) + end +end diff --git a/app/controllers/api/v1/questionnaires_controller.rb b/app/controllers/api/v1/questionnaires_controller.rb index 9bc2cd876..cebd1f258 100644 --- a/app/controllers/api/v1/questionnaires_controller.rb +++ b/app/controllers/api/v1/questionnaires_controller.rb @@ -76,7 +76,7 @@ def toggle_access @questionnaire.toggle!(:private) @access = @questionnaire.private ? 'private' : 'public' render json: "The questionnaire \"#{@questionnaire.name}\" has been successfully made #{@access}. ", - status: :ok + status: :ok rescue ActiveRecord::RecordNotFound render json: $ERROR_INFO.to_s, status: :not_found rescue ActiveRecord::RecordInvalid @@ -87,10 +87,13 @@ def toggle_access private def questionnaire_params - params.require(:questionnaire).permit(:name, :questionnaire_type, :private, :min_question_score, :max_question_score, :instructor_id) + params.require(:questionnaire).permit(:name, :questionnaire_type, :private, :min_question_score, + :max_question_score, :instructor_id, :assignment_id) end def sanitize_display_type(type) + return nil if type.nil? # Return immediately if type is nil + display_type = type.split('Questionnaire')[0] if %w[AuthorFeedback CourseSurvey TeammateReview GlobalSurvey AssignmentSurvey BookmarkRating].include?(display_type) display_type = (display_type.split(/(?=[A-Z])/)).join('%') @@ -98,4 +101,5 @@ def sanitize_display_type(type) display_type end + end \ No newline at end of file diff --git a/app/controllers/api/v1/questions_controller.rb b/app/controllers/api/v1/questions_controller.rb index 10c26875d..bdc3a99e9 100644 --- a/app/controllers/api/v1/questions_controller.rb +++ b/app/controllers/api/v1/questions_controller.rb @@ -26,7 +26,8 @@ def create question = questionnaire.questions.build( txt: params[:txt], question_type: params[:question_type], - break_before: true + break_before: true, + skippable: params[:skippable] ) case question.question_type @@ -120,10 +121,9 @@ def types end private - # Only allow a list of trusted parameters through. def question_params params.permit(:txt, :weight, :seq, :questionnaire_id, :question_type, :size, - :alternatives, :break_before, :max_label, :min_label) + :alternatives, :break_before, :max_label, :min_label, :assignment_id, :correct_answer, :score_value) end end 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..5b98c6549 --- /dev/null +++ b/app/controllers/api/v1/student_quizzes_controller.rb @@ -0,0 +1,188 @@ +class Api::V1::StudentQuizzesController < ApplicationController + before_action :check_instructor_role, except: [:submit_quiz] + before_action :set_student_quiz, only: [:show, :update, :destroy] + + include ResourceFinder + + # Rescue from ActiveRecord::RecordInvalid exceptions and render an error response + rescue_from ActiveRecord::RecordInvalid do |exception| + render_error(exception.message) + end + + # GET /student_quizzes + # Fetch and render all quizzes + def index + quizzes = Questionnaire.all + render_success(quizzes) + end + + # GET /student_quizzes/:id + # Fetch and render a specific quiz by ID + def show + render_success(@student_quiz) + end + + # GET /student_quizzes/:id/get_score + # Calculate and render the score for a specific quiz attempt + def get_score + response_map = ResponseMap.find_by(id: params[:id]) + if response_map + render_success({ score: response_map.get_score }) + else + render_error('Attempt not found or you do not have permission to view this score.', :not_found) + end + end + + # POST /student_quizzes + # Create a new quiz with associated questions and answers + def create + questionnaire = ActiveRecord::Base.transaction do + questionnaire = create_questionnaire(questionnaire_params.except(:questions_attributes)) + create_questions_and_answers(questionnaire, questionnaire_params[:questions_attributes]) + questionnaire + end + render_success(questionnaire, :created) + rescue StandardError => e + render_error(e.message, :unprocessable_entity) + end + + # Helper method to create a questionnaire + # @param params [Hash] the parameters for creating a questionnaire + # @return [Questionnaire] the created questionnaire + def create_questionnaire(params) + Questionnaire.create!(params) + end + + # POST /student_quizzes/assign + # Assign a quiz to a student + def assign_quiz + participant = find_resource_by_id(Participant, params[:participant_id]) + questionnaire = find_resource_by_id(Questionnaire, params[:questionnaire_id]) + + # Stop execution if either the participant or questionnaire does not exist + return unless participant && questionnaire + + # Check if the quiz has already been assigned to this student + if quiz_already_assigned?(participant, questionnaire) + render_error("This student is already assigned to the quiz.", :unprocessable_entity) + return + end + + # Create a new response map to link the student and the quiz + response_map = ResponseMap.build_response_map(participant.user_id, questionnaire) + + # Attempt to save the response map and render appropriate success or error messages + if response_map.save + render_success(response_map, :created) + else + render_error(response_map.errors.full_messages.to_sentence, :unprocessable_entity) + end + end + + # POST /student_quizzes/submit_answers + # Submit answers for a quiz and calculate the total score + def submit_quiz + ActiveRecord::Base.transaction do + response_map = find_response_map_for_current_user + unless response_map + render_error("You are not assigned to take this quiz.", :forbidden) + return + end + + total_score = response_map.process_answers(params[:answers]) + response_map.update!(score: total_score) + response_map.update!(score: total_score) + render_success({ total_score: total_score }) + end + rescue ActiveRecord::RecordInvalid => e + render_error(e.message, :unprocessable_entity) + end + + # PUT /student_quizzes/:id + # Update a specific quiz by ID + def update + if @student_quiz.update(questionnaire_params) + render_success(@student_quiz) + else + render_error(@student_quiz.errors.full_messages.to_sentence, :unprocessable_entity) + end + end + + # DELETE /student_quizzes/:id + # Delete a specific quiz by ID + def destroy + @student_quiz.destroy + render json: { message: "Quiz with id #{params[:id]}, deleted" }, status: :ok + rescue ActiveRecord::RecordNotFound + render_error('Record does not exist', :not_found) + end + + private + + # Fetch and set the quiz from the database + # @return [void] + def set_student_quiz + @student_quiz = find_resource_by_id(Questionnaire, params[:id]) + end + + # Find the response map for the current user's attempt to submit quiz answers + # @return [ResponseMap, nil] the response map if found, otherwise nil + def find_response_map_for_current_user + ResponseMap.find_by(reviewee_id: current_user.id, reviewed_object_id: params[:questionnaire_id]) + end + + # Check if the quiz has already been assigned to the student + # @param participant [Participant] the participant + # @param questionnaire [Questionnaire] the questionnaire + # @return [Boolean] true if the quiz is already assigned, false otherwise + def quiz_already_assigned?(participant, questionnaire) + ResponseMap.exists?(reviewee_id: participant.user_id, reviewed_object_id: questionnaire.id) + end + + # Create questions and their respective answers for a questionnaire + # @param questionnaire [Questionnaire] the questionnaire + # @param questions_attributes [Array] the attributes for the questions + # @return [void] + def create_questions_and_answers(questionnaire, questions_attributes) + questions_attributes.each do |question_attr| + question = questionnaire.questions.create!(question_attr.except(:answers_attributes)) + question_attr[:answers_attributes]&.each do |answer_attr| + question.answers.create!(answer_attr) + end + end + end + + # Permit and require the necessary parameters for creating/updating a questionnaire + # @return [ActionController::Parameters] the permitted parameters + def questionnaire_params + params.require(:questionnaire).permit( + :name, :instructor_id, :min_question_score, :max_question_score, :assignment_id, + questions_attributes: [:id, :txt, :question_type, :break_before, :correct_answer, :score_value, + { answers_attributes: %i[id answer_text correct] }] + ) + end + + # Render a success response with optional custom status code + # @param data [Object] the data to render + # @param status [Symbol] the HTTP status code + # @return [void] + def render_success(data, status = :ok) + render json: data, status: status + end + + # Render an error response with message and status code + # @param message [String] the error message + # @param status [Symbol] the HTTP status code + # @return [void] + def render_error(message, status = :unprocessable_entity) + render json: { error: message }, status: status + end + + # Ensure only instructors can perform certain actions + # @return [void] + def check_instructor_role + unless current_user.role.instructor? + render_error('Only instructors are allowed to perform this action', :forbidden) + end + end +end \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4c8c36ece..eb9c0d64b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,18 @@ class ApplicationController < ActionController::API include JwtToken -end + + def find_resource_by_id(model, id) + model.find(id) + rescue ActiveRecord::RecordNotFound + render_error("#{model.name} not found", :not_found) + nil + end + + def render_success(data, status = :ok) + render json: data, status: status + end + + def render_error(message, status = :unprocessable_entity) + render json: { error: message }, status: status + end +end \ No newline at end of file diff --git a/app/controllers/concerns/resource_finder.rb b/app/controllers/concerns/resource_finder.rb new file mode 100644 index 000000000..073ce50b8 --- /dev/null +++ b/app/controllers/concerns/resource_finder.rb @@ -0,0 +1,17 @@ +module ResourceFinder + extend ActiveSupport::Concern + + included do + + # Find a resource by its ID and handle the case where it is not found + # @param resource [Class] the resource class to search + # @param id [Integer] the ID of the resource + # @return [Object, nil] the found resource or nil if not found + def find_resource_by_id(resource, id) + resource.find(id) + rescue ActiveRecord::RecordNotFound + render_error("#{resource.name} not found", :not_found) + nil + end + end +end \ No newline at end of file diff --git a/app/models/answer.rb b/app/models/answer.rb index 6b063ed8c..c2a182530 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,4 +1,5 @@ class Answer < ApplicationRecord - belongs_to :response belongs_to :question + validates :answer_text, presence: true + validates :correct, inclusion: {in: [true, false]} end diff --git a/app/models/question.rb b/app/models/question.rb index 4dc76ae10..4a2557f88 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -1,28 +1,39 @@ class Question < ApplicationRecord - before_create :set_seq + before_validation :set_seq, on: :create belongs_to :questionnaire # each question belongs to a specific questionnaire has_many :answers, dependent: :destroy - - validates :seq, presence: true, numericality: true # sequence must be numeric + accepts_nested_attributes_for :answers # Allows nested attributes for answers + 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 - validates :break_before, presence: true + validates :break_before, inclusion: { in: [true, false] } + validates :correct_answer, presence: true + validates :score_value, presence: true + validates :skippable, inclusion: { in: [true, false] } + def scorable? false end - - def set_seq - self.seq = questionnaire.questions.size + 1 - end def as_json(options = {}) - super(options.merge({ - only: %i[txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at], - include: { - questionnaire: { only: %i[name id] } - } - })).tap do |hash| - end + super(options.merge({ + only: %i[txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at], + include: { + questionnaire: { only: %i[name id] } + } + })).tap do |hash| + end + end + + + private + + def set_seq + if questionnaire.present? + max_seq = questionnaire.questions.maximum(:seq) + self.seq = max_seq.to_i + 1 + end end -end + +end \ No newline at end of file diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index d576bc421..134891465 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -2,11 +2,12 @@ class Questionnaire < ApplicationRecord belongs_to :assignment, foreign_key: 'assignment_id', inverse_of: false belongs_to :instructor has_many :questions, dependent: :destroy # the collection of questions associated with this Questionnaire + accepts_nested_attributes_for :questions # Allows nested attributes for questions before_destroy :check_for_question_associations 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) @@ -25,31 +26,39 @@ def self.copy_questionnaire_details(params) questionnaire end - # validate the entries for this questionnaire + def validate_questionnaire - errors.add(:max_question_score, 'The maximum question score must be a positive integer.') if max_question_score < 1 - errors.add(:min_question_score, 'The minimum question score must be a positive integer.') if min_question_score < 0 - errors.add(:min_question_score, 'The minimum question score must be less than the maximum.') if min_question_score >= max_question_score - results = Questionnaire.where('id <> ? and name = ? and instructor_id = ?', id, name, instructor_id) - errors.add(:name, 'Questionnaire names must be unique.') if results.present? + if max_question_score.nil? || max_question_score < 1 + errors.add(:max_question_score, 'The maximum question score must be a positive integer.') + end + + if min_question_score.nil? || min_question_score.negative? + errors.add(:min_question_score, 'The minimum question score must be a positive integer.') + end + + return unless min_question_score && max_question_score && min_question_score >= max_question_score + + errors.add(:min_question_score, 'The minimum question score must be less than the maximum.') + end # Check_for_question_associations checks if questionnaire has associated questions or not def check_for_question_associations - if questions.any? - raise ActiveRecord::DeleteRestrictionError.new(:base, "Cannot delete record because dependent questions exist") - end + return unless questions.any? + + raise ActiveRecord::DeleteRestrictionError, :base + end def as_json(options = {}) - super(options.merge({ - only: %i[id name private min_question_score max_question_score created_at updated_at questionnaire_type instructor_id], - include: { - instructor: { only: %i[name email fullname password role] - } - } - })).tap do |hash| - hash['instructor'] ||= { id: nil, name: nil } - end + super(options.merge({ + only: %i[id name private min_question_score max_question_score created_at updated_at + questionnaire_type instructor_id], + include: { + instructor: { only: %i[name email fullname password role] } + } + })).tap do |hash| + hash['instructor'] ||= { id: nil, name: nil } + end end -end +end \ No newline at end of file diff --git a/app/models/response.rb b/app/models/response.rb index c63bdb5fd..eb9d703c1 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -1,59 +1,6 @@ # frozen_string_literal: true class Response < ApplicationRecord - include ScorableHelper - include MetricHelper - - belongs_to :response_map, class_name: 'ResponseMap', foreign_key: 'map_id', inverse_of: false - has_many :scores, class_name: 'Answer', foreign_key: 'response_id', dependent: :destroy, inverse_of: false - - alias map response_map - delegate :questionnaire, :reviewee, :reviewer, to: :map - - def reportable_difference? - map_class = map.class - # gets all responses made by a reviewee - existing_responses = map_class.assessments_for(map.reviewee) - - count = 0 - total = 0 - # gets the sum total percentage scores of all responses that are not this response - existing_responses.each do |response| - unless id == response.id # the current_response is also in existing_responses array - count += 1 - total += response.aggregate_questionnaire_score.to_f / response.maximum_score - end - end - - # if this response is the only response by the reviewee, there's no grade conflict - return false if count.zero? - - # calculates the average score of all other responses - average_score = total / count - - # This score has already skipped the unfilled scorable question(s) - score = aggregate_questionnaire_score.to_f / maximum_score - questionnaire = questionnaire_by_answer(scores.first) - assignment = map.assignment - assignment_questionnaire = AssignmentQuestionnaire.find_by(assignment_id: assignment.id, questionnaire_id: questionnaire.id) - - # notification_limit can be specified on 'Rubrics' tab on assignment edit page. - allowed_difference_percentage = assignment_questionnaire.notification_limit.to_f - - # the range of average_score_on_same_artifact_from_others and score is [0,1] - # the range of allowed_difference_percentage is [0, 100] - (average_score - score).abs * 100 > allowed_difference_percentage - end - - def aggregate_questionnaire_score - # only count the scorable questions, only when the answer is not nil - # we accept nil as answer for scorable questions, and they will not be counted towards the total score - sum = 0 - scores.each do |s| - question = Question.find(s.question_id) - # For quiz responses, the weights will be 1 or 0, depending on if correct - sum += s.answer * question.weight unless s.answer.nil? || !question.scorable? - end - sum - end + belongs_to :response_map + belongs_to :question end diff --git a/app/models/response_map.rb b/app/models/response_map.rb index d27124d76..3ae66d8ea 100644 --- a/app/models/response_map.rb +++ b/app/models/response_map.rb @@ -1,41 +1,59 @@ -class ResponseMap < ApplicationRecord - has_many :response, foreign_key: 'map_id', dependent: :destroy, inverse_of: false - 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 - - alias map_id id - - # returns the assignment related to the response map - def response_assignment - return Participant.find(self.reviewer_id).assignment - end - - def self.assessments_for(team) - responses = [] - # stime = Time.now - if team - array_sort = [] - sort_to = [] - maps = where(reviewee_id: team.id) - maps.each do |map| - next if map.response.empty? - - all_resp = Response.where(map_id: map.map_id).last - if map.type.eql?('ReviewResponseMap') - # If its ReviewResponseMap then only consider those response which are submitted. - array_sort << all_resp if all_resp.is_submitted - else - array_sort << all_resp - end - # sort all versions in descending order and get the latest one. - sort_to = array_sort.sort # { |m1, m2| (m1.updated_at and m2.updated_at) ? m2.updated_at <=> m1.updated_at : (m1.version_num ? -1 : 1) } - responses << sort_to[0] unless sort_to[0].nil? - array_sort.clear - sort_to.clear - end - responses = responses.sort { |a, b| a.map.reviewer.fullname <=> b.map.reviewer.fullname } - end - responses - end -end +class ResponseMap < ApplicationRecord + # 'reviewer_id' points to the User who is the instructor. + belongs_to :reviewer, class_name: 'User', foreign_key: 'reviewer_id', optional: true + belongs_to :reviewee, class_name: 'User', foreign_key: 'reviewee_id', optional: true + belongs_to :questionnaire, foreign_key: 'reviewed_object_id', optional: true + has_many :responses + validates :reviewee_id, uniqueness: { scope: :reviewed_object_id, message: "is already assigned to this questionnaire" } + + # Gets the score from this response map + def calculate_score + responses.sum do |response| + question = response.question + response.skipped ? 0 : (question.correct_answer == response.submitted_answer ? question.score_value : 0) + end + end + + def get_score + self.score + end + + # Save the submitted answers and check if that answer is correct + def process_answers(answers) + answers.sum do |answer| + question = Question.find(answer[:question_id]) + submitted_answer = answer[:answer_value] + skipped = answer[:skipped] || false + + puts "#{skipped}" + puts "#{question.skippable}" + if skipped && !question.skippable + raise ActiveRecord::RecordInvalid.new("Question #{question.id} cannot be skipped.") + end + + response = find_or_initialize_response(self.id, question.id) + response.submitted_answer = submitted_answer + response.is_submitted = true + response.skipped = skipped + response.save! + + skipped ? 0 : (question.correct_answer == submitted_answer ? question.score_value : 0) + end + end + + # Build a new ResponseMap instance for assigning a quiz to a student + def self.build_response_map(student_id, questionnaire) + instructor_id = questionnaire.assignment.instructor_id + ResponseMap.new( + reviewee_id: student_id, + reviewer_id: instructor_id, + reviewed_object_id: questionnaire.id + ) + end + + private + + def find_or_initialize_response(response_map_id, question_id) + Response.find_or_initialize_by(response_map_id: response_map_id, question_id: question_id) + end +end \ No newline at end of file diff --git a/app/models/role.rb b/app/models/role.rb index b6d223ad3..61dbc549d 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -65,4 +65,4 @@ def other_roles def as_json(options = nil) super(options.merge({ only: %i[id name parent_id] })) end -end +end \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index b9f5aa055..f42127b82 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,18 +1,55 @@ +# MySQL. Versions 5.5.8 and up are supported. +# +# Install the MySQL driver +# gem install mysql2 +# +# Ensure the MySQL gem is defined in your Gemfile +# gem "mysql2" +# +# And be sure to use new-style password hashing: +# https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html +# default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - port: 3306 - socket: /var/run/mysqld/mysqld.sock + username: dev + password: root + development: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> + database: reimplementation_development +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> + database: reimplementation_test +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="mysql2://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# production: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_production + username: reimplementation + password: <%= ENV["REIMPLEMENTATION_DATABASE_PASSWORD"] %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index fc8a710a2..acfae35c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,18 @@ get :processed, action: :processed_requests end end + resources :student_quizzes do + member do + get :get_score + get :index + end + collection do + post 'assign', to: 'student_quizzes#assign_quiz' + post :create + end + end + post 'student_quizzes/submit_quiz', to: 'student_quizzes#submit_quiz' + resources :participants, only: [:create] end end end \ No newline at end of file diff --git a/db/migrate/20240322223515_add_assignment_id_to_questionnaires.rb b/db/migrate/20240322223515_add_assignment_id_to_questionnaires.rb new file mode 100644 index 000000000..aacf5ee82 --- /dev/null +++ b/db/migrate/20240322223515_add_assignment_id_to_questionnaires.rb @@ -0,0 +1,5 @@ +class AddAssignmentIdToQuestionnaires < ActiveRecord::Migration[7.0] + def change + add_reference :questionnaires, :assignment, null: false, foreign_key: true + end +end diff --git a/db/migrate/20240322225112_add_correct_answer_to_questions.rb b/db/migrate/20240322225112_add_correct_answer_to_questions.rb new file mode 100644 index 000000000..4dc5f5e21 --- /dev/null +++ b/db/migrate/20240322225112_add_correct_answer_to_questions.rb @@ -0,0 +1,7 @@ +class AddCorrectAnswerToQuestions < ActiveRecord::Migration[7.0] + def change + unless column_exists? :questions, :correct_answer + add_column :questions, :correct_answer, :string + end + end +end diff --git a/db/migrate/20240322231234_add_score_value_to_questions.rb b/db/migrate/20240322231234_add_score_value_to_questions.rb new file mode 100644 index 000000000..52bfff0c7 --- /dev/null +++ b/db/migrate/20240322231234_add_score_value_to_questions.rb @@ -0,0 +1,7 @@ +class AddScoreValueToQuestions < ActiveRecord::Migration[7.0] + def change + unless column_exists? :questions, :score_value + add_column :questions, :score_value, :integer + end + end +end diff --git a/db/migrate/20240322231719_add_answer_text_to_answers.rb b/db/migrate/20240322231719_add_answer_text_to_answers.rb new file mode 100644 index 000000000..d22f79e7b --- /dev/null +++ b/db/migrate/20240322231719_add_answer_text_to_answers.rb @@ -0,0 +1,7 @@ +class AddAnswerTextToAnswers < ActiveRecord::Migration[7.0] + def change + unless column_exists? :answers, :answer_text + add_column :answers, :answer_text, :text + end + end +end diff --git a/db/migrate/20240322232203_add_correct_to_answers.rb b/db/migrate/20240322232203_add_correct_to_answers.rb new file mode 100644 index 000000000..126b00e4c --- /dev/null +++ b/db/migrate/20240322232203_add_correct_to_answers.rb @@ -0,0 +1,5 @@ +class AddCorrectToAnswers < ActiveRecord::Migration[7.0] + def change + add_column :answers, :correct, :boolean, default: false + end +end diff --git a/db/migrate/20240323155800_modify_response.rb b/db/migrate/20240323155800_modify_response.rb new file mode 100644 index 000000000..211e87349 --- /dev/null +++ b/db/migrate/20240323155800_modify_response.rb @@ -0,0 +1,7 @@ +class ModifyResponse < ActiveRecord::Migration[7.0] + def change + add_reference :responses, :response_map, foreign_key: true + add_reference :responses, :question, foreign_key: true + add_column :responses, :submitted_answer, :string + end +end diff --git a/db/migrate/20240323155906_add_score_to_response_map.rb b/db/migrate/20240323155906_add_score_to_response_map.rb new file mode 100644 index 000000000..b3d514e8a --- /dev/null +++ b/db/migrate/20240323155906_add_score_to_response_map.rb @@ -0,0 +1,5 @@ +class AddScoreToResponseMap < ActiveRecord::Migration[7.0] + def change + add_column :response_maps, :score, :integer, default: 0 + end +end diff --git a/db/migrate/20241204052902_add_skipped_to_responses.rb b/db/migrate/20241204052902_add_skipped_to_responses.rb new file mode 100644 index 000000000..3ee5ed4c7 --- /dev/null +++ b/db/migrate/20241204052902_add_skipped_to_responses.rb @@ -0,0 +1,5 @@ +class AddSkippedToResponses < ActiveRecord::Migration[7.0] + def change + add_column :responses, :skipped, :boolean, default: false + end +end diff --git a/db/migrate/20241204052911_add_skippable_to_questions.rb b/db/migrate/20241204052911_add_skippable_to_questions.rb new file mode 100644 index 000000000..a49277a09 --- /dev/null +++ b/db/migrate/20241204052911_add_skippable_to_questions.rb @@ -0,0 +1,5 @@ +class AddSkippableToQuestions < ActiveRecord::Migration[7.0] + def change + add_column :questions, :skippable, :boolean, default: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 1770d8997..12dba3b7c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_04_15_192048) do +ActiveRecord::Schema[7.0].define(version: 2024_12_04_052911) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -32,6 +32,8 @@ t.text "comments" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "answer_text" + t.boolean "correct", default: false t.index ["question_id"], name: "fk_score_questions" t.index ["response_id"], name: "fk_score_response" end @@ -205,6 +207,8 @@ t.text "instruction_loc" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "assignment_id", null: false + t.index ["assignment_id"], name: "index_questionnaires_on_assignment_id" end create_table "questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -220,6 +224,9 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "questionnaire_id", null: false + t.string "correct_answer" + t.integer "score_value" + t.boolean "skippable", default: true t.index ["questionnaire_id"], name: "fk_question_questionnaires" t.index ["questionnaire_id"], name: "index_questions_on_questionnaire_id" end @@ -230,6 +237,7 @@ t.integer "reviewee_id", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "score", default: 0 t.index ["reviewer_id"], name: "fk_response_map_reviewer" end @@ -239,7 +247,13 @@ t.boolean "is_submitted", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "response_map_id" + t.bigint "question_id" + t.string "submitted_answer" + t.boolean "skipped", default: false t.index ["map_id"], name: "fk_response_response_map" + t.index ["question_id"], name: "index_responses_on_question_id" + t.index ["response_map_id"], name: "index_responses_on_response_map_id" end create_table "roles", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -341,7 +355,10 @@ add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" add_foreign_key "participants", "users" + add_foreign_key "questionnaires", "assignments" add_foreign_key "questions", "questionnaires" + add_foreign_key "responses", "questions" + add_foreign_key "responses", "response_maps" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade add_foreign_key "sign_up_topics", "assignments" add_foreign_key "signed_up_teams", "sign_up_topics" diff --git a/db/seeds.rb b/db/seeds.rb index 134ac82e5..4224245d5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,5 +1,5 @@ begin - #Create an instritution + #Create an institution Institution.create!( name: 'North Carolina State University', ) @@ -13,6 +13,117 @@ institution_id: 1, role_id: 1 ) + + User.create!( + name: 'instructor', + email: 'instructor@example.com', + password: 'password123', + full_name: 'instructor instructor', + institution_id: 1, + role_id: 3 + ) + + User.create!( + name: 'student', + email: 'student@example.com', + password: 'password123', + full_name: 'student student', + institution_id: 1, + role_id: 5 + ) + + Course.create!( + name: 'Software Engineering', + directory_path: 'software_engineering', + instructor_id: 2, + institution_id: 1 + ) + + Assignment.create!( + name: 'Assignment 1', + course_id: 1, + instructor_id: 2, + staggered_deadline: false, + enable_pair_programming: false, + has_badge: false, + is_calibrated: false + ) + + AssignmentParticipant.create!( + assignment_id: 1, + user_id: 3, + handle: 'student_handle' + ) + + Questionnaire.create!( + name: 'Questionnaire', + instructor_id: 2, + assignment_id: 1, + min_question_score: 0, + max_question_score: 10, + private: false, + questionnaire_type: 'AuthorFeedbackReview' + ) + + Question.create!( + seq: 1, + txt: "test question 1", + question_type: "multiple_choice", + break_before: true, + weight: 5, + questionnaire_id: 1, + correct_answer: "A", + score_value: 5, + skippable: true + ) + + Question.create!( + seq: 2, + txt: "test question 2", + question_type: "multiple_choice", + break_before: false, + weight: 5, + questionnaire_id: 1, + correct_answer: "A", + score_value: 5, + skippable: true + ) + + Questionnaire.create!( + name: 'Questionnaire 2', + assignment_id: 1, + instructor_id: 2, + min_question_score: 0, + max_question_score: 10, + private: false, + questionnaire_type: 'AuthorFeedbackReview' + ) + + Question.create!( + seq: 1, + txt: "test question 1 for 2", + question_type: "multiple_choice", + break_before: true, + weight: 5, + questionnaire_id: 2, + correct_answer: "A", + score_value: 5, + skippable: true + ) + + Question.create!( + seq: 2, + txt: "test question 2 for 2", + question_type: "multiple_choice", + break_before: false, + weight: 5, + questionnaire_id: 2, + correct_answer: "A", + score_value: 5, + skippable: true + ) + + rescue ActiveRecord::RecordInvalid => e puts 'The db has already been seeded' -end \ No newline at end of file +end diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb index d36f0d95a..a02bafacc 100644 --- a/spec/models/assignment_spec.rb +++ b/spec/models/assignment_spec.rb @@ -2,34 +2,74 @@ require 'rails_helper' RSpec.describe Assignment, type: :model do + let(:instructor) { create(:user) } + let(:course) { create(:course) } + let(:assignment) { + Assignment.create( + name: 'Test Assignment', + directory_path: '/path/to/assignment', + course_id: course.id, + instructor_id: instructor.id, + require_quiz: true, + num_quiz_questions: 5, + description: 'Assignment Description' + ) + } - let(:team) {Team.new} - let(:assignment) { Assignment.new(id: 1, name: 'Test Assignment') } - let(:review_response_map) { ReviewResponseMap.new(assignment: assignment, reviewee: team) } - let(:answer) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } - let(:answer2) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } + describe 'Model associations and validations' do + it 'belongs to course' do + expect(assignment).to belong_to(:course) + end + + it 'belongs to instructor' do + expect(assignment).to belong_to(:instructor) + end + + it 'validates presence of name' do + expect(assignment).to validate_presence_of(:name) + end + + it 'validates presence of directory_path' do + expect(assignment).to validate_presence_of(:directory_path) + end + + it 'validates presence of require_quiz' do + expect(assignment.require_quiz).to be true + end + + it 'validates numericality of num_quiz_questions' do + expect(assignment).to validate_numericality_of(:num_quiz_questions).is_greater_than_or_equal_to(0) + end + + it 'has a valid factory' do + expect(assignment).to be_valid + end + end describe '.get_all_review_comments' do + let(:team) { Team.new } + let(:review_response_map) { ReviewResponseMap.new(assignment: assignment, reviewee: team) } + let(:answer) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } + let(:answer2) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } + it 'returns concatenated review comments and # of reviews in each round' do - allow(Assignment).to receive(:find).with(1).and_return(assignment) - allow(assignment).to receive(:num_review_rounds).and_return(2) - allow(ReviewResponseMap).to receive_message_chain(:where, :find_each).with(reviewed_object_id: 1, reviewer_id: 1) + allow(ReviewResponseMap).to receive_message_chain(:where, :find_each).with(reviewed_object_id: assignment.id) .with(no_args).and_yield(review_response_map) response1 = double('Response', round: 1, additional_comment: '') response2 = double('Response', round: 2, additional_comment: 'LGTM') allow(review_response_map).to receive(:response).and_return([response1, response2]) allow(response1).to receive(:scores).and_return([answer]) allow(response2).to receive(:scores).and_return([answer2]) - expect(assignment.get_all_review_comments(1)).to eq([[nil, 'Answer text', 'Answer textLGTM', ''], [nil, 1, 1, 0]]) + + result = assignment.get_all_review_comments(1) + expect(result).to eq([[nil, 'Answer text', 'Answer textLGTM', ''], [nil, 1, 1, 0]]) end end - # Get a collection of all comments across all rounds of a review as well as a count of the total number of comments. Returns the above - # information both for totals and in a list per-round. describe '.volume_of_review_comments' do it 'returns volumes of review comments in each round' do allow(assignment).to receive(:get_all_review_comments).with(1) - .and_return([[nil, 'Answer text', 'Answer textLGTM', ''], [nil, 1, 1, 0]]) + .and_return([[nil, 'Answer text', 'Answer textLGTM', ''], [nil, 1, 1, 0]]) expect(assignment.volume_of_review_comments(1)).to eq([1, 2, 2, 0]) end end diff --git a/spec/requests/api/v1/participants_controller_spec.rb b/spec/requests/api/v1/participants_controller_spec.rb new file mode 100644 index 000000000..596141e6c --- /dev/null +++ b/spec/requests/api/v1/participants_controller_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe Api::V1::ParticipantsController, type: :request do + before(:each) do + @user = User.create!(username: 'testuser', email: 'test@example.com', password: 'password123', role_id: 2) + @course = Course.create!( + name: 'Intro to Testing', + subject: 'Software Engineering' + ) + @assignment = Assignment.create!(name: 'Test Assignment', + course_id: @course.id, + instructor_id: @user.id, + description: 'This is a test assignment.') + end + + describe 'POST /api/v1/participants' do + context 'when the user and assignment exist' do + let(:valid_attributes) do + { + participant: { + user_id: @user.id, + assignment_id: @assignment.id + } + } + end + + it 'creates a new Participant' do + expect { + post '/api/v1/participants', params: valid_attributes + }.to change(Participant, :count).by(1) + + expect(response).to have_http_status(:created) + end + end + + context 'when the user does not exist' do + let(:invalid_attributes) do + { + participant: { + user_id: nil, + assignment_id: @assignment.id + } + } + end + + it 'does not create a participant and returns a not found status' do + expect { + post '/api/v1/participants', params: invalid_attributes + }.not_to change(Participant, :count) + + expect(response).to have_http_status(:not_found) + expect(response.body).to include('User does not exist') + end + end + + context 'when the assignment does not exist' do + let(:invalid_attributes) do + { + participant: { + user_id: @user.id, + assignment_id: nil + } + } + end + + it 'does not create a participant and returns a not found status' do + expect { + post '/api/v1/participants', params: invalid_attributes + }.not_to change(Participant, :count) + + expect(response).to have_http_status(:not_found) + expect(response.body).to include('Assignment does not exist') + end + end + end + + after(:each) do + User.delete_all + Assignment.delete_all + end +end diff --git a/spec/requests/api/v1/student_quizzes_controller_spec.rb b/spec/requests/api/v1/student_quizzes_controller_spec.rb new file mode 100644 index 000000000..563cb5316 --- /dev/null +++ b/spec/requests/api/v1/student_quizzes_controller_spec.rb @@ -0,0 +1,150 @@ +require 'swagger_helper' + +RSpec.describe 'StudentQuizzes API', type: :request do + let!(:role) { create(:role, name: "Instructor") } # Creating a role with the name "Instructor" + let!(:instructor) { create(:user, role: role) } # Creating a user with the role created above + let!(:student) { create(:user, role:'Student') } + let!(:questionnaire) { create(:questionnaire, instructor_id: instructor.id) } + let(:questionnaire_id) { questionnaire.id } + let(:valid_attributes) do + { + questionnaire: { + name: 'Quiz 1', + instructor_id: instructor.id, + min_question_score: 1, + max_question_score: 5, + assignment_id: create(:assignment).id, + questions_attributes: [ + { + txt: 'What is Ruby?', + question_type: 'text', + break_before: true, + correct_answer: 'Programming Language', + score_value: 1, + answers_attributes: [ + { answer_text: 'Programming Language', correct: true }, + { answer_text: 'A Gem', correct: false } + ] + } + ] + } + } + end + let(:invalid_attributes) do + { questionnaire: { name: '' } } + end + + describe 'POST /api/v1/student_quizzes' do + context 'with valid parameters' do + it 'creates a new Student Quiz' do + expect { + post '/api/v1/student_quizzes', params: valid_attributes + }.to change(Questionnaire, :count).by(1) + + expect(response).to have_http_status(:created) + end + end + + context 'with invalid parameters' do + it 'does not create a new Student Quiz' do + expect { + post '/api/v1/student_quizzes', params: invalid_attributes + }.to change(Questionnaire, :count).by(0) + + expect(response).to have_http_status(:bad_request) + end + end + end + + describe 'GET /api/v1/student_quizzes/{quiz_id}/get_score' do + it 'gets score for a given quiz' do + get "/api/v1/student_quizzes/#{questionnaire_id}/get_score" + expect(response).to have_http_status(:ok) + end + end + + describe 'POST /api/v1/student_quizzes/assign' do + let(:assign_attributes) do + { participant_id: student.id, questionnaire_id: questionnaire_id } + end + + it 'assigns a quiz to a student' do + post '/api/v1/student_quizzes/assign', params: assign_attributes + expect(response).to have_http_status(:created) + end + end + + describe 'POST /api/v1/student_quizzes/submit_answers' do + let(:submit_attributes) do + { + questionnaire_id: questionnaire_id, + answers: [ + { question_id: create(:question, questionnaire: questionnaire).id, answer_value: 'Programming Language' } + ] + } + end + + it 'submits answers and calculates the total score' do + post '/api/v1/student_quizzes/submit_answers', params: submit_attributes + expect(response).to have_http_status(:ok) + end + end + + + describe 'PUT /api/v1/student_quizzes/{id}' do + let(:valid_attributes_update) do + { + questionnaire: { + name: 'Updated Quiz Name', + } + } + end + + context 'when the record exists' do + before { put "/api/v1/student_quizzes/#{questionnaire_id}", params: valid_attributes_update } + + it 'updates the record' do + expect(response).to have_http_status(:ok) + updated_quiz = Questionnaire.find(questionnaire_id) + expect(updated_quiz.name).to match(/Updated Quiz Name/) + end + end + + context 'when the record does not exist' do + before { put "/api/v1/student_quizzes/#{questionnaire_id + 100}", params: valid_attributes_update } # Assuming an ID that does not exist + + it 'returns status code 404' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with invalid parameters' do + before { put "/api/v1/student_quizzes/#{questionnaire_id}", params: invalid_attributes } + + it 'returns status code 422' do + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'DELETE /api/v1/student_quizzes/{id}' do + let!(:quiz_to_delete) { create(:questionnaire, instructor_id: instructor.id) } + + context 'when the record exists' do + it 'deletes the record' do + expect { + delete "/api/v1/student_quizzes/#{quiz_to_delete.id}" + }.to change(Questionnaire, :count).by(-1) + expect(response).to have_http_status(:no_content) + end + end + + context 'when the record does not exist' do + before { delete "/api/v1/student_quizzes/#{quiz_to_delete.id + 100}" } # Assuming an ID that does not exist + + it 'returns status code 404' do + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index f23cb7b20..ca20a34ee 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -1279,6 +1279,266 @@ paths: description: signed up team deleted '422': description: invalid request + # Create - Create a new questionnaire with questions and answers + "/api/v1/student_quizzes": + post: + summary: Create a new questionnaire with questions and answers + tags: + - Student Quizzes + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + questionnaire: + type: object + properties: + name: + type: string + instructor_id: + type: integer + min_question_score: + type: integer + max_question_score: + type: integer + assignment_id: + type: integer + questions_attributes: + type: array + items: + type: object + properties: + txt: + type: string + question_type: + type: string + break_before: + type: boolean + correct_answer: + type: string + score_value: + type: integer + answers_attributes: + type: array + items: + type: object + properties: + answer_text: + type: string + correct: + type: boolean + required: + - txt + - question_type + - break_before + - correct_answer + - score_value + - answers_attributes + required: + - name + - instructor_id + - min_question_score + - max_question_score + - assignment_id + - questions_attributes + responses: + '201': + description: Questionnaire created successfully + '400': + description: Bad request + get: + summary: Lists all questionnaires (quizzes) + tags: + - Student Quizzes + responses: + '200': + description: A list of all quizzes + "/api/v1/student_quizzes/{quiz_id}/calculate_score": + get: + summary: Calculate and retrieve the score for a specific student quiz + tags: + - Student Quizzes + parameters: + - name: quiz_id + in: path + description: ID of the student quiz to calculate the score for + required: true + schema: + type: integer + responses: + '200': + description: The calculated score for the specified student quiz + '404': + description: Student quiz not found or permission denied to view the score + "/api/v1/student_quizzes/assign": + post: + summary: Assign a quiz to a student + tags: + - Student Quizzes + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + participant_id: + type: string + description: The ID of the participant to whom the quiz is assigned + questionnaire_id: + type: string + description: The ID of the questionnaire (quiz) to be assigned + required: + - participant_id + - questionnaire_id + responses: + '201': + description: Quiz successfully assigned to the student + '422': + description: Unable to assign the quiz (e.g., quiz already assigned or validation errors) + "/api/v1/student_quizzes/submit_answers": + post: + summary: Submit answers for a quiz and calculate the total score + tags: + - Student Quizzes + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + questionnaire_id: + type: integer + description: The ID of the questionnaire for which answers are being submitted + answers: + type: array + items: + type: object + properties: + question_id: + type: integer + description: The ID of the question to which the answer corresponds + answer_value: + type: string + description: The value of the submitted answer + required: + - question_id + - answer_value + required: + - questionnaire_id + - answers + responses: + '200': + description: Answers submitted successfully and total score calculated + '403': + description: Forbidden action, such as trying to submit answers for a quiz not assigned to the user + '422': + description: Unprocessable entity, such as validation errors in the submitted answers + "/api/v1/student_quizzes/{id}": + put: + summary: Update an existing student quiz + tags: + - Student Quizzes + parameters: + - name: id + in: path + description: The ID of the student quiz to update + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + questionnaire: + type: object + properties: + name: + type: string + instructor_id: + type: integer + min_question_score: + type: integer + max_question_score: + type: integer + assignment_id: + type: integer + questions_attributes: + type: array + items: + type: object + properties: + id: + type: integer + txt: + type: string + question_type: + type: string + break_before: + type: boolean + correct_answer: + type: string + score_value: + type: integer + answers_attributes: + type: array + items: + type: object + properties: + id: + type: integer + answer_text: + type: string + correct: + type: boolean + required: + - name + - instructor_id + - min_question_score + - max_question_score + - assignment_id + - questions_attributes + responses: + '200': + description: Student quiz successfully updated + '422': + description: Validation error with provided data + delete: + summary: Delete a specific student quiz + tags: + - Student Quizzes + parameters: + - name: id + in: path + description: The ID of the student quiz to delete + required: true + schema: + type: integer + responses: + '204': + description: Student quiz successfully deleted, no content returned + '404': + description: Student quiz not found + get: + summary: Retrieve a specific student quiz + tags: + - Student Quizzes + parameters: + - name: id + in: path + description: ID of the student quiz to retrieve + required: true + schema: + type: integer + responses: + '200': + description: Details of the specified student quiz "/login": post: summary: Logs in a user