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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ for `DF_SECRET_KEY_BASE`, `DF_SECRET_KEY_ATTR`, `DF_SECRET_KEY_DEVISE`,
| `TII_REGISTER_WEBHOOK` | Register the Turnitin webhook. | `false` |
| `TCA_API_KEY` | Turnitin Core API key. | Unset |
| `TCA_HOST` | Turnitin institution host. | Unset |
| `DF_MOODLE_API_URL` | Moodle base URL used by unit integrations. | Unset |
| `DF_JPLAG_MIN_TOKENS` | Minimum matching-token threshold used by JPlag. | `-1` |
| `DF_JPLAG_SKIP_CLUSTER_CHECK` | Skip JPlag cluster calculation. | `false` |
| `DF_JPLAG_MAX_SHOWN_COMPARISONS` | Maximum comparisons retained in a JPlag report; `-1` means all. | `2500` |
Expand Down
2 changes: 2 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class ApiRoot < Grape::API
mount DiscussionCommentApi
mount EngagementsApi
mount ExtensionCommentsApi
mount MoodleIntegrationApi
mount ScormExtensionCommentsApi
mount GroupSetsApi
mount LearningOutcomesApi
Expand Down Expand Up @@ -119,6 +120,7 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to DiscussionCommentApi
AuthenticationHelpers.add_auth_to EngagementsApi
AuthenticationHelpers.add_auth_to ExtensionCommentsApi
AuthenticationHelpers.add_auth_to MoodleIntegrationApi
AuthenticationHelpers.add_auth_to ScormExtensionCommentsApi
AuthenticationHelpers.add_auth_to GroupSetsApi
AuthenticationHelpers.add_auth_to LearningOutcomesApi
Expand Down
14 changes: 14 additions & 0 deletions app/api/entities/moodle_group_mapping_entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Entities
class MoodleGroupMappingEntity < Grape::Entity
expose :id
expose :moodle_group_id
expose :moodle_group_name
expose :target_type
expose :group_set_id
expose :group_id
expose :campus_id
expose :tutorial_stream_id
expose :tutorial_id
expose :create_if_missing
end
end
22 changes: 22 additions & 0 deletions app/api/entities/moodle_integration_entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
require 'entities/moodle_group_mapping_entity'

module Entities
class MoodleIntegrationEntity < Grape::Entity
expose :id
expose :course_id
expose :assignment_id
expose :assignment_name
expose :fetch_extensions
expose :auto_sync_students
expose :auto_sync_extensions
expose :group_mapping_enabled
expose :validated
expose :validated_at
expose :moodle_group_mappings,
as: :group_mappings,
using: Entities::MoodleGroupMappingEntity
expose :api_key_configured do |integration|
integration.api_key.present?
end
end
end
1 change: 1 addition & 0 deletions app/api/entities/unit_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
unless: :summary_only

expose :overseer_image_id, unless: :summary_only, if: lambda { |unit, options| can_read_unit_config?(options[:my_role]) }
expose :moodle_enabled, unless: :summary_only, if: lambda { |unit, options| can_read_unit_config?(options[:my_role]) }

Check warning on line 59 in app/api/entities/unit_entity.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefix unused parameter 'unit' with an underscore (e.g., '_unit'), or remove it if it is not needed.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-FZw_VwDWHbHQ9rH&open=AaCt-FZw_VwDWHbHQ9rH&pullRequest=690
expose :assessment_enabled, unless: :summary_only

expose :auto_apply_extension_before_deadline, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) }
Expand Down
27 changes: 27 additions & 0 deletions app/api/lti_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,31 @@ class LtiApi < Grape::API

projects_hash
end

desc 'Issue a one-time login token so an embedded LTI session can open OnTrack in its own tab'
params do
requires :ltik, type: String, desc: 'LtiKey asserting the launch user of an active LTI session'
end
post '/lti/app-handoff' do
authenticated?

token = decode_lti_token(params[:ltik])

# Stops other LTI tokens, such as enrolment requests, being replayed here.
unless token['purpose'] == 'app_handoff'
error!({ error: 'Invalid LTI token.' }, 403)
end

launch_email = token['email'].to_s.strip
if launch_email.empty? || !current_user.email.to_s.casecmp?(launch_email)
logger.warn "Rejected LTI app handoff for #{current_user.username} from #{request.ip}"
error!({ error: 'This OnTrack session does not belong to the LMS user who launched OnTrack. Relaunch OnTrack from the LMS.' }, 403)
end

