diff --git a/.env.example b/.env.example index eea2f54f..4eafd801 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,16 @@ CANVAS_URL='https://ucberkeleysandbox.instructure.com' # This email must be invited to each Gradescope course as a TA or Instructor GRADESCOPE_EMAIL='' GRADESCOPE_PASSWORD='' +## Pensive Configuration +# The Pensive account must be invited to every class Flextensions manages. +# Generate the API token from that account's profile. +PENSIEVE_EMAIL='' +PENSIEVE_API_TOKEN='' +# Pensive's legacy extension API does not list assignments. Configure the path +# supplied by Pensive for GET requests with a `class_id` query parameter. +# See docs/integrations.md for the expected response payload. +PENSIEVE_ASSIGNMENTS_PATH='' +# PENSIEVE_API_URL='https://api.pensieve.co' # 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/facades/pensive_facade.rb b/app/facades/pensive_facade.rb new file mode 100644 index 00000000..d6f540e5 --- /dev/null +++ b/app/facades/pensive_facade.rb @@ -0,0 +1,127 @@ +class PensiveFacade < LmsFacade + class PensiveAPIError < LmsFacade::LmsAPIError; end + + API_URL = ENV.fetch('PENSIEVE_API_URL', 'https://api.pensieve.co') + GRANT_EXTENSION_PATH = '/api/b2s/v1/external-client/grant-extension' + + def initialize(token = nil, conn = nil) + @email = ENV['PENSIEVE_EMAIL'] + @api_token = token.presence || ENV['PENSIEVE_API_TOKEN'] + raise PensiveAPIError, 'PENSIEVE_EMAIL must be set to use Pensive' if @email.blank? + raise PensiveAPIError, 'PENSIEVE_API_TOKEN must be set to use Pensive' if @api_token.blank? + + @pensive_conn = conn || Faraday.new( + url: API_URL, + headers: { + 'Authorization' => "Bearer #{@api_token}", + 'Content-Type' => 'application/json' + }, + request: { timeout: 30 } + ) + end + + # Pensive uses an integration account rather than the acting user's token. + def self.from_user(_user = nil) + new + end + + # Pensive identifies assignments by URL. Assignment sync therefore stores + # the URL as external_assignment_id, making this normally an identity method. + def self.assignment_url(base_url, external_course_id, external_assignment_id) + return external_assignment_id if external_assignment_id.to_s.match?(%r{\Ahttps?://}) + + "#{base_url.to_s.chomp('/')}/teacher/classes/#{external_course_id}/my-assignments/#{external_assignment_id}" + end + + # The legacy Pensive API only exposes grant-extension. PENSIEVE_ASSIGNMENTS_PATH + # supplies the assignment-list endpoint separately so the rest of the + # Flextensions assignment-sync pipeline can stay LMS-agnostic. + # + # Expected response: + # { "success": true, "assignments": [ + # { "assignment_url": "https://...", "name": "Homework 1", + # "release_date": "...", "due_date": "...", "hard_due_date": "..." } + # ] } + def get_all_assignments(course_id) + path = ENV['PENSIEVE_ASSIGNMENTS_PATH'].presence + unless path + raise PensiveAPIError, + 'PENSIEVE_ASSIGNMENTS_PATH must be set to a Pensive assignment-list API endpoint' + end + + response = request(:get, path, { class_id: course_id }) + data = parse_success_response(response, operation: 'fetch assignments') + assignments = data['assignments'] + raise PensiveAPIError, 'Pensive assignment response did not contain an assignments array' unless assignments.is_a?(Array) + + assignments.map { |assignment| Lmss::Pensive::Assignment.new(assignment) } + rescue ArgumentError => e + raise PensiveAPIError, "Pensive returned an invalid assignment: #{e.message}" + end + + def get_assignment_overrides(_course_id, _assignment_id) + raise PensiveAPIError, 'Pensive does not expose an API for listing assignment extensions' + end + + # Pensive's API differs from date-based LMS APIs: it accepts a whole-day + # extension instead of an absolute due date. The caller supplies both forms + # so this method remains compatible with LmsFacade#provision_extension. + def provision_extension(_course_id, student_email, assignment_url, _new_due_date, + _new_late_due_date = nil, extension_days:) + unless assignment_url.to_s.match?(%r{\Ahttps?://}) + raise PensiveAPIError, 'Pensive assignment URL must be an absolute HTTP(S) URL' + end + + days = Integer(extension_days) + raise PensiveAPIError, 'Pensive extension days must be positive' unless days.positive? + + response = request( + :post, + GRANT_EXTENSION_PATH, + { + assignment_url: assignment_url, + student_email: student_email, + num_days: days + } + ) + data = parse_success_response(response, operation: 'grant extension') + Lmss::Pensive::Override.new(data, student_email: student_email, extension_days: days) + rescue ArgumentError, TypeError + raise PensiveAPIError, 'Pensive extension days must be a positive integer' + end + + private + + def request(method, path, payload) + case method + when :get + @pensive_conn.get(path, payload) + when :post + @pensive_conn.post(path) { |request| request.body = payload.to_json } + else + raise ArgumentError, "Unsupported HTTP method: #{method}" + end + rescue Faraday::Error => e + raise PensiveAPIError, "Pensive request failed: #{e.message}" + end + + def parse_success_response(response, operation:) + unless response.status.between?(200, 299) + raise PensiveAPIError, + "Pensive could not #{operation} (HTTP #{response.status}): #{truncate(response.body)}" + end + + data = JSON.parse(response.body) + unless data.is_a?(Hash) && data['success'] == true + raise PensiveAPIError, "Pensive could not #{operation}: #{truncate(data.inspect)}" + end + + data + rescue JSON::ParserError + raise PensiveAPIError, "Pensive returned invalid JSON while attempting to #{operation}" + end + + def truncate(value) + value.to_s.truncate(500) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ec2c373a..4880b3fd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -41,15 +41,18 @@ def sidebar_nav_item(path:, icon:, nav:, text: nil, &block) def assignment_link_for(assignment, course) case assignment.course_to_lms.lms_id - when 1 + when CANVAS_LMS_ID url = "#{ENV.fetch('CANVAS_URL')}/courses/#{course.canvas_id}/assignments/#{assignment.external_assignment_id}" name = 'bCourses' - when 2 + when GRADESCOPE_LMS_ID url = "#{course.course_settings.gradescope_course_url}/assignments/#{assignment.external_assignment_id}" name = 'Gradescope' else - nil + url = assignment.external_url + name = assignment.course_to_lms.lms.lms_name end + return if url.blank? + link_to url, target: '_blank', class: 'text-nowrap ms-2', rel: 'noopener' do safe_join([ name, content_tag(:i, '', class: 'fas fa-up-right-from-square') ], ' ') end diff --git a/app/models/course.rb b/app/models/course.rb index 4a9d237f..35be9a1a 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -189,6 +189,10 @@ def gradescope_id external_course_id_for(GRADESCOPE_LMS_ID) end + def pensive_id + external_course_id_for(PENSIVE_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/lms.rb b/app/models/lms.rb index 127d6b8a..153d215f 100644 --- a/app/models/lms.rb +++ b/app/models/lms.rb @@ -38,10 +38,21 @@ def self.GRADESCOPE_LMS end end + def self.PENSIVE_LMS + @pensive_lms ||= find_by(id: PENSIVE_LMS_ID) || find_or_create_by!( + id: PENSIVE_LMS_ID, + lms_name: 'Pensive' + ) 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? + @pensive_lms = find_by(id: PENSIVE_LMS_ID) if @pensive_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 PENSIVE_LMS_ID + PensiveFacade else raise "Unsupported LMS ID: #{id}" end diff --git a/app/models/request.rb b/app/models/request.rb index 868d8e49..e9a54578 100644 --- a/app/models/request.rb +++ b/app/models/request.rb @@ -222,18 +222,26 @@ def approve(lms_facade, processed_user_id) when GradescopeFacade course_id = course.gradescope_id user_id = user.email + when PensiveFacade + course_id = course.pensive_id + user_id = user.email else raise "Unsupported LMS Facade: #{lms_facade.class.name}" end dates = date_calculator.calculate - override = lms_facade.provision_extension( + provision_args = [ course_id, user_id, assignment.external_assignment_id, dates[:due_date].iso8601, dates[:late_due_date]&.iso8601 - ) + ] + if lms_facade.is_a?(PensiveFacade) + override = lms_facade.provision_extension(*provision_args, extension_days: calculate_days_difference) + else + override = lms_facade.provision_extension(*provision_args) + end rescue => e Rails.logger.error "Error during LMS extension provisioning for request #{id}: #{e.message}" Rails.error.report(e, handled: true, 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..a1281d28 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 +PENSIVE_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/20260825000000_add_pensive_lms.rb b/db/migrate/20260825000000_add_pensive_lms.rb new file mode 100644 index 00000000..a91ae1a4 --- /dev/null +++ b/db/migrate/20260825000000_add_pensive_lms.rb @@ -0,0 +1,21 @@ +class AddPensiveLms < ActiveRecord::Migration[8.1] + def up + safety_assured do + execute <<~SQL.squish + INSERT INTO lmss (id, lms_name, lms_base_url, use_auth_token, created_at, updated_at) + VALUES (3, 'Pensive', 'https://www.pensieve.co', FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO UPDATE SET + lms_name = EXCLUDED.lms_name, + lms_base_url = EXCLUDED.lms_base_url, + use_auth_token = EXCLUDED.use_auth_token, + updated_at = EXCLUDED.updated_at + SQL + end + end + + def down + safety_assured do + execute "DELETE FROM lmss WHERE id = 3 AND lms_name = 'Pensive'" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b2eeeb89..5d1bd956 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_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/db/seeds.rb b/db/seeds.rb index f172fb25..b35b425c 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -5,6 +5,11 @@ # Gradescope Lms.find_or_create_by!(id: 2, lms_name: 'Gradescope', use_auth_token: false) +# Pensive (the API retains the legacy pensieve.co domain and environment names) +Lms.find_or_create_by!(id: 3, lms_name: 'Pensive', use_auth_token: false) do |lms| + lms.lms_base_url = 'https://www.pensieve.co' +end + # 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..849c7c8a 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -29,3 +29,61 @@ Flextensions uses a Slack Webhook to send notifications to your Slack workspace, 2. Click on **Slack Integration**. 3. Paste the Webhook URL into the provided field. 4. Click **Save** to enable Slack notifications. + +## Pensive + +Pensive uses an application-level integration account rather than each +instructor's credentials. The Pensive account must be invited to every Pensive +class Flextensions will manage. Generate an API token from that account's +profile, then configure: + +```dotenv +PENSIEVE_EMAIL=service-account@example.edu +PENSIEVE_API_TOKEN=... +PENSIEVE_ASSIGNMENTS_PATH=/path/provided-by-pensive +``` + +The `PENSIEVE_*` spelling is retained for compatibility with Pensive's legacy +API and the original extensions integration. `PENSIEVE_API_URL` can override +the default API host (`https://api.pensieve.co`) when needed. + +Link each Flextensions course to LMS id `3`, using the Pensive class id as the +`CourseToLms.external_course_id`. The existing +`POST /api/v1/courses/:course_id/lmss` API can create this link; send `lms_id` +and `external_course_id` in the request body. + +### Assignment sync contract + +The extracted legacy integration only provides extension posting; it does not +provide an assignment-list endpoint. Flextensions calls the configured +`PENSIEVE_ASSIGNMENTS_PATH` with a bearer token and a `class_id` query +parameter. Pensive must provide that endpoint with this response shape: + +```json +{ + "success": true, + "assignments": [ + { + "assignment_url": "https://www.pensieve.co/teacher/classes/example/my-assignments/online/assignment-id/extensions", + "name": "Homework 1", + "release_date": "2026-08-01T00:00:00Z", + "due_date": "2026-08-08T07:00:00Z", + "hard_due_date": "2026-08-10T07:00:00Z" + } + ] +} +``` + +`assignment_url` and `name` are required. The date fields may be null. The full +assignment URL is stored as the external assignment id because Pensive's +extension API uses that URL, rather than a standalone assignment id, to select +the assignment. + +### Posting extensions + +Flextensions posts approved requests to +`/api/b2s/v1/external-client/grant-extension` with the assignment URL, student +email, and requested number of whole extension days. A successful response must +contain `{"success": true}`. If Pensive supplies an `extension_id`, it is saved +on the request; the legacy response omits it, so that field otherwise remains +blank. diff --git a/lib/lmss/pensive/assignment.rb b/lib/lmss/pensive/assignment.rb new file mode 100644 index 00000000..311a0f5c --- /dev/null +++ b/lib/lmss/pensive/assignment.rb @@ -0,0 +1,23 @@ +module Lmss + module Pensive + class Assignment < BaseAssignment + attr_reader :id, :name, :release_date, :due_date, :late_due_date + + def initialize(data) + @id = data['assignment_url'].presence || raise(ArgumentError, 'Pensive assignment URL is missing') + @name = data['name'].presence || data['title'].presence || raise(ArgumentError, 'Pensive assignment name is missing') + @release_date = parse_date(data['release_date']) + @due_date = parse_date(data['due_date']) + @late_due_date = parse_date(data['hard_due_date'] || data['late_due_date']) + end + + private + + def parse_date(value) + Time.zone.parse(value) if value.present? + rescue ArgumentError + raise ArgumentError, "Invalid Pensive assignment date: #{value}" + end + end + end +end diff --git a/lib/lmss/pensive/override.rb b/lib/lmss/pensive/override.rb new file mode 100644 index 00000000..37e83f32 --- /dev/null +++ b/lib/lmss/pensive/override.rb @@ -0,0 +1,18 @@ +module Lmss + module Pensive + class Override < BaseOverride + attr_reader :id, :student_id, :extension_days, :message + + def initialize(data, student_email:, extension_days:) + @id = data['extension_id'] + @student_id = student_email + @extension_days = extension_days + @message = data['message'] + end + + def override_release_date = nil + def override_due_date = nil + def override_late_due_date = nil + end + end +end diff --git a/spec/Helpers/application_helper_spec.rb b/spec/Helpers/application_helper_spec.rb index 495fb8ae..fd0cd24b 100644 --- a/spec/Helpers/application_helper_spec.rb +++ b/spec/Helpers/application_helper_spec.rb @@ -80,6 +80,31 @@ def section_for(controller, action) end end + describe '#assignment_link_for' do + it 'links Pensive assignments using their stored assignment URL' do + course = create(:course) + course_to_lms = create( + :course_to_lms, + course: course, + lms: Lms.PENSIVE_LMS, + external_course_id: 'class-123' + ) + assignment_url = + 'https://www.pensieve.co/teacher/classes/class-123/my-assignments/online/assignment-456/extensions' + assignment = create( + :assignment, + course_to_lms: course_to_lms, + external_assignment_id: assignment_url + ) + + link = helper.assignment_link_for(assignment, course) + + expect(link).to include(%(href="#{assignment_url}")) + expect(link).to include('Pensive') + expect(link).to include('target="_blank"') + end + end + describe '#deployment_note' do let(:deployed_at) { Time.zone.local(2026, 8, 5, 10, 30) } let(:sha) { 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2' } diff --git a/spec/facades/pensive_facade_spec.rb b/spec/facades/pensive_facade_spec.rb new file mode 100644 index 00000000..7d16287f --- /dev/null +++ b/spec/facades/pensive_facade_spec.rb @@ -0,0 +1,187 @@ +require 'rails_helper' + +RSpec.describe PensiveFacade do + subject(:facade) { described_class.new } + + let(:email) { 'integration@example.edu' } + let(:token) { 'pensive-token' } + let(:assignment_url) do + 'https://www.pensieve.co/teacher/classes/class-123/my-assignments/online/assignment-456/extensions' + end + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('PENSIEVE_EMAIL').and_return(email) + allow(ENV).to receive(:[]).with('PENSIEVE_API_TOKEN').and_return(token) + allow(ENV).to receive(:[]).with('PENSIEVE_ASSIGNMENTS_PATH') + .and_return('/api/b2s/v1/external-client/assignments') + end + + describe '.from_user' do + it 'uses the service account instead of the acting user' do + expect(described_class.from_user(Object.new)).to be_a(described_class) + end + end + + describe '.assignment_url' do + it 'returns the stored Pensive assignment URL' do + expect(described_class.assignment_url('https://www.pensieve.co', 'class-123', assignment_url)) + .to eq(assignment_url) + end + + it 'builds a URL when given a bare assignment id' do + expect(described_class.assignment_url('https://www.pensieve.co/', 'class-123', 'assignment-456')) + .to eq('https://www.pensieve.co/teacher/classes/class-123/my-assignments/assignment-456') + end + end + + describe '#get_all_assignments' do + let(:response_body) do + { + success: true, + assignments: [ + { + assignment_url: assignment_url, + name: 'Homework 1', + release_date: '2026-08-01T00:00:00Z', + due_date: '2026-08-08T07:00:00Z', + hard_due_date: '2026-08-10T07:00:00Z' + } + ] + }.to_json + end + + before do + stub_request(:get, "#{described_class::API_URL}/api/b2s/v1/external-client/assignments") + .with( + query: { class_id: 'class-123' }, + headers: { 'Authorization' => "Bearer #{token}" } + ) + .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' }) + end + + it 'maps the assignment-list response to shared assignment objects' do + assignments = facade.get_all_assignments('class-123') + + expect(assignments.length).to eq(1) + expect(assignments.first).to be_a(Lmss::Pensive::Assignment) + expect(assignments.first.id).to eq(assignment_url) + expect(assignments.first.name).to eq('Homework 1') + expect(assignments.first.release_date).to eq(Time.zone.parse('2026-08-01T00:00:00Z')) + expect(assignments.first.due_date).to eq(Time.zone.parse('2026-08-08T07:00:00Z')) + expect(assignments.first.late_due_date).to eq(Time.zone.parse('2026-08-10T07:00:00Z')) + end + + it 'requires the configured assignment-list endpoint' do + allow(ENV).to receive(:[]).with('PENSIEVE_ASSIGNMENTS_PATH').and_return(nil) + + expect { facade.get_all_assignments('class-123') } + .to raise_error(described_class::PensiveAPIError, /PENSIEVE_ASSIGNMENTS_PATH/) + end + + it 'rejects unsuccessful payloads' do + stub_request(:get, "#{described_class::API_URL}/api/b2s/v1/external-client/assignments") + .with(query: { class_id: 'class-123' }) + .to_return(status: 200, body: { success: false }.to_json) + + expect { facade.get_all_assignments('class-123') } + .to raise_error(described_class::PensiveAPIError, /could not fetch assignments/) + end + + it 'rejects assignments without a URL' do + stub_request(:get, "#{described_class::API_URL}/api/b2s/v1/external-client/assignments") + .with(query: { class_id: 'class-123' }) + .to_return( + status: 200, + body: { success: true, assignments: [ { name: 'Homework 1' } ] }.to_json + ) + + expect { facade.get_all_assignments('class-123') } + .to raise_error(described_class::PensiveAPIError, /assignment URL is missing/) + end + end + + describe '#provision_extension' do + before do + stub_request(:post, "#{described_class::API_URL}#{described_class::GRANT_EXTENSION_PATH}") + .with( + headers: { + 'Authorization' => "Bearer #{token}", + 'Content-Type' => 'application/json' + }, + body: { + assignment_url: assignment_url, + student_email: 'student@example.edu', + num_days: 3 + }.to_json + ) + .to_return( + status: 200, + body: { success: true, message: 'Successfully updated extension' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'posts the legacy Pensive extension payload and returns an override' do + override = facade.provision_extension( + 'class-123', + 'student@example.edu', + assignment_url, + '2026-08-11T07:00:00Z', + nil, + extension_days: 3 + ) + + expect(override).to be_a(Lmss::Pensive::Override) + expect(override.student_id).to eq('student@example.edu') + expect(override.extension_days).to eq(3) + expect(override.id).to be_nil + end + + it 'rejects non-positive extension days without making a request' do + expect do + facade.provision_extension( + 'class-123', 'student@example.edu', assignment_url, '2026-08-08T07:00:00Z', + extension_days: 0 + ) + end.to raise_error(described_class::PensiveAPIError, /must be positive/) + end + + it 'rejects a bare assignment id without making a request' do + expect do + facade.provision_extension( + 'class-123', 'student@example.edu', 'assignment-456', '2026-08-11T07:00:00Z', + extension_days: 3 + ) + end.to raise_error(described_class::PensiveAPIError, /absolute HTTP/) + end + + it 'raises a facade error when Pensive rejects the request' do + stub_request(:post, "#{described_class::API_URL}#{described_class::GRANT_EXTENSION_PATH}") + .to_return(status: 401, body: 'unauthorized') + + expect do + facade.provision_extension( + 'class-123', 'student@example.edu', assignment_url, '2026-08-11T07:00:00Z', + extension_days: 3 + ) + end.to raise_error(described_class::PensiveAPIError, /HTTP 401/) + end + end + + describe 'configuration' do + it 'requires an integration email' do + allow(ENV).to receive(:[]).with('PENSIEVE_EMAIL').and_return(nil) + + expect { described_class.new } + .to raise_error(described_class::PensiveAPIError, /PENSIEVE_EMAIL/) + end + + it 'requires an API token' do + allow(ENV).to receive(:[]).with('PENSIEVE_API_TOKEN').and_return(nil) + + expect { described_class.new } + .to raise_error(described_class::PensiveAPIError, /PENSIEVE_API_TOKEN/) + end + end +end diff --git a/spec/factories/lms.rb b/spec/factories/lms.rb index 95bd90e3..07e2bdc9 100644 --- a/spec/factories/lms.rb +++ b/spec/factories/lms.rb @@ -14,5 +14,12 @@ lms_name { 'Gradescope' } use_auth_token { false } end + + trait :pensive do + id { 3 } + lms_name { 'Pensive' } + lms_base_url { 'https://www.pensieve.co' } + use_auth_token { false } + end end end diff --git a/spec/jobs/sync_all_course_assignments_job_spec.rb b/spec/jobs/sync_all_course_assignments_job_spec.rb index e00bdf13..31b8597b 100644 --- a/spec/jobs/sync_all_course_assignments_job_spec.rb +++ b/spec/jobs/sync_all_course_assignments_job_spec.rb @@ -151,6 +151,52 @@ }.not_to raise_error end end + + context 'with a Pensive course link' do + let(:pensive_course_to_lms) do + create( + :course_to_lms, + course: course, + lms: Lms.PENSIVE_LMS, + external_course_id: 'pensive-class' + ) + end + let(:pensive_facade) { instance_double(PensiveFacade) } + let(:assignment_url) do + 'https://www.pensieve.co/teacher/classes/pensive-class/my-assignments/online/assignment-id/extensions' + end + let(:pensive_assignments) do + [ + Lmss::Pensive::Assignment.new( + 'assignment_url' => assignment_url, + 'name' => 'Pensive Homework', + 'release_date' => '2026-08-01T00:00:00Z', + 'due_date' => '2026-08-08T07:00:00Z', + 'hard_due_date' => '2026-08-10T07:00:00Z' + ) + ] + end + + before do + allow(PensiveFacade).to receive(:from_user).with(sync_user).and_return(pensive_facade) + allow(pensive_facade).to receive(:get_all_assignments) + .with('pensive-class') + .and_return(pensive_assignments) + end + + it 'syncs Pensive assignment URLs and dates through the shared job' do + described_class.perform_now(pensive_course_to_lms.id, sync_user.id) + + assignment = Assignment.find_by( + course_to_lms: pensive_course_to_lms, + external_assignment_id: assignment_url + ) + expect(assignment.name).to eq('Pensive Homework') + expect(assignment.release_date).to eq(Time.zone.parse('2026-08-01T00:00:00Z')) + expect(assignment.due_date).to eq(Time.zone.parse('2026-08-08T07:00:00Z')) + expect(assignment.late_due_date).to eq(Time.zone.parse('2026-08-10T07:00:00Z')) + end + end end describe '#sync_assignment' do diff --git a/spec/models/lms_spec.rb b/spec/models/lms_spec.rb index 7956f7b9..1f0ed5e1 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(:@pensive_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(:@pensive_lms, nil) end describe '.preload!' do @@ -60,4 +62,21 @@ expect(described_class.CANVAS_LMS.id).to eq(CANVAS_LMS_ID) end end + + describe '.PENSIVE_LMS' do + it 'loads the Pensive integration row' do + pensive = described_class.PENSIVE_LMS + + expect(pensive.id).to eq(PENSIVE_LMS_ID) + expect(pensive.lms_name).to eq('Pensive') + expect(pensive.lms_base_url).to eq('https://www.pensieve.co') + expect(pensive.use_auth_token).to be(false) + end + end + + describe '.facade_class' do + it 'maps the Pensive LMS id to its facade' do + expect(described_class.facade_class(PENSIVE_LMS_ID)).to eq(PensiveFacade) + end + end end diff --git a/spec/models/request_spec.rb b/spec/models/request_spec.rb index 68737df7..fb1c38ce 100644 --- a/spec/models/request_spec.rb +++ b/spec/models/request_spec.rb @@ -830,6 +830,51 @@ expect(request.status).to eq('approved') end end + + context 'with Pensive facade' do + let(:pensive_facade) { PensiveFacade.new('pensive-token') } + let(:pensive_override) { instance_double(Lmss::Pensive::Override, id: nil) } + let(:pensive_assignment_url) do + 'https://www.pensieve.co/teacher/classes/pensive-class/my-assignments/online/assignment-id/extensions' + end + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('PENSIEVE_EMAIL').and_return('integration@example.edu') + pensive_link = CourseToLms.create!( + course: course, + lms: Lms.PENSIVE_LMS, + external_course_id: 'pensive-class' + ) + assignment.update!( + course_to_lms: pensive_link, + external_assignment_id: pensive_assignment_url + ) + allow(pensive_facade).to receive(:provision_extension).and_return(pensive_override) + end + + it 'provisions an extension by student email and whole-day count' do + due_date = assignment.due_date + requested_due_date = due_date + 3.days + request.update!(requested_due_date: requested_due_date) + allow(mock_date_calculator).to receive(:calculate).and_return({ + release_date: nil, + due_date: requested_due_date, + late_due_date: nil + }) + allow(request).to receive(:date_calculator).and_return(mock_date_calculator) + + expect(request.approve(pensive_facade, instructor)).to be(true) + expect(pensive_facade).to have_received(:provision_extension).with( + 'pensive-class', + user.email, + pensive_assignment_url, + requested_due_date.iso8601, + nil, + extension_days: 3 + ) + end + end end describe '#reject' do