Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ CANVAS_URL='https://ucberkeleysandbox.instructure.com'
# This email must be invited to each Gradescope course as a TA or Instructor
GRADESCOPE_EMAIL=''
GRADESCOPE_PASSWORD=''
## Pensieve Configuration
# Pensieve issues an external-client API token for the integration.
# Contact Pensieve support to obtain one for your deployment.
PENSIEVE_API_TOKEN=''
# This is required to be set.
DEFAULT_FROM_EMAIL='flextensions@berkeley.edu'
# Release info shown in the footer. Normally you do not set these: the build
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ def course_settings_params
:enable_extensions,
:enable_gradescope,
:gradescope_course_url,
:enable_pensieve,
:pensieve_course_url,
:enable_slack_webhook_url,
:slack_webhook_url,
:pending_notification_frequency,
Expand Down
129 changes: 129 additions & 0 deletions app/facades/pensieve_facade.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
##
# Facade for the Pensieve assignment platform (https://www.pensieve.co).
#
# Extracted from the legacy berkeley-cdss/extensions integration. Pensieve's
# external-client API differs from the other LMSs in two important ways:
#
# 1. Assignments are identified by their Pensieve URL, not a numeric id, so
# `external_assignment_id` holds the assignment URL and
# `external_course_id` holds the course URL.
# 2. Extensions are granted as a number of whole days past the original
# deadline (`num_days`), not as an absolute date, so provisioning converts
# the requested due date into days relative to the assignment's due date.
# The time-of-day of the requested due date cannot be sent to Pensieve, and
# Pensieve has no concept of a separate late due date.
class PensieveFacade < LmsFacade
class PensieveAPIError < StandardError; end

PENSIEVE_URL = ENV.fetch('PENSIEVE_URL', 'https://www.pensieve.co')

def initialize(_token = nil)
@pensieve_conn = nil # Wait until first use to read credentials.
end

# Pensieve uses a course-wide service token (PENSIEVE_API_TOKEN) rather than
# per-user tokens. We maintain this method for compatibility with other
# facade instances.
def self.from_user(_user = nil)
new
end

# Pensieve external assignment ids are already full assignment URLs, so no
# URL needs to be assembled.
def self.assignment_url(_base_url, _external_course_id, external_assignment_id)
external_assignment_id
end

##
# Gets all Pensieve assignments for a course.
#
# NOTE: this depends on an assignment-listing endpoint Pensieve has not
# published yet (see Lmss::Pensieve::Client::LIST_ASSIGNMENTS_PATH). Until
# Pensieve confirms it, this returns [] (which SyncAllCourseAssignmentsJob
# treats as a no-op rather than disabling existing assignments).
#
# @param [String] course_id the Pensieve course URL to fetch assignments for.
# @return [Array<Lmss::Pensieve::Assignment>] list of assignments in the course.
def get_all_assignments(course_id)
ensure_authenticated!
begin
@pensieve_conn.list_assignments(course_id).map { |data| Lmss::Pensieve::Assignment.new(data) }
rescue Lmss::Pensieve::AuthenticationError => e
Rails.logger.error "Pensieve authentication failed: #{e.message}"
raise e
rescue => e
Rails.logger.error "Failed to fetch Pensieve assignments: #{e.message}"
Rails.error.report(e, handled: true,
context: { component: 'pensieve', operation: 'get_all_assignments', course_id: course_id })
[]
end
end

# Pensieve's API cannot read extensions back, only grant them.
def get_assignment_overrides(_course_id, _assignment_id)
[]
end

##
# Provisions a new extension to a user.
#
# @param [String] course_id the Pensieve course URL to provision the extension in.
# @param [String] student_email email of the student to provision the extension for.
# @param [String] assignment_id the Pensieve assignment URL to extend.
# @param [String] new_due_date the date the assignment should be due.
# @param [String] _new_late_due_date ignored; Pensieve has no late due date.
# @return [Lmss::Pensieve::Override, nil] the extension that was provisioned.
def provision_extension(course_id, student_email, assignment_id, new_due_date, _new_late_due_date = nil)
ensure_authenticated!

num_days = extension_days(course_id, assignment_id, new_due_date)
return nil if num_days.nil?

begin
data = @pensieve_conn.grant_extension(
assignment_url: assignment_id,
student_email: student_email,
num_days: num_days
)
Lmss::Pensieve::Override.new(data, student_email: student_email, override_due_date: new_due_date)
rescue => e
Rails.logger.error "Failed to provision Pensieve extension: #{e.message}"
raise e
end
end

private

##
# Converts an absolute requested due date into the whole number of days past
# the assignment's original deadline, which is the only form Pensieve accepts.
# Returns nil (and logs) when the assignment or its due date is unknown or the
# requested date grants no additional days.
def extension_days(course_id, assignment_id, new_due_date)
assignment = Assignment.joins(:course_to_lms)
.where(course_to_lmss: { lms_id: PENSIEVE_LMS_ID, external_course_id: course_id })
.find_by(external_assignment_id: assignment_id)
if assignment.nil? || assignment.due_date.nil?
Rails.logger.error "Cannot extend Pensieve assignment #{assignment_id}: no synced assignment with a due date"
return nil
end