onetime_token = current_user.generate_temporary_authentication_token!
logger.info "LTI app handoff for #{current_user.username} from #{request.ip}"

present :username, current_user.username
present :auth_token, onetime_token.authentication_token
end
end
195 changes: 195 additions & 0 deletions app/api/moodle_integration_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# frozen_string_literal: true

require 'grape'
require 'entities/moodle_integration_entity'
require 'entities/sidekiq_job_entity'

class MoodleIntegrationApi < Grape::API
helpers AuthenticationHelpers
helpers AuthorisationHelpers
helpers SidekiqHelper

before do
authenticated?
end

desc 'Get Moodle settings for a unit'
get '/units/:unit_id/moodle' do
unit = Unit.find(params[:unit_id])
error!({ error: 'Moodle integration is not enabled for this unit' }, 404) unless unit.moodle_enabled?

Check failure on line 19 in app/api/moodle_integration_api.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Moodle integration is not enabled for this unit" 7 times.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-FaC_VwDWHbHQ9rI&open=AaCt-FaC_VwDWHbHQ9rI&pullRequest=690
unless authorise?(current_user, unit, :update)
error!({ error: 'Not authorised to manage Moodle for this unit' }, 403)

Check failure on line 21 in app/api/moodle_integration_api.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Not authorised to manage Moodle for this unit" 7 times.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-FaC_VwDWHbHQ9rJ&open=AaCt-FaC_VwDWHbHQ9rJ&pullRequest=690
end

integration = unit.moodle_integration || unit.build_moodle_integration
present integration, with: Entities::MoodleIntegrationEntity
end

desc 'Update Moodle settings for a unit'
params do
requires :course_id, type: Integer
optional :api_key, type: String
optional :assignment_id, type: Integer
optional :assignment_name, type: String
optional :fetch_extensions, type: Boolean, default: false
optional :auto_sync_students, type: Boolean, default: false
optional :auto_sync_extensions, type: Boolean, default: false
optional :group_mapping_enabled, type: Boolean, default: false
optional :group_mappings, type: Array do
requires :moodle_group_id, type: Integer
requires :moodle_group_name, type: String
requires :target_type, type: String, values: MoodleGroupMapping::TARGET_TYPES
optional :group_set_id, type: Integer
optional :group_id, type: Integer
optional :campus_id, type: Integer
optional :tutorial_stream_id, type: Integer
optional :tutorial_id, type: Integer
optional :create_if_missing, type: Boolean, default: false
end
end
put '/units/:unit_id/moodle' do
unit = Unit.find(params[:unit_id])
error!({ error: 'Moodle integration is not enabled for this unit' }, 404) unless unit.moodle_enabled?
unless authorise?(current_user, unit, :update)
error!({ error: 'Not authorised to manage Moodle for this unit' }, 403)
end

integration = unit.moodle_integration || unit.build_moodle_integration
MoodleIntegration.transaction do
integration.course_id = params[:course_id]
integration.api_key = params[:api_key] if params[:api_key].present?
integration.fetch_extensions = params[:fetch_extensions]
integration.assignment_id = params[:fetch_extensions] ? params[:assignment_id] : nil
integration.assignment_name = params[:fetch_extensions] ? params[:assignment_name] : nil
integration.auto_sync_students = params[:auto_sync_students]
integration.auto_sync_extensions = params[:fetch_extensions] && params[:auto_sync_extensions]
integration.group_mapping_enabled = params[:group_mapping_enabled]
integration.validated = false
integration.validated_at = nil
integration.save!

if integration.group_mapping_enabled?
integration.moodle_group_mappings.delete_all
Array(params[:group_mappings]).each do |mapping|
integration.moodle_group_mappings.create!(
moodle_group_id: mapping[:moodle_group_id],
moodle_group_name: mapping[:moodle_group_name],
target_type: mapping[:target_type],
group_set_id: mapping[:group_set_id],
group_id: mapping[:group_id],
campus_id: mapping[:campus_id],
tutorial_stream_id: mapping[:tutorial_stream_id],
tutorial_id: mapping[:tutorial_id],
create_if_missing: mapping[:create_if_missing]
)
end
end
end

integration.moodle_group_mappings.reload
present integration, with: Entities::MoodleIntegrationEntity
end

desc 'Validate Moodle settings against the current Moodle course'
post '/units/:unit_id/moodle/validate' do
unit = Unit.find(params[:unit_id])
error!({ error: 'Moodle integration is not enabled for this unit' }, 404) unless unit.moodle_enabled?
unless authorise?(current_user, unit, :update)
error!({ error: 'Not authorised to manage Moodle for this unit' }, 403)
end
error!({ error: 'Configure Moodle for this unit first' }, 422) if unit.moodle_integration.blank?

