diff --git a/app/controllers/requests_controller.rb b/app/controllers/requests_controller.rb index 1ba636e5..1c4200c5 100644 --- a/app/controllers/requests_controller.rb +++ b/app/controllers/requests_controller.rb @@ -136,7 +136,8 @@ def cancel def approve @assignment = Assignment.find_by(id: @request.assignment_id) lms_facade = @assignment.lms_facade - if @request.approve(lms_facade.from_user(@user), @user) + feedback_message = params[:feedback_message] + if @request.approve(lms_facade.from_user(@user), @user, feedback_message: feedback_message) redirect_to course_requests_path(@course), notice: 'Request approved and extension created successfully in Canvas.' else flash[:alert] = "Failed to approve the request. #{@request.errors.full_messages.join(', ')}" @@ -145,7 +146,8 @@ def approve end def reject - if @request.reject(@user) + feedback_message = params[:feedback_message] + if @request.reject(@user, feedback_message: feedback_message) redirect_to course_requests_path(@course), notice: 'Request denied successfully.' else redirect_to course_requests_path(@course), alert: 'Failed to deny the request.' diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index 4368abc9..330048fb 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -48,6 +48,21 @@ class CourseSettings < ApplicationRecord {{course_name}} Staff LIQUID + DEFAULT_REJECTION_EMAIL_SUBJECT = 'Extension Request Status: {{status}} - {{course_code}}' + + DEFAULT_REJECTION_EMAIL_TEMPLATE = <<~LIQUID.freeze + Hello {{student_name}}, + + Your extension request for {{assignment_name}} in {{course_name}} ({{course_code}}) has been {{status}}. + + Reason for rejection: {{feedback_message}} + + If you have any questions, please reach out to your course staff. + + Thank you, + {{course_name}} Staff + LIQUID + belongs_to :course before_save :ensure_system_user_for_auto_approval diff --git a/app/models/request.rb b/app/models/request.rb index db55e928..fc95c2a4 100644 --- a/app/models/request.rb +++ b/app/models/request.rb @@ -34,6 +34,8 @@ # fk_rails_... (user_id => users.id) # class Request < ApplicationRecord + DEFAULT_FEEDBACK_MESSAGE = 'No additional feedback provided.' + belongs_to :course belongs_to :assignment belongs_to :user @@ -145,7 +147,7 @@ def auto_approve(lms_facade_from_user) end # TODO: All of these code should really be moved to each LMS' facade class - def approve(lms_facade, processed_user_id) + def approve(lms_facade, processed_user_id, feedback_message: nil) begin case lms_facade when CanvasFacade @@ -173,13 +175,14 @@ def approve(lms_facade, processed_user_id) update( status: 'approved', last_processed_by_user_id: processed_user_id.id, - external_extension_id: override&.id) + external_extension_id: override&.id, + feedback_message: feedback_message) send_email_response if course.course_settings&.enable_emails true end - def reject(processed_user_id) - update(status: 'denied', last_processed_by_user_id: processed_user_id.id) + def reject(processed_user_id, feedback_message: nil) + update(status: 'denied', last_processed_by_user_id: processed_user_id.id, feedback_message: feedback_message) # Only send email if the person processing is the same as the request's user send_email_response if course.course_settings&.enable_emails && processed_user_id.id != user_id true @@ -204,7 +207,7 @@ def calculate_new_assignment_dates } end -def send_email_response + def send_email_response return unless course.course_settings&.enable_emails cs = course.course_settings @@ -220,15 +223,25 @@ def send_email_response 'status' => status.capitalize, 'original_due_date' => assignment.due_date.strftime('%a, %b %-d, %Y %-I:%M %p'), 'new_due_date' => requested_due_date.strftime('%a, %b %-d, %Y %-I:%M %p'), - 'extension_days' => calculate_days_difference.to_s + 'extension_days' => calculate_days_difference.to_s, + 'feedback_message' => feedback_message.presence || DEFAULT_FEEDBACK_MESSAGE } + # Use rejection templates for denied status + if status == 'denied' + subject_template = cs.rejection_email_subject.presence || CourseSettings::DEFAULT_REJECTION_EMAIL_SUBJECT + body_template = cs.rejection_email_template.presence || CourseSettings::DEFAULT_REJECTION_EMAIL_TEMPLATE + else + subject_template = cs.email_subject + body_template = cs.email_template + end + EmailService.send_email( to: to, from: ENV.fetch('DEFAULT_FROM_EMAIL'), reply_to: reply_to, - subject_template: cs.email_subject, - body_template: cs.email_template, + subject_template: subject_template, + body_template: body_template, mapping: mapping, deliver_later: false # or true if you prefer .deliver_later ) diff --git a/app/views/requests/instructor_show.html.erb b/app/views/requests/instructor_show.html.erb index 16141ade..5ded1c10 100644 --- a/app/views/requests/instructor_show.html.erb +++ b/app/views/requests/instructor_show.html.erb @@ -120,8 +120,12 @@
<% if @request.status == 'pending' %> - <%= button_to 'Approve', approve_course_request_path(@course, @request), method: :post, class: 'btn btn-success me-3' %> - <%= button_to 'Reject', reject_course_request_path(@course, @request), method: :post, class: 'btn btn-danger me-3' %> + + <%= link_to 'Edit Request', edit_course_request_path(@course, @request), class: "btn btn-secondary me-3" %> <%= link_to 'Back', course_requests_path(@course), class: "btn btn-dark" %> <% else %> @@ -130,4 +134,64 @@
+ + + + + + \ No newline at end of file diff --git a/db/migrate/20260123233143_add_feedback_message_to_requests.rb b/db/migrate/20260123233143_add_feedback_message_to_requests.rb new file mode 100644 index 00000000..ed7783ae --- /dev/null +++ b/db/migrate/20260123233143_add_feedback_message_to_requests.rb @@ -0,0 +1,5 @@ +class AddFeedbackMessageToRequests < ActiveRecord::Migration[7.2] + def change + add_column :requests, :feedback_message, :text + end +end diff --git a/db/migrate/20260123233144_add_rejection_email_template_to_course_settings.rb b/db/migrate/20260123233144_add_rejection_email_template_to_course_settings.rb new file mode 100644 index 00000000..9b6d13cd --- /dev/null +++ b/db/migrate/20260123233144_add_rejection_email_template_to_course_settings.rb @@ -0,0 +1,6 @@ +class AddRejectionEmailTemplateToCourseSettings < ActiveRecord::Migration[7.2] + def change + add_column :course_settings, :rejection_email_subject, :string + add_column :course_settings, :rejection_email_template, :text + end +end diff --git a/docs/instructors.md b/docs/instructors.md index 7e120468..31355f41 100644 --- a/docs/instructors.md +++ b/docs/instructors.md @@ -68,6 +68,21 @@ You can respond to requests in two ways: 3. Click **Approve** or **Reject** at the bottom. 4. The request status will update in real time. +### Providing Feedback to Students + +When approving or rejecting requests, you can provide feedback that will be included in the email notification sent to the student: + +**Approving Requests:** +- An optional feedback field allows you to provide additional context or instructions +- Example: "Approved. Please ensure you submit by the extended deadline." +- If no feedback is provided, a standard approval email will be sent + +**Rejecting Requests:** +- A feedback field is **required** when rejecting a request +- Provide a clear reason for the rejection to help students understand the decision +- Example: "The requested due date is past the final exam. Please contact me during office hours to discuss alternatives." +- This feedback will be included in the rejection email sent to the student + ## Viewing Request History To view all requests made in the course, click the **View all Requests** button at the top left. @@ -139,7 +154,29 @@ Use provided dynamic variables to personalize each email. `{{course_name}}, {{course_code}}, {{assignment_name}}` - **Extension Information** - `{{original_due_date}}, {{new_due_date}}, {{extension_days}}, {{status}}` + `{{original_due_date}}, {{new_due_date}}, {{extension_days}}, {{status}}, {{feedback_message}}` + +#### Rejection Email Template + +You can create a separate email template specifically for rejected requests. This allows you to customize the messaging when denying extension requests. + +To set up a custom rejection template: +1. In the **Email Settings** section, configure the rejection email subject and body +2. Include the `{{feedback_message}}` variable to insert the rejection reason you provided + +**Example Rejection Template:** +``` +Hello {{student_name}}, + +Your extension request for {{assignment_name}} in {{course_name}} ({{course_code}}) has been denied. + +Reason for rejection: {{feedback_message}} + +If you have any questions or would like to discuss this decision, please reach out to your course staff. + +Thank you, +{{course_name}} Staff +``` Click **Reset to Default** to restore the system default template. diff --git a/spec/controllers/requests_controller_spec.rb b/spec/controllers/requests_controller_spec.rb index 810033d2..9c5ba05b 100644 --- a/spec/controllers/requests_controller_spec.rb +++ b/spec/controllers/requests_controller_spec.rb @@ -329,6 +329,16 @@ expect(flash[:notice]).to match(/approved/i) end + it 'passes feedback_message to approve method when provided' do + expect_any_instance_of(Request).to receive(:approve).with( + anything, anything, hash_including(feedback_message: 'Great work!') + ).and_return(true) + + post :approve, params: { course_id: course.id, id: request.id, feedback_message: 'Great work!' } + + expect(response).to redirect_to(course_requests_path(course)) + end + # it 'shows error if approval fails' do # # stub the *same* request to return false here # allow(request).to receive(:approve).and_return(false) @@ -353,6 +363,16 @@ expect(flash[:notice]).to match(/denied/i) end + it 'passes feedback_message to reject method when provided' do + expect_any_instance_of(Request).to receive(:reject).with( + anything, hash_including(feedback_message: 'Cannot grant extension.') + ).and_return(true) + + post :reject, params: { course_id: course.id, id: request.id, feedback_message: 'Cannot grant extension.' } + + expect(response).to redirect_to(course_requests_path(course)) + end + it 'shows error if rejection fails' do allow_any_instance_of(Request).to receive(:reject).and_return(false) post :reject, params: { course_id: course.id, id: request.id } diff --git a/spec/models/request_spec.rb b/spec/models/request_spec.rb index 9e4156f3..7cef47ea 100644 --- a/spec/models/request_spec.rb +++ b/spec/models/request_spec.rb @@ -512,6 +512,17 @@ expect(request.status).to eq('approved') end end + + it 'saves feedback_message when provided' do + feedback = 'Approved with a note to complete on time next time.' + request.approve(lms_facade, instructor, feedback_message: feedback) + expect(request.reload.feedback_message).to eq(feedback) + end + + it 'allows nil feedback_message' do + request.approve(lms_facade, instructor, feedback_message: nil) + expect(request.reload.feedback_message).to be_nil + end end describe '#reject' do @@ -524,6 +535,17 @@ request.reject(instructor) expect(request.last_processed_by_user_id).to eq(instructor.id) end + + it 'saves feedback_message when provided' do + feedback = 'We cannot approve this request because the deadline is too close.' + request.reject(instructor, feedback_message: feedback) + expect(request.reload.feedback_message).to eq(feedback) + end + + it 'allows nil feedback_message' do + request.reject(instructor, feedback_message: nil) + expect(request.reload.feedback_message).to be_nil + end end describe '#send_email_response' do @@ -581,5 +603,48 @@ expect(EmailService).not_to receive(:send_email) request.send_email_response end + + context 'when request is denied' do + let(:rejection_template) do + <<~TEMPLATE + Dear {{student_name}}, + Your extension request has been {{status}}. + Reason: {{feedback_message}} + TEMPLATE + end + + before do + course_settings.update( + rejection_email_subject: 'Rejection: {{course_code}}', + rejection_email_template: rejection_template + ) + request.update(status: 'denied', feedback_message: 'Unable to grant extension at this time.') + end + + it 'uses rejection email template for denied requests' do + expect(EmailService).to receive(:send_email).with( + hash_including( + subject_template: 'Rejection: {{course_code}}', + body_template: rejection_template, + mapping: hash_including( + 'feedback_message' => 'Unable to grant extension at this time.' + ) + ) + ) + request.send_email_response + end + end + + it 'includes feedback_message in mapping with default message when nil' do + request.update(feedback_message: nil) + expect(EmailService).to receive(:send_email).with( + hash_including( + mapping: hash_including( + 'feedback_message' => 'No additional feedback provided.' + ) + ) + ) + request.send_email_response + end end end