-
Notifications
You must be signed in to change notification settings - Fork 172
E2550 Response hierarchy and responses_controller back end #227
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 43 commits
7e593fa
05ce778
21c2f4c
f903ff4
dfba845
a9732e4
1067bc9
a7b4311
ab3abbc
778de56
4c101db
ea4eaae
77138e3
b0aec87
a1c620d
8bb157c
9535b2f
ec5e0ab
e5bb8e4
d268e98
1015d4c
9222eb7
fdc02a1
3178c85
d79fe2a
aff9052
11f4634
b1de8bd
281f33f
02d702d
f49b5d3
fb9b315
afa1c61
005c1ac
391d244
7377636
6de4fe3
8fcb4ae
75bfed7
a166726
921b75e
d0f2c54
d913551
8349a11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class ResponsesController < ApplicationController | ||
| before_action :set_response, only: [:update, :submit, :unsubmit, :destroy] | ||
|
|
||
| # Authorization: determines if current user can perform the action | ||
| def action_allowed? | ||
| case action_name | ||
| when 'create' | ||
| true | ||
| when 'update', 'submit' | ||
| @response = Response.find(params[:id]) | ||
| unless response_belongs_to? || current_user_has_admin_privileges? || | ||
| (current_user_has_instructor_privileges? && current_user_instructs_response_assignment?) | ||
| render json: { error: 'forbidden' }, status: :forbidden | ||
| end | ||
| when 'unsubmit', 'destroy' | ||
| # Only allow if user is the instructor of the associated assignment or has admin privileges | ||
| unless current_user_has_admin_privileges? || | ||
| (current_user_has_instructor_privileges? && current_user_instructs_response_assignment?) | ||
| render json: { error: 'forbidden' }, status: :forbidden | ||
| end | ||
| else | ||
| render json: { error: 'forbidden' }, status: :forbidden | ||
| end | ||
| true | ||
| end | ||
|
|
||
| # POST /responses | ||
| def create | ||
| @response_map = ResponseMap.find_by(id: params[:response_map_id] || params[:map_id]) | ||
| return render json: { error: 'ResponseMap not found' }, status: :not_found unless @response_map | ||
|
|
||
| @response = Response.new( | ||
| map_id: @response_map.id, | ||
| is_submitted: false, | ||
| created_at: Time.current | ||
| ) | ||
|
|
||
| if @response.save | ||
| render json: { message: "#{response_map_label} submission started successfully", response: @response }, status: :created | ||
| else | ||
| render json: { error: @response.errors.full_messages.to_sentence }, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| # PATCH /responses/:id | ||
| # Reviewer edits existing draft (still unsubmitted) | ||
| def update | ||
| return render json: { error: 'forbidden' }, status: :forbidden if @response.is_submitted? | ||
|
|
||
| if @response.update(response_params) | ||
| render json: { message: "#{response_map_label} submission saved successfully", response: @response }, status: :ok | ||
| else | ||
| render json: { error: @response.errors.full_messages.to_sentence }, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| # PATCH /responses/:id/submit | ||
| # Lock the response and calculate final score | ||
| def submit | ||
| return render json: { error: 'Submission not found' }, status: :not_found unless @response | ||
| if @response.is_submitted? | ||
| return render json: { error: 'Submission has already been locked' }, status: :unprocessable_entity | ||
| end | ||
| # Check deadline | ||
| unless submission_window_open?(@response) | ||
| return render json: { error: 'Submission deadline has passed' }, status: :forbidden | ||
| end | ||
|
|
||
| # Lock response | ||
| @response.is_submitted = true | ||
|
|
||
| # Calculate score via ScorableHelper | ||
| total_score = @response.aggregate_questionnaire_score | ||
|
|
||
| if @response.save | ||
| render json: { | ||
| message: "#{response_map_label} submission locked and scored successfully", | ||
| response: @response, | ||
| total_score: total_score | ||
| }, status: :ok | ||
| else | ||
| render json: { error: @response.errors.full_messages.to_sentence }, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| # PATCH /responses/:id/unsubmit | ||
| # Instructor/Admin reopens a submitted response for further editing | ||
| def unsubmit | ||
| return render json: { error: "#{response_map_label} submission not found" }, status: :not_found unless @response | ||
|
|
||
| if @response.is_submitted? | ||
| @response.update(is_submitted: false) | ||
| render json: { message: "#{response_map_label} submission reopened for edits. The reviewer can now make changes.", response: @response }, status: :ok | ||
| else | ||
| render json: { error: "This #{response_map_label.downcase} submission is not locked, so it cannot be reopened" }, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| # DELETE /responses/:id | ||
| # Instructor/Admin deletes invalid/test response | ||
| def destroy | ||
| return render json: { error: 'Submission not found' }, status: :not_found unless @response | ||
|
|
||
| @response.destroy | ||
| head :no_content | ||
| rescue StandardError => e | ||
| render json: { error: e.message }, status: :unprocessable_entity | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_response | ||
| @response = Response.find_by(id: params[:id]) | ||
| end | ||
|
|
||
| def response_params | ||
| params.require(:response).permit( | ||
| :map_id, | ||
| :is_submitted, | ||
| :submitted_at, | ||
| scores_attributes: [:id, :question_id, :answer, :comment] | ||
| ) | ||
| end | ||
|
|
||
| def response_belongs_to? | ||
| # Member actions: we have @response from set_response | ||
| return @response.map&.reviewer&.id == current_user.id if @response&.map&.reviewer && current_user | ||
|
|
||
| # Collection actions (create, next_action): check map ownership | ||
| map_id = params[:response_map_id] || params[:map_id] | ||
| return false if map_id.blank? | ||
|
|
||
| map = ResponseMap.find_by(id: map_id) | ||
| return false unless map | ||
|
|
||
| map.reviewer == current_user | ||
| end | ||
|
|
||
| # Checks whether the current_user is the instructor for the assignment | ||
| # associated with the response identified by params[:id]. | ||
| # Uses the shared authorization method from Authorization concern. | ||
| def current_user_instructs_response_assignment? | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not only the instructor, but also TAs for the course and the admin who created the instructor, need to be able to perform this method. |
||
| resp = Response.find_by(id: params[:id]) | ||
| return false unless resp&.response_map | ||
|
|
||
| assignment = resp.response_map&.assignment | ||
| return false unless assignment | ||
|
|
||
| # Delegate to the shared authorization helper | ||
| current_user_instructs_assignment?(assignment) | ||
| end | ||
|
|
||
| # Returns the friendly label for the response's map type (e.g., "Review", "Assignment Survey") | ||
| # Falls back to a generic "Submission" if the label cannot be determined. | ||
| def response_map_label | ||
| return 'Submission' unless @response&.response_map | ||
|
|
||
| map_label = @response.response_map&.response_map_label | ||
| map_label.presence || 'Submission' | ||
| end | ||
|
|
||
| # Returns true if the assignment's due date is in the future or no due date is set | ||
| def submission_window_open?(response) | ||
| assignment = response&.response_map&.assignment | ||
| return true if assignment.nil? | ||
| return true if assignment.due_dates.nil? | ||
|
|
||
| # Check if due_date has a future? method, otherwise compare timestamps | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there is an "upcoming" method that does what you think future? does.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think "upcoming" is a better name.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's E2566, finish DueDates, who is using the upcoming method. |
||
| due_dates = assignment.due_dates | ||
| if due_dates.respond_to?(:future?) | ||
| return due_dates.first.future? | ||
| end | ||
|
|
||
| # Fallback: compare timestamps | ||
| return true if due_dates.first.nil? | ||
|
|
||
| due_dates.first.due_at > Time.current | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ class Response < ApplicationRecord | |
|
|
||
| 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 | ||
| accepts_nested_attributes_for :scores | ||
|
|
||
| alias map response_map | ||
| delegate :response_assignment, :reviewee, :reviewer, to: :map | ||
|
|
@@ -15,6 +16,20 @@ def questionnaire | |
| response_assignment.assignment_questionnaires.find_by(used_in_round: self.round).questionnaire | ||
| end | ||
|
|
||
| # returns a string of response name, needed so the front end can tell students which rubric they are filling out | ||
| def rubric_label | ||
| return 'Response' if map.nil? | ||
|
|
||
| if map.respond_to?(:response_map_label) | ||
| label = map.response_map_label | ||
| return label if label.present? | ||
| end | ||
|
|
||
| # response type doesn't exist | ||
| 'Unknown Type' | ||
| end | ||
|
|
||
| # Returns true if this response's score differs from peers by more than the assignment notification limit | ||
| def reportable_difference? | ||
bestinlalu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| map_class = map.class | ||
| # gets all responses made by a reviewee | ||
|
|
@@ -23,10 +38,11 @@ def reportable_difference? | |
| count = 0 | ||
| total = 0 | ||
| # gets the sum total percentage scores of all responses that are not this response | ||
| # (each response can omit questions, so maximum_score may differ and we normalize before averaging) | ||
| existing_responses.each do |response| | ||
| unless id == response.id # the current_response is also in existing_responses array | ||
| count += 1 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be in the ReviewAggregator mixin? |
||
| total += response.aggregate_questionnaire_score.to_f / response.maximum_score | ||
| total += response.aggregate_questionnaire_score.to_f / response.maximum_score | ||
|
||
| end | ||
| end | ||
|
|
||
|
|
@@ -40,7 +56,8 @@ def reportable_difference? | |
| 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) | ||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not whether current_user_has_admin_privileges?; it's whether this user created (is the parent of) the instructor whose course this is. One of the current projects is writing/has written code for this. Not sure which, but it should be fairly easy to guess.