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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions app/facades/pensive_facade.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.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', '')
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions app/models/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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
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
Expand Down
21 changes: 21 additions & 0 deletions db/migrate/20260825000000_add_pensive_lms.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
5 changes: 5 additions & 0 deletions db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 23 additions & 0 deletions lib/lmss/pensive/assignment.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading