Skip to content
6 changes: 4 additions & 2 deletions app/controllers/requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ')}"
Expand All @@ -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.'
Expand Down
15 changes: 15 additions & 0 deletions app/models/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 21 additions & 8 deletions app/models/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
)
Expand Down
68 changes: 66 additions & 2 deletions app/views/requests/instructor_show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,12 @@
<!-- Display Buttons -->
<div class="d-flex justify-content-center mt-4">
<% 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' %>
<button type="button" class="btn btn-success me-3" data-bs-toggle="modal" data-bs-target="#approveModal">
Approve
</button>
<button type="button" class="btn btn-danger me-3" data-bs-toggle="modal" data-bs-target="#rejectModal">
Reject
</button>
<%= 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 %>
Expand All @@ -130,4 +134,64 @@
</div>
</div>
</div>

<!-- Approve Modal -->
<div class="modal fade" id="approveModal" tabindex="-1" aria-labelledby="approveModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<%= form_with url: approve_course_request_path(@course, @request), method: :post, local: true do |form| %>
<div class="modal-header">
<h5 class="modal-title" id="approveModalLabel">Approve Extension Request</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to approve this extension request?</p>
<div class="mb-3">
<%= form.label :feedback_message, "Optional feedback message:", class: "form-label" %>
<%= form.text_area :feedback_message, class: "form-control", rows: 3,
placeholder: "Optionally provide additional feedback for the student...",
aria_describedby: "approveFeedbackHelpText" %>
<div id="approveFeedbackHelpText" class="form-text">
This message will be included in the approval email if provided.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<%= form.submit "Approve Request", class: "btn btn-success" %>
</div>
<% end %>
</div>
</div>
</div>

<!-- Reject Modal -->
<div class="modal fade" id="rejectModal" tabindex="-1" aria-labelledby="rejectModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<%= form_with url: reject_course_request_path(@course, @request), method: :post, local: true do |form| %>
<div class="modal-header">
<h5 class="modal-title" id="rejectModalLabel">Reject Extension Request</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<%= form.label :feedback_message, "Reason for rejection:", class: "form-label" %>
<%= form.text_area :feedback_message, class: "form-control", rows: 4,
placeholder: "Please provide a reason for rejecting this extension request...",
aria_describedby: "rejectFeedbackHelpText",
required: true %>
<div id="rejectFeedbackHelpText" class="form-text">
This message will be sent to the student via email.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<%= form.submit "Reject Request", class: "btn btn-danger" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
5 changes: 5 additions & 0 deletions db/migrate/20260123233143_add_feedback_message_to_requests.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddFeedbackMessageToRequests < ActiveRecord::Migration[7.2]
def change
add_column :requests, :feedback_message, :text
end
end
Original file line number Diff line number Diff line change
@@ -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
39 changes: 38 additions & 1 deletion docs/instructors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
20 changes: 20 additions & 0 deletions spec/controllers/requests_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }
Expand Down
65 changes: 65 additions & 0 deletions spec/models/request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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