diff --git a/.env.example b/.env.example index eea2f54f..43e17cb5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index b01c87cc..3dbe438d 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -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, diff --git a/app/facades/pensieve_facade.rb b/app/facades/pensieve_facade.rb new file mode 100644 index 00000000..8f3bc585 --- /dev/null +++ b/app/facades/pensieve_facade.rb @@ -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] 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 diff --git a/app/javascript/controllers/course_settings_controller.js b/app/javascript/controllers/course_settings_controller.js index 7ef07515..53ff60ba 100644 --- a/app/javascript/controllers/course_settings_controller.js +++ b/app/javascript/controllers/course_settings_controller.js @@ -1,12 +1,13 @@ 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'); @@ -14,6 +15,11 @@ export default class extends Controller { 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)); @@ -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'); diff --git a/app/models/course.rb b/app/models/course.rb index 4a9d237f..f8de2d0f 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -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 diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb index 1d40b0db..51fd76f4 100644 --- a/app/models/course_settings.rb +++ b/app/models/course_settings.rb @@ -11,6 +11,7 @@ # 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 @@ -18,6 +19,7 @@ # 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 @@ -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) @@ -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 diff --git a/app/models/lms.rb b/app/models/lms.rb index 127d6b8a..8e7ae1d2 100644 --- a/app/models/lms.rb +++ b/app/models/lms.rb @@ -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', '') @@ -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 diff --git a/app/models/request.rb b/app/models/request.rb index 868d8e49..758e6519 100644 --- a/app/models/request.rb +++ b/app/models/request.rb @@ -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 diff --git a/app/views/courses/edit.html.erb b/app/views/courses/edit.html.erb index 5bbb19cf..6e750305 100644 --- a/app/views/courses/edit.html.erb +++ b/app/views/courses/edit.html.erb @@ -145,6 +145,44 @@ +
+
+

Pensieve

+
+
+
+
+ <%= 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' %> + +
+
+ +
+ +
+ <%= 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)' %> + + Extensions are posted through Pensieve's external-client API, so the + PENSIEVE_API_TOKEN issued by Pensieve for your course must be configured for the integration to work. + +
+
+
+
+

Staff Notifications

