Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ CANVAS_URL='https://ucberkeleysandbox.instructure.com'
# We use a single username/password for Gradescope
# This should be a "service account" that can be used to set course settings
# This email must be invited to each Gradescope course as a TA or Instructor
GRADESCOPE_EMAIL='gradescope-bot@berkeley.edu'
GRADESCOPE_PASSWORD=
GRADESCOPE_EMAIL='michael@gradescope.com'
GRADESCOPE_PASSWORD=KoH-z92-oVJ-yqr
# This is required to be set.
DEFAULT_FROM_EMAIL='flextensions@berkeley.edu'
# Generally recommended / best practices
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ rerun.txt
pickle-email-*.html
# All Heroku dumps.
*.dump
# GenAI stuff
.claude

# Ignore all logfiles and tempfiles.
/log/*
Expand Down
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ RSpec:
RSpec/ExampleLength:
Max: 40

# Default 3
# I'm not sure this is good?
RSpec/NestedGroups:
Max: 5

RSpec/MultipleMemoizedHelpers:
Max: 20

Expand Down
1 change: 1 addition & 0 deletions app/controllers/course_settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def course_settings_params
:max_auto_approve,
:enable_gradescope,
:gradescope_course_url,
:extend_late_due_date,
:enable_emails,
:reply_email,
:email_subject,
Expand Down
14 changes: 8 additions & 6 deletions app/facades/canvas_facade.rb
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,11 @@ def delete_assignment_override(courseId, assignmentId, overrideId)
# @param [Integer] studentId the student to provisoin the extension for.
# @param [Integer] assignmentId the assignment the extension should be provisioned for.
# @param [String] newDueDate the date the assignment should be due.
# @param [String] new_close_date the close date for submissions (optional, nil means no close date set).
# @return [Lmss::Canvas::Override] the override that acts as the extension.
# @raises [FailedPipelineError] if the creation response body could not be parsed.
# @raises [NotFoundError] if the user has an existing override that cannot be located.
def provision_extension(course_id, student_id, assignment_id, new_due_date)
def provision_extension(course_id, student_id, assignment_id, new_due_date, new_close_date = nil)
# get existing_overrides for an assignment
student_override = get_existing_student_override(course_id, student_id, assignment_id)
if !student_override.nil?
Expand All @@ -323,7 +324,7 @@ def provision_extension(course_id, student_id, assignment_id, new_due_date)
# create new override
override_title = "#{student_id} extended to #{new_due_date}"
create_response = create_assignment_override(
course_id, assignment_id, [ student_id ], override_title, new_due_date, get_current_formatted_time, new_due_date
course_id, assignment_id, [ student_id ], override_title, new_due_date, get_current_formatted_time, new_close_date
)

decoded_response = parse_create_response(create_response)
Expand All @@ -334,7 +335,7 @@ def provision_extension(course_id, student_id, assignment_id, new_due_date)
curr_override = fetch_existing_override(course_id, student_id, assignment_id)
handle_response = handle_override_logic(
course_id, curr_override, student_id, assignment_id, override_title,
new_due_date
new_due_date, new_close_date
)
Lmss::Canvas::Override.new(parse_create_response(handle_response))
end
Expand Down Expand Up @@ -455,17 +456,18 @@ def fetch_existing_override(courseId, studentId, assignmentId)
# @param [Integer] assignmentId the assignmentId to handle the override logic for.
# @param [String] overrideTitle the title of the override.
# @param [String] newDueDate the new due date for the override.
# @param [String] newCloseDate the close date for the override (maps to lock_at in Canvas API).
# @return [Faraday::Response] the response from updating or creating the override.
def handle_override_logic(courseId, curr_override, studentId, assignmentId, overrideTitle, newDueDate)
def handle_override_logic(courseId, curr_override, studentId, assignmentId, overrideTitle, newDueDate, newCloseDate)
if curr_override.student_ids.length == 1
update_assignment_override(
courseId, assignmentId, curr_override.id, curr_override.student_ids, overrideTitle, newDueDate,
get_current_formatted_time, newDueDate
get_current_formatted_time, newCloseDate
)
else
remove_student_from_override(courseId, curr_override, studentId)
create_assignment_override(
courseId, assignmentId, [ studentId ], overrideTitle, newDueDate, get_current_formatted_time, newDueDate
courseId, assignmentId, [ studentId ], overrideTitle, newDueDate, get_current_formatted_time, newCloseDate
)
end
end
Expand Down
14 changes: 9 additions & 5 deletions app/facades/gradescope_facade.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ def get_existing_student_override(course_id, assignment_id, student_id)
# @param [String] email of student to provision the extension for.
# @param [String] assignmentId the assignment the extension should be provisioned for.
# @param [String] newDueDate the date the assignment should be due.
# @param [String] newLateDueDate the late due date (optional, nil means no late due date set).
# @return [Lmss::Gradescope::BaseExtension] the extension that was provisioned.
def provision_extension(course_id, student_email, assignment_id, new_due_date)
def provision_extension(course_id, student_email, assignment_id, new_due_date, new_late_due_date = nil)
ensure_authenticated!
begin
# get extension page
Expand Down Expand Up @@ -123,10 +124,13 @@ def provision_extension(course_id, student_email, assignment_id, new_due_date)
'type' => 'absolute',
'value' => new_due_date
}
request_payload['override']['settings']['hard_due_date'] = {
'type' => 'absolute',
'value' => new_due_date
}
# Only set hard_due_date (late due date) if explicitly provided
if new_late_due_date
request_payload['override']['settings']['hard_due_date'] = {
'type' => 'absolute',
'value' => new_late_due_date
}
end
end

@gradescope_conn.post(
Expand Down
1 change: 1 addition & 0 deletions app/models/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# enable_extensions :boolean default(FALSE)
# enable_gradescope :boolean default(FALSE)
# enable_slack_webhook_url :boolean
# extend_late_due_date :boolean default(TRUE), not null
# gradescope_course_url :string
# max_auto_approve :integer default(0)
# reply_email :string
Expand Down
21 changes: 20 additions & 1 deletion app/models/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,14 @@ def approve(lms_facade, processed_user_id)
else
raise "Unsupported LMS Facade: #{lms_facade.class.name}"
end

dates = date_calculator.calculate
override = lms_facade.provision_extension(
course_id,
user_id,
assignment.external_assignment_id,
requested_due_date.iso8601
dates[:due_date].iso8601,
dates[:late_due_date]&.iso8601
)
rescue => e
Rails.logger.error "Error during LMS extension provisioning: #{e.message}"
Expand All @@ -178,6 +181,22 @@ def approve(lms_facade, processed_user_id)
true
end

# Returns the AssignmentDateCalculator for this request
def date_calculator
@date_calculator ||= AssignmentDateCalculator.new(
assignment: assignment,
request: self,
course_settings: course.course_settings
)
end

# Calculates the new late due date for an extension based on course settings.
# Returns nil if the assignment has no late due date.
# Delegates to AssignmentDateCalculator for the actual calculation.
def calculate_new_late_due_date
date_calculator.late_due_date
end

def reject(processed_user_id)
update(status: 'denied', last_processed_by_user_id: processed_user_id.id)
# Only send email if the person processing is the same as the request's user
Expand Down
75 changes: 75 additions & 0 deletions app/services/assignment_date_calculator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Calculates the dates to be set on an external LMS when provisioning an extension.
#
# This service encapsulates the logic for determining:
# - release_date: When the assignment becomes available (currently always nil)
# - due_date: The new due date from the approved extension request
# - late_due_date: The deadline for late submissions, calculated based on course settings
#
# The late_due_date logic:
# - If the assignment has no late_due_date, returns nil (no late due date should be set)
# - If extend_late_due_date setting is true (default), shifts the late due date by the same
# delta as the extension (preserving the gap between due date and late due date)
# - If extend_late_due_date setting is false, returns the later of the original late due date
# and the new extended due date
class AssignmentDateCalculator
attr_reader :assignment, :request, :course_settings

# @param assignment [Assignment] The assignment being extended
# @param request [Request] The extension request with the new requested_due_date
# @param course_settings [CourseSettings, nil] The course settings (may be nil)
def initialize(assignment:, request:, course_settings:)
@assignment = assignment
@request = request
@course_settings = course_settings
end

# Returns the calculated dates for the extension
# @return [Hash] with keys :release_date, :due_date, :late_due_date
def calculate
{
release_date: release_date,
due_date: due_date,
late_due_date: late_due_date
}
end

# The release date for the assignment (currently always nil)
# @return [nil]
def release_date
nil
end

# The new due date from the extension request
# @return [DateTime]
def due_date
request.requested_due_date
end

# Calculates the new late due date for an extension based on course settings.
# Returns nil if the assignment has no late due date.
# @return [DateTime, nil]
def late_due_date
original_late_due_date = assignment.late_due_date
return nil if original_late_due_date.blank?

if extend_late_due_date?
# Shift the late due date by the same delta as the extension
extension_delta = request.requested_due_date - assignment.due_date
original_late_due_date + extension_delta
else
# Return the later of the original late due date and the new extended due date
[ original_late_due_date, request.requested_due_date ].max
end
end

private

# Determines whether to extend the late due date by the same delta as the extension.
# Defaults to true if the setting is nil (for backwards compatibility).
# @return [Boolean]
def extend_late_due_date?
setting = course_settings&.extend_late_due_date
# Default to true if setting is nil (for backwards compatibility)
setting.nil? ? true : setting
end
end
22 changes: 19 additions & 3 deletions app/views/courses/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
<%= hidden_field_tag :course_id, @course.id %>
<%= hidden_field_tag :tab, params[:tab] || 'general' %>
<div class="tab-content" id="courses-tabContent">
<!-- General Settings Card -->
<div class="tab-pane fade <%= params[:tab] == 'email' ? '' : 'show active' %>" id="course-default" role="tabpanel" aria-labelledby="course-default-tab" tabindex="0">
<div class="card rounded-0 mb-4">
<div class="card-header bg-light">
Expand Down Expand Up @@ -89,7 +88,7 @@
</div>
</div>

<div class="mb-3 row border-bottom pb-3">
<div class="mb-3 row pb-3">
<label for="max-auto-approve" class="col-sm-4 col-form-label">Maximum requests to auto approve</label>
<div class="col-sm-8">
<%= number_field_tag 'course_settings[max_auto_approve]',
Expand All @@ -103,6 +102,23 @@
</div>
</div>


<div class="mb-3 border-bottom pb-3">
<div class="form-check form-switch">
<%= hidden_field_tag 'course_settings[extend_late_due_date]', false %>
<%= check_box_tag 'course_settings[extend_late_due_date]',
true,
@course.course_settings&.extend_late_due_date.nil? ? true : @course.course_settings&.extend_late_due_date,
class: 'form-check-input',
id: 'extend-late-due-date' %>
<label class="form-check-label" for="extend-late-due-date">Extend Late Due Date Automatically</label>
</div>
<small class="text-muted">
When enabled, the late due date (if present) will be shifted by the same amount as the extension.
When disabled, the new late due date will be set to the later of the original late due date and the extended due date.
</small>
</div>

<div class="mb-3">
<div class="form-check form-switch">
<%= hidden_field_tag 'course_settings[enable_gradescope]', false %>
Expand All @@ -128,7 +144,7 @@
pattern: 'https://(www\.)?gradescope\.com/courses/\d+/?',
title: 'Must be a valid Gradescope course URL (e.g. https://www.gradescope.com/courses/123456)' %>
<small class="text-muted">
Please invite the user <code class="js-copytext">gradescope-bot@berkeley.edu</code>
Please invite the user <code class="js-copytext"><%= ENV.fetch('GRADESCOPE_EMAIL') { 'gradescope-bot@berkeley.edu' } %></code>
<button class="btn btn-sm btn-link p-0 ms-2" data-action="click->course-settings#copyToClipboard"title="Copy to clipboard"><i class="fas fa-clipboard"></i></button> as a TA to your Gradescope course for integration to work.
</small>
<script>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddExtendLateDueDateToCourseSettings < ActiveRecord::Migration[7.2]
def change
add_column :course_settings, :extend_late_due_date, :boolean, default: true, null: false
end
end
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.2].define(version: 2025_10_01_192900) do
ActiveRecord::Schema[7.2].define(version: 2026_02_02_000001) do
create_schema "hypershield"
Comment thread
cycomachead marked this conversation as resolved.

# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"

Expand Down Expand Up @@ -102,6 +104,7 @@
t.boolean "enable_slack_webhook_url"
t.boolean "enable_gradescope", default: false
t.string "gradescope_course_url"
t.boolean "extend_late_due_date", default: true, null: false
t.index ["course_id"], name: "index_course_settings_on_course_id"
end

Expand Down
35 changes: 35 additions & 0 deletions lib/tasks/backfill_extend_late_due_date.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

namespace :course_settings do
desc 'Backfill extend_late_due_date setting for all existing courses (sets to true for all courses)'
task backfill_extend_late_due_date: :environment do
puts 'Starting backfill of extend_late_due_date setting for all courses...'

total_courses = Course.count
updated_count = 0
skipped_count = 0
created_count = 0

Course.find_each do |course|
if course.course_settings.nil?
# Create course settings if they don't exist
CourseSettings.create!(course: course, extend_late_due_date: true)
created_count += 1
puts "Created course settings for course #{course.id} (#{course.course_name})"
elsif course.course_settings.extend_late_due_date.nil?
# Update existing course settings if extend_late_due_date is nil
course.course_settings.update!(extend_late_due_date: true)
updated_count += 1
puts "Updated course #{course.id} (#{course.course_name})"
else
skipped_count += 1
end
end

puts "\nBackfill complete!"
puts "Total courses: #{total_courses}"
puts "Settings created: #{created_count}"
puts "Settings updated: #{updated_count}"
puts "Skipped (already set): #{skipped_count}"
end
end
Loading