num_days = (Time.zone.parse(new_due_date.to_s).to_date - assignment.due_date.to_date).to_i
if num_days < 1
Rails.logger.error "Cannot extend Pensieve assignment #{assignment_id}: requested due date #{new_due_date} grants no additional days"
return nil
end

num_days
end

# Builds the API client on first use so credentials are only required when
# Pensieve is actually used.
def ensure_authenticated!
return if @pensieve_conn

@pensieve_conn = Lmss::Pensieve::Client.new(ENV.fetch('PENSIEVE_API_TOKEN'))
rescue KeyError, Lmss::Pensieve::AuthenticationError
raise PensieveAPIError, 'PENSIEVE_API_TOKEN must be set to use Pensieve'
end
end
17 changes: 16 additions & 1 deletion app/javascript/controllers/course_settings_controller.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["emailField", "gradescopeField", "slackWebhookField", "pendingNotificationEmail"];
static targets = ["emailField", "gradescopeField", "pensieveField", "slackWebhookField", "pendingNotificationEmail"];

connect() {
this.toggleEmailFields();
this.toggleSlackWebhookField();
this.toggleGradescopeFields();
this.togglePensieveFields();
this.togglePendingNotificationEmail();

const gradescopeToggle = document.getElementById('enable-gradescope');
if (gradescopeToggle) {
gradescopeToggle.addEventListener('change', this.toggleGradescopeFields.bind(this));
}

const pensieveToggle = document.getElementById('enable-pensieve');
if (pensieveToggle) {
pensieveToggle.addEventListener('change', this.togglePensieveFields.bind(this));
}

const emailToggle = document.getElementById('enable-email');
if (emailToggle) {
emailToggle.addEventListener('change', this.toggleEmailFields.bind(this));
Expand All @@ -35,6 +41,15 @@ export default class extends Controller {
}
}

togglePensieveFields() {
const pensieveToggle = document.getElementById('enable-pensieve');
const pensieveCourseUrlField = document.getElementById('pensieve-course-url');

if (pensieveToggle && pensieveCourseUrlField) {
pensieveCourseUrlField.disabled = !pensieveToggle.checked;
}
}

toggleEmailFields() {
const emailToggle = document.getElementById('enable-email');
const replyEmailField = document.getElementById('reply-email');
Expand Down
5 changes: 5 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ def gradescope_id
external_course_id_for(GRADESCOPE_LMS_ID)
end

# Pensieve courses are identified by their URL rather than a numeric id.
def pensieve_id
external_course_id_for(PENSIEVE_LMS_ID)
end

# Returns the external course id for the given LMS. A course should have at
# most one link per LMS, but when several exist we deterministically prefer a
# link that actually carries an external id (ordered by id) so callers never
Expand Down
24 changes: 24 additions & 0 deletions app/models/course_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
# enable_extensions :boolean default(FALSE)
# enable_gradescope :boolean default(FALSE)
# enable_min_hours_before_deadline :boolean default(TRUE), not null
# enable_pensieve :boolean default(FALSE), not null
# enable_slack_webhook_url :boolean
# extend_late_due_date :boolean default(TRUE), not null
# gradescope_course_url :string
# max_auto_approve :integer default(0)
# min_hours_before_deadline :integer default(0), not null
# pending_notification_email :string
# pending_notification_frequency :string
# pensieve_course_url :string
# reply_email :string
# slack_webhook_url :string
# created_at :datetime not null
Expand Down Expand Up @@ -72,10 +74,12 @@ class CourseSettings < ApplicationRecord
before_create :apply_default_email_templates

validate :gradescope_url_is_valid, if: :enable_gradescope?
validate :pensieve_url_is_valid, if: :enable_pensieve?
validates :pending_notification_frequency, inclusion: { in: VALID_NOTIFICATION_FREQUENCIES }, allow_nil: true
validates :pending_notification_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP },
if: -> { pending_notification_frequency.present? }
after_save :create_or_update_gradescope_link
after_save :create_or_update_pensieve_link

scope :with_pending_notifications, ->(frequency) {
where(pending_notification_frequency: frequency)
Expand Down Expand Up @@ -134,4 +138,24 @@ def extract_gradescope_course_id(gradescope_course_url)
match = gradescope_course_url&.match(%r{gradescope\.com/courses/(\d+)})
match && match[1]
end

VALID_PENSIEVE_URL = %r{\Ahttps://(www\.)?pensieve\.co/\S+\z}

# Pensieve's API identifies courses (and assignments) by URL rather than by a
# numeric id, so the whole URL is stored as the external course id.
# TODO: if disabled should unsync Pensieve assignments
def create_or_update_pensieve_link
return unless enable_pensieve

CourseToLms.find_or_initialize_by(course_id: course.id, lms_id: PENSIEVE_LMS_ID).tap do |course_to_lms|
course_to_lms.external_course_id = pensieve_course_url
course_to_lms.save!
end
end

def pensieve_url_is_valid
return if pensieve_course_url&.match?(VALID_PENSIEVE_URL)

errors.add(:pensieve_course_url, 'must be a valid Pensieve course URL like https://www.pensieve.co/courses/123456')
end
end
13 changes: 13 additions & 0 deletions app/models/lms.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,21 @@ def self.GRADESCOPE_LMS
end
end

def self.PENSIEVE_LMS
@pensieve_lms ||= find_by(id: PENSIEVE_LMS_ID) || find_or_create_by!(
id: PENSIEVE_LMS_ID,
lms_name: 'Pensieve'
) do |lms|
lms.lms_base_url = 'https://www.pensieve.co'
lms.use_auth_token = false
end
end

# Asserts that the Canvas LMS row exists (creating it if necessary) and
# caches the table's rows in memory. Called once at boot, not per request.
def self.preload!
@gradescope_lms = find_by(id: GRADESCOPE_LMS_ID) if @gradescope_lms.nil?
@pensieve_lms = find_by(id: PENSIEVE_LMS_ID) if @pensieve_lms.nil?
@canvas_lms = find_or_create_by!(id: CANVAS_LMS_ID) do |lms|
lms.lms_name = 'Canvas'
lms.lms_base_url = ENV.fetch('CANVAS_URL', '')
Expand All @@ -58,6 +69,8 @@ def self.facade_class(id)
CanvasFacade
when GRADESCOPE_LMS_ID
GradescopeFacade
when PENSIEVE_LMS_ID
PensieveFacade
else
raise "Unsupported LMS ID: #{id}"
end
Expand Down
3 changes: 3 additions & 0 deletions app/models/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ def approve(lms_facade, processed_user_id)
when GradescopeFacade
course_id = course.gradescope_id
user_id = user.email
when PensieveFacade
course_id = course.pensieve_id
user_id = user.email
else
raise "Unsupported LMS Facade: #{lms_facade.class.name}"
end
Expand Down
38 changes: 38 additions & 0 deletions app/views/courses/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,44 @@
</div>
</div>

<div class="card rounded-0 mb-4">
<div class="card-header bg-light">
<h2 class="card-title mb-0 h5">Pensieve</h2>
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<%= hidden_field_tag 'course_settings[enable_pensieve]', false %>
<%= check_box_tag 'course_settings[enable_pensieve]',
true,
@course.course_settings&.enable_pensieve,
class: 'form-check-input',
id: 'enable-pensieve' %>
<label class="form-check-label" for="enable-pensieve">Link Pensieve course</label>
</div>
</div>

<div class="mb-3 row">
<label for="pensieve-course-url" class="col-sm-4 col-form-label">Course's Pensieve URL</label>
<div class="col-sm-8">
<%= url_field_tag 'course_settings[pensieve_course_url]',
@course.course_settings&.pensieve_course_url,
class: 'form-control',
id: 'pensieve-course-url',
placeholder: 'https://www.pensieve.co/courses/123456',
data: { course_settings_target: "pensieveField" },
disabled: !@course.course_settings&.enable_pensieve,
pattern: 'https://(www\.)?pensieve\.co/\S+',
title: 'Must be a valid Pensieve course URL (e.g. https://www.pensieve.co/courses/123456)' %>
<small class="text-muted">
Extensions are posted through Pensieve's external-client API, so the
<code>PENSIEVE_API_TOKEN</code> issued by Pensieve for your course must be configured for the integration to work.
</small>
</div>
</div>
</div>
</div>

<div class="card rounded-0 mb-4">
<div class="card-header bg-light">
<h2 class="card-title mb-0 h5">Staff Notifications</h2>
Expand Down
6 changes: 6 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@

config.hosts << "flextensions.lvh.me:3000"

if ENV["AGENT_WEB_HOST"].present?
# Superconductor serves previews through a tunneled host and embeds them in an iframe.
config.hosts << ENV["AGENT_WEB_HOST"]
config.action_dispatch.default_headers.delete("X-Frame-Options")
end

config.action_mailer.delivery_method = :letter_opener_web
config.action_mailer.perform_deliveries = true
config.action_mailer.default_url_options = {
Expand Down
1 change: 1 addition & 0 deletions config/initializers/lms_integrations.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Well-known LMS row ids (see db/seeds.rb and Lms.preload!).
CANVAS_LMS_ID = 1
GRADESCOPE_LMS_ID = 2
PENSIEVE_LMS_ID = 3

# When the app boots, assert that the Canvas LMS row (id 1) exists — creating
# it if necessary — and preload the lms table into memory so requests never
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20260825000001_add_pensieve_to_course_settings.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class AddPensieveToCourseSettings < ActiveRecord::Migration[8.1]
def change
# Adding a column with a default backfills all existing rows (courses) with
# the default on Postgres 11+, so existing courses get Pensieve disabled.
safety_assured do
change_table :course_settings, bulk: true do |t|
t.boolean :enable_pensieve, default: false, null: false
t.string :pensieve_course_url
end
end
end
end
Loading
Loading