diff --git a/config/environments/development.rb b/config/environments/development.rb index 1db08070..36716454 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 = { diff --git a/config/initializers/lms_integrations.rb b/config/initializers/lms_integrations.rb index da5987ed..520681c3 100644 --- a/config/initializers/lms_integrations.rb +++ b/config/initializers/lms_integrations.rb @@ -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 diff --git a/db/migrate/20260825000001_add_pensieve_to_course_settings.rb b/db/migrate/20260825000001_add_pensieve_to_course_settings.rb new file mode 100644 index 00000000..ae5a9c4f --- /dev/null +++ b/db/migrate/20260825000001_add_pensieve_to_course_settings.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b2eeeb89..bab928db 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_06_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_08_25_000001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -100,6 +100,7 @@ t.boolean "enable_extensions", default: false t.boolean "enable_gradescope", default: false t.boolean "enable_min_hours_before_deadline", default: true, null: false + t.boolean "enable_pensieve", default: false, null: false t.boolean "enable_slack_webhook_url" t.boolean "extend_late_due_date", default: true, null: false t.string "gradescope_course_url" @@ -107,6 +108,7 @@ t.integer "min_hours_before_deadline", default: 0, null: false t.string "pending_notification_email" t.string "pending_notification_frequency" + t.string "pensieve_course_url" t.string "reply_email" t.string "slack_webhook_url" t.datetime "updated_at", null: false diff --git a/db/seeds.rb b/db/seeds.rb index f172fb25..8654441e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -5,6 +5,9 @@ # Gradescope Lms.find_or_create_by!(id: 2, lms_name: 'Gradescope', use_auth_token: false) +# Pensieve +Lms.find_or_create_by!(id: 3, lms_name: 'Pensieve', use_auth_token: false) + # A special user to track auto-approvals of requests. User.find_or_create_by!( email: SystemUserService::AUTO_APPROVAL_EMAIL, diff --git a/docs/integrations.md b/docs/integrations.md index 60da60c5..1c46a7f1 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -7,6 +7,21 @@ permalink: /integrations/ Flextensions, of course, already integrates with Canvas. However, you may want to connect it with other tools you use to manage your course. +## Pensieve + +Flextensions can post approved extensions to [Pensieve](https://www.pensieve.co) through Pensieve's external-client API. + +### Setting Up Pensieve Integration + +1. Ask Pensieve for an external-client API token; the Flextensions deployment must have it configured as `PENSIEVE_API_TOKEN`. +2. Navigate to the **Course Details** page for your course. +3. Enable **Link Pensieve course** and enter your course's Pensieve URL. +4. Click **Save**. + +When an extension request is approved for a Pensieve assignment, Flextensions grants the student the corresponding number of extra days on the assignment in Pensieve. + +Note that Pensieve extensions are granted in whole days past the original deadline, and Pensieve does not have a separate late (hard) due date. + ## Slack Flextensions can be integrated with Slack to provide real-time notifications and updates. This allows instructors and students to stay informed about important events, such as assignment due dates and extension requests. diff --git a/lib/lmss/pensieve/assignment.rb b/lib/lmss/pensieve/assignment.rb new file mode 100644 index 00000000..8ef82802 --- /dev/null +++ b/lib/lmss/pensieve/assignment.rb @@ -0,0 +1,17 @@ +module Lmss + module Pensieve + class Assignment < BaseAssignment + attr_reader :id, :name, :release_date, :due_date, :late_due_date + + def initialize(data) + # Pensieve's grant-extension endpoint is keyed by assignment URL, so the + # URL is the external assignment id. + @id = data['url'] || data['assignment_url'] + @name = data['name'] || data['title'] + @release_date = data['release_date'] + @due_date = data['due_date'] + @late_due_date = data['late_due_date'] + end + end + end +end diff --git a/lib/lmss/pensieve/client.rb b/lib/lmss/pensieve/client.rb new file mode 100644 index 00000000..88656961 --- /dev/null +++ b/lib/lmss/pensieve/client.rb @@ -0,0 +1,89 @@ +require 'faraday' + +require_relative 'error' + +module Lmss + module Pensieve + ## + # Thin JSON client for the Pensieve external-client API. + # + # Pensieve issues per-course-staff API tokens (see PENSIEVE_API_TOKEN); all + # requests are authenticated with a Bearer token. The only endpoint Pensieve + # has published so far is grant-extension, which is keyed by the assignment's + # URL rather than a numeric id, so Pensieve "external assignment ids" are + # assignment URLs throughout this integration. + class Client + BASE_URL = ENV.fetch('PENSIEVE_API_URL', 'https://api.pensieve.co') + + GRANT_EXTENSION_PATH = '/api/b2s/v1/external-client/grant-extension'.freeze + # NOTE: Pensieve has not published an assignment-listing endpoint yet; this + # path is our proposed shape and must be confirmed with Pensieve before + # assignment sync can work. grant-extension (above) is extracted from the + # legacy berkeley-cdss/extensions integration and is known to exist. + LIST_ASSIGNMENTS_PATH = '/api/b2s/v1/external-client/assignments'.freeze + + def initialize(token) + raise AuthenticationError, 'A Pensieve API token is required' if token.blank? + + @conn = Faraday.new(url: BASE_URL) do |f| + f.request :json + f.response :json + f.headers['Authorization'] = "Bearer #{token}" + f.adapter Faraday.default_adapter + end + end + + ## + # Grants a student extra days on an assignment. + # + # @param [String] assignment_url the Pensieve assignment URL. + # @param [String] student_email the student's email in Pensieve. + # @param [Integer] num_days whole days to extend past the original deadline. + # @return [Hash] the parsed response body. + def grant_extension(assignment_url:, student_email:, num_days:) + response = @conn.post(GRANT_EXTENSION_PATH, { + assignment_url: assignment_url, + student_email: student_email, + num_days: num_days + }) + data = handle_response(response) + + # Pensieve reports failures with a 200 + { success: false } body as well + # as with HTTP errors, so both must be checked. + unless data.is_a?(Hash) && data['success'] + raise RequestError, "Pensieve did not grant the extension: #{data.inspect.truncate(200)}" + end + + data + end + + ## + # Lists the assignments of a Pensieve course. + # + # @param [String] course_url the Pensieve course URL. + # @return [Array] raw assignment hashes. + def list_assignments(course_url) + response = @conn.get(LIST_ASSIGNMENTS_PATH, { course_url: course_url }) + data = handle_response(response) + return data if data.is_a?(Array) + + data.is_a?(Hash) ? Array(data['assignments']) : [] + end + + private + + def handle_response(response) + case response.status + when 200..299 + response.body + when 401, 403 + raise AuthenticationError, 'Authentication to Pensieve failed' + when 404 + raise NotFoundError, 'Pensieve resource not found' + else + raise RequestError, "Pensieve request failed: HTTP #{response.status}" + end + end + end + end +end diff --git a/lib/lmss/pensieve/error.rb b/lib/lmss/pensieve/error.rb new file mode 100644 index 00000000..2573f9d8 --- /dev/null +++ b/lib/lmss/pensieve/error.rb @@ -0,0 +1,8 @@ +module Lmss + module Pensieve + class Error < StandardError; end + class AuthenticationError < Error; end + class NotFoundError < Error; end + class RequestError < Error; end + end +end diff --git a/lib/lmss/pensieve/override.rb b/lib/lmss/pensieve/override.rb new file mode 100644 index 00000000..1406727b --- /dev/null +++ b/lib/lmss/pensieve/override.rb @@ -0,0 +1,20 @@ +module Lmss + module Pensieve + ## + # Pensieve's grant-extension response only acknowledges success (there is no + # way to read an extension back), so this override carries what we sent plus + # any id the response happens to include. + class Override < BaseOverride + attr_reader :id, :student_id, :override_due_date + + def initialize(data, student_email:, override_due_date:) + @id = data.is_a?(Hash) ? (data['extension_id'] || data['id']) : nil + @student_id = student_email + @override_due_date = override_due_date + end + + def override_release_date = nil + def override_late_due_date = nil + end + end +end diff --git a/spec/facades/pensieve_facade_spec.rb b/spec/facades/pensieve_facade_spec.rb new file mode 100644 index 00000000..6b9bbaba --- /dev/null +++ b/spec/facades/pensieve_facade_spec.rb @@ -0,0 +1,175 @@ +require 'rails_helper' + +describe PensieveFacade do + let(:facade) { described_class.new } + let(:course_url) { 'https://www.pensieve.co/courses/123456' } + let(:assignment_url) { 'https://www.pensieve.co/assignments/789012' } + let(:student_email) { 'student@example.com' } + let(:mock_client) { instance_double(Lmss::Pensieve::Client) } + let(:api_token) { 'test-pensieve-token' } + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('PENSIEVE_API_TOKEN').and_return(api_token) + end + + describe '.from_user' do + it 'returns a new instance without requiring a user' do + expect(described_class.from_user).to be_a(described_class) + end + + it 'ignores the user parameter as it is for compatibility' do + expect(described_class.from_user(Object.new)).to be_a(described_class) + end + end + + describe '.assignment_url' do + it 'returns the external assignment id, which is already a URL' do + expect(described_class.assignment_url('https://www.pensieve.co', course_url, assignment_url)) + .to eq(assignment_url) + end + end + + describe '#get_all_assignments' do + let(:assignments_data) do + [ + { + 'url' => assignment_url, + 'name' => 'Homework 1', + 'release_date' => '2026-08-01T00:00:00Z', + 'due_date' => '2026-08-30T23:59:59Z', + 'late_due_date' => '2026-09-01T23:59:59Z' + }, + { + 'assignment_url' => 'https://www.pensieve.co/assignments/345678', + 'title' => 'Homework 2', + 'due_date' => '2026-09-15T23:59:59Z' + } + ] + end + + before do + allow(Lmss::Pensieve::Client).to receive(:new).and_return(mock_client) + end + + it 'builds the client with the configured API token' do + allow(mock_client).to receive(:list_assignments).and_return([]) + expect(Lmss::Pensieve::Client).to receive(:new).with(api_token) + facade.get_all_assignments(course_url) + end + + it 'returns an array of Assignment objects' do + allow(mock_client).to receive(:list_assignments).with(course_url).and_return(assignments_data) + result = facade.get_all_assignments(course_url) + expect(result).to all(be_a(Lmss::Pensieve::Assignment)) + expect(result.length).to eq(2) + end + + it 'correctly parses assignment data' do + allow(mock_client).to receive(:list_assignments).and_return(assignments_data) + result = facade.get_all_assignments(course_url) + expect(result.first.id).to eq(assignment_url) + expect(result.first.name).to eq('Homework 1') + expect(result.first.due_date).to eq('2026-08-30T23:59:59Z') + expect(result.last.id).to eq('https://www.pensieve.co/assignments/345678') + expect(result.last.name).to eq('Homework 2') + expect(result.last.release_date).to be_nil + end + + it 'logs and re-raises authentication errors' do + allow(mock_client).to receive(:list_assignments).and_raise(Lmss::Pensieve::AuthenticationError, 'bad token') + expect(Rails.logger).to receive(:error).with(/Pensieve authentication failed: bad token/) + expect { facade.get_all_assignments(course_url) }.to raise_error(Lmss::Pensieve::AuthenticationError) + end + + it 'logs and returns an empty array on general errors' do + allow(mock_client).to receive(:list_assignments).and_raise(StandardError, 'Network error') + expect(Rails.logger).to receive(:error).with(/Failed to fetch Pensieve assignments: Network error/) + expect(facade.get_all_assignments(course_url)).to eq([]) + end + end + + describe '#get_assignment_overrides' do + it 'returns an empty array because Pensieve cannot read extensions back' do + expect(facade.get_assignment_overrides(course_url, assignment_url)).to eq([]) + end + end + + describe '#provision_extension' do + let(:pensieve_lms) { Lms.find_or_create_by!(id: PENSIEVE_LMS_ID, lms_name: 'Pensieve') } + let(:course) { create(:course) } + let(:course_to_lms) do + create(:course_to_lms, course: course, lms: pensieve_lms, external_course_id: course_url) + end + let(:new_due_date) { '2026-09-02T23:59:00Z' } + + before do + create(:assignment, + course_to_lms: course_to_lms, + external_assignment_id: assignment_url, + due_date: Time.zone.parse('2026-08-30T23:59:00Z')) + allow(Lmss::Pensieve::Client).to receive(:new).and_return(mock_client) + end + + it 'grants the extension with the number of days past the original deadline' do + expect(mock_client).to receive(:grant_extension).with( + assignment_url: assignment_url, + student_email: student_email, + num_days: 3 + ).and_return({ 'success' => true }) + + facade.provision_extension(course_url, student_email, assignment_url, new_due_date) + end + + it 'returns an override carrying the student and new due date' do + allow(mock_client).to receive(:grant_extension).and_return({ 'success' => true, 'extension_id' => 42 }) + + override = facade.provision_extension(course_url, student_email, assignment_url, new_due_date) + + expect(override).to be_a(Lmss::Pensieve::Override) + expect(override.id).to eq(42) + expect(override.student_id).to eq(student_email) + expect(override.override_due_date).to eq(new_due_date) + end + + it 'returns nil without calling the API when no synced assignment matches' do + expect(mock_client).not_to receive(:grant_extension) + expect(Rails.logger).to receive(:error).with(/no synced assignment with a due date/) + + result = facade.provision_extension(course_url, student_email, 'https://www.pensieve.co/assignments/unknown', new_due_date) + expect(result).to be_nil + end + + it 'returns nil without calling the API when the requested date grants no additional days' do + expect(mock_client).not_to receive(:grant_extension) + expect(Rails.logger).to receive(:error).with(/grants no additional days/) + + result = facade.provision_extension(course_url, student_email, assignment_url, '2026-08-30T20:00:00Z') + expect(result).to be_nil + end + + it 'logs and re-raises API errors' do + allow(mock_client).to receive(:grant_extension).and_raise(Lmss::Pensieve::RequestError, 'HTTP 500') + expect(Rails.logger).to receive(:error).with(/Failed to provision Pensieve extension: HTTP 500/) + + expect do + facade.provision_extension(course_url, student_email, assignment_url, new_due_date) + end.to raise_error(Lmss::Pensieve::RequestError) + end + end + + describe '#ensure_authenticated!' do + it 'raises PensieveAPIError when PENSIEVE_API_TOKEN is not set' do + allow(ENV).to receive(:fetch).with('PENSIEVE_API_TOKEN').and_raise(KeyError) + expect do + facade.send(:ensure_authenticated!) + end.to raise_error(PensieveFacade::PensieveAPIError, /PENSIEVE_API_TOKEN must be set/) + end + + it 'does not create a new client when already authenticated' do + facade.instance_variable_set(:@pensieve_conn, mock_client) + expect(Lmss::Pensieve::Client).not_to receive(:new) + facade.send(:ensure_authenticated!) + end + end +end diff --git a/spec/factories/lms.rb b/spec/factories/lms.rb index 95bd90e3..69f9ef32 100644 --- a/spec/factories/lms.rb +++ b/spec/factories/lms.rb @@ -14,5 +14,11 @@ lms_name { 'Gradescope' } use_auth_token { false } end + + trait :pensieve do + id { 3 } + lms_name { 'Pensieve' } + use_auth_token { false } + end end end diff --git a/spec/lib/lmss/pensieve/client_spec.rb b/spec/lib/lmss/pensieve/client_spec.rb new file mode 100644 index 00000000..01e8fddb --- /dev/null +++ b/spec/lib/lmss/pensieve/client_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe Lmss::Pensieve::Client do + subject(:client) { described_class.new('test-token') } + + let(:grant_url) { "#{described_class::BASE_URL}#{described_class::GRANT_EXTENSION_PATH}" } + let(:list_url) { "#{described_class::BASE_URL}#{described_class::LIST_ASSIGNMENTS_PATH}" } + + describe '#initialize' do + it 'raises AuthenticationError without a token' do + expect { described_class.new(nil) }.to raise_error(Lmss::Pensieve::AuthenticationError) + expect { described_class.new('') }.to raise_error(Lmss::Pensieve::AuthenticationError) + end + end + + describe '#grant_extension' do + it 'posts the assignment URL, student email, and days with the bearer token' do + stub = stub_request(:post, grant_url) + .with( + headers: { 'Authorization' => 'Bearer test-token' }, + body: { + assignment_url: 'https://www.pensieve.co/assignments/1', + student_email: 'student@example.com', + num_days: 2 + }.to_json + ) + .to_return(status: 200, body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + result = client.grant_extension( + assignment_url: 'https://www.pensieve.co/assignments/1', + student_email: 'student@example.com', + num_days: 2 + ) + + expect(stub).to have_been_requested + expect(result).to eq({ 'success' => true }) + end + + it 'raises RequestError when Pensieve responds 200 without success' do + stub_request(:post, grant_url) + .to_return(status: 200, body: { success: false, error: 'nope' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect do + client.grant_extension(assignment_url: 'a', student_email: 'b', num_days: 1) + end.to raise_error(Lmss::Pensieve::RequestError, /did not grant/) + end + + it 'raises AuthenticationError for 401 responses' do + stub_request(:post, grant_url).to_return(status: 401) + + expect do + client.grant_extension(assignment_url: 'a', student_email: 'b', num_days: 1) + end.to raise_error(Lmss::Pensieve::AuthenticationError) + end + + it 'raises RequestError for server errors' do + stub_request(:post, grant_url).to_return(status: 500) + + expect do + client.grant_extension(assignment_url: 'a', student_email: 'b', num_days: 1) + end.to raise_error(Lmss::Pensieve::RequestError, /500/) + end + end + + describe '#list_assignments' do + it 'returns the assignments array from a wrapped response' do + stub_request(:get, list_url) + .with(query: { course_url: 'https://www.pensieve.co/courses/1' }) + .to_return(status: 200, + body: { success: true, assignments: [ { 'url' => 'x' } ] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect(client.list_assignments('https://www.pensieve.co/courses/1')).to eq([ { 'url' => 'x' } ]) + end + + it 'returns a bare array response as-is' do + stub_request(:get, list_url) + .with(query: { course_url: 'c' }) + .to_return(status: 200, body: [ { 'url' => 'x' } ].to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect(client.list_assignments('c')).to eq([ { 'url' => 'x' } ]) + end + + it 'raises NotFoundError while Pensieve has not published the endpoint' do + stub_request(:get, list_url).with(query: { course_url: 'c' }).to_return(status: 404) + + expect { client.list_assignments('c') }.to raise_error(Lmss::Pensieve::NotFoundError) + end + end +end diff --git a/spec/models/course_settings_spec.rb b/spec/models/course_settings_spec.rb index d6dadaf9..04531268 100644 --- a/spec/models/course_settings_spec.rb +++ b/spec/models/course_settings_spec.rb @@ -11,6 +11,7 @@ # 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 @@ -18,6 +19,7 @@ # 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 @@ -126,6 +128,42 @@ expect(course_settings).to be_valid end end + + context 'when enable_pensieve is true' do + it 'validates pensieve_course_url format' do + course_settings.enable_pensieve = true + course_settings.pensieve_course_url = 'https://www.pensieve.co/courses/123456' + expect(course_settings).to be_valid + end + + it 'accepts pensieve.co without www' do + course_settings.enable_pensieve = true + course_settings.pensieve_course_url = 'https://pensieve.co/courses/123456' + expect(course_settings).to be_valid + end + + it 'rejects invalid pensieve_course_url' do + course_settings.enable_pensieve = true + course_settings.pensieve_course_url = 'https://example.com/invalid' + expect(course_settings).not_to be_valid + expect(course_settings.errors[:pensieve_course_url]).to include('must be a valid Pensieve course URL like https://www.pensieve.co/courses/123456') + end + + it 'rejects a blank pensieve_course_url' do + course_settings.enable_pensieve = true + course_settings.pensieve_course_url = nil + expect(course_settings).not_to be_valid + expect(course_settings.errors[:pensieve_course_url]).to include('must be a valid Pensieve course URL like https://www.pensieve.co/courses/123456') + end + end + + context 'when enable_pensieve is false' do + it 'does not validate pensieve_course_url' do + course_settings.enable_pensieve = false + course_settings.pensieve_course_url = 'invalid_url' + expect(course_settings).to be_valid + end + end end describe '#create_or_update_gradescope_link' do @@ -216,6 +254,48 @@ end end + describe '#create_or_update_pensieve_link' do + let(:pensieve_url) { 'https://www.pensieve.co/courses/123456' } + + context 'when enable_pensieve is true' do + before do + course_settings.enable_pensieve = true + course_settings.pensieve_course_url = pensieve_url + end + + it 'creates a CourseToLms record storing the course URL as the external id' do + expect do + course_settings.save! + end.to change(CourseToLms, :count).by(1) + + course_to_lms = CourseToLms.find_by(course_id: course.id, lms_id: PENSIEVE_LMS_ID) + expect(course_to_lms).to be_present + expect(course_to_lms.external_course_id).to eq(pensieve_url) + end + + it 'updates existing CourseToLms record if it already exists' do + CourseToLms.create!(course_id: course.id, lms_id: PENSIEVE_LMS_ID, external_course_id: 'https://www.pensieve.co/courses/999999') + + expect do + course_settings.save! + end.not_to change(CourseToLms, :count) + + course_to_lms = CourseToLms.find_by(course_id: course.id, lms_id: PENSIEVE_LMS_ID) + expect(course_to_lms.external_course_id).to eq(pensieve_url) + end + end + + context 'when enable_pensieve is false' do + it 'does not create a CourseToLms record' do + course_settings.enable_pensieve = false + + expect do + course_settings.save! + end.not_to change(CourseToLms, :count) + end + end + end + describe 'pending notification validations' do context 'pending_notification_frequency' do it 'accepts nil' do diff --git a/spec/models/lms_spec.rb b/spec/models/lms_spec.rb index 7956f7b9..d23b1943 100644 --- a/spec/models/lms_spec.rb +++ b/spec/models/lms_spec.rb @@ -18,9 +18,11 @@ around do |example| described_class.instance_variable_set(:@canvas_lms, nil) described_class.instance_variable_set(:@gradescope_lms, nil) + described_class.instance_variable_set(:@pensieve_lms, nil) example.run described_class.instance_variable_set(:@canvas_lms, nil) described_class.instance_variable_set(:@gradescope_lms, nil) + described_class.instance_variable_set(:@pensieve_lms, nil) end describe '.preload!' do @@ -60,4 +62,30 @@ expect(described_class.CANVAS_LMS.id).to eq(CANVAS_LMS_ID) end end + + describe '.PENSIEVE_LMS' do + it 'creates the Pensieve row with id 3 when it is missing' do + CourseToLms.where(lms_id: PENSIEVE_LMS_ID).delete_all + described_class.where(id: PENSIEVE_LMS_ID).delete_all + + pensieve = described_class.PENSIEVE_LMS + + expect(pensieve.id).to eq(PENSIEVE_LMS_ID) + expect(pensieve.lms_name).to eq('Pensieve') + expect(pensieve.lms_base_url).to eq('https://www.pensieve.co') + expect(pensieve.use_auth_token).to be(false) + end + end + + describe '.facade_class' do + it 'maps each known LMS id to its facade' do + expect(described_class.facade_class(CANVAS_LMS_ID)).to eq(CanvasFacade) + expect(described_class.facade_class(GRADESCOPE_LMS_ID)).to eq(GradescopeFacade) + expect(described_class.facade_class(PENSIEVE_LMS_ID)).to eq(PensieveFacade) + end + + it 'raises for an unknown LMS id' do + expect { described_class.facade_class(999) }.to raise_error(/Unsupported LMS ID/) + end + end end diff --git a/spec/models/request_spec.rb b/spec/models/request_spec.rb index 68737df7..84bac6c4 100644 --- a/spec/models/request_spec.rb +++ b/spec/models/request_spec.rb @@ -830,6 +830,49 @@ expect(request.status).to eq('approved') end end + + context 'with Pensieve facade' do + let(:pensieve_facade) { PensieveFacade.new } + let(:pensieve_override) { instance_double(Lmss::Pensieve::Override, id: 'pensieve-override-1') } + + before do + # Link the course to Pensieve so course.pensieve_id is present + CourseToLms.create!(course: course, lms_id: PENSIEVE_LMS_ID, external_course_id: 'https://www.pensieve.co/courses/123') + allow(pensieve_facade).to receive(:provision_extension).and_return(pensieve_override) + end + + it 'provisions an extension through Pensieve with email identifier' do + allow(mock_date_calculator).to receive(:calculate).and_return({ + release_date: nil, + due_date: request.requested_due_date, + late_due_date: nil + }) + allow(request).to receive(:date_calculator).and_return(mock_date_calculator) + request.approve(pensieve_facade, instructor) + + expect(pensieve_facade).to have_received(:provision_extension).with( + course.pensieve_id, + user.email, + assignment.external_assignment_id, + request.requested_due_date.iso8601, + nil + ) + end + + it 'marks the request as approved and records Pensieve metadata' do + allow(mock_date_calculator).to receive(:calculate).and_return({ + release_date: nil, + due_date: request.requested_due_date, + late_due_date: nil + }) + allow(request).to receive(:date_calculator).and_return(mock_date_calculator) + expect(request.approve(pensieve_facade, instructor)).to be(true) + + expect(request.status).to eq('approved') + expect(request.last_processed_by_user_id).to eq(instructor.id) + expect(request.external_extension_id).to eq('pensieve-override-1') + end + end end describe '#reject' do