Check failure on line 100 in app/api/moodle_integration_api.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Configure Moodle for this unit first" 5 times.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-FaC_VwDWHbHQ9rK&open=AaCt-FaC_VwDWHbHQ9rK&pullRequest=690

job_id = ValidateMoodleIntegrationJob.perform_async(unit.id)
present setup_job(job_id), with: Entities::SidekiqJobEntity
end

desc 'Test Moodle API permissions for a unit'
post '/units/:unit_id/moodle/test' do
unit = Unit.find(params[:unit_id])
error!({ error: 'Moodle integration is not enabled for this unit' }, 404) unless unit.moodle_enabled?
unless authorise?(current_user, unit, :update)
error!({ error: 'Not authorised to manage Moodle for this unit' }, 403)
end
error!({ error: 'Configure Moodle for this unit first' }, 422) if unit.moodle_integration.blank?

job_id = TestMoodleConnectionJob.perform_async(unit.id)
job = setup_job(job_id)
present job, with: Entities::SidekiqJobEntity
end

desc 'Pre-fill Moodle group mappings using institution settings'
params do
requires :groups, type: Array do
requires :id, type: Integer
requires :name, type: String
optional :idnumber, type: String
end
end
post '/units/:unit_id/moodle/prefill_group_mappings' do
unit = Unit.find(params[:unit_id])
error!({ error: 'Moodle integration is not enabled for this unit' }, 404) unless unit.moodle_enabled?
unless authorise?(current_user, unit, :update)
error!({ error: 'Not authorised to manage Moodle for this unit' }, 403)
end
error!({ error: 'Configure Moodle for this unit first' }, 422) if unit.moodle_integration.blank?

settings = Doubtfire::Application.config.institution_settings
groups = params[:groups].map { |group| group.to_h.symbolize_keys }
mappings = if settings.respond_to?(:prefill_moodle_group_mappings)
settings.prefill_moodle_group_mappings(unit, groups)
else
groups.map do |group|
{
moodle_group_id: group[:id],
moodle_group_name: group[:name],
target_type: 'ignore'
}
end
end
{ group_mappings: mappings }
end

desc 'Import active Moodle students into a unit'
params do
requires :preview_only, type: Boolean, default: false
end
post '/units/:unit_id/moodle/import_students' do
unit = Unit.find(params[:unit_id])
error!({ error: 'Moodle integration is not enabled for this unit' }, 404) unless unit.moodle_enabled?
unless authorise?(current_user, unit, :upload_csv)
error!({ error: 'Not authorised to manage Moodle for this unit' }, 403)
end
integration = unit.moodle_integration
error!({ error: 'Configure Moodle for this unit first' }, 422) if integration.blank?
unless integration.validated?
error!({ error: 'Validate the Moodle integration before importing students' }, 422)
end

job_id = ImportMoodleStudentsJob.perform_async(unit.id, params[:preview_only])
present setup_job(job_id), with: Entities::SidekiqJobEntity
end

desc 'Import Moodle assignment extensions into a unit'
params do
requires :preview_only, type: Boolean, default: false
end
post '/units/:unit_id/moodle/import_extensions' do
unit = Unit.find(params[:unit_id])
error!({ error: 'Moodle integration is not enabled for this unit' }, 404) unless unit.moodle_enabled?
unless authorise?(current_user, unit, :update)
error!({ error: 'Not authorised to manage Moodle for this unit' }, 403)
end

integration = unit.moodle_integration
error!({ error: 'Configure Moodle for this unit first' }, 422) if integration.blank?
unless integration.validated?
error!({ error: 'Validate the Moodle integration before importing extensions' }, 422)
end
unless integration.fetch_extensions && integration.assignment_id.present?
error!({ error: 'Enable extension imports and select a Moodle assignment first' }, 422)
end

job_id = ImportMoodleExtensionsJob.perform_async(unit.id, params[:preview_only])
present setup_job(job_id), with: Entities::SidekiqJobEntity
end
end
2 changes: 2 additions & 0 deletions app/api/units_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class UnitsApi < Grape::API
optional :send_notifications, type: Boolean, desc: 'Indicates if emails should be sent on updates each week'
optional :enable_sync_timetable, type: Boolean, desc: 'Sync to timetable automatically if supported by deployment'
optional :enable_sync_enrolments, type: Boolean, desc: 'Sync student enrolments automatically if supported by deployment'
optional :moodle_enabled, type: Boolean, desc: 'Enable the Moodle integration for this unit'
optional :draft_task_definition_id, type: Integer, desc: 'Indicates the ID of the task definition used as the "draft learning summary task"'
optional :portfolio_auto_generation_date, type: Date, desc: 'Indicates a date where student portfolio will automatically compile'
optional :allow_flexible_dates, type: Boolean, desc: 'Can turn on/off flexible dates for tasks in this unit'
Expand Down Expand Up @@ -128,6 +129,7 @@ class UnitsApi < Grape::API
:send_notifications,
:enable_sync_timetable,
:enable_sync_enrolments,
:moodle_enabled,
:draft_task_definition_id,
:portfolio_auto_generation_date,
:allow_flexible_dates,
Expand Down
54 changes: 54 additions & 0 deletions app/models/moodle_group_mapping.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

class MoodleGroupMapping < ApplicationRecord
TARGET_TYPES = %w[group campus tutorial ignore].freeze

belongs_to :moodle_integration
belongs_to :group_set, optional: true
belongs_to :group, optional: true
belongs_to :campus, optional: true
belongs_to :tutorial_stream, optional: true
belongs_to :tutorial, optional: true

validates :moodle_group_id, numericality: { only_integer: true, greater_than: 0 }
validates :moodle_group_name, presence: true
validates :target_type, inclusion: { in: TARGET_TYPES }
validate :valid_target

private

def valid_target

Check failure on line 20 in app/models/moodle_group_mapping.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 41 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-Fbp_VwDWHbHQ9rO&open=AaCt-Fbp_VwDWHbHQ9rO&pullRequest=690
unit = moodle_integration&.unit

case target_type

Check failure on line 23 in app/models/moodle_group_mapping.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default clause to this "case" statement.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-Fbp_VwDWHbHQ9rN&open=AaCt-Fbp_VwDWHbHQ9rN&pullRequest=690
when 'group'
errors.add(:group_set, 'must be selected') if group_set.blank?

Check failure on line 25 in app/models/moodle_group_mapping.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "must be selected" 5 times.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-Fbp_VwDWHbHQ9rP&open=AaCt-Fbp_VwDWHbHQ9rP&pullRequest=690
errors.add(:group_set, 'must belong to this unit') if group_set.present? && group_set.unit != unit

Check failure on line 26 in app/models/moodle_group_mapping.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "must belong to this unit" 4 times.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaCt-Fbp_VwDWHbHQ9rQ&open=AaCt-Fbp_VwDWHbHQ9rQ&pullRequest=690
if create_if_missing?
if tutorial.blank? == tutorial_stream.blank?
errors.add(:base, 'select an existing tutorial or a tutorial stream for the new group')
end
errors.add(:tutorial, 'must belong to this unit') if tutorial.present? && tutorial.unit != unit
if tutorial_stream.present? && tutorial_stream.unit != unit
errors.add(:tutorial_stream, 'must belong to this unit')
end
else
errors.add(:group, 'must be selected') if group.blank?
if group.present? && (group.group_set != group_set || group.unit != unit)
errors.add(:group, 'must belong to the selected group set')
end
end
when 'campus'
errors.add(:campus, 'must be selected') if campus.blank?
when 'tutorial'
errors.add(:tutorial_stream, 'must be selected') if tutorial_stream.blank?
if tutorial_stream.present? && tutorial_stream.unit != unit
errors.add(:tutorial_stream, 'must belong to this unit')
end
errors.add(:tutorial, 'must be selected') if tutorial.blank?
if tutorial.present? && (tutorial.tutorial_stream != tutorial_stream || tutorial.unit != unit)
errors.add(:tutorial, 'must belong to the selected tutorial stream')
end
end
end
end
12 changes: 12 additions & 0 deletions app/models/moodle_integration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class MoodleIntegration < ApplicationRecord
belongs_to :unit
has_many :moodle_group_mappings, dependent: :destroy

encrypts :api_key

validates :course_id, numericality: { only_integer: true, greater_than: 0 }
validates :assignment_id, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validates :unit_id, uniqueness: true
end
3 changes: 3 additions & 0 deletions app/models/unit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class Unit < ApplicationRecord
include MimeCheckHelpers
include CsvHelper


has_one :moodle_integration, dependent: :destroy

#
# Permissions around unit data
#
Expand Down
Loading
Loading