". A chunk is linked into place, which
+# fails if the name exists, so a request retried while the original is still running can't store the
+# same chunk twice. No locks are needed, which matters because file locks hang on some NFS mounts.
+class BatchFeedbackUpload
+ CHUNK_SIZE = Doubtfire::Application.config.batch_feedback_chunk_size
+ EXPIRES_AFTER = 24.hours
+ ID_PATTERN = /\A\h{32}\z/
+ CHUNK_NAME_PATTERN = /\A(\d{20})-(\h{64})\z/
+
+ class Error < StandardError; end
+ class NotFound < Error; end
+
+ class OffsetMismatch < Error
+ attr_reader :offset
+
+ def initialize(offset)
+ @offset = offset
+ super("Expected a chunk starting at byte #{offset}.")
+ end
+ end
+
+ attr_reader :id, :unit_id, :task_definition_id, :user_id, :filename, :size
+
+ def self.root
+ File.join(FileHelper.tmp_file_dir, 'batch-feedback-uploads')
+ end
+
+ def self.create(unit:, task_definition:, user:, filename:, size:)
+ raise Error, 'Upload size must be greater than zero.' unless size.to_i.positive?
+
+ upload = new(
+ id: SecureRandom.hex(16),
+ unit_id: unit.id,
+ task_definition_id: task_definition.id,
+ user_id: user.id,
+ filename: File.basename(filename.to_s),
+ size: size.to_i
+ )
+
+ FileUtils.mkdir_p(upload.chunks_dir)
+ File.write(upload.metadata_path, upload.metadata.to_json)
+ upload
+ end
+
+ def self.find(id)
+ return nil unless id.to_s.match?(ID_PATTERN)
+
+ path = File.join(root, id, 'upload.json')
+ return nil unless File.exist?(path)
+
+ new(**JSON.parse(File.read(path)).symbolize_keys)
+ end
+
+ def self.remove_expired
+ Dir.glob(File.join(root, '*')).each do |dir|
+ # Storing a chunk updates the chunks directory, not the upload's own directory
+ last_activity = [dir, File.join(dir, 'chunks')].select { |path| File.exist?(path) }.map { |path| File.mtime(path) }.max
+ FileUtils.rm_rf(dir) if last_activity < EXPIRES_AFTER.ago
+ end
+ end
+
+ def initialize(id:, unit_id:, task_definition_id:, user_id:, filename:, size:)
+ @id = id
+ @unit_id = unit_id
+ @task_definition_id = task_definition_id
+ @user_id = user_id
+ @filename = filename
+ @size = size
+ end
+
+ def dir
+ File.join(self.class.root, id)
+ end
+
+ def metadata_path
+ File.join(dir, 'upload.json')
+ end
+
+ def chunks_dir
+ File.join(dir, 'chunks')
+ end
+
+ def metadata
+ { id: id, unit_id: unit_id, task_definition_id: task_definition_id, user_id: user_id, filename: filename, size: size }
+ end
+
+ # The chunks received so far, in order from the start of the file.
+ def chunks
+ received_chunks.map { |chunk| chunk.slice(:size, :sha256) }
+ end
+
+ def offset
+ received_chunks.sum { |chunk| chunk[:size] }
+ end
+
+ def status
+ received = chunks
+ { id: id, offset: received.sum { |chunk| chunk[:size] }, size: size, chunk_size: CHUNK_SIZE, chunks: received }
+ end
+
+ # Stores a chunk that starts at offset, after checking it arrived intact.
+ def append(offset, chunk_path, sha256)
+ sha256 = sha256.to_s.downcase
+ chunk_size = File.size(chunk_path)
+ raise Error, 'Chunk is empty.' if chunk_size.zero?
+ raise Error, "Chunk exceeds the #{CHUNK_SIZE / 1.megabyte}MB chunk limit." if chunk_size > CHUNK_SIZE
+ raise Error, 'Chunk does not match its checksum.' unless Digest::SHA256.file(chunk_path).hexdigest == sha256
+
+ received = self.offset
+ raise OffsetMismatch, received unless offset == received
+ raise Error, 'Chunk extends past the end of the upload.' if received + chunk_size > size
+
+ # Copy beside the chunks first, as the request's tempfile may be on another filesystem
+ temp_path = File.join(chunks_dir, ".tmp-#{SecureRandom.hex(8)}")
+ FileUtils.cp(chunk_path, temp_path)
+ File.link(temp_path, File.join(chunks_dir, format('%020d-%s', offset, sha256)))
+ rescue Errno::EEXIST
+ # Another request stored this chunk first
+ raise OffsetMismatch, self.offset
+ rescue Errno::ENOENT
+ raise NotFound
+ ensure
+ FileUtils.rm_f(temp_path) if temp_path
+ end
+
+ # Joins the chunks into the file the import job reads, then yields its path to enqueue the job.
+ # The chunks are kept if no job is enqueued, so the caller can complete the upload again later.
+ def complete!
+ received = received_chunks
+ total = received.sum { |chunk| chunk[:size] }
+ raise Error, "Upload is incomplete: received #{total} of #{size} bytes." unless total == size
+
+ path = import_path
+ temp_path = "#{path}.tmp-#{SecureRandom.hex(8)}"
+ File.open(temp_path, 'wb') do |file|
+ received.each { |chunk| IO.copy_stream(chunk[:path], file) }
+ end
+ # Linking fails if another request already completed this upload
+ File.link(temp_path, path)
+
+ job_id = yield path
+ if job_id.nil?
+ FileUtils.rm_f(path)
+ else
+ FileUtils.rm_rf(dir)
+ end
+ job_id
+ rescue Errno::EEXIST, Errno::ENOENT
+ raise NotFound
+ ensure
+ FileUtils.rm_f(temp_path) if temp_path
+ end
+
+ def destroy
+ FileUtils.rm_rf(dir)
+ end
+
+ private
+
+ # The stored chunks that run on from each other from the start of the file.
+ def received_chunks
+ received = 0
+ stored_chunks.filter_map do |chunk_offset, sha256, path|
+ raise Error, 'Upload has conflicting chunks, please start it again.' if chunk_offset < received
+ next if chunk_offset > received
+
+ chunk_size = File.size(path)
+ received += chunk_size
+ { size: chunk_size, sha256: sha256, path: path }
+ end
+ end
+
+ # [offset, sha256, path] for each stored chunk, sorted by offset.
+ def stored_chunks
+ Dir.children(chunks_dir).filter_map do |name|
+ match = CHUNK_NAME_PATTERN.match(name)
+ [match[1].to_i, match[2], File.join(chunks_dir, name)] if match
+ end.sort
+ rescue Errno::ENOENT
+ raise NotFound
+ end
+
+ def import_path
+ import_dir = File.join(FileHelper.tmp_file_dir, 'batch-feedback')
+ FileUtils.mkdir_p(import_dir)
+
+ extension = File.extname(filename)
+ extension = '.upload' unless extension.match?(/\A\.[a-z0-9]{1,10}\z/i)
+ File.join(import_dir, "batch-feedback-#{unit_id}-#{task_definition_id}-#{id}#{extension}")
+ end
+end
diff --git a/app/services/communication_transfer.rb b/app/services/communication_transfer.rb
new file mode 100644
index 0000000000..760d04c155
--- /dev/null
+++ b/app/services/communication_transfer.rb
@@ -0,0 +1,200 @@
+# Moves communication sets and rules between units as a JSON document.
+#
+# References travel as abbreviations rather than database ids. One with no
+# equivalent in the target unit is left nil instead of failing the import, so
+# the rule keeps its conditions and actions; CommunicationRule#unresolved? then
+# flags it and blocks the set from running until someone repoints it.
+class CommunicationTransfer
+ class InvalidDocument < StandardError; end
+
+ VERSION = 1
+ SET_FORMAT = 'ontrack.communication_set'.freeze
+ RULE_FORMAT = 'ontrack.communication_rule'.freeze
+
+ SCHEDULE_ATTRIBUTES = %w[
+ name active anchor_week anchor_day hour minute timezone recurrence interval repeat_count until_at
+ ].freeze
+ RULE_ATTRIBUTES = %w[name operator position active send_log_to_convenors].freeze
+ CONDITION_ATTRIBUTES = %w[
+ type operator target_grade task_statuses task_status_count task_target_grade
+ activity_days spec_con_days submitted_portfolio
+ ].freeze
+ ACTION_ATTRIBUTES = %w[type subject body plain_text email_tutors email_convenors target_grade].freeze
+
+ # Abbreviated references. Campuses are shared between units; everything else
+ # is unit scoped. Group sets and groups are named rather than abbreviated, so
+ # they are matched separately.
+ SCOPES = {
+ task_definition: ->(unit) { unit.task_definitions },
+ tutorial: ->(unit) { unit.tutorials },
+ tutorial_stream: ->(unit) { unit.tutorial_streams },
+ campus: ->(_unit) { Campus }
+ }.freeze
+
+ class << self
+ def export_set(set)
+ envelope(SET_FORMAT, set.unit).merge(
+ 'set' => {
+ 'name' => set.name,
+ 'active' => set.active,
+ 'schedules' => set.communication_set_schedules.map { |schedule| attributes_of(schedule, SCHEDULE_ATTRIBUTES) },
+ 'rules' => set.communication_rules.map { |rule| rule_document(rule) }
+ }
+ )
+ end
+
+ def export_rule(rule)
+ envelope(RULE_FORMAT, rule.unit).merge('rule' => rule_document(rule))
+ end
+
+ def import_set(document, unit)
+ document = expect!(document, SET_FORMAT)['set']
+ raise InvalidDocument, 'Document is missing its communication set' if document.blank?
+
+ ApplicationRecord.transaction do
+ set = unit.communication_sets.create!(
+ name: available_name(unit, document['name']),
+ active: document.fetch('active', true)
+ )
+
+ Array(document['schedules']).each do |schedule|
+ # Switched off on the way in: the schedule runner is the one place an
+ # active flag is honoured, so this is what stops a set mailing students
+ # before anyone has looked at it.
+ set.communication_set_schedules.create!(schedule.slice(*SCHEDULE_ATTRIBUTES).merge('active' => false))
+ end
+
+ Array(document['rules']).each_with_index { |rule, index| build_rule(set, rule, index) }
+ set
+ end
+ end
+
+ def import_rule(document, set)
+ document = expect!(document, RULE_FORMAT)['rule']
+ raise InvalidDocument, 'Document is missing its communication rule' if document.blank?
+
+ ApplicationRecord.transaction { build_rule(set, document, set.communication_rules.count) }
+ end
+
+ private
+
+ def envelope(format, unit)
+ {
+ 'format' => format,
+ 'version' => VERSION,
+ 'exported_at' => Time.zone.now.iso8601,
+ 'source' => { 'unit_code' => unit.code, 'teaching_period' => unit.teaching_period&.detailed_name }
+ }
+ end
+
+ def rule_document(rule)
+ attributes_of(rule, RULE_ATTRIBUTES).merge(
+ 'conditions' => rule.communication_conditions.map { |condition| record_document(condition, CONDITION_ATTRIBUTES) },
+ 'actions' => rule.communication_actions.map { |action| record_document(action, ACTION_ATTRIBUTES) }
+ )
+ end
+
+ def record_document(record, attributes)
+ document = attributes_of(record, attributes)
+ reference = record.required_reference
+ target = reference && record.public_send(reference)
+ document['reference'] = reference_document(reference, target) if target
+
+ document
+ end
+
+ # A group name is only unique inside its group set, so a group travels with
+ # the name of the set it sits in.
+ def reference_document(reference, target)
+ case reference
+ when :group_set then { 'group_set' => target.name }
+ when :group then { 'group' => target.name, 'group_set' => target.group_set.name }
+ else { reference.to_s => target.abbreviation }
+ end
+ end
+
+ def attributes_of(record, attributes)
+ attributes.index_with { |attribute| record.public_send(attribute) }
+ end
+
+ def build_rule(set, document, position)
+ rule = set.communication_rules.create!(document.slice(*RULE_ATTRIBUTES).merge('position' => position))
+
+ Array(document['conditions']).each do |condition|
+ build_record(rule.communication_conditions, condition, CONDITION_ATTRIBUTES, rule)
+ end
+
+ Array(document['actions']).each do |action|
+ build_record(rule.communication_actions, action, ACTION_ATTRIBUTES, rule)
+ end
+
+ rule
+ end
+
+ def build_record(association, document, attributes, rule)
+ record = association.new(document.slice(*attributes))
+ reference = record.required_reference
+
+ if reference.present?
+ record.public_send("#{reference}=", find_reference(reference, document['reference'] || {}, rule.unit))
+ end
+
+ record.save!
+ rescue ActiveRecord::RecordInvalid => e
+ # Not every failure here is a missing reference -- units enable different
+ # target grades, so a grade-based rule can be valid where it was written
+ # and invalid here. Name the rule rather than the bare model.
+ raise InvalidDocument, "Rule '#{rule.name}' cannot be used in this unit: #{e.record.errors.full_messages.join(', ')}"
+ end
+
+ def find_reference(reference, document, unit)
+ case reference
+ when :group_set then find_group_set(document['group_set'], unit)
+ when :group then find_group(document['group'], document['group_set'], unit)
+ else
+ abbreviation = document[reference.to_s]
+ return nil if abbreviation.blank?
+
+ SCOPES.fetch(reference).call(unit).find_by(abbreviation: abbreviation)
+ end
+ end
+
+ def find_group_set(name, unit)
+ find_named(unit.group_sets, name)
+ end
+
+ def find_group(name, group_set_name, unit)
+ group_set = find_group_set(group_set_name, unit)
+ return nil if group_set.nil?
+
+ find_named(group_set.groups, name)
+ end
+
+ # Names are nullable on both group sets and groups, so a blank one must not
+ # be allowed to match the first unnamed record.
+ def find_named(scope, name)
+ return nil if name.blank?
+
+ scope.find_by(name: name)
+ end
+
+ def expect!(document, format)
+ document = document.deep_stringify_keys if document.respond_to?(:deep_stringify_keys)
+ raise InvalidDocument, "Expected a #{format} document" unless document.is_a?(Hash) && document['format'] == format
+ raise InvalidDocument, "Unsupported document version #{document['version'].inspect}" unless document['version'] == VERSION
+
+ document
+ end
+
+ # Pasting a set back into the unit it came from is a fair way to duplicate
+ # it, so a clashing name is suffixed rather than rejected.
+ def available_name(unit, name)
+ name = name.presence || 'Communication set'
+ return name unless unit.communication_sets.exists?(name: name)
+
+ counter = 2
+ counter += 1 while unit.communication_sets.exists?(name: "#{name} (#{counter})")
+ "#{name} (#{counter})"
+ end
+ end
+end
diff --git a/app/services/engagement_tracker.rb b/app/services/engagement_tracker.rb
new file mode 100644
index 0000000000..c94df16340
--- /dev/null
+++ b/app/services/engagement_tracker.rb
@@ -0,0 +1,103 @@
+class EngagementTracker
+ DEBOUNCE = 15.minutes
+
+ def self.record_attendance(user:, project:, occurred_at: Time.zone.now)
+ return unless during_enrolled_tutorial?(project, occurred_at)
+
+ record_engagement(
+ user: user,
+ project: project,
+ engagement_type: 'Attendance',
+ note: 'Attended tutorial and QR scanned.',
+ occurred_at: occurred_at
+ )
+ end
+
+ def self.record_class_discussion(user:, project:, task_status_updates:, occurred_at: Time.zone.now)
+ timing = during_enrolled_tutorial?(project, occurred_at) ? 'during tutorial' : 'outside of tutorial'
+ note = if task_status_updates.any?
+ status_update_note(timing, task_status_updates)
+ else
+ "Class discussion #{timing}; no task statuses were updated."
+ end
+
+ record_engagement(
+ user: user,
+ project: project,
+ engagement_type: 'Discussion',
+ note: note,
+ occurred_at: occurred_at
+ ) do |recent_engagement|
+ merge_status_updates!(recent_engagement, timing, task_status_updates)
+ end
+ end
+
+ def self.record_engagement(user:, project:, engagement_type:, note:, occurred_at:)
+ project.with_lock do
+ recent_engagement = recently_recorded(project, engagement_type, occurred_at)
+ if recent_engagement
+ yield recent_engagement if block_given?
+ return
+ end
+
+ project.engagements.create!(
+ user: user,
+ engagement_type: engagement_type,
+ note: note,
+ occurred_at: occurred_at
+ )
+ end
+ end
+
+ def self.recently_recorded(project, engagement_type, occurred_at)
+ project.engagements
+ .where('LOWER(engagement_type) = ?', engagement_type.downcase)
+ .where(occurred_at: (occurred_at - DEBOUNCE)..occurred_at)
+ .order(occurred_at: :desc)
+ .first
+ end
+
+ def self.status_update_note(timing, task_status_updates)
+ ["Updated task statuses #{timing}.", *status_update_sentences(task_status_updates)].join(' ')
+ end
+
+ def self.status_update_sentences(task_status_updates)
+ task_status_updates.map do |update|
+ "#{update[:task]}: #{update[:from]} → #{update[:to]}."
+ end
+ end
+
+ def self.merge_status_updates!(engagement, timing, task_status_updates)
+ updates = status_update_sentences(task_status_updates)
+ return if updates.empty?
+
+ if engagement.note.include?('no task statuses were updated')
+ engagement.update!(note: status_update_note(timing, task_status_updates))
+ return
+ end
+
+ new_updates = updates.reject { |update| engagement.note.include?(update) }
+ engagement.update!(note: "#{engagement.note} #{new_updates.join(' ')}") if new_updates.any?
+ end
+
+ def self.during_enrolled_tutorial?(project, occurred_at)
+ project.tutorial_enrolments.includes(tutorial: :campus).any? do |enrolment|
+ tutorial = enrolment.tutorial
+ timezone = Time.zone
+ timezone = ActiveSupport::TimeZone[tutorial.campus&.timezone] if tutorial.campus&.timezone.present?
+ local_time = occurred_at.in_time_zone(timezone)
+
+ next false unless tutorial.meeting_day == local_time.strftime('%A')
+
+ tutorial_start = timezone.parse("#{local_time.to_date} #{tutorial.meeting_time}")
+ local_time >= tutorial_start && local_time < tutorial_start + tutorial.duration_minutes.minutes
+ end
+ end
+
+ private_class_method :record_engagement,
+ :recently_recorded,
+ :status_update_note,
+ :status_update_sentences,
+ :merge_status_updates!,
+ :during_enrolled_tutorial?
+end
diff --git a/app/services/lms_group_mapping_applier.rb b/app/services/lms_group_mapping_applier.rb
new file mode 100644
index 0000000000..f9a4cdf69b
--- /dev/null
+++ b/app/services/lms_group_mapping_applier.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+#
+# Applies a student's LMS group mappings to their OnTrack project: campus, tutorial enrolments and
+# group membership, creating mapped groups and tutorials when a mapping allows it.
+#
+module LmsGroupMappingApplier
+ module_function
+
+ def mapping_errors(mappings)
+ errors = []
+ campus_mappings = mappings.select { |mapping| mapping.target_type == 'campus' }
+ errors << 'Student belongs to multiple mapped campuses' if campus_mappings.map(&:campus_id).uniq.length > 1
+
+ mappings.select { |mapping| mapping.target_type == 'tutorial' }
+ .group_by(&:tutorial_stream_id)
+ .each_value do |stream_mappings|
+ errors << 'Student belongs to multiple LMS groups mapped to the same tutorial stream' if stream_mappings.length > 1
+ end
+ mappings.select { |mapping| mapping.target_type == 'group' }
+ .group_by(&:group_set_id)
+ .each_value do |group_mappings|
+ errors << 'Student belongs to multiple LMS groups mapped to the same group set' if group_mappings.length > 1
+ end
+
+ mappings.each do |mapping|
+ case mapping.target_type
+ when 'group'
+ if mapping.create_if_missing?
+ if mapping.tutorial.blank? == mapping.tutorial_stream.blank?
+ errors << 'Select an existing tutorial or a tutorial stream for the new group'
+ end
+ elsif mapping.group.blank?
+ errors << "Select an existing group in #{mapping.group_set.name}"
+ end
+ when 'tutorial'
+ errors << "Select an existing tutorial in #{mapping.tutorial_stream.name}" if mapping.tutorial.blank?
+ end
+ end
+ errors
+ end
+
+ # Returns true when the project changed.
+ def apply(project, mappings)
+ ActiveRecord::Base.transaction do
+ changed = false
+ campus = mappings.find { |mapping| mapping.target_type == 'campus' }&.campus
+ if campus && project.campus_id != campus.id
+ project.update!(campus: campus)
+ changed = true
+ end
+
+ mappings.select { |mapping| mapping.target_type == 'tutorial' }.each do |mapping|
+ tutorial = mapping.tutorial
+ if project.tutorial_for_stream(mapping.tutorial_stream)&.id != tutorial.id
+ project.enrol_in(tutorial)
+ changed = true
+ end
+ end
+
+ mappings.select { |mapping| mapping.target_type == 'group' }.each do |mapping|
+ group = mapping.group
+ if mapping.create_if_missing?
+ group = mapping.group_set.groups.where('LOWER(name) = ?', mapping.lms_group_name.downcase).first
+ tutorial = group&.tutorial || mapping.tutorial
+ if tutorial.blank?
+ tutorial = mapping.tutorial_stream.tutorials.where(
+ unit: project.unit
+ ).where('LOWER(abbreviation) = ?', mapping.lms_group_name.downcase).first
+ tutorial ||= Tutorial.create!(
+ unit: project.unit,
+ tutorial_stream: mapping.tutorial_stream,
+ abbreviation: mapping.lms_group_name,
+ meeting_day: 'LMS',
+ meeting_time: '',
+ meeting_location: mapping.lms_group_name
+ )
+ end
+ group ||= Group.create!(
+ group_set: mapping.group_set,
+ tutorial: tutorial,
+ name: mapping.lms_group_name
+ )
+ if project.tutorial_for_stream(tutorial.tutorial_stream)&.id != tutorial.id
+ project.enrol_in(tutorial)
+ changed = true
+ end
+ end
+ if project.group_for_groupset(mapping.group_set)&.id != group.id
+ group.add_member(project)
+ changed = true
+ end
+ end
+ changed
+ end
+ end
+end
diff --git a/app/services/lms_integration_validator.rb b/app/services/lms_integration_validator.rb
new file mode 100644
index 0000000000..868af852f9
--- /dev/null
+++ b/app/services/lms_integration_validator.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+#
+# Checks saved group mappings and the selected assignment against the live LMS course, so imports
+# never apply mappings for groups or assignments that were renamed or deleted in the LMS.
+#
+class LmsIntegrationValidator
+ class ValidationError < LtiCourseDataSource::Error
+ attr_reader :issues
+
+ def initialize(issues)
+ @issues = issues
+ super("LMS integration requires review: #{issues.pluck(:message).join('; ')}")
+ end
+ end
+
+ def initialize(integration)
+ @integration = integration
+ end
+
+ def validate(groups:, assignments:, record_success: true)
+ groups = Array(groups).map(&:with_indifferent_access)
+ assignments = Array(assignments).map(&:with_indifferent_access)
+ group_issues, notices = group_mapping_issues(groups)
+ issues = group_issues + assignment_issues(assignments)
+ valid = issues.empty?
+ validated_at = valid ? Time.current : nil
+
+ if record_success || !valid
+ @integration.update!(validated: valid, validated_at: validated_at)
+ end
+
+ {
+ valid: valid,
+ validated_at: validated_at,
+ groups: groups.map { |group| group.slice(:id, :name, :idnumber) },
+ assignments: assignments.map { |assignment| assignment.slice(:id, :name, :due_date) },
+ issues: issues,
+ notices: notices
+ }
+ end
+
+ def validate!(groups:, assignments:)
+ result = validate(groups: groups, assignments: assignments)
+ return result if result[:valid]
+
+ raise ValidationError, result[:issues]
+ end
+
+ private
+
+ def group_mapping_issues(groups)
+ return [[], []] unless @integration.group_mapping_enabled?
+
+ mappings = @integration.lms_group_mappings.includes(
+ :group_set, :group, :campus, :tutorial_stream, :tutorial
+ ).to_a
+ live_groups = groups.index_by { |group| group[:id].to_i }
+ mappings_by_id = mappings.group_by(&:lms_group_id)
+ issues = []
+ notices = []
+
+ live_groups.each do |id, group|
+ group_mappings = mappings_by_id[id]
+ if group_mappings.blank?
+ issues << {
+ type: 'group_missing', lms_group_id: id, lms_group_name: group[:name],
+ message: 'This LMS group has no mapping.'
+ }
+ else
+ if group_mappings.many?
+ notices << {
+ type: 'group_duplicate', lms_group_id: id, lms_group_name: group[:name],
+ message: "This LMS group has #{group_mappings.length} mappings; all will be applied."
+ }
+ end
+
+ group_mappings.each do |mapping|
+ if mapping.lms_group_name != group[:name]
+ issues << {
+ type: 'group_renamed', lms_group_id: id, lms_group_name: group[:name],
+ message: "LMS group name changed from “#{mapping.lms_group_name}” to “#{group[:name]}”."
+ }
+ elsif mapping.invalid?
+ issues << {
+ type: 'group_invalid', lms_group_id: id, lms_group_name: group[:name],
+ message: mapping.errors.full_messages.join(', ')
+ }
+ end
+ end
+ end
+ end
+
+ mappings.each do |mapping|
+ next if live_groups.key?(mapping.lms_group_id)
+
+ issues << {
+ type: 'group_deleted', lms_group_id: mapping.lms_group_id,
+ lms_group_name: mapping.lms_group_name,
+ message: 'This mapped LMS group no longer exists.'
+ }
+ end
+
+ [issues, notices]
+ end
+
+ def assignment_issues(assignments)
+ return [] unless @integration.fetch_extensions?
+
+ if @integration.assignment_id.blank? || @integration.assignment_name.blank?
+ return [{ type: 'assignment_missing', message: 'Select an LMS assignment.' }]
+ end
+
+ assignment = assignments.find { |item| item[:id].to_i == @integration.assignment_id }
+ if assignment.blank?
+ return [{
+ type: 'assignment_deleted', assignment_id: @integration.assignment_id,
+ assignment_name: @integration.assignment_name,
+ message: "The LMS assignment “#{@integration.assignment_name}” no longer exists."
+ }]
+ end
+ return [] if assignment[:name] == @integration.assignment_name
+
+ [{
+ type: 'assignment_renamed', assignment_id: assignment[:id],
+ assignment_name: assignment[:name],
+ message: "The LMS assignment was renamed from “#{@integration.assignment_name}” to “#{assignment[:name]}”."
+ }]
+ end
+end
diff --git a/app/services/lti_course_data_source.rb b/app/services/lti_course_data_source.rb
new file mode 100644
index 0000000000..afef390313
--- /dev/null
+++ b/app/services/lti_course_data_source.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+#
+# Reads LMS course data through the LTI service.
+#
+# Names and Role Provisioning is the baseline and works with any LTI 1.3 platform. When the
+# Moodle OnTrack course-data plugin is available, members gain student ids, enrolment state and
+# groups, and assignments with extensions become available.
+#
+# Another source, such as a direct Moodle web service client, only needs to provide the same
+# public methods and be returned from LmsIntegration.data_source_for.
+#
+class LtiCourseDataSource
+ class Error < StandardError; end
+
+ def initialize(unit)
+ @unit = unit
+ @server = LtiServer.new(unit.id)
+ end
+
+ def link
+ @link = @server.link unless defined?(@link)
+ @link
+ rescue LtiServer::Error => e
+ raise Error, e.message
+ end
+
+ def linked?
+ link.present?
+ end
+
+ def course_data_available?
+ link.present? && link['courseDataAvailable'] == true
+ end
+
+ # Members of the LMS course, including staff. Each member keeps its Names and Roles fields so
+ # institution settings can decide how to employ or enrol them.
+ def members
+ require_link!
+ membership = @server.members
+ plugin_users = {}
+ group_ids_by_user = Hash.new { |hash, key| hash[key] = [] }
+
+ data = optional_course_data(%w[users groups])
+ if data
+ Array(data['users']).each { |user| plugin_users[user['id'].to_s] = user }
+ Array(data['groups']).each do |group|
+ Array(group['member_user_ids']).each { |user_id| group_ids_by_user[user_id.to_s] << group['id'].to_i }
+ end
+ end
+
+ Array(membership['members']).map do |member|
+ user_id = member['user_id'].to_s
+ plugin_user = plugin_users[user_id]
+ # The plugin's username is the same LMS username, for platforms that leave it out of Names and Roles
+ lms_username = member['ext_user_username'].presence || plugin_user&.fetch('username', nil)
+ {
+ lms_user_id: user_id,
+ login_id: UserIdentity.lti_user_id_data(member.merge('ext_user_username' => lms_username))[:login_id],
+ email: member['email'].presence || plugin_user&.fetch('email', nil),
+ name: member['name'],
+ first_name: member['given_name'].presence || plugin_user&.fetch('first_name', nil),
+ last_name: member['family_name'].presence || plugin_user&.fetch('last_name', nil),
+ student_id: plugin_user&.fetch('idnumber', nil).presence,
+ lis_person_sourcedid: member['lis_person_sourcedid'].presence,
+ roles: Array(member['roles']),
+ active: member_active?(member, plugin_user),
+ group_ids: group_ids_by_user[user_id],
+ member: member
+ }
+ end
+ rescue LtiServer::Error => e
+ raise Error, e.message
+ end
+
+ def course_data
+ snapshot = course_data_snapshot(%w[groups assignments])
+ { course: course(snapshot), groups: groups(snapshot), assignments: assignments(snapshot) }
+ end
+
+ def groups(snapshot = nil)
+ (snapshot || course_data_snapshot(%w[groups])).fetch('groups', []).map do |group|
+ { id: group['id'].to_i, name: group['name'], idnumber: group['idnumber'] }
+ end
+ end
+
+ def assignments(snapshot = nil)
+ (snapshot || course_data_snapshot(%w[assignments])).fetch('assignments', []).map do |assignment|
+ { id: assignment['id'].to_i, name: assignment['name'], due_date: assignment['due_date'].to_i }
+ end
+ end
+
+ def course(snapshot = nil)
+ require_link!
+ details = {
+ context_id: link['contextId'],
+ label: link['contextLabel'],
+ title: link['contextTitle'],
+ start_date: nil,
+ end_date: nil
+ }
+ data = snapshot || optional_course_data(%w[groups])
+ return details unless data
+
+ context = data['context'] || {}
+ details.merge(
+ label: context['label'].presence || details[:label],
+ title: context['title'].presence || details[:title],
+ start_date: context['start_date'].to_i.positive? ? context['start_date'].to_i : nil,
+ end_date: context['end_date'].to_i.positive? ? context['end_date'].to_i : nil
+ )
+ rescue LtiServer::Error => e
+ raise Error, e.message
+ end
+
+ # The assignment with its per-user extension dates, keyed by LMS user id.
+ def assignment_extensions(assignment_id)
+ data = course_data_snapshot(%w[users assignments], assignment_id: assignment_id)
+ assignment = Array(data['assignments']).find { |item| item['id'].to_i == assignment_id.to_i }
+ raise Error, 'The selected assignment was not found in the LMS course' if assignment.blank?
+
+ users = Array(data['users']).index_by { |user| user['id'].to_s }
+ {
+ assignment: { id: assignment['id'].to_i, name: assignment['name'], due_date: assignment['due_date'].to_i },
+ extensions: Array(assignment['extensions']).map do |extension|
+ user = users[extension['user_id'].to_s]
+ {
+ lms_user_id: extension['user_id'].to_s,
+ login_id: user&.fetch('username', nil),
+ email: user&.fetch('email', nil),
+ extension_due_date: extension['extension_due_date'].to_i
+ }
+ end
+ }
+ end
+
+ private
+
+ def require_link!
+ raise Error, 'This unit is not linked to an LMS course. Link it by launching OnTrack from the LMS.' unless linked?
+ end
+
+ def require_course_data!
+ require_link!
+ return if course_data_available?
+
+ raise Error, 'The Moodle OnTrack course-data plugin is not available for this course.'
+ end
+
+ # Plugin data, or nil so callers fall back to Names and Roles when the plugin has been removed.
+ def optional_course_data(include)
+ return nil unless course_data_available?
+
+ @server.course_data(include: include)
+ rescue LtiServer::Error => e
+ raise unless e.status == 422
+
+ link['courseDataAvailable'] = false
+ nil
+ end
+
+ def course_data_snapshot(include, assignment_id: nil)
+ require_course_data!
+ @server.course_data(include: include, assignment_id: assignment_id)
+ rescue LtiServer::Error => e
+ link['courseDataAvailable'] = false if e.status == 422
+ raise Error, e.message
+ end
+
+ def member_active?(member, plugin_user)
+ return false unless member['status'].blank? || member['status'] == 'Active'
+ return true if plugin_user.nil?
+
+ !plugin_user['suspended'] && !plugin_user['deleted'] && Array(plugin_user['enrolments']).any? { |enrolment| enrolment['active'] }
+ end
+end
diff --git a/app/services/lti_server.rb b/app/services/lti_server.rb
new file mode 100644
index 0000000000..5f6141254e
--- /dev/null
+++ b/app/services/lti_server.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'net/http'
+require 'uri'
+
+#
+# Server-to-server client for the LTI service's internal API. Every request is keyed by an
+# OnTrack unit id, so callers must authorise the user for that unit before using it.
+#
+class LtiServer
+ class Error < StandardError
+ attr_reader :status
+
+ def initialize(message, status: 502)
+ @status = status
+ super(message)
+ end
+ end
+
+ OPEN_TIMEOUT = 5
+ READ_TIMEOUT = 120
+
+ def self.configured?
+ config = Doubtfire::Application.config
+ config.lti_internal_url.present? && config.lti_internal_key.present?
+ end
+
+ # Raises when the LTI service or its database is unavailable
+ def self.check_health!
+ internal_request(:get, 'health')
+ end
+
+ def self.internal_request(method, path, body = nil)
+ config = Doubtfire::Application.config
+ unless configured?
+ raise Error.new('The LTI service is not configured (LTI_INTERNAL_URL and LTI_INTERNAL_SYNC_KEY)', status: 503)
+ end
+
+ uri = URI.parse("#{config.lti_internal_url.sub(%r{/+\z}, '')}/lti/api/internal/#{path}")
+ request = case method
+ when :get then Net::HTTP::Get.new(uri)
+ when :post then Net::HTTP::Post.new(uri)
+ when :delete then Net::HTTP::Delete.new(uri)
+ end
+ request['Accept'] = 'application/json'
+ request['X-Internal-Key'] = config.lti_internal_key
+ if body
+ request['Content-Type'] = 'application/json'
+ request.body = body.to_json
+ end
+
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
+ http.request(request)
+ end
+
+ payload = response.body.present? ? JSON.parse(response.body) : nil
+ unless response.is_a?(Net::HTTPSuccess)
+ message = payload.is_a?(Hash) && payload['error'].is_a?(String) ? payload['error'] : "The LTI service responded #{response.code}"
+ raise Error.new(message, status: response.code.to_i)
+ end
+
+ payload
+ rescue JSON::ParserError
+ raise Error, 'The LTI service returned an invalid response'
+ rescue URI::InvalidURIError
+ raise Error.new('LTI_INTERNAL_URL is invalid', status: 503)
+ rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
+ raise Error.new("Unable to reach the LTI service: #{e.message}", status: 503)
+ end
+
+ def initialize(unit_id)
+ @unit_id = Integer(unit_id)
+ end
+
+ # Returns nil when the unit is not linked to an LMS course.
+ def link
+ request(:get, 'link')
+ rescue Error => e
+ raise unless e.status == 404
+
+ nil
+ end
+
+ def unlink
+ request(:delete, 'link')
+ end
+
+ def members
+ request(:get, 'members')
+ end
+
+ def course_data(include: nil, assignment_id: nil)
+ body = {}
+ body[:include] = include if include.present?
+ body[:assignmentId] = assignment_id if assignment_id.present?
+ request(:post, 'course-data', body)
+ end
+
+ def grade_line_item
+ request(:get, 'grade-line-item')
+ end
+
+ def ensure_grade_line_item
+ request(:post, 'grade-line-item')
+ end
+
+ def submit_scores(scores)
+ request(:post, 'scores', { scores: scores })
+ end
+
+ private
+
+ def request(method, path, body = nil)
+ LtiServer.internal_request(method, "units/#{@unit_id}/#{path}", body)
+ end
+end
diff --git a/app/services/lti_service_addresses.rb b/app/services/lti_service_addresses.rb
new file mode 100644
index 0000000000..10c871a6d1
--- /dev/null
+++ b/app/services/lti_service_addresses.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'ipaddr'
+require 'resolv'
+
+#
+# Recognises the LTI service by the address a request connects from. Entries are IPs, CIDR ranges
+# or hostnames, and hostnames are resolved on every check because container IPs change on restart.
+#
+module LtiServiceAddresses
+ module_function
+
+ def allowed?(remote_addr, entries = Doubtfire::Application.config.lti_service_hosts)
+ address = normalise(remote_addr)
+ return false if address.nil?
+
+ Array(entries).any? { |entry| addresses_for(entry).any? { |allowed| allowed.include?(address) } }
+ end
+
+ def addresses_for(entry)
+ [IPAddr.new(entry)]
+ rescue IPAddr::Error
+ Resolv.getaddresses(entry).filter_map { |ip| normalise(ip) }
+ end
+
+ def normalise(ip)
+ address = IPAddr.new(ip.to_s)
+ address.ipv4_mapped? ? address.native : address
+ rescue IPAddr::Error
+ nil
+ end
+end
diff --git a/app/services/session_tracker.rb b/app/services/session_tracker.rb
index 441d1e5d75..b2c34fdd39 100644
--- a/app/services/session_tracker.rb
+++ b/app/services/session_tracker.rb
@@ -59,7 +59,7 @@ def self.find_or_create_session(user, unit, ip_address)
tz = ActiveSupport::TimeZone[tutorial.campus&.timezone] if tutorial.campus&.timezone.present?
tutorial_start = tz.parse("#{now.to_date} #{tutorial.meeting_time}")
- tutorial_end = tutorial_start + 2.hours
+ tutorial_end = tutorial_start + tutorial.duration_minutes.minutes
if now >= tutorial_start && now < tutorial_end
is_during_tutorial = true
diff --git a/app/services/user_identity.rb b/app/services/user_identity.rb
new file mode 100644
index 0000000000..7057979aa0
--- /dev/null
+++ b/app/services/user_identity.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+#
+# Finds OnTrack users by their institution login id, then username, then email. SAML sign in,
+# LTI launches and LMS imports all share this lookup so a person always resolves to the same account.
+#
+module UserIdentity
+ module_function
+
+ def user_id_data(login_id:, email:)
+ {
+ login_id: login_id.presence,
+ email: email,
+ username: email.to_s[/(.*)@/, 1]
+ }
+ end
+
+ # Institution settings decide which LTI member field holds the institution login id
+ def lti_user_id_data(member)
+ settings = Doubtfire::Application.config.institution_settings
+ return settings.map_lti_member_to_user_id(member) if settings.respond_to?(:map_lti_member_to_user_id)
+
+ user_id_data(login_id: member['ext_user_username'].presence || member['user_id'], email: member['email'])
+ end
+
+ # Institution settings can reject login ids that are not institution ids, such as old LMS user ids
+ def institution_login_id(login_id)
+ return nil if login_id.blank?
+
+ settings = Doubtfire::Application.config.institution_settings
+ return login_id unless settings.respond_to?(:institution_login_id?)
+
+ settings.institution_login_id?(login_id) ? login_id : nil
+ end
+
+ def find_user(login_id:, email:, username: nil)
+ login_id = institution_login_id(login_id)
+ username ||= email.to_s[/(.*)@/, 1]
+ (login_id.present? && User.find_by(login_id: login_id)) ||
+ (username.present? && User.find_by(username: username)) ||
+ (email.present? && User.find_by(email: email)) ||
+ nil
+ end
+
+ # True when the account already belongs to a different institution login id
+ def mismatch?(user, login_id)
+ incoming = institution_login_id(login_id)
+ stored = institution_login_id(user.login_id)
+ incoming.present? && stored.present? && !stored.casecmp?(incoming)
+ end
+
+ # Logs a matched account that belongs to a different login id, and returns true when
+ # DF_ENFORCE_LOGIN_ID_MATCH says it must not be used. Also logs a changed email, which may need a
+ # manual username change.
+ def blocked?(user, login_id:, email:, source:)
+ if mismatch?(user, login_id)
+ log_identity_event('login_id_mismatch', user, login_id: login_id, email: email, source: source)
+ return Doubtfire::Application.config.enforce_login_id_match
+ end
+
+ if email.present? && user.email.present? && !user.email.casecmp?(email)
+ log_identity_event('login_id_email_changed', user, login_id: login_id, email: email, source: source)
+ end
+ alert_username_changed(user, login_id: login_id, email: email, source: source) if username_changed?(user, login_id, email)
+ false
+ end
+
+ # Same person by login id, but their email now derives a different username
+ def username_changed?(user, login_id, email)
+ incoming = institution_login_id(login_id)
+ username = email.to_s[/(.*)@/, 1]
+ incoming.present? && user.login_id.to_s.casecmp?(incoming) &&
+ username.present? && user.username.present? && !user.username.casecmp?(username)
+ end
+
+ # Emails the details to the error address and sends Sentry a message without user details, once a day per change
+ def alert_username_changed(user, login_id:, email:, source:)
+ username = email.to_s[/(.*)@/, 1]
+ return unless Rails.cache.write("user_identity/username_changed/#{user.id}/#{username.downcase}", true, expires_in: 1.day, unless_exist: true)
+
+ Sentry.capture_message('User matched by login id has a changed username', level: :warning, extra: { source: source }) if defined?(Sentry)
+
+ message = "User #{user.id} (#{user.username}) matched login id #{login_id} during #{source}, " \
+ "but their email #{email} now gives the username #{username}. The username may need to be changed manually."
+ ErrorLogMailer.error_message('Username changed', message, StandardError.new(message))&.deliver_now
+ rescue StandardError => e
+ Rails.logger.error "Failed to send username change alert for user #{user.id}: #{e.message}"
+ end
+
+ # Fills in a missing login id or username on a matched account, never replacing either. A stored
+ # login id the institution does not recognise counts as missing.
+ def link_identity(user, login_id:, username: nil)
+ return false if user.new_record?
+
+ login_id = institution_login_id(login_id)
+ missing = {}
+ missing[:login_id] = login_id if login_id && institution_login_id(user.login_id).nil?
+ missing[:username] = username if username.present? && user.username.blank?
+ missing.any? && user.update(missing)
+ rescue ActiveRecord::RecordNotUnique
+ log_identity_event('login_id_taken', user, login_id: login_id, email: nil, source: 'link')
+ false
+ end
+
+ def log_identity_event(event, user, login_id:, email:, source:)
+ Rails.logger.warn({
+ event: event,
+ source: source,
+ user_id: user.id,
+ username: user.username,
+ stored_login_id: user.login_id,
+ incoming_login_id: login_id,
+ stored_email: user.email,
+ incoming_email: email
+ }.to_json)
+ end
+end
diff --git a/app/sidekiq/accept_overseer_job.rb b/app/sidekiq/accept_overseer_job.rb
index 5f176e2dfd..bc8d06c4de 100644
--- a/app/sidekiq/accept_overseer_job.rb
+++ b/app/sidekiq/accept_overseer_job.rb
@@ -8,9 +8,9 @@ class AcceptOverseerJob
include ApplicationHelper
include FileHelper
- sidekiq_options lock: :until_executed,
- # TODO: should students be allowed to submit a new task submission when the previous overseer job has not started/completed?
- lock_args_method: ->(args) { [args.first, 'overseer-assessment'] },
+ sidekiq_options queue: 'default',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first, args.last, 'overseer-assessment'] },
on_conflict: :reject,
retry: 1
@@ -36,12 +36,15 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment
total_steps: active_overseer_steps.size
)
- work_dir_name = "#{task.id}-#{overseer_assessment_id}"
+ work_dir_name = [
+ Time.current.to_i, task.unit.code, 'overseer', task.project.student.username,
+ task_definition.abbreviation, task.id, overseer_assessment_id, SecureRandom.uuid
+ ].map { |part| part.to_s.gsub(/[^A-Za-z0-9_-]/, '_') }.join('-')
work_dir = Rails.root.join("tmp", "overseer", work_dir_name)
FileUtils.mkdir_p(work_dir)
- extract_student_submission_files(task, submission, work_dir)
+ extract_student_submission_files(task, submission, work_dir, timestamp)
extract_overseer_resource_files(assessment, work_dir)
success_status = nil
@@ -57,6 +60,11 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment
assessment_pass = true
+ overseer_image = task_definition.overseer_image ||
+ task.unit.overseer_image ||
+ OverseerImage.find_by(tag: docker_image_name_tag)
+ ensure_docker_image_present(docker_image_name_tag, overseer_image)
+
active_overseer_steps.each do |step|
result = run_overseer_step(
step: step,
@@ -96,13 +104,19 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment
end
else
oa.update!(status: :failed)
- unless failure_status.nil?
+ # preserve_status_on_failure = [TaskStatus.time_exceeded.id, TaskStatus.assess_in_portfolio.id].include?(task.task_status_id)
+
+ unless failure_status.nil? # || preserve_status_on_failure
# TODO: have an override status setting for the step? eg. if the task is overdue, let it remain overdue, otherwise use this task status
task.update!(task_status: failure_status)
task.add_status_comment(task.project.tutor_for(task.task_definition), failure_status)
oa.update!(result_task_status: failure_status.status_key.to_s)
end
- task.add_text_comment(task.project.tutor_for(task.task_definition), "**Automated comment**: Some tests did not pass for this submission. Please review the Overseer report, verify your output, and resubmit.")
+ task.add_text_comment(
+ task.project.tutor_for(task.task_definition),
+ "**Automated comment**: Some tests did not pass for this submission. Please review the Overseer report, verify your output, and resubmit.",
+ attention_audience: :student
+ )
end
FileUtils.rm_rf(work_dir)
@@ -113,6 +127,18 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment
raise e
end
+ def ensure_docker_image_present(docker_image_name_tag, overseer_image)
+ _, _, inspect_status = Open3.capture3('docker', 'image', 'inspect', docker_image_name_tag)
+ return if inspect_status.success?
+
+ raise "Docker image #{docker_image_name_tag} is not configured" if overseer_image.nil?
+
+ overseer_image.pull_from_docker
+ return if overseer_image.success?
+
+ raise "Unable to pull Docker image #{docker_image_name_tag}: #{overseer_image.pulled_image_text}"
+ end
+
def run_overseer_step(step:, work_dir:, work_dir_name:, task_id:, timestamp:, docker_image_name_tag:, overseer_assessment_id:)
script_contents = step.run_command
raise "Execution script is empty" if script_contents.blank?
@@ -155,6 +181,7 @@ def run_overseer_step(step:, work_dir:, work_dir_name:, task_id:, timestamp:, do
command = %(
timeout #{timeout} docker run --rm -i \
+ --pull never \
--cpus 1 \
--network none \
#{volume_mount} \
@@ -239,25 +266,32 @@ def run_overseer_step(step:, work_dir:, work_dir_name:, task_id:, timestamp:, do
)
end
- def extract_student_submission_files(task, submission, work_dir)
- # Extract submission files, removing any parent folders
- Zip::File.open(submission) do |zip_file|
- zip_file.each do |entry|
- next if entry.name_is_directory?
+ def extract_student_submission_files(task, submission, work_dir, timestamp)
+ # Submission files are stored directly under their timestamp in the task archive.
+ Zip::File.open(submission) do |history_zip|
+ prefix = "#{FileHelper.sanitized_path(timestamp.to_s)}/"
+ entries = history_zip.entries.reject(&:name_is_directory?).select { |entry| entry.name.start_with?(prefix) }
+ raise "Submission history entries not found for timestamp: #{timestamp}" if entries.empty?
+
+ extract_submission_entries(task, entries, work_dir, prefix)
+ end
+ end
- parts = entry.name.split('/')[1..]
- next unless parts.length >= 1
+ def extract_submission_entries(task, entries, work_dir, prefix)
+ # Extract submission files, removing any parent folders.
+ entries.each do |entry|
+ parts = entry.name.delete_prefix(prefix).split('/')
+ next unless parts.first == task.id.to_s && parts.length >= 2
- file_name = parts.first
- index = file_name.to_i
+ file_name = parts.second
+ index = file_name.to_i
- file = task.upload_requirements[index]
- final_name = file['name']
+ file = task.upload_requirements[index]
+ final_name = file['name']
- dest_path = File.join(work_dir, final_name)
- FileUtils.mkdir_p(File.dirname(dest_path))
- zip_file.extract(entry, dest_path) { true }
- end
+ dest_path = File.join(work_dir, final_name)
+ FileUtils.mkdir_p(File.dirname(dest_path))
+ entry.extract(dest_path) { true }
end
end
diff --git a/app/sidekiq/accept_submission_job.rb b/app/sidekiq/accept_submission_job.rb
index 15a92d084e..a7db16770c 100644
--- a/app/sidekiq/accept_submission_job.rb
+++ b/app/sidekiq/accept_submission_job.rb
@@ -2,14 +2,13 @@ class AcceptSubmissionJob
include Sidekiq::Job
include LogHelper
- def perform(task_id, user_id, accepted_tii_eula, test_submission)
- begin
- # Ensure cwd is valid...
- FileUtils.cd(Rails.root)
- rescue StandardError => e
- logger.error e
- end
+ sidekiq_options queue: 'default',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first] },
+ on_conflict: :reject,
+ retry: false
+ def perform(task_id, user_id, accepted_tii_eula, test_submission)
begin
task = Task.find(task_id)
user = User.find(user_id)
@@ -21,7 +20,7 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission)
begin
logger.info "Accepting submission for task #{task.id} by user #{user.id}"
# Convert submission to PDF
- task.convert_submission_to_pdf(log_to_stdout: true)
+ raise "Failed to convert submission to PDF for task #{task.id}" unless task.convert_submission_to_pdf(log_to_stdout: true)
rescue StandardError => e
logger.error e
@@ -29,15 +28,25 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission)
if task.project.student.receive_task_notifications
begin
PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver
- rescue StandardError => e
- logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{e.message}"
+ rescue StandardError => mail_error
+ logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{mail_error.message}"
end
end
begin
# Notify system admin
+ if defined?(Sentry)
+ Sentry.capture_exception(
+ e,
+ extra: {
+ task_id: task.id,
+ task_definition_abbreviation: task.task_definition.abbreviation,
+ latex_log_message: e.respond_to?(:log_message) ? e.log_message.to_s.last(5000) : nil
+ }
+ )
+ end
mail = ErrorLogMailer.error_message('Accept Submission', "Failed to convert submission to PDF for task #{task.log_details}", e)
- mail.deliver if mail.present?
+ mail.presence&.deliver
rescue StandardError => e
logger.error "Failed to send error log to admin"
end
@@ -60,19 +69,24 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission)
task.send_documents_to_tii(user, accepted_tii_eula: accepted_tii_eula)
end
- if task.overseer_enabled? || test_submission
- overseer_assessment = OverseerAssessment.create_for(task, test_submission)
- if overseer_assessment.present?
- logger.info "Launching Overseer assessment for task_def_id: #{task.task_definition.id} task_id: #{task.id}"
-
- overseer_assessment.send_to_overseer(test_submission: test_submission)
-
- else
- logger.info "Overseer assessment for task_def_id: #{task.task_definition.id} task_id: #{task.id} was not performed #{overseer_assessment.inspect}"
- end
+ if SubmissionHistory.enabled_requirements(task).any?
+ submission_timestamp = Time.now.utc.to_i
+ SubmissionHistory.mark_pending(task)
+ CreateSubmissionHistoryJob.perform_async(task.id, submission_timestamp, test_submission)
+ elsif task.overseer_enabled? || test_submission
+ logger.error "Overseer assessment was not performed because task definition #{task.task_definition.id} has no submission history files configured"
end
rescue StandardError => e # to raise error message to avoid unnecessary retry
logger.error e
+ if defined?(Sentry)
+ Sentry.capture_exception(
+ e,
+ extra: {
+ task_id: task&.id,
+ task_definition_abbreviation: task&.task_definition&.abbreviation
+ }
+ )
+ end
task.clear_in_process
end
end
diff --git a/app/sidekiq/aggregate_task_completion_stats_job.rb b/app/sidekiq/aggregate_task_completion_stats_job.rb
new file mode 100644
index 0000000000..80c3a77529
--- /dev/null
+++ b/app/sidekiq/aggregate_task_completion_stats_job.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class AggregateTaskCompletionStatsJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+ include LogHelper
+ include ApplicationHelper
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first] },
+ on_conflict: :reject,
+ retry: false
+
+ def perform(unit_id = nil)
+ logger.info 'Starting task completion stats aggregation...'
+
+ at(0)
+ total(1)
+
+ if unit_id.present?
+ Unit.find(unit_id).capture_task_complete_stats_snapshot!
+ else
+ Unit.within_teaching_dates.find_each(&:capture_task_complete_stats_snapshot!)
+ end
+
+ at(1)
+ logger.info 'Completed task completion stats aggregation!'
+ rescue StandardError => e
+ logger.error e
+ raise e
+ end
+end
diff --git a/app/sidekiq/archive_old_units_job.rb b/app/sidekiq/archive_old_units_job.rb
index 7ebb68e666..f4bca72efa 100644
--- a/app/sidekiq/archive_old_units_job.rb
+++ b/app/sidekiq/archive_old_units_job.rb
@@ -4,6 +4,8 @@
class ArchiveOldUnitsJob
include Sidekiq::Job
+ sidekiq_options queue: 'parallel'
+
def perform
return unless Doubtfire::Application.config.archive_units
@@ -17,6 +19,7 @@ def perform
rescue StandardError => e
begin
# Notify system admin
+ Sentry.capture_exception(e) if defined?(Sentry)
mail = ErrorLogMailer.error_message('Archive Units', "Failed to move old units to archive", e)
mail.deliver if mail.present?
diff --git a/app/sidekiq/clear_access_tokens_job.rb b/app/sidekiq/clear_access_tokens_job.rb
index b9a4524bb0..789d9c3e53 100644
--- a/app/sidekiq/clear_access_tokens_job.rb
+++ b/app/sidekiq/clear_access_tokens_job.rb
@@ -4,6 +4,8 @@
class ClearAccessTokensJob
include Sidekiq::Job
+ sidekiq_options queue: 'parallel'
+
def perform
UserOauthToken.destroy_old_tokens
UserOauthState.destroy_old_states
diff --git a/app/sidekiq/communication_rule_job.rb b/app/sidekiq/communication_rule_job.rb
new file mode 100644
index 0000000000..25c645f031
--- /dev/null
+++ b/app/sidekiq/communication_rule_job.rb
@@ -0,0 +1,41 @@
+class CommunicationRuleJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+ include LogHelper
+ include ApplicationHelper
+ include FileHelper
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first, args.last, 'communication-rule'] },
+ on_conflict: :reject,
+ retry: 1
+
+ def perform(rule_id)
+ logger.info "Starting communication rule job..."
+
+ at(0)
+ total(1)
+
+ rule = CommunicationRule.find(rule_id)
+
+ projects = rule.communication_set.preview_projects_for_rule(rule)
+ store(
+ result: projects.map do |project|
+ {
+ username: project.user&.username,
+ student_id: project.user&.student_id,
+ target_grade: project.target_grade,
+ last_sign_in_at: project.user&.last_sign_in_at,
+ last_viewed_at: project.last_viewed_at
+ }
+ end
+ )
+
+ logger.info "Completed communication job"
+ rescue StandardError => e
+ logger.error e
+ raise e
+ end
+
+end
diff --git a/app/sidekiq/create_submission_history_job.rb b/app/sidekiq/create_submission_history_job.rb
new file mode 100644
index 0000000000..1e1b172ce9
--- /dev/null
+++ b/app/sidekiq/create_submission_history_job.rb
@@ -0,0 +1,28 @@
+class CreateSubmissionHistoryJob
+ include Sidekiq::Job
+ include LogHelper
+
+ sidekiq_options queue: 'default',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first, 'submission-history'] },
+ on_conflict: :reject,
+ retry: false
+
+ def perform(task_id, submission_timestamp, test_submission)
+ task = Task.find(task_id)
+ history = SubmissionHistory.create_archive!(task, submission_timestamp)
+
+ return unless task.overseer_enabled? || test_submission
+
+ assessment = OverseerAssessment.create_for(history, test_submission)
+ return if assessment.nil?
+
+ assessment.update!(student_notified_at: Time.current) if test_submission
+ assessment.send_to_overseer(test_submission: test_submission)
+ rescue StandardError => e
+ logger.error e
+ Sentry.capture_exception(e, extra: { task_id: task_id }) if defined?(Sentry)
+ ensure
+ SubmissionHistory.clear_pending(task) if task
+ end
+end
diff --git a/app/sidekiq/d2l_post_grades_job.rb b/app/sidekiq/d2l_post_grades_job.rb
index 10bbf80e59..8c1d857b53 100644
--- a/app/sidekiq/d2l_post_grades_job.rb
+++ b/app/sidekiq/d2l_post_grades_job.rb
@@ -4,7 +4,8 @@ class D2lPostGradesJob
include Sidekiq::Job
include LogHelper
- sidekiq_options lock: :until_executed
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed
def perform(unit_id, user_id)
unit = Unit.find(unit_id)
diff --git a/app/sidekiq/download_marking_sessions_job.rb b/app/sidekiq/download_marking_sessions_job.rb
index f06f5b5fd3..08fa92c42a 100644
--- a/app/sidekiq/download_marking_sessions_job.rb
+++ b/app/sidekiq/download_marking_sessions_job.rb
@@ -9,9 +9,7 @@ class DownloadMarkingSessionsJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
- lock_args_method: ->(args) { [args.first] },
- on_conflict: :reject,
+ sidekiq_options queue: 'parallel',
retry: false
def perform(unit_role_id, start_date, end_date, timezone)
diff --git a/app/sidekiq/download_overflow_task_claims_csv_job.rb b/app/sidekiq/download_overflow_task_claims_csv_job.rb
new file mode 100644
index 0000000000..3541708a09
--- /dev/null
+++ b/app/sidekiq/download_overflow_task_claims_csv_job.rb
@@ -0,0 +1,31 @@
+require 'csv'
+
+class DownloadOverflowTaskClaimsCsvJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+ include LogHelper
+ include ApplicationHelper
+ include FileHelper
+ include MimeCheckHelpers
+ include CsvHelper
+
+ sidekiq_options queue: 'parallel',
+ retry: false
+
+ def perform(unit_id)
+ logger.info "Starting overflow task claims csv download..."
+
+ at(0)
+ total(1)
+
+ unit = Unit.find(unit_id)
+ csv = unit.overflow_task_claims_csv
+
+ store(result: csv)
+
+ logger.info "Completed overflow task claims csv download!"
+ rescue StandardError => e
+ logger.error e
+ raise e
+ end
+end
diff --git a/app/sidekiq/download_portfolios_job.rb b/app/sidekiq/download_portfolios_job.rb
index 378ded4d47..a6b3529e04 100644
--- a/app/sidekiq/download_portfolios_job.rb
+++ b/app/sidekiq/download_portfolios_job.rb
@@ -5,9 +5,10 @@ class DownloadPortfoliosJob
include Sidekiq::Status::Worker
include LogHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { args },
- on_conflict: :reject,
+ on_conflict: :log,
retry: false
def perform(current_user_id, unit_id)
diff --git a/app/sidekiq/download_submission_files_job.rb b/app/sidekiq/download_submission_files_job.rb
index 0c5ece014e..132d0e9560 100644
--- a/app/sidekiq/download_submission_files_job.rb
+++ b/app/sidekiq/download_submission_files_job.rb
@@ -5,9 +5,10 @@ class DownloadSubmissionFilesJob
include Sidekiq::Status::Worker
include LogHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { args },
- on_conflict: :reject,
+ on_conflict: :log,
retry: false
def perform(current_user_id, unit_id, task_definition_id)
diff --git a/app/sidekiq/download_submission_pdfs_job.rb b/app/sidekiq/download_submission_pdfs_job.rb
index 015dd3a5b1..0fdc066326 100644
--- a/app/sidekiq/download_submission_pdfs_job.rb
+++ b/app/sidekiq/download_submission_pdfs_job.rb
@@ -5,9 +5,10 @@ class DownloadSubmissionPdfsJob
include Sidekiq::Status::Worker
include LogHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { args },
- on_conflict: :reject,
+ on_conflict: :log,
retry: false
def perform(current_user_id, unit_id, task_definition_id)
diff --git a/app/sidekiq/download_task_assessment_counts_csv_job.rb b/app/sidekiq/download_task_assessment_counts_csv_job.rb
index a136168a1a..920aa02bbd 100644
--- a/app/sidekiq/download_task_assessment_counts_csv_job.rb
+++ b/app/sidekiq/download_task_assessment_counts_csv_job.rb
@@ -9,9 +9,7 @@ class DownloadTaskAssessmentCountsCsvJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
- lock_args_method: ->(args) { [args.first] },
- on_conflict: :reject,
+ sidekiq_options queue: 'parallel',
retry: false
def perform(unit_id)
diff --git a/app/sidekiq/download_task_completion_csv_job.rb b/app/sidekiq/download_task_completion_csv_job.rb
index 3ea9bc1516..e9efabb338 100644
--- a/app/sidekiq/download_task_completion_csv_job.rb
+++ b/app/sidekiq/download_task_completion_csv_job.rb
@@ -9,9 +9,7 @@ class DownloadTaskCompletionCsvJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
- lock_args_method: ->(args) { [args.first] },
- on_conflict: :reject,
+ sidekiq_options queue: 'parallel',
retry: false
def perform(unit_id)
diff --git a/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb b/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb
index 6be0876a40..b65a0d4058 100644
--- a/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb
+++ b/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb
@@ -9,9 +9,7 @@ class DownloadTasksAwaitingFeedbackCsvJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
- lock_args_method: ->(args) { [args.first] },
- on_conflict: :reject,
+ sidekiq_options queue: 'parallel',
retry: false
def perform(unit_id)
diff --git a/app/sidekiq/download_tutor_assessment_stats_job.rb b/app/sidekiq/download_tutor_assessment_stats_job.rb
index 4a1f8a3728..f17d67d55c 100644
--- a/app/sidekiq/download_tutor_assessment_stats_job.rb
+++ b/app/sidekiq/download_tutor_assessment_stats_job.rb
@@ -9,9 +9,7 @@ class DownloadTutorAssessmentStatsJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
- lock_args_method: ->(args) { [args.first] },
- on_conflict: :reject,
+ sidekiq_options queue: 'parallel',
retry: false
def perform(unit_id)
diff --git a/app/sidekiq/download_unit_tutor_times_summary_job.rb b/app/sidekiq/download_unit_tutor_times_summary_job.rb
index 3bbda469f9..44ea64eb72 100644
--- a/app/sidekiq/download_unit_tutor_times_summary_job.rb
+++ b/app/sidekiq/download_unit_tutor_times_summary_job.rb
@@ -9,9 +9,7 @@ class DownloadUnitTutorTimesSummaryJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
- lock_args_method: ->(args) { [args.first] },
- on_conflict: :reject,
+ sidekiq_options queue: 'parallel',
retry: false
def perform(unit_id, start_date, end_date, timezone, ignore_sessions_during_tutorials)
diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb
new file mode 100644
index 0000000000..2523efb2a3
--- /dev/null
+++ b/app/sidekiq/execute_communication_set_job.rb
@@ -0,0 +1,623 @@
+require 'csv'
+
+class ExecuteCommunicationSetJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args[0], args[1], 'communication-set'] },
+ on_conflict: :reject,
+ retry: 1
+
+ def perform(communication_set_id, target_rule_id = nil)
+ communication_set = CommunicationSet.find(communication_set_id)
+ rules = communication_set.communication_rules.to_a
+
+ # Refuse the whole set, not just the broken rules: allocation is a waterfall,
+ # so a rule that cannot be evaluated sends its students into a later rule and
+ # mails them the wrong thing.
+ unresolved_rules = rules.select(&:unresolved?)
+ if unresolved_rules.any?
+ raise "Communication set #{communication_set.id} cannot run: #{unresolved_rules.map(&:name).join(', ')} " \
+ 'reference records that do not exist in this unit'
+ end
+ target_rule_id = target_rule_id&.to_i
+
+ if target_rule_id.present? && rules.none? { |rule| rule.id == target_rule_id }
+ raise ActiveRecord::RecordNotFound, "CommunicationRule #{target_rule_id} not found in set #{communication_set_id}"
+ end
+
+ eligible_projects = communication_set.eligible_projects
+ remaining_projects = eligible_projects.dup
+ executed_rules = []
+ action_results = []
+
+ rules_to_process =
+ if target_rule_id.present?
+ cutoff_index = rules.index { |rule| rule.id == target_rule_id }
+ rules.first(cutoff_index + 1)
+ else
+ rules
+ end
+
+ at(0)
+ total(rules_to_process.length.nonzero? || 1)
+
+ rules_to_process.each_with_index do |rule, index|
+ matched_projects = rule.matching_projects(remaining_projects)
+
+ executed_rules << {
+ rule_id: rule.id,
+ rule_name: rule.name,
+ matched_project_ids: matched_projects.map(&:id)
+ }
+
+ should_execute_actions = target_rule_id.present? ? rule.id == target_rule_id : true
+
+ if should_execute_actions
+ rule_action_results = rule.communication_actions.flat_map do |action|
+ execute_action(action, matched_projects, communication_set.unit, rule)
+ end
+
+ action_results.concat(rule_action_results)
+ if rule.send_log_to_convenors?
+ action_results.concat(
+ send_action_log_to_convenors(
+ matched_projects,
+ communication_set.unit,
+ rule,
+ rule_action_results
+ )
+ )
+ end
+ end
+
+ remaining_projects -= matched_projects
+ at(index + 1)
+ end
+
+ store(
+ result: {
+ communication_set_id: communication_set.id,
+ target_rule_id: target_rule_id,
+ executed_rule_ids: executed_rules.map { |item| item[:rule_id] },
+ remaining_project_ids: remaining_projects.map(&:id),
+ rules: executed_rules,
+ actions: action_results
+ }
+ )
+ rescue StandardError => e
+ logger.error("ExecuteCommunicationSetJob failed: #{e.class} #{e.message}")
+ raise e
+ end
+
+ private
+
+ def execute_action(action, projects, unit, rule)
+ case action.type
+ when 'ChangeTargetGradeAction'
+ execute_change_target_grade_action(action, projects)
+ when 'EmailStudentAction'
+ execute_email_student_action(action, projects, unit, rule)
+ when 'EmailStaffAction'
+ execute_email_staff_action(action, projects, unit, rule)
+ when 'TaskCommentAction'
+ execute_task_comment_action(action, projects, unit, rule)
+ else
+ [{
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ reason: 'unsupported action type'
+ }]
+ end
+ end
+
+ def execute_change_target_grade_action(action, projects)
+ projects.map do |project|
+ previous_target_grade = project.target_grade
+
+ project.update!(target_grade: action.target_grade)
+
+ {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'updated',
+ project_id: project.id,
+ username: project.user&.username,
+ previous_target_grade: previous_target_grade,
+ target_grade: action.target_grade
+ }
+ end
+ end
+
+ def execute_email_student_action(action, projects, unit, rule)
+ projects.filter_map do |project|
+ recipient = project.user
+ sender = sender_for(unit)
+
+ if recipient&.email.blank?
+ next {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ project_id: project.id,
+ reason: 'student email missing'
+ }
+ end
+
+ if sender.blank?
+ next {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ project_id: project.id,
+ reason: 'sender email missing'
+ }
+ end
+
+ subject = render_template(action.subject, project, unit, rule, projects.length)
+ body = render_template(action.body, project, unit, rule, projects.length)
+
+ CommunicationsMailer.communication_email(
+ to: formatted_email(recipient),
+ from: sender,
+ subject: subject,
+ body: body,
+ plain_text: action.plain_text,
+ recipient: recipient,
+ sender: sender_user_for(unit)
+ ).deliver_now
+
+ {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'sent',
+ project_id: project.id,
+ username: recipient.username,
+ recipient_email: recipient.email
+ }
+ end
+ end
+
+ def execute_email_staff_action(action, projects, unit, rule)
+ projects.flat_map do |project|
+ sender = sender_for(unit)
+
+ if sender.blank?
+ next [{
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ project_id: project.id,
+ username: project.user&.username,
+ reason: 'sender email missing'
+ }]
+ end
+
+ staff_recipients_for(project, unit, action).map do |recipient|
+ subject = render_template(action.subject, project, unit, rule, projects.length)
+ body = render_template(action.body, project, unit, rule, projects.length)
+
+ CommunicationsMailer.communication_email(
+ to: formatted_email(recipient),
+ from: sender,
+ subject: subject,
+ body: body,
+ plain_text: action.plain_text,
+ recipient: recipient,
+ sender: sender_user_for(unit)
+ ).deliver_now
+
+ {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'sent',
+ project_id: project.id,
+ username: project.user&.username,
+ recipient_email: recipient.email,
+ recipient_username: recipient.username
+ }
+ end
+ end
+ end
+
+ def execute_task_comment_action(action, projects, unit, rule)
+ comment_author = sender_user_for(unit)
+
+ if comment_author.blank?
+ return [{
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ reason: 'comment author missing'
+ }]
+ end
+
+ comment_text_template = action.body.to_s.strip
+
+ projects.map do |project|
+ task_definition = action.task_definition || unit.task_definitions.find_by(id: action.task_definition_id)
+ if task_definition.blank?
+ next {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ project_id: project.id,
+ username: project.user&.username,
+ reason: 'task definition missing'
+ }
+ end
+
+ task = project.task_for_task_definition(task_definition)
+ rendered_comment = render_template(comment_text_template, project, unit, rule, projects.length)
+
+ if rendered_comment.blank?
+ next {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ project_id: project.id,
+ username: project.user&.username,
+ reason: 'comment text missing'
+ }
+ end
+
+ comment = task.add_text_comment(comment_author, rendered_comment, attention_audience: :student)
+
+ if comment.nil?
+ next {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'skipped',
+ project_id: project.id,
+ username: project.user&.username,
+ reason: 'duplicate comment'
+ }
+ end
+
+ {
+ action_id: action.id,
+ action_type: action.type,
+ status: 'commented',
+ project_id: project.id,
+ username: project.user&.username,
+ task_definition_id: task_definition.id,
+ task_definition_name: task_definition.name,
+ comment_id: comment.id
+ }
+ end
+ end
+
+ def send_action_log_to_convenors(projects, unit, rule, prior_action_results)
+ sender = sender_for(unit)
+
+ if sender.blank?
+ return [{
+ action_id: nil,
+ action_type: 'SendLogToConvenors',
+ status: 'skipped',
+ reason: 'sender email missing'
+ }]
+ end
+
+ recipients = unit.convenors.includes(:user).map(&:user).select { |user| user&.email.present? }.uniq(&:id)
+
+ if recipients.empty?
+ return [{
+ action_id: nil,
+ action_type: 'SendLogToConvenors',
+ status: 'skipped',
+ reason: 'convenor email missing'
+ }]
+ end
+
+ csv_content = build_action_log_csv(rule, projects, prior_action_results)
+ csv_filename = "communication-rule-#{rule.id}-action-log.csv"
+ body = action_log_email_body(rule, prior_action_results)
+ subject = action_log_email_subject(unit, rule)
+
+ recipients.map do |recipient|
+ CommunicationsMailer.action_log_email(
+ to: formatted_email(recipient),
+ from: sender,
+ subject: subject,
+ body: body,
+ recipient: recipient,
+ sender: sender_user_for(unit),
+ unit: unit,
+ rule: rule,
+ csv_content: csv_content,
+ csv_filename: csv_filename,
+ affected_students_count: projects.length
+ ).deliver_now
+
+ {
+ action_id: nil,
+ action_type: 'SendLogToConvenors',
+ status: 'sent',
+ recipient_email: recipient.email,
+ recipient_username: recipient.username,
+ attachment_filename: csv_filename,
+ affected_students_count: projects.length
+ }
+ end
+ end
+
+ def staff_recipients_for(project, unit, action)
+ recipients = []
+
+ if action.email_tutors
+ recipients.concat(
+ project.tutorial_enrolments.filter_map do |tutorial_enrolment|
+ tutorial_enrolment.tutorial&.unit_role&.user
+ end
+ )
+ end
+
+ if action.email_convenors
+ recipients.concat(unit.convenors.includes(:user).map(&:user))
+ end
+
+ recipients.select { |recipient| recipient&.email.present? }.uniq(&:id)
+ end
+
+ def render_template(template, project, unit, rule, affected_students_count, target_grade_override = nil, action_results = [])
+ return '' if template.blank?
+
+ student = project&.user
+ convenor = unit.main_convenor_user
+ target_grade_value = target_grade_override.nil? ? project&.target_grade : target_grade_override
+
+ replacements = {
+ '{{student.first_name}}' => student&.first_name.to_s,
+ '{{student.last_name}}' => student&.last_name.to_s,
+ '{{student.preferred_name}}' => (student&.nickname.presence || student&.first_name).to_s,
+ '{{student.full_name}}' => [student&.first_name, student&.last_name].compact.join(' '),
+ '{{student.username}}' => student&.username.to_s,
+ '{{student.student_id}}' => student&.student_id.to_s,
+ '{{convenor.preferred_name}}' => (convenor&.nickname&.strip.presence || convenor&.first_name).to_s,
+ '{{affected_students_count}}' => affected_students_count.to_s,
+ '{{unit.code}}' => unit.code.to_s,
+ '{{unit.name}}' => unit.name.to_s,
+ '{{rule.name}}' => rule.name.to_s,
+ '{{target_grade}}' => target_grade_name(target_grade_value, unit),
+ '{{conditions_summary}}' => conditions_summary(rule),
+ '{{actions_summary}}' => actions_summary(rule, action_results)
+ }
+
+ replacements.reduce(template.dup) do |rendered, (token, value)|
+ rendered.gsub(token, value)
+ end
+ end
+
+ def target_grade_name(value, unit = nil)
+ (unit&.grade_label(value) || GradeHelper.grade_for(value)).to_s
+ end
+
+ def formatted_email(user)
+ return nil if user&.email.blank?
+
+ %("#{user.name}" <#{user.email}>)
+ end
+
+ def sender_for(unit)
+ formatted_email(sender_user_for(unit))
+ end
+
+ def sender_user_for(unit)
+ unit.main_convenor_user || unit.convenors.includes(:user).first&.user
+ end
+
+ def conditions_summary(rule)
+ rule.communication_conditions.map do |condition|
+ "- #{human_condition_summary(condition)}"
+ end.join("\n")
+ end
+
+ def actions_summary(rule, action_results)
+ return rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n") if action_results.blank?
+
+ rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n")
+ end
+
+ def build_action_log_csv(rule, projects, action_results)
+ action_order = rule.communication_actions.each_with_index.to_h { |action, index| [action.id, index] }
+ ordered_results = action_results.sort_by do |result|
+ project = projects.find { |item| item.id == result[:project_id] }
+ student = project&.user
+
+ [
+ student&.username.to_s,
+ action_order.fetch(result[:action_id], Float::INFINITY),
+ result[:recipient_email].to_s
+ ]
+ end
+
+ CSV.generate(headers: true) do |csv|
+ csv << [
+ 'student_username',
+ 'student_id',
+ 'student_name',
+ 'rule_name',
+ 'action_type',
+ 'status',
+ 'details',
+ # 'previous_target_grade',
+ # 'new_target_grade',
+ 'recipient_email',
+ 'executed_at'
+ ]
+
+ ordered_results.each do |result|
+ project = projects.find { |item| item.id == result[:project_id] }
+ student = project&.user
+ details = if result[:status] == 'updated'
+ "Changed target grade from #{target_grade_name(result[:previous_target_grade], rule.unit)} to #{target_grade_name(result[:target_grade], rule.unit)}"
+ elsif result[:status] == 'commented'
+ task_definition = TaskDefinition.find_by(id: result[:task_definition_id])
+ "Added comment to #{task_definition_label(task_definition)}"
+ elsif result[:recipient_email].present?
+ "Sent email to #{result[:recipient_email]}"
+ else
+ result[:reason].to_s
+ end
+
+ csv << [
+ student&.username,
+ student&.student_id,
+ student&.name,
+ rule.name,
+ result[:action_type],
+ result[:status],
+ details,
+ # target_grade_name(result[:previous_target_grade]),
+ # target_grade_name(result[:target_grade]),
+ result[:recipient_email],
+ Time.current.iso8601
+ ]
+ end
+ end
+ end
+
+ def action_log_email_subject(unit, rule)
+ "#{unit.code} #{rule.name} action log"
+ end
+
+ def action_log_email_body(rule, action_results)
+ [
+ action_log_conditions_intro(rule),
+ conditions_summary(rule),
+ 'The following actions have been applied to these students:',
+ actions_summary(rule, action_results)
+ ].join("\n")
+ end
+
+ def action_log_conditions_intro(rule)
+ if rule.operator == 'or'
+ 'A scheduled rule has been run for students that match any of the following conditions:'
+ else
+ 'A scheduled rule has been run for students that match all of the following conditions:'
+ end
+ end
+
+ def human_condition_summary(condition)
+ case condition.type
+ when 'TaskDefinitionStatusCondition'
+ predicate = condition.operator == 'not_equal_to' ? 'Not In' : 'In'
+ task = TaskDefinition.find_by(id: condition.task_definition_id)
+ task_label = if task
+ "Task #{task.abbreviation} #{task.name}"
+ else
+ "Task #{condition.task_definition_id}"
+ end
+ "Students that have #{task_label} #{predicate} [#{Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ')}]"
+ when 'TargetGradeCondition'
+ "Students with a Target Grade #{operator_label(condition.operator)} #{target_grade_name(condition.target_grade, condition.communication.unit)}"
+ when 'TaskStatusCountCondition'
+ grade_label = target_grade_name(condition.task_target_grade, condition.communication.unit)
+ statuses = Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ')
+ "Students that have #{operator_label(condition.operator)} #{condition.task_status_count} #{grade_label} tasks in [#{statuses}]"
+ when 'LoginStatusCondition'
+ relative_activity_summary(condition, 'signed in')
+ when 'UnitViewedStatusCondition'
+ relative_activity_summary(condition, 'viewed this unit')
+ when 'SpecConCondition'
+ "Students with Special Consideration Days #{operator_label(condition.operator)} #{condition.spec_con_days}"
+ when 'TutorialEnrolmentCondition'
+ tutorial = Tutorial.find_by(id: condition.tutorial_id)
+ tutorial_label =
+ if tutorial
+ [tutorial.abbreviation, tutorial.name].compact.join(' ')
+ else
+ "Tutorial #{condition.tutorial_id}"
+ end
+ "Students #{enrolment_label(condition.operator).downcase} #{tutorial_label}"
+ when 'TutorialStreamEnrolmentCondition'
+ tutorial_stream = TutorialStream.find_by(id: condition.tutorial_stream_id)
+ stream_label =
+ if tutorial_stream
+ [tutorial_stream.abbreviation, tutorial_stream.name].compact.join(' ')
+ else
+ "Tutorial Stream #{condition.tutorial_stream_id}"
+ end
+ "Students #{enrolment_label(condition.operator).downcase} #{stream_label}"
+ when 'CampusCondition'
+ campus = Campus.find_by(id: condition.campus_id)
+ campus_label = campus&.name || "Campus #{condition.campus_id}"
+ "Students #{enrolment_label(condition.operator).downcase} #{campus_label}"
+ else
+ "#{condition.type.to_s.underscore.humanize} #{condition.operator.to_s.humanize}"
+ end
+ end
+
+ def relative_activity_summary(condition, activity)
+ duration = "#{condition.activity_days} #{'day'.pluralize(condition.activity_days)}"
+
+ if condition.operator == 'more_than'
+ "Students who have not #{activity} for more than #{duration}"
+ else
+ "Students who #{activity} within the last #{duration}"
+ end
+ end
+
+ def human_action_summary(action)
+ case action.type
+ when 'EmailStudentAction'
+ 'Send email'
+ when 'EmailStaffAction'
+ 'Send staff email'
+ when 'ChangeTargetGradeAction'
+ "Change Target Grade to #{target_grade_name(action.target_grade, action.communication_rule.unit)}"
+ when 'TaskCommentAction'
+ "Add comment to #{task_definition_label(action.task_definition)}"
+ else
+ human_action_type_name(action.type)
+ end
+ end
+
+ def human_action_type_name(type)
+ case type
+ when 'EmailStudentAction'
+ 'Send email'
+ when 'EmailStaffAction'
+ 'Send staff email'
+ when 'ChangeTargetGradeAction'
+ 'Change target grade'
+ when 'TaskCommentAction'
+ 'Add task comment'
+ else
+ type.to_s.underscore.humanize
+ end
+ end
+
+ def operator_label(operator)
+ case operator.to_s
+ when 'greater_than'
+ 'Greater Than'
+ when 'greater_than_or_equal_to'
+ 'Greater Than Or Equal To'
+ when 'less_than'
+ 'Less Than'
+ when 'less_than_or_equal_to'
+ 'Less Than Or Equal To'
+ when 'equal_to'
+ 'Equal To'
+ when 'not_equal_to'
+ 'Not Equal To'
+ else
+ operator.to_s.humanize
+ end
+ end
+
+ def enrolment_label(operator)
+ operator.to_s == 'not_enrolled_in' ? 'Not Enrolled In' : 'Enrolled In'
+ end
+
+ def task_definition_label(task_definition)
+ return 'Task' if task_definition.blank?
+
+ "Task #{task_definition.abbreviation} #{task_definition.name}"
+ end
+end
diff --git a/app/sidekiq/execute_communication_set_schedule_job.rb b/app/sidekiq/execute_communication_set_schedule_job.rb
new file mode 100644
index 0000000000..d1dab980ae
--- /dev/null
+++ b/app/sidekiq/execute_communication_set_schedule_job.rb
@@ -0,0 +1,25 @@
+class ExecuteCommunicationSetScheduleJob
+ include Sidekiq::Job
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first, 'communication-set-schedule'] },
+ on_conflict: :reject,
+ retry: 1
+
+ def perform(schedule_id)
+ schedule = CommunicationSetSchedule.find(schedule_id)
+ return unless schedule.active?
+ return unless schedule.unit.active?
+
+ now = Time.zone.now
+ return unless schedule.due?(now)
+
+ ExecuteCommunicationSetJob.perform_async(schedule.communication_set_id)
+ schedule.update!(
+ last_enqueued_at: now,
+ last_run_at: now,
+ next_run_at: schedule.next_occurrence_after(now + 1.second)
+ )
+ end
+end
diff --git a/app/sidekiq/import_batch_feedback_job.rb b/app/sidekiq/import_batch_feedback_job.rb
new file mode 100644
index 0000000000..5351979f41
--- /dev/null
+++ b/app/sidekiq/import_batch_feedback_job.rb
@@ -0,0 +1,40 @@
+class ImportBatchFeedbackJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+ include LogHelper
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { args.first(3) },
+ on_conflict: :reject,
+ retry: false
+
+ def perform(unit_id, assessor_id, task_definition_id, path_to_upload)
+ logger.info "Starting batch feedback import..."
+
+ at(0, 'Preparing batch feedback import')
+ total(1)
+
+ unit = Unit.find(unit_id)
+ task_definition = unit.task_definitions.find(task_definition_id)
+ assessor = User.find(assessor_id)
+
+ file = File.open(path_to_upload, 'rb')
+ result = unit.upload_batch_feedback_csv(
+ assessor,
+ task_definition,
+ { 'tempfile' => file },
+ progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil|
+ total(total_rows) if total_rows
+ at(rows_processed, message) unless rows_processed.nil?
+ }
+ )
+
+ store(result: result.to_json)
+
+ logger.info "Completed batch feedback import!"
+ ensure
+ file&.close if defined?(file) && file.present?
+ FileUtils.rm_f(path_to_upload) if path_to_upload.present?
+ end
+end
diff --git a/app/sidekiq/import_grades_csv_job.rb b/app/sidekiq/import_grades_csv_job.rb
index ce9ddd560f..a6e84cf4e5 100644
--- a/app/sidekiq/import_grades_csv_job.rb
+++ b/app/sidekiq/import_grades_csv_job.rb
@@ -9,7 +9,8 @@ class ImportGradesCsvJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { [args.first] },
on_conflict: :reject,
retry: false
diff --git a/app/sidekiq/import_lms_extensions_job.rb b/app/sidekiq/import_lms_extensions_job.rb
new file mode 100644
index 0000000000..b38aeda4a5
--- /dev/null
+++ b/app/sidekiq/import_lms_extensions_job.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+#
+# Sets special consideration days from extensions granted on the selected LMS assignment.
+#
+class ImportLmsExtensionsJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first] },
+ on_conflict: :reject,
+ retry: false
+
+ def perform(unit_id, preview_only, scheduled)
+ total(2)
+ at(0, 'Validating LMS integration')
+
+ unit = Unit.find(unit_id)
+ integration = unit.lms_integration
+ unless integration&.fetch_extensions? && integration.assignment_id.present?
+ raise LtiCourseDataSource::Error, 'Enable extension imports and select an LMS assignment first'
+ end
+
+ source = integration.data_source
+ LmsIntegrationValidator.new(integration).validate!(
+ groups: integration.group_mapping_enabled? ? source.groups : [],
+ assignments: source.assignments
+ )
+
+ at(1, 'Fetching LMS extensions')
+ data = source.assignment_extensions(integration.assignment_id)
+ due_date = Time.zone.at(data[:assignment][:due_date]).to_date
+ extensions = data[:extensions].select { |extension| extension[:extension_due_date].positive? }
+ total(2 + extensions.length)
+
+ result = { success: [], ignored: [], errors: [] }
+ extensions.each_with_index do |extension, index|
+ extension_date = Time.zone.at(extension[:extension_due_date]).to_date
+ row = {
+ lms_username: extension[:login_id],
+ email: extension[:email],
+ extension_date: extension_date.iso8601,
+ spec_con_days: nil
+ }
+
+ begin
+ days = [(extension_date - due_date).to_i, 0].max
+ row[:spec_con_days] = days
+
+ user = UserIdentity.find_user(login_id: extension[:login_id], email: extension[:email])
+ if user && UserIdentity.blocked?(user, login_id: extension[:login_id], email: extension[:email], source: 'lms_extensions')
+ result[:errors] << { row: row, message: "#{user.username} is linked to a different login id" }
+ next
+ end
+
+ project = user && unit.projects.find_by(user_id: user.id)
+ if project.blank?
+ result[:ignored] << { row: row, message: 'Student is not enrolled in OnTrack' }
+ next
+ end
+
+ if project.spec_con_days == days
+ result[:ignored] << { row: row, message: 'Special consideration days are unchanged' }
+ else
+ project.update!(spec_con_days: days) unless preview_only
+ message = preview_only ? "Would update special consideration to #{days} days" : "Special consideration updated to #{days} days"
+ result[:success] << { row: row, message: message }
+ end
+ rescue StandardError => e
+ result[:errors] << { row: row, message: e.message }
+ ensure
+ at(2 + index + 1, 'Importing extensions')
+ end
+ end
+
+ store(result: result.to_json)
+ integration&.auto_sync_succeeded! if scheduled
+ rescue StandardError => e
+ LmsIntegration.find_by(unit_id: unit_id)&.auto_sync_failed!(:extensions, e) if scheduled
+ raise
+ end
+end
diff --git a/app/sidekiq/import_lms_students_job.rb b/app/sidekiq/import_lms_students_job.rb
new file mode 100644
index 0000000000..50b297b97b
--- /dev/null
+++ b/app/sidekiq/import_lms_students_job.rb
@@ -0,0 +1,230 @@
+# frozen_string_literal: true
+
+#
+# Imports LMS course members into a unit. Based on ImportStudentsLtiJob, but reads members through
+# the unit's LMS data source so it can run from OnTrack or on a schedule without an LTI launch.
+#
+# The LMS is the source of truth: when withdrawing is enabled, enrolled students who are no longer
+# active students in the LMS are withdrawn, and returning students are re-enrolled.
+#
+class ImportLmsStudentsJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+ include LogHelper
+ include LtiHelper
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first] },
+ on_conflict: :reject,
+ retry: false
+
+ def perform(unit_id, preview_only, withdraw_missing, scheduled)
+ at(0, 'Fetching LMS course members')
+ total(0)
+
+ unit = Unit.find(unit_id)
+ integration = unit.lms_integration
+ source = LmsIntegration.data_source_for(unit)
+ mappings = student_mappings(integration, source)
+ members = source.members
+ settings = Doubtfire::Application.config.institution_settings
+ total(members.length)
+
+ result = { success: [], ignored: [], errors: [] }
+ active_student_user_ids = Set.new
+ active_student_count = 0
+ incomplete_member_count = 0
+
+ members.each_with_index do |lms_member, index|
+ at(index, 'Importing members')
+ member_mappings = lms_member[:group_ids].flat_map { |group_id| mappings.fetch(group_id, []) }
+ row = display_row(unit, lms_member, member_mappings)
+
+ begin
+ member = lms_member[:member]
+ valid_member, missing = valid_lti_member?(member)
+ unless valid_member
+ incomplete_member_count += 1
+ result[:ignored] << { row: row, message: "Missing required fields: #{missing.join(', ')}" }
+ next
+ end
+
+ staff_role = settings.should_employ_lti_member(member)
+ enrol = settings.should_enrol_lti_member(member)
+ if staff_role.nil? && !enrol
+ result[:ignored] << { row: row, message: 'Not a student or staff member' }
+ next
+ end
+
+ unless lms_member[:active]
+ result[:ignored] << { row: row, message: 'Inactive in the LMS' }
+ next
+ end
+
+ user = UserIdentity.find_user(login_id: lms_member[:login_id], email: lms_member[:email])
+ active_student_count += 1 if enrol
+ active_student_user_ids << user.id if enrol && user
+
+ # Still counted as active above, so an account we cannot trust is never withdrawn
+ if user && UserIdentity.blocked?(user, login_id: lms_member[:login_id], email: lms_member[:email], source: 'lms_import')
+ result[:errors] << { row: row, message: "#{user.username} is linked to a different login id" }
+ next
+ end
+
+ mapping_errors = enrol ? LmsGroupMappingApplier.mapping_errors(member_mappings) : []
+ if preview_only
+ record_preview(result, unit, row, user, staff_role, enrol, mapping_errors)
+ else
+ user ||= create_user(settings, lms_member)
+ UserIdentity.link_identity(user, login_id: lms_member[:login_id])
+ active_student_user_ids << user.id if enrol
+ import_member(result, unit, row, user, staff_role, enrol, member_mappings, mapping_errors)
+ end
+ rescue StandardError => e
+ result[:errors] << { row: row, message: e.message }
+ end
+ end
+
+ if withdraw_missing
+ withdraw_missing_students(result, unit, active_student_user_ids, active_student_count, incomplete_member_count, preview_only)
+ end
+
+ store(result: result.to_json)
+ integration&.auto_sync_succeeded! if scheduled
+ rescue StandardError => e
+ LmsIntegration.find_by(unit_id: unit_id)&.auto_sync_failed!(:students, e) if scheduled
+ raise
+ end
+
+ private
+
+ def student_mappings(integration, source)
+ return {} unless integration&.group_mapping_enabled? && source.course_data_available?
+
+ at(0, 'Validating LMS group mappings')
+ LmsIntegrationValidator.new(integration).validate!(
+ groups: source.groups,
+ assignments: integration.fetch_extensions? ? source.assignments : []
+ )
+ integration.lms_group_mappings.includes(:group_set, :group, :campus, :tutorial_stream, :tutorial)
+ .group_by(&:lms_group_id)
+ rescue LtiCourseDataSource::Error
+ raise if source.course_data_available?
+
+ {}
+ end
+
+ def display_row(unit, lms_member, mappings)
+ {
+ unit_code: unit.code,
+ lms_username: lms_member[:login_id],
+ student_id: lms_member[:student_id],
+ lis_person_sourcedid: lms_member[:lis_person_sourcedid],
+ first_name: lms_member[:first_name],
+ last_name: lms_member[:last_name],
+ email: lms_member[:email],
+ lms_roles: lms_member[:roles].join(', '),
+ lms_groups: mappings.map(&:lms_group_name).uniq.join("\n"),
+ mapped_campus: mappings.filter_map { |mapping| mapping.campus&.name }.uniq.join(', '),
+ mapped_tutorial: mappings.select { |mapping| mapping.target_type == 'tutorial' }.map { |mapping| mapping.tutorial&.abbreviation || mapping.lms_group_name }.uniq.join(', '),
+ mapped_group: mappings.select { |mapping| mapping.target_type == 'group' }.map { |mapping| mapping.group&.name || mapping.lms_group_name }.uniq.join(', ')
+ }
+ end
+
+ def create_user(settings, lms_member)
+ user_id_data = UserIdentity.user_id_data(login_id: lms_member[:login_id], email: lms_member[:email])
+ user = User.create! do |new_user|
+ settings.update_user_from_lti_response(new_user, user_id_data, lms_member[:member])
+ end
+ user.update!(student_id: lms_member[:student_id]) if lms_member[:student_id].present? && user.student_id.blank?
+ user
+ end
+
+ def record_preview(result, unit, row, user, staff_role, enrol, mapping_errors)
+ messages = []
+ messages << "Would add staff (#{staff_role.name})" if staff_role && (user.nil? || unit.unit_role_for(user).nil?)
+
+ if enrol
+ project = user && unit.projects.find_by(user_id: user.id)
+ messages << if user.nil?
+ 'Would create user and enrol student'
+ elsif project.nil?
+ 'Would enrol student'
+ elsif !project.enrolled
+ 'Would re-enrol student'
+ end
+ end
+ messages.compact!
+
+ if mapping_errors.any?
+ result[:errors] << { row: row, message: mapping_errors.join('; ') }
+ elsif messages.any?
+ result[:success] << { row: row, message: messages.join('; ') }
+ else
+ result[:ignored] << { row: row, message: 'No change' }
+ end
+ end
+
+ def import_member(result, unit, row, user, staff_role, enrol, mappings, mapping_errors)
+ messages = []
+ if staff_role && unit.unit_role_for(user).nil?
+ staff = unit.employ_staff(user, staff_role)
+ messages << "Added staff (#{staff_role.name})" if staff&.persisted?
+ end
+
+ if enrol
+ project = unit.projects.find_by(user_id: user.id)
+ was_enrolled = project&.enrolled
+ project = unit.enrol_student(user, project&.campus)
+ messages << (was_enrolled.nil? ? 'Enrolled student' : 'Re-enrolled student') unless was_enrolled
+
+ if mapping_errors.any?
+ result[:errors] << { row: row, message: ([messages.join('; ')].compact_blank + mapping_errors).join('; ') }
+ return
+ end
+ messages << 'LMS group mappings updated' if mappings.any? && LmsGroupMappingApplier.apply(project, mappings)
+ end
+
+ if messages.any?
+ result[:success] << { row: row, message: messages.join('; ') }
+ else
+ result[:ignored] << { row: row, message: 'No change' }
+ end
+ end
+
+ def withdraw_missing_students(result, unit, active_student_user_ids, active_student_count, incomplete_member_count, preview_only)
+ # Incomplete or empty member data usually means LMS privacy or link settings are wrong, so never treat it as students leaving.
+ if active_student_count.zero?
+ result[:errors] << { row: { unit_code: unit.code }, message: 'No active LMS students were found, so no students were withdrawn' }
+ return
+ end
+ if incomplete_member_count.positive?
+ result[:errors] << {
+ row: { unit_code: unit.code },
+ message: "#{incomplete_member_count} LMS members were missing required details, so no students were withdrawn. Check the tool shares names and emails."
+ }
+ return
+ end
+
+ unit.projects.where(enrolled: true).where.not(user_id: active_student_user_ids.to_a).includes(:user).find_each do |project|
+ row = nil
+ row = {
+ unit_code: unit.code,
+ username: project.user.username,
+ student_id: project.user.student_id,
+ first_name: project.user.first_name,
+ last_name: project.user.last_name,
+ email: project.user.email
+ }
+ if preview_only
+ result[:success] << { row: row, message: 'Would withdraw: not an active student in the LMS' }
+ else
+ project.update!(enrolled: false)
+ result[:success] << { row: row, message: 'Withdrawn: not an active student in the LMS' }
+ end
+ rescue StandardError => e
+ result[:errors] << { row: row, message: e.message }
+ end
+ end
+end
diff --git a/app/sidekiq/import_staff_notes_csv_job.rb b/app/sidekiq/import_staff_notes_csv_job.rb
new file mode 100644
index 0000000000..42c03ce850
--- /dev/null
+++ b/app/sidekiq/import_staff_notes_csv_job.rb
@@ -0,0 +1,40 @@
+require 'csv'
+
+class ImportStaffNotesCsvJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+ include LogHelper
+ include ApplicationHelper
+ include FileHelper
+ include MimeCheckHelpers
+ include CsvHelper
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first] },
+ on_conflict: :reject,
+ retry: false
+
+ def perform(unit_id, author_id, path_to_csv)
+ logger.info "Starting staff note imports..."
+
+ at(0)
+
+ ensure_csv!(path_to_csv)
+
+ unit = Unit.find(unit_id)
+ result = unit.import_staff_notes_from_csv(path_to_csv, author_id, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil|
+ total(total_rows) if total_rows
+ at(rows_processed, message) if rows_processed
+ })
+
+ store(result: result.to_json)
+
+ FileUtils.rm(path_to_csv)
+
+ logger.info "Completed staff note imports!"
+ rescue StandardError => e
+ logger.error e
+ raise e
+ end
+end
diff --git a/app/sidekiq/import_students_csv_job.rb b/app/sidekiq/import_students_csv_job.rb
index 3669c53dbf..8b2919b8dd 100644
--- a/app/sidekiq/import_students_csv_job.rb
+++ b/app/sidekiq/import_students_csv_job.rb
@@ -9,7 +9,8 @@ class ImportStudentsCsvJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { [args.first] },
on_conflict: :reject,
retry: false
diff --git a/app/sidekiq/import_students_job.rb b/app/sidekiq/import_students_job.rb
index 363a49d793..4887b1ff6a 100644
--- a/app/sidekiq/import_students_job.rb
+++ b/app/sidekiq/import_students_job.rb
@@ -9,7 +9,8 @@ class ImportStudentsJob
include MimeCheckHelpers
include CsvHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { [args.first] },
on_conflict: :reject,
retry: false
diff --git a/app/sidekiq/import_students_lti_job.rb b/app/sidekiq/import_students_lti_job.rb
index 18a6d3f9f4..f708e117ab 100644
--- a/app/sidekiq/import_students_lti_job.rb
+++ b/app/sidekiq/import_students_lti_job.rb
@@ -10,7 +10,8 @@ class ImportStudentsLtiJob
include CsvHelper
include LtiHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { [args.first] },
on_conflict: :reject,
retry: false
@@ -37,30 +38,29 @@ def perform(unit_id, members)
next
end
- user_id_data = {
- login_id: member["user_id"],
- email: member["email"],
- username: member["email"][/(.*)@/, 1]
- }
+ user_id_data = UserIdentity.lti_user_id_data(member)
- user = User.find_by(login_id: user_id_data[:login_id]) ||
- User.find_by(username: user_id_data[:username]) ||
- User.find_by(email: user_id_data[:email]) ||
+ user = UserIdentity.find_user(login_id: user_id_data[:login_id], email: user_id_data[:email], username: user_id_data[:username]) ||
User.create! do |new_user|
- # Update new user with details from the SAML response
+ # Update new user with details from the LTI response
Doubtfire::Application.config.institution_settings.update_user_from_lti_response(
new_user,
user_id_data,
member
)
end
+ if UserIdentity.blocked?(user, login_id: user_id_data[:login_id], email: user_id_data[:email], source: 'lti_bulk_enrol')
+ result[:errors] << { row: member, message: "#{user.username} is linked to a different login id" }
+ next
+ end
+ UserIdentity.link_identity(user, login_id: user_id_data[:login_id], username: user_id_data[:username])
if user.valid?
unit_role = Doubtfire::Application.config.institution_settings.should_employ_lti_member(member)
unless unit_role.nil?
staff = unit.employ_staff(user, unit_role)
if staff&.valid?
- result[:success] << { row: member, message: "Successfully added staff (#{unit_role.name})" }
+ result[:success] << { row: member, message: "Added staff (#{unit_role.name})" }
end
end
@@ -71,7 +71,7 @@ def perform(unit_id, members)
project = unit.enrol_student(user, nil)
if project.valid?
- result[:success] << { row: member, message: "Successfully enrolled user" }
+ result[:success] << { row: member, message: "Enrolled student" }
else
result[:errors] << { row: member, message: "Failed to enrol student" }
end
diff --git a/app/sidekiq/login_docker_job.rb b/app/sidekiq/login_docker_job.rb
index 9616c1f3c8..fb71c5e4d8 100644
--- a/app/sidekiq/login_docker_job.rb
+++ b/app/sidekiq/login_docker_job.rb
@@ -6,7 +6,8 @@ class LoginDockerJob
include LogHelper
include ApplicationHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { args },
on_conflict: :reject,
retry: 1
diff --git a/app/sidekiq/notify_discuss_timeout_job.rb b/app/sidekiq/notify_discuss_timeout_job.rb
new file mode 100644
index 0000000000..d137bfbb05
--- /dev/null
+++ b/app/sidekiq/notify_discuss_timeout_job.rb
@@ -0,0 +1,13 @@
+class NotifyDiscussTimeoutJob
+ include Sidekiq::Job
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(_args) { ['notify-discuss-timeout'] },
+ on_conflict: :reject,
+ retry: 1
+
+ def perform
+ Unit.notify_discuss_timeouts!
+ end
+end
diff --git a/app/sidekiq/notify_tutor_notes_job.rb b/app/sidekiq/notify_tutor_notes_job.rb
index 194c6b4f35..6db73fce4f 100644
--- a/app/sidekiq/notify_tutor_notes_job.rb
+++ b/app/sidekiq/notify_tutor_notes_job.rb
@@ -1,6 +1,8 @@
class NotifyTutorNotesJob
include Sidekiq::Job
+ sidekiq_options queue: 'parallel'
+
def perform(tutor_note_id, recipient_user_id)
tutor_note = TutorNote.find(tutor_note_id)
recipient = User.find(recipient_user_id)
diff --git a/app/sidekiq/poll_communication_set_schedules_job.rb b/app/sidekiq/poll_communication_set_schedules_job.rb
new file mode 100644
index 0000000000..161283f34e
--- /dev/null
+++ b/app/sidekiq/poll_communication_set_schedules_job.rb
@@ -0,0 +1,20 @@
+class PollCommunicationSetSchedulesJob
+ include Sidekiq::Job
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(_args) { ['poll-communication-set-schedules'] },
+ on_conflict: :reject,
+ retry: 1
+
+ def perform
+ CommunicationSetSchedule
+ .includes(:communication_set)
+ .active
+ .with_active_unit
+ .due(Time.zone.now)
+ .find_each do |schedule|
+ ExecuteCommunicationSetScheduleJob.perform_async(schedule.id)
+ end
+ end
+end
diff --git a/app/sidekiq/pull_docker_image_job.rb b/app/sidekiq/pull_docker_image_job.rb
index 5b1e9aa798..9198bc048a 100644
--- a/app/sidekiq/pull_docker_image_job.rb
+++ b/app/sidekiq/pull_docker_image_job.rb
@@ -5,7 +5,8 @@ class PullDockerImageJob
include ApplicationHelper
include FileHelper
- sidekiq_options lock: :until_executed,
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
lock_args_method: ->(args) { [args.first] },
on_conflict: :reject,
retry: false
diff --git a/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb b/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb
index de38dc9a80..9fc4d30229 100644
--- a/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb
+++ b/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb
@@ -3,9 +3,13 @@
class RefreshModerationFeedbackTimestampsJob
include Sidekiq::Job
+ sidekiq_options queue: 'parallel'
+
def perform
ModeratedTask
.where(state: %i[open waiting_for_new_feedback])
+ .joins(task: { project: :unit })
+ .where(units: { id: Unit.within_teaching_dates.select(:id) })
.includes(task: :comments)
.find_each do |moderated_task|
task = moderated_task.task
diff --git a/app/sidekiq/send_discuss_timeout_email_job.rb b/app/sidekiq/send_discuss_timeout_email_job.rb
new file mode 100644
index 0000000000..f9277ae000
--- /dev/null
+++ b/app/sidekiq/send_discuss_timeout_email_job.rb
@@ -0,0 +1,26 @@
+class SendDiscussTimeoutEmailJob
+ include Sidekiq::Job
+
+ sidekiq_options queue: 'parallel',
+ retry: 5
+
+ def perform(task_id, sender_id, notification_type, expiry_date = nil)
+ task = Task.find_by(id: task_id)
+ sender = User.find_by(id: sender_id)
+ return if task.blank? || sender.blank?
+ return unless task.unit.discuss_timeout_active?
+ return unless task.unit.send_notifications
+ return unless task.project.student.receive_feedback_notifications
+
+ mail = case notification_type
+ when 'approaching'
+ NotificationsMailer.discussion_deadline_approaching(task, sender, Date.iso8601(expiry_date))
+ when 'missed'
+ NotificationsMailer.discussion_deadline_missed(task, sender)
+ else
+ raise ArgumentError, "Unknown discussion deadline notification type: #{notification_type}"
+ end
+
+ mail.deliver_now
+ end
+end
diff --git a/app/sidekiq/sync_lms_grades_job.rb b/app/sidekiq/sync_lms_grades_job.rb
new file mode 100644
index 0000000000..8fce6878ef
--- /dev/null
+++ b/app/sidekiq/sync_lms_grades_job.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+#
+# Sends each enrolled student's OnTrack grade to the LMS grade item created when the unit was linked.
+# A preview matches LMS members to OnTrack students and reports the grades without sending them.
+#
+class SyncLmsGradesJob
+ include Sidekiq::Job
+ include Sidekiq::Status::Worker
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(args) { [args.first] },
+ on_conflict: :reject,
+ retry: false
+
+ def perform(unit_id, preview_only)
+ total(2)
+ at(0, 'Fetching LMS course members')
+
+ unit = Unit.find(unit_id)
+ integration = unit.lms_integration
+ skip_ungraded = integration.nil? || integration.skip_ungraded?
+ send_rationale = integration&.send_grade_rationale? || false
+ source = LmsIntegration.data_source_for(unit)
+ members = source.members
+ server = LtiServer.new(unit.id)
+
+ result = { success: [], ignored: [], errors: [] }
+ scores = []
+ rows_by_user_id = {}
+ matched_project_ids = Set.new
+
+ members.each do |lms_member|
+ row = { lms_user_id: lms_member[:lms_user_id], lms_username: lms_member[:login_id], email: lms_member[:email], name: lms_member[:name] }
+ next unless Doubtfire::Application.config.institution_settings.should_enrol_lti_member(lms_member[:member])
+
+ user = UserIdentity.find_user(login_id: lms_member[:login_id], email: lms_member[:email])
+ project = user && unit.projects.find_by(user_id: user.id, enrolled: true)
+ if user
+ row[:ontrack_username] = user.username
+ row[:ontrack_name] = user.name
+ end
+
+ if user && UserIdentity.blocked?(user, login_id: lms_member[:login_id], email: lms_member[:email], source: 'lms_grade_sync')
+ matched_project_ids << project.id if project
+ result[:ignored] << { row: row, message: 'OnTrack account is linked to a different login id' }
+ next
+ end
+
+ if project.nil?
+ result[:ignored] << { row: row, message: user ? 'Not enrolled in OnTrack' : 'No matching OnTrack user' }
+ elsif project.grade.nil? || (skip_ungraded && project.grade.zero?)
+ matched_project_ids << project.id
+ result[:ignored] << { row: row, message: 'Not graded yet' }
+ else
+ matched_project_ids << project.id
+ row[:grade] = project.grade
+ rows_by_user_id[lms_member[:lms_user_id]] = row
+ score = { userId: lms_member[:lms_user_id], scoreGiven: project.grade }
+ if send_rationale && project.grade_rationale.present?
+ score[:comment] = project.grade_rationale
+ row[:feedback] = project.grade_rationale
+ end
+ scores << score
+ end
+ end
+
+ # Graded OnTrack students with no LMS member would otherwise be skipped silently
+ unmatched = unit.projects.where(enrolled: true).where.not(grade: nil).where.not(id: matched_project_ids.to_a)
+ unmatched = unmatched.where.not(grade: 0) if skip_ungraded
+ unmatched.includes(:user).find_each do |project|
+ row = { ontrack_username: project.user.username, ontrack_name: project.user.name, email: project.user.email, grade: project.grade }
+ result[:ignored] << { row: row, message: 'Not a student in the LMS course' }
+ end
+
+ if preview_only
+ rows_by_user_id.each_value { |row| result[:success] << { row: row, message: "Will send: #{row[:grade]}%" } }
+ elsif scores.any?
+ at(1, "Sending #{scores.length} grades to the LMS")
+ Array(server.submit_scores(scores)['results']).each do |score_result|
+ row = rows_by_user_id[score_result['userId']]
+ if score_result['success']
+ result[:success] << { row: row, message: "Grade synced: #{row[:grade]}%" }
+ else
+ result[:errors] << { row: row, message: score_result['error'] || 'Failed to submit score' }
+ end
+ end
+ end
+
+ at(2, preview_only ? 'Grade sync preview complete' : 'Grade sync complete')
+ store(result: result.to_json)
+ end
+end
diff --git a/app/sidekiq/sync_lms_integrations_job.rb b/app/sidekiq/sync_lms_integrations_job.rb
new file mode 100644
index 0000000000..a448700778
--- /dev/null
+++ b/app/sidekiq/sync_lms_integrations_job.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+class SyncLmsIntegrationsJob
+ include Sidekiq::Job
+
+ sidekiq_options queue: 'parallel',
+ lock: :until_executed,
+ lock_args_method: ->(_args) { ['sync-lms-integrations'] },
+ on_conflict: :reject,
+ retry: 1
+
+ def perform
+ return unless Doubtfire::Application.config.lti_enabled && LtiServer.configured?
+
+ today = Time.zone.today
+ outage = lti_outage
+
+ LmsIntegration.auto_syncing.includes(:unit).find_each do |integration|
+ syncs = due_syncs(integration, today, check_course_data: outage.nil?)
+ next if syncs.empty?
+
+ if outage
+ integration.record_auto_sync_outage!(nil, outage)
+ else
+ ImportLmsStudentsJob.perform_async(integration.unit_id, false, integration.withdraw_missing_students, true) if syncs.include?(:students)
+ ImportLmsExtensionsJob.perform_async(integration.unit_id, false, true) if syncs.include?(:extensions)
+ end
+ rescue StandardError => e
+ logger.error "Failed to schedule the LMS sync for unit #{integration.unit_id}: #{e.message}"
+ integration.auto_sync_failed!(nil, e)
+ end
+ end
+
+ private
+
+ # Checked once so an outage sends one email to ops instead of failing every unit's sync
+ def lti_outage
+ LtiServer.check_health!
+ nil
+ rescue LtiServer::Error => e
+ logger.error "Skipping the scheduled LMS sync because the LTI service is unavailable: #{e.message}"
+ email_ops(e)
+ e
+ end
+
+ def email_ops(error)
+ ErrorLogMailer.error_message('LMS sync skipped', 'The LTI service is unavailable, so the scheduled LMS sync was skipped.', error).deliver_now
+ rescue StandardError => e
+ logger.error "Failed to email the LMS sync outage: #{e.message}"
+ end
+
+ def due_syncs(integration, today, check_course_data:)
+ unit = integration.unit
+ return [] unless unit.active?
+
+ syncs = []
+ if integration.auto_sync_students? && today.between?(unit.start_date.to_date, unit.end_date.to_date) &&
+ mappings_ready?(integration, check_course_data)
+ syncs << :students
+ end
+ if integration.auto_sync_extensions? && integration.validated? && integration.fetch_extensions? &&
+ integration.assignment_id.present? && today.between?(unit.start_date.to_date, unit.end_date.to_date + 14.days)
+ syncs << :extensions
+ end
+ syncs
+ end
+
+ # Mappings are skipped when the plugin is unavailable, so they do not need validating
+ def mappings_ready?(integration, check_course_data)
+ !integration.group_mapping_enabled? || integration.validated? || (check_course_data && !course_data_available?(integration))
+ end
+
+ def course_data_available?(integration)
+ integration.data_source.course_data_available?
+ rescue LtiServer::Error, LtiCourseDataSource::Error
+ true
+ end
+end
diff --git a/app/sidekiq/tii_action_job.rb b/app/sidekiq/tii_action_job.rb
index a81bca1b11..85106c22bf 100644
--- a/app/sidekiq/tii_action_job.rb
+++ b/app/sidekiq/tii_action_job.rb
@@ -1,6 +1,8 @@
class TiiActionJob
include Sidekiq::Job
+ sidekiq_options queue: 'parallel'
+
def perform(id)
action = TiiAction.find(id)
diff --git a/app/sidekiq/tii_check_progress_job.rb b/app/sidekiq/tii_check_progress_job.rb
index ca8084bee0..b6b2e22ac2 100644
--- a/app/sidekiq/tii_check_progress_job.rb
+++ b/app/sidekiq/tii_check_progress_job.rb
@@ -6,6 +6,8 @@
class TiiCheckProgressJob
include Sidekiq::Job
+ sidekiq_options queue: 'parallel'
+
def perform
return unless TurnItIn.enabled?
diff --git a/app/sidekiq/tii_register_web_hook_job.rb b/app/sidekiq/tii_register_web_hook_job.rb
index f61de7bea4..73bb7facc7 100644
--- a/app/sidekiq/tii_register_web_hook_job.rb
+++ b/app/sidekiq/tii_register_web_hook_job.rb
@@ -5,6 +5,8 @@
class TiiRegisterWebHookJob
include Sidekiq::Job
+ sidekiq_options queue: 'parallel'
+
def perform
return unless TurnItIn.enabled?
diff --git a/app/validators/allowed_characters_validator.rb b/app/validators/allowed_characters_validator.rb
new file mode 100644
index 0000000000..768e0fcc57
--- /dev/null
+++ b/app/validators/allowed_characters_validator.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class AllowedCharactersValidator < ActiveModel::EachValidator
+ ALPHANUMERIC_CHARACTER = /\A[\p{L}\p{M}\p{N}]\z/u
+ CHARACTER_WHITELISTS = {
+ first_name: " -()_/,,.'’",
+ last_name: " -()_/,,.'’",
+ preferred_name: " -()_/,,.'’",
+ unit_name: " -#()",
+ title: " -#()&/:_.'\"*+",
+ unit_code: "-#/_"
+ }.freeze
+
+ def validate_each(record, attribute, value)
+ return if value.blank?
+
+ type = options.fetch(:type)
+ whitelist = CHARACTER_WHITELISTS.fetch(type)
+ if type == :title && !alphanumeric?(value[0])
+ record.errors.add(attribute, 'must begin with an alphanumeric character')
+ end
+ return if value.each_char.all? { |character| alphanumeric?(character) || whitelist.include?(character) }
+
+ record.errors.add(attribute, 'contains unsupported characters')
+ end
+
+ private
+
+ def alphanumeric?(character)
+ character.match?(ALPHANUMERIC_CHARACTER)
+ end
+end
diff --git a/app/views/communications_mailer/action_log_email.html.erb b/app/views/communications_mailer/action_log_email.html.erb
new file mode 100644
index 0000000000..7df5658bf1
--- /dev/null
+++ b/app/views/communications_mailer/action_log_email.html.erb
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
<%= @doubtfire_product_name %> Notification
+
+
Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>,
+
+ <% @body_paragraphs.each do |paragraph| %>
+
<%= paragraph %>
+ <% end %>
+
+
+
+
+ <%= @affected_students_count %>
+ students matched this rule and were affected by the configured actions.
+
+
+
A full log of actions has been attached as a CSV.
+
+
+ Cheers,
+ The <%= @doubtfire_product_name %> Team
+
+
+
+
+
diff --git a/app/views/communications_mailer/action_log_email.text.erb b/app/views/communications_mailer/action_log_email.text.erb
new file mode 100644
index 0000000000..f980a817f3
--- /dev/null
+++ b/app/views/communications_mailer/action_log_email.text.erb
@@ -0,0 +1,18 @@
+Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>,
+
+<% @body_paragraphs.each do |paragraph| %>
+<%= paragraph %>
+
+<% end %>
+---
+
+<%= @affected_students_count %> students matched this rule and were affected by the configured actions.
+
+A full log of actions has been attached as a CSV.
+
+Cheers,
+The <%= @doubtfire_product_name %> Team
+
+---
+
+Generated with <%= @doubtfire_product_name %>
diff --git a/app/views/communications_mailer/communication_email.html.erb b/app/views/communications_mailer/communication_email.html.erb
new file mode 100644
index 0000000000..7933f82475
--- /dev/null
+++ b/app/views/communications_mailer/communication_email.html.erb
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
<%= @doubtfire_product_name %> Notification
+
+ <% @body_paragraphs.each do |paragraph| %>
+
<%= paragraph %>
+ <% end %>
+
+
+
+
diff --git a/app/views/communications_mailer/communication_email.text.erb b/app/views/communications_mailer/communication_email.text.erb
new file mode 100644
index 0000000000..5ad89c15fb
--- /dev/null
+++ b/app/views/communications_mailer/communication_email.text.erb
@@ -0,0 +1,12 @@
+<%# Hi <%= @recipient.nickname.presence || @recipient.first_name %> %>
+
+<% @body_paragraphs.each do |paragraph| %>
+<%= paragraph %>
+
+<% end %>
+<%# Cheers,
+The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> %>
+
+---
+
+Generated with <%= @doubtfire_product_name %>
diff --git a/app/views/layouts/application.pdf.erbtex b/app/views/layouts/application.pdf.erbtex
index b051661808..fa21d6e190 100644
--- a/app/views/layouts/application.pdf.erbtex
+++ b/app/views/layouts/application.pdf.erbtex
@@ -27,6 +27,12 @@
}
\usepackage[fencedCode,hashEnumerators,pipeTables,texMathDollars]{markdown}
+\markdownSetup{rendererPrototypes={
+ ulBeginTight={\begin{itemize}},
+ ulEndTight={\end{itemize}},
+ olBeginTight={\begin{enumerate}},
+ olEndTight={\end{enumerate}}
+}}
\usepackage{luatextra}
\defaultfontfeatures{Ligatures=TeX}
@@ -38,6 +44,9 @@
\usepackage{pdfpages}
\usepackage{epstopdf}
\usepackage{amssymb}
+% Notebook Markdown may use these comparison commands in inline maths.
+\providecommand{\lt}{\mathrel{<}}
+\providecommand{\gt}{\mathrel{>}}
\usepackage{colortbl}
\usepackage{lastpage}
\usepackage{multirow}
@@ -96,20 +105,22 @@ end
\fancyfoot[R]{}
}
-\definecolor{complete}{rgb}{0.357, 0.718, 0.357}
-\definecolor{rff}{rgb}{0.0,0.47,0.84}
+\definecolor{complete}{HTML}{5BB75B}
+\definecolor{rff}{HTML}{0079D8}
-\definecolor{notstarted}{rgb}{0.8,0.8,0.8}
-\definecolor{workingonit}{rgb}{0.922, 0.561, 0.024}
-\definecolor{needhelp}{rgb}{0.643, 0.561, 0.808}
-\definecolor{fix}{rgb}{0.949, 0.847, 0.361}
-\definecolor{dnr}{rgb}{0.831, 0.42, 0.329}
-\definecolor{redo}{rgb}{0.502, 0.251, 0}
-\definecolor{discuss}{rgb}{0.192, 0.69, 0.835}
-\definecolor{demo}{rgb}{0.259, 0.545, 0.792}
-\definecolor{fail}{rgb}{0.851, 0.216, 0.075}
-\definecolor{timeexceeded}{rgb}{0.851, 0.216, 0.075}
-\definecolor{assessinportfolio}{rgb}{0.568, 0.721, 0.568}
+\definecolor{notstarted}{HTML}{CCCCCC}
+\definecolor{workingonit}{HTML}{EB8F06}
+\definecolor{needhelp}{HTML}{A48FCE}
+\definecolor{fix}{HTML}{F2D85C}
+\definecolor{dnr}{HTML}{D46B54}
+\definecolor{redo}{HTML}{804000}
+\definecolor{discuss}{HTML}{31B0D5}
+\definecolor{rediscuss}{HTML}{126352}
+\definecolor{demo}{HTML}{428BCA}
+\definecolor{fail}{HTML}{D93713}
+\definecolor{timeexceeded}{HTML}{D93713}
+\definecolor{assessinportfolio}{HTML}{F2D85C}
+\definecolor{attentionrequired}{HTML}{F1814D}
\begin{document}
diff --git a/app/views/layouts/discussion_deadline_mailer.html.erb b/app/views/layouts/discussion_deadline_mailer.html.erb
new file mode 100644
index 0000000000..36b8b3f822
--- /dev/null
+++ b/app/views/layouts/discussion_deadline_mailer.html.erb
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+ <%= yield %>
+
+
+ Cheers,
+ The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %>
+
+
+
+
+
diff --git a/app/views/layouts/discussion_deadline_mailer.text.erb b/app/views/layouts/discussion_deadline_mailer.text.erb
new file mode 100644
index 0000000000..37f0bddbd7
--- /dev/null
+++ b/app/views/layouts/discussion_deadline_mailer.text.erb
@@ -0,0 +1 @@
+<%= yield %>
diff --git a/app/views/layouts/jupynotex.py b/app/views/layouts/jupynotex.py
index 525a424f48..db3e9ca18d 100644
--- a/app/views/layouts/jupynotex.py
+++ b/app/views/layouts/jupynotex.py
@@ -9,6 +9,7 @@
"""
import base64
+import binascii
import json
import os
import re
@@ -17,6 +18,7 @@
import tempfile
import textwrap
import traceback
+import urllib.parse
# basic verbatim start/end
@@ -27,6 +29,13 @@
MARKDOWN_BEGIN = [r"\begin{markdown}"]
MARKDOWN_END = [r"\end{markdown}"+"\n"]
+ATTACHMENT_MIMETYPES = (
+ ('image/png', '.png'),
+ ('image/jpeg', '.jpg'),
+ ('image/gif', '.gif'),
+ ('image/bmp', '.bmp'),
+)
+
# highlighers for different languages (block beginning and ending)
HIGHLIGHTERS = {
'python': ([r'\begin{minted}[fontsize=\footnotesize,breaklines,breakanywhere,tabsize=4]{python}'], [r'\end{minted}']),
@@ -60,6 +69,13 @@ def _validator_positive_int(value):
return value
+def _as_lines(value):
+ """Return notebook text stored as either a string or list of strings."""
+ if isinstance(value, str):
+ return value.splitlines()
+ return value
+
+
def _process_plain_text(lines, config_options=None):
"""Wrap a series of lines around a verbatim indication."""
if config_options is None:
@@ -67,6 +83,7 @@ def _process_plain_text(lines, config_options=None):
result = []
result.extend(VERBATIM_BEGIN)
+ lines = _as_lines(lines)
for line in lines:
line = line.strip()
@@ -117,17 +134,18 @@ def process_plain_text(self, lines):
def process_png(self, image_data):
"""Process a PNG: just save the received b64encoded data to a temp file."""
- _, fname = tempfile.mkstemp(suffix='.png')
- with open(fname, 'wb') as fh:
+ descriptor, fname = tempfile.mkstemp(suffix='.png')
+ with os.fdopen(descriptor, 'wb') as fh:
fh.write(base64.b64decode(image_data))
return fname
def process_svg(self, image_data):
"""Process a SVG: save the data, transform to PDF, and then use that."""
- _, svg_fname = tempfile.mkstemp(suffix='.svg')
- _, pdf_fname = tempfile.mkstemp(suffix='.pdf')
+ svg_descriptor, svg_fname = tempfile.mkstemp(suffix='.svg')
+ pdf_descriptor, pdf_fname = tempfile.mkstemp(suffix='.pdf')
+ os.close(pdf_descriptor)
raw_svg = ''.join(image_data).encode('utf8')
- with open(svg_fname, 'wb') as fh:
+ with os.fdopen(svg_descriptor, 'wb') as fh:
fh.write(raw_svg)
cmd = ['rsvg-convert', '--format=pdf', '--output={}'.format(pdf_fname), svg_fname]
@@ -169,8 +187,8 @@ def __init__(self, path, config_options):
with open(path, 'rt', encoding='utf8') as fh:
nb_data = json.load(fh)
- # get the languaje, to highlight
- lang = nb_data['metadata']['language_info']['name']
+ # get the language, when available, to highlight
+ lang = nb_data.get('metadata', {}).get('language_info', {}).get('name')
self._highlight_delimiters = HIGHLIGHTERS.get(lang, HIGHLIGHTERS[None])
# get all cells
@@ -184,9 +202,73 @@ def _validate_config(self, config):
config[key] = new_value
return config
+ def _save_markdown_attachment(self, mime_bundle):
+ """Save a supported attachment MIME bundle and return its temporary path."""
+ for mimetype, suffix in ATTACHMENT_MIMETYPES:
+ if mimetype not in mime_bundle:
+ continue
+
+ image_data = mime_bundle[mimetype]
+ if isinstance(image_data, list):
+ image_data = ''.join(image_data)
+
+ descriptor, fname = tempfile.mkstemp(suffix=suffix)
+ try:
+ with os.fdopen(descriptor, 'wb') as fh:
+ fh.write(base64.b64decode(image_data))
+ except Exception:
+ os.unlink(fname)
+ raise
+ return fname
+
+ if 'image/svg+xml' in mime_bundle:
+ return ItemProcessor(self.cell_options, self.config_options).process_svg(
+ mime_bundle['image/svg+xml']
+ )
+
+ return None
+
+ def _process_markdown_attachments(self, content, source):
+ """Extract embedded notebook images and rewrite their attachment URLs."""
+ source_text = '\n'.join(source)
+
+ # Use the longest names first so one attachment name cannot partially
+ # replace another, for example image.png and my-image.png.
+ attachments = content.get('attachments') or {}
+ for name in sorted(attachments, key=len, reverse=True):
+ try:
+ attachment_path = self._save_markdown_attachment(attachments[name])
+ except (OSError, TypeError, ValueError, binascii.Error):
+ attachment_path = None
+
+ if not attachment_path:
+ continue
+
+ # Notebook editors may leave spaces literal or URL-encode them.
+ references = {
+ f'attachment:{name}',
+ f'attachment:{urllib.parse.quote(name)}',
+ f'attachment:{urllib.parse.quote(name, safe="")}',
+ }
+ for reference in references:
+ source_text = source_text.replace(reference, attachment_path)
+
+ # A missing, corrupt, or unsupported embedded image should not make the
+ # whole submission PDF fail. Keep a visible marker in its place.
+ source_text = re.sub(
+ r'!\[([^\]]*)\]\(attachment:([^)]+)\)',
+ lambda match: (
+ f'**[Image attachment unavailable: '
+ f'{match.group(1) or match.group(2)}]**'
+ ),
+ source_text,
+ )
+
+ return source_text.splitlines()
+
def _proc_src(self, content):
"""Process the source of a cell."""
- source = content['source']
+ source = _as_lines(content['source'])
result = []
if content['cell_type'] == 'code':
begin, end = self._highlight_delimiters
@@ -194,6 +276,7 @@ def _proc_src(self, content):
result.extend(textwrap.fill(line[:1000] + ' [The rest of this line has been truncated by the system to improve readability.] ' * (len(line) > 1000), width=90, subsequent_indent=' ') for line in source)
result.extend(end)
elif content['cell_type'] == 'markdown':
+ source = self._process_markdown_attachments(content, source)
result.extend(MARKDOWN_BEGIN)
result.extend(line.replace('```markdown', '```md').strip() for line in source)
result.extend(MARKDOWN_END)
diff --git a/app/views/lms_integration_mailer/auto_sync_failed.html.erb b/app/views/lms_integration_mailer/auto_sync_failed.html.erb
new file mode 100644
index 0000000000..ea31330901
--- /dev/null
+++ b/app/views/lms_integration_mailer/auto_sync_failed.html.erb
@@ -0,0 +1,114 @@
+
+
+
+ <%= @unit.code %>: Daily LMS <%= @sync_name %> failed and has been turned off
+
+
+
+
+
<%= @unit.name %> - Daily LMS Sync Turned Off
+
<%= @unit.code %>
+
+
Hi <%= @convenor.first_name %>,
+
+ <% if @failing_since %>
+
+ The daily LMS <%= @sync_name %> for <%= @unit.code %> has failed every night since
+ <%= @failing_since.strftime('%A %-d %B') %>, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS.
+
+
+ This is usually an outage of the LMS or of <%= @doubtfire_product_name %>'s LMS connection rather than a problem with the unit's settings.
+ The last error was: <%= @error_message %>
+
+ <% else %>
+
+ The daily LMS <%= @sync_name %> for <%= @unit.code %> failed, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS until you review it.
+
+ <% if @issues.any? %>
+
+ The LMS integration no longer matches the LMS course:
+
+ <% @issues.each do |issue| %>
+ -
+ <% if issue[:lms_group_name].present? %><%= issue[:lms_group_name] %>:<% end %>
+ <%= issue[:message] %>
+
+ <% end %>
+
+
+ <% else %>
+
The error was: <%= @error_message %>
+ <% end %>
+ <% end %>
+
+
+ These settings have been turned off:
+
+ <% @turned_off.each do |setting| %>
+ - <%= setting %>
+ <% end %>
+
+
+
+ <% if @failing_since %>
+
+ Once the LMS is working again, run a preview import to check what will change, then turn these settings back on in the
+ unit's LMS tab.
+
+ <% else %>
+
Fix the integration in the unit's LMS tab, then turn these settings back on.
+ <% end %>
+
+
+ Cheers,
+ The <%= @doubtfire_product_name %> Team
+
+
+
+
+
diff --git a/app/views/lms_integration_mailer/auto_sync_failed.text.erb b/app/views/lms_integration_mailer/auto_sync_failed.text.erb
new file mode 100644
index 0000000000..dd4b2138c1
--- /dev/null
+++ b/app/views/lms_integration_mailer/auto_sync_failed.text.erb
@@ -0,0 +1,33 @@
+Hi <%= @convenor.first_name %>,
+
+<% if @failing_since %>
+The daily LMS <%= @sync_name %> for <%= @unit.code %> <%= @unit.name %> has failed every night since <%= @failing_since.strftime('%A %-d %B') %>, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS.
+
+This is usually an outage of the LMS or of <%= @doubtfire_product_name %>'s LMS connection rather than a problem with the unit's settings. The last error was: <%= @error_message %>
+<% else %>
+The daily LMS <%= @sync_name %> for <%= @unit.code %> <%= @unit.name %> failed, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS until you review it.
+
+<% if @issues.any? %>
+The LMS integration no longer matches the LMS course:
+<% @issues.each do |issue| %>
+ * <%= [issue[:lms_group_name], issue[:message]].compact_blank.join(': ') %>
+<% end %>
+<% else %>
+The error was: <%= @error_message %>
+<% end %>
+<% end %>
+
+These settings have been turned off:
+<% @turned_off.each do |setting| %>
+ * <%= setting %>
+<% end %>
+
+<% if @failing_since %>
+Once the LMS is working again, run a preview import to check what will change, then turn these settings back on in the unit's LMS tab:
+<% else %>
+Fix the integration in the unit's LMS tab, then turn these settings back on:
+<% end %>
+<%= @lms_url %>
+
+Cheers,
+The <%= @doubtfire_product_name %> Team
diff --git a/app/views/notifications_mailer/discussion_deadline_approaching.html.erb b/app/views/notifications_mailer/discussion_deadline_approaching.html.erb
new file mode 100644
index 0000000000..94573e7541
--- /dev/null
+++ b/app/views/notifications_mailer/discussion_deadline_approaching.html.erb
@@ -0,0 +1,16 @@
+<%= @unit.name %> - Discussion Deadline Approaching
+<%= @task.task_definition.abbreviation %> - <%= @task.task_definition.name %>
+
+Hi <%= @student.first_name %>,
+
+
+ You must discuss task <%= @task.task_definition.abbreviation %> with your tutor on or before
+ <%= @deadline %>.
+
+
+
+ If it is not discussed by then, it will move to Fix and Resubmit, and you will
+ need to resubmit your work.
+
+
+View this task in <%= @doubtfire_product_name %>.
diff --git a/app/views/notifications_mailer/discussion_deadline_approaching.text.erb b/app/views/notifications_mailer/discussion_deadline_approaching.text.erb
new file mode 100644
index 0000000000..05cd0bf61f
--- /dev/null
+++ b/app/views/notifications_mailer/discussion_deadline_approaching.text.erb
@@ -0,0 +1,14 @@
+Hi <%= @student.first_name %>,
+
+You must discuss task <%= @task.task_definition.abbreviation %> with your tutor on or before <%= @deadline %>.
+
+If it is not discussed by then, it will move to Fix and Resubmit, and you will need to resubmit your work.
+
+View this task in <%= @doubtfire_product_name %>: <%= @task_url %>
+
+Cheers,
+The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %>
+
+---
+
+Visit <%= @unsubscribe_url %> to unsubscribe from these notifications.
diff --git a/app/views/notifications_mailer/discussion_deadline_missed.html.erb b/app/views/notifications_mailer/discussion_deadline_missed.html.erb
new file mode 100644
index 0000000000..eade95411a
--- /dev/null
+++ b/app/views/notifications_mailer/discussion_deadline_missed.html.erb
@@ -0,0 +1,13 @@
+<%= @unit.name %> - Discussion Deadline Missed
+<%= @task.task_definition.abbreviation %> - <%= @task.task_definition.name %>
+
+Hi <%= @student.first_name %>,
+
+
+ Task <%= @task.task_definition.abbreviation %> moved to
+ Fix and Resubmit because it was not discussed by the deadline.
+
+
+Review any feedback and resubmit it when you are ready.
+
+View this task in <%= @doubtfire_product_name %>.
diff --git a/app/views/notifications_mailer/discussion_deadline_missed.text.erb b/app/views/notifications_mailer/discussion_deadline_missed.text.erb
new file mode 100644
index 0000000000..bbc19c18a5
--- /dev/null
+++ b/app/views/notifications_mailer/discussion_deadline_missed.text.erb
@@ -0,0 +1,14 @@
+Hi <%= @student.first_name %>,
+
+<%= @task.task_definition.abbreviation %> - <%= @task.task_definition.name %> moved to Fix and Resubmit because it was not discussed by the deadline.
+
+Review any feedback and resubmit it when you are ready.
+
+View this task in <%= @doubtfire_product_name %>: <%= @task_url %>
+
+Cheers,
+The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %>
+
+---
+
+Visit <%= @unsubscribe_url %> to unsubscribe from these notifications.
diff --git a/app/views/notifications_mailer/weekly_student_summary.html.erb b/app/views/notifications_mailer/weekly_student_summary.html.erb
index 3a0593d36e..6487df32f9 100644
--- a/app/views/notifications_mailer/weekly_student_summary.html.erb
+++ b/app/views/notifications_mailer/weekly_student_summary.html.erb
@@ -61,7 +61,7 @@
<% end %>
<% if @project.tutorial_enrolments.blank? %>
- Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that on the tutorials page.
+ Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that on the tutorials page.
<% end %>
Here is what you should focus on right now.
diff --git a/app/views/notifications_mailer/weekly_student_summary.text.erb b/app/views/notifications_mailer/weekly_student_summary.text.erb
index 808fc7c3bd..b4630e3046 100644
--- a/app/views/notifications_mailer/weekly_student_summary.text.erb
+++ b/app/views/notifications_mailer/weekly_student_summary.text.erb
@@ -15,7 +15,7 @@ Hope you had a good week! Here's a summary of what has happened in this unit ove
<% if @project.tutorial_enrolments.blank? %>
-Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that here: <%= @doubtfire_host %>/#/projects/<%= @project.id %>/tutorials
+Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that here: <%= @doubtfire_host %>/projects/<%= @project.id %>/tutorials
Here is what you should focus on right now.
<% else %>
diff --git a/app/views/portfolio/portfolio_pdf.pdf.erb b/app/views/portfolio/portfolio_pdf.pdf.erb
index 64704249c1..f4a9afe006 100644
--- a/app/views/portfolio/portfolio_pdf.pdf.erb
+++ b/app/views/portfolio/portfolio_pdf.pdf.erb
@@ -13,10 +13,13 @@ def latex_color_for_status(task)
when :need_help then 'needhelp'
when :working_on_it then 'workingonit'
when :discuss then 'discuss'
+ when :rediscuss then 'rediscuss'
when :ready_for_feedback then 'rff'
when :demonstrate then 'demo'
when :fail then 'fail'
when :assess_in_portfolio then 'assessinportfolio'
+ when :attention_required then 'attentionrequired'
+ else raise ArgumentError, "No portfolio PDF color for task status #{task.status}"
end
end
end
@@ -96,7 +99,12 @@ No Tutor
% DATE SECTION
%----------------------------------------------------------------------------------------
-{\large \today}\\[3cm] % Date, change the \today to a set date if you want to be precise
+<% if @formatted_submission_date.present? %>
+{\large <%= lesc @formatted_submission_date %>}\\[0.2cm]
+{\large \textcolor{gray}{<%= lesc @formatted_submission_time %>}}\\[3cm]
+<% else %>
+\vspace{3cm}
+<% end %>
%----------------------------------------------------------------------------------------
% LOGO SECTION
@@ -125,7 +133,7 @@ No Tutor
\textbf{Task} & \textbf{Status} & \textbf{Times Assessed} \\
\hline
<% @task_defs.each do |td| %>
- <%= lesc td.name %> &
+ <%= lesc "#{td.abbreviation} #{td.name}" %> &
<% task = task_for_def(td)
if task.nil?
%>
@@ -233,7 +241,11 @@ end # if there are linked outcomes
\section{<%= lesc task.task_definition.name %>}
\label{sec_<%= lesc task.task_definition.id %>}
+<% if task.task_definition.description.present? %>
<%= lesc task.task_definition.description %>
+<% else %>
+ \textit{no task description}
+<% end %>
\bigskip
\\
<%
diff --git a/app/views/portfolio_evidence_mailer/overseer_assessment_failed.html.erb b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.html.erb
new file mode 100644
index 0000000000..9cead91931
--- /dev/null
+++ b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.html.erb
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
<%= @doubtfire_product_name %> Notification
+
Hi <%= @student.first_name %>,
+
+ Automated assessment did not pass for the tasks below. Please review the Overseer report in <%= @doubtfire_product_name %>, verify your output, and resubmit.
+
+
+ Tasks to review:
+
+
+
+ Cheers,
+ The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %>
+
+
+
+
+
diff --git a/app/views/portfolio_evidence_mailer/overseer_assessment_failed.text.erb b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.text.erb
new file mode 100644
index 0000000000..37248debfa
--- /dev/null
+++ b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.text.erb
@@ -0,0 +1,17 @@
+Hi <%= @student.first_name %>,
+
+Automated assessment did not pass for the tasks below. Please review the Overseer report in <%= @doubtfire_product_name %>, verify your output, and resubmit.
+
+Tasks to review:
+<% @tasks.each do |task| %>
+ * <%= task.task_definition.abbreviation %> - <%= task.task_definition.name %>
+<% end %>
+
+Cheers,
+The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %>
+
+---
+
+Visit <%= @unsubscribe_url %> to unsubscribe from these notifications.
+
+Generated with <%= @doubtfire_product_name %>
diff --git a/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb b/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb
index 8857c89996..bcac7f75ab 100644
--- a/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb
+++ b/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb
@@ -50,7 +50,7 @@
<% end %>
- There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to <%= @doubtfire_product_name %> and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again.
+ There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to <%= @doubtfire_product_name %> and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again.
Cheers,
diff --git a/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb b/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb
index fa52bd5a0a..096d3e02c1 100644
--- a/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb
+++ b/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb
@@ -50,7 +50,7 @@
<% end %>
- Your portfolio has been compiled and can now be viewed and downloaded from <%= @doubtfire_product_name %>.
+ Your portfolio has been compiled and can now be viewed and downloaded from <%= @doubtfire_product_name %>.
Cheers,
diff --git a/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb b/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb
index 362e6f86b6..5e5095810d 100644
--- a/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb
+++ b/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb
@@ -50,7 +50,7 @@
<% @tasks.each do |task| %>
<%= task.task_definition.abbreviation %> -
-
+
<%=task.task_definition.name%>
<%= task.is_last_comment_by?(@tutor) ? ' with comments' : ''%>
diff --git a/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb b/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb
index 99d27c61e1..a1396f4bc1 100644
--- a/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb
+++ b/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb
@@ -53,7 +53,7 @@
<% @tasks.each do |task| %>
<%= task.task_definition.abbreviation %> -
-
+
<%=task.task_definition.name%>
diff --git a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb
index 87810ec5f0..98a9497115 100644
--- a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb
+++ b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb
@@ -50,7 +50,7 @@
<% @tasks.each do |task| %>
<%= task.task_definition.abbreviation %> -
-
+
<%=task.task_definition.name%>
diff --git a/app/views/shared/_file.pdf.erb b/app/views/shared/_file.pdf.erb
index 036b4b2635..d97d945e7a 100644
--- a/app/views/shared/_file.pdf.erb
+++ b/app/views/shared/_file.pdf.erb
@@ -26,3 +26,9 @@
<% end # end for %>
<% end %>
+
+<% if %w[zip archive].include?(file_type) %>
+\begin{tcolorbox}[colback=blue!5!white,colframe=blue!75!black]
+ This zip file was uploaded with the portfolio. Zip contents are not expanded into this PDF.
+\end{tcolorbox}
+<% end %>
diff --git a/app/views/task/task_pdf.pdf.erb b/app/views/task/task_pdf.pdf.erb
index 44e8a83861..86c40c525e 100644
--- a/app/views/task/task_pdf.pdf.erb
+++ b/app/views/task/task_pdf.pdf.erb
@@ -31,7 +31,7 @@
<% else %>
<%= lesc @task.student.first_name %> \textsc{<%= lesc @task.student.last_name %>}\\
<%= lesc @task.student.username %>\\
-<%= lesc @task.submission_date.nil? ? "" : @task.submission_date.localtime.strftime("%Y/%m/%d %H:%M") %>
+<%= lesc @submission_date.nil? ? "" : @submission_date.localtime.strftime("%Y/%m/%d %H:%M") %>
<% end %>
\end{flushleft}
\end{minipage}
@@ -136,6 +136,45 @@ No Tutor % Supervisor's Name
\includepdf[pages={<%= page_idx %>-<%= page_idx %>},fitpaper]{<%= file[:path] %>}
<% end # end for %>
<% end # end if %>
+ <% if %w[zip archive].include?(file[:type]) %>
+<% archive_tree = FileHelper.zip_file_tree(file[:path], File.basename(file[:path])) %>
+\begin{tcolorbox}[enhanced,breakable,colback=orange!8!white,colframe=orange!80!black,boxrule=0.9pt,left=4mm,right=4mm,top=3mm,bottom=3mm]
+\begin{minipage}{0.12\textwidth}
+\centering
+{\Huge \faFileArchiveO}
+\end{minipage}
+\hfill
+\begin{minipage}{0.82\textwidth}
+\textbf{Zip submission file}\\[0.35em]
+This zip file was uploaded with the submission. Zip contents are not expanded into this PDF.\\[0.65em]
+\href{<%= @submitted_files_url %>}{\faDownload\ Download submitted files} {\footnotesize (Must be already authenticated in <%= lesc @doubtfire_product_name %>)}
+\end{minipage}
+
+\tcblower
+\textbf{Zip contents}\\[0.35em]
+<% if archive_tree[:error] %>
+The zip file tree could not be read while generating this PDF. Download the submitted files to inspect the contents.
+<% elsif archive_tree[:lines].empty? %>
+No readable file entries were found in this zip.
+<% else %>
+{\footnotesize\ttfamily
+\begin{tabular}{@{}p{0.96\textwidth}@{}}
+<% archive_tree[:lines].each do |line| %>
+<%
+ indent = line[/\A */].length
+ content = line.lstrip
+ indent_width = indent * 0.45
+%>
+\hspace*{<%= indent_width %>em}<%= raw lesc(content).to_s.gsub('↳', '\ensuremath{\hookrightarrow}') %>\\
+<% end %>
+\end{tabular}
+}
+<% if archive_tree[:truncated] %>
+\\[0.35em]\emph{Showing the first <%= archive_tree[:lines].length %> of <%= archive_tree[:tree_lines] %> tree lines. Download the submitted files to inspect everything.}
+<% end %>
+<% end %>
+\end{tcolorbox}
+ <% end %>
<% end # list of documents %>
<%
diff --git a/config/application.rb b/config/application.rb
index fe280a2899..7a0d450d91 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -5,6 +5,7 @@
require 'csv'
require 'yaml'
require 'bunny-pub-sub/services_manager'
+require_relative '../app/middleware/sentry_tunnel_middleware'
# Precompile assets before deploying to production
if defined?(Bundler)
@@ -17,6 +18,8 @@ module Doubtfire
#
class Application < Rails::Application
config.load_defaults 7.0
+ config.credentials.content_path = Rails.root.join('config/credentials/credentials.yml.enc')
+ config.credentials.key_path = Rails.root.join('config/credentials/master.key')
# Remove Action Mailbox and Active Storage routes - not used
initializer(:remove_action_mailbox_and_activestorage_routes, after: :add_routing_paths) do |app|
@@ -34,6 +37,16 @@ class Application < Rails::Application
# are: database, ldap, aaf, or saml. It can be overridden using the DF_AUTH_METHOD
# environment variable.
config.auth_method = (ENV['DF_AUTH_METHOD'] || :database).to_sym
+ config.access_token_expiry = ENV.fetch('DF_ACCESS_TOKEN_EXPIRY_SECONDS', 2.hours.to_i).to_i.seconds
+ config.refresh_token_expiry = ENV.fetch('DF_REFRESH_TOKEN_EXPIRY_SECONDS', 1.week.to_i).to_i.seconds
+ auth_rate_limit_enabled = ENV.fetch('ONTRACK_AUTH_RATE_LIMIT_ENABLED', 'true').downcase
+ raise ArgumentError, 'ONTRACK_AUTH_RATE_LIMIT_ENABLED must be true, false, 1, or 0' unless %w[true false 1 0].include?(auth_rate_limit_enabled)
+
+ config.auth_rate_limit_enabled = %w[true 1].include?(auth_rate_limit_enabled)
+ config.auth_rate_limit = Integer(ENV.fetch('ONTRACK_AUTH_RATE_LIMIT', 5))
+ config.auth_rate_period_seconds = Integer(ENV.fetch('ONTRACK_AUTH_RATE_PERIOD_SECONDS', 20))
+ raise ArgumentError, 'ONTRACK_AUTH_RATE_LIMIT must be positive' unless config.auth_rate_limit.positive?
+ raise ArgumentError, 'ONTRACK_AUTH_RATE_PERIOD_SECONDS must be positive' unless config.auth_rate_period_seconds.positive?
# ==> Student work directory
# File server location for storing student's work. Defaults to `student_work`
@@ -41,6 +54,10 @@ class Application < Rails::Application
# variable.
config.student_work_dir = ENV['DF_STUDENT_WORK_DIR'] || Rails.root.join('student_work').to_s
+ # Shared secret used to authenticate Caddy's internal file-authorisation
+ # requests. A blank value keeps the internal endpoints disabled.
+ config.caddy_download_auth_secret = ENV.fetch('CADDY_DOWNLOAD_AUTH_SECRET', nil)
+
# ==> Archive directory
# File server location for storing archived student work. Defaults to a subfolder of student work
# Set using DF_ARCHIVE_DIR environment variable.
@@ -52,9 +69,29 @@ class Application < Rails::Application
# Period for which to keep units
config.unit_archive_after_period = ENV.fetch('DF_UNIT_ARCHIVE_PERIOD', 2).to_f * 1.year
+ # Minimum time to wait before notifying a student about an unread failed overseer assessment
+ config.overseer_student_notification_grace_period = ENV.fetch('OVERSEER_STUDENT_NOTIFICATION_GRACE_PERIOD_MINUTES', 30).to_i.minutes
+
# Limit number of pdf generators to run at once
config.pdfgen_max_processes = ENV['DF_MAX_PDF_GEN_PROCESSES'] || 2
+ # Size of each chunk in a batch feedback upload; keep under the proxy's request body limit
+ config.batch_feedback_chunk_size = ENV.fetch('BATCH_FEEDBACK_CHUNK_SIZE_MB', 50).to_i.megabytes
+
+ # Each Word document conversion runs a short-lived, network-isolated
+ # Gotenberg container. The image includes the conversion entrypoint.
+ config.gotenberg_image = ENV.fetch('GOTENBERG_IMAGE', nil)
+
+ # Absolute host path to tmp/gotenberg. Production uses this to mount only
+ # the current conversion's work directory into its one-shot container.
+ config.gotenberg_workdir_volume_mount = ENV.fetch('GOTENBERG_WORKDIR_VOLUME_MOUNT', nil)
+
+ # Development fallback matching Overseer. This exposes the fallback
+ # container's entire tmp/gotenberg mount to each conversion container.
+ config.gotenberg_fallback_volume_container = ENV.fetch('GOTENBERG_FALLBACK_VOLUME_CONTAINER', nil)
+ config.word_document_build_path = ENV.fetch('WORD_DOCUMENT_BUILD_PATH', '/gotenberg/word_document_build.sh')
+ config.word_document_conversion_timeout_seconds = ENV.fetch('WORD_DOCUMENT_CONVERSION_TIMEOUT_SECONDS', 120)
+
# Date range for auditors to view
config.auditor_unit_access_years = ENV.fetch('DF_AUDITOR_UNIT_ACCESS_YEARS', 2).to_f * 1.year
@@ -64,38 +101,102 @@ def self.fetch_boolean_env(name)
%w'true 1'.include?(ENV.fetch(name, 'false').downcase)
end
+ def self.fetch_credential_or_env(*credential_path, env_key:, default: nil)
+ credential_value =
+ if credential_path.length == 1 && credentials.respond_to?(credential_path.first)
+ credentials.public_send(credential_path.first)
+ else
+ credentials.dig(*credential_path)
+ end
+
+ credential_value.nil? ? ENV.fetch(env_key, default) : credential_value
+ end
+
+ def self.cors_origin_for(host)
+ host = host.to_s.strip
+ return host if host.start_with?('http://', 'https://')
+
+ "https://#{host}"
+ end
+
+ LTI_SECRET_MIN_BYTES = 32
+ LTI_SECRET_PLACEHOLDERS = %w[your-secret-lti-shared-api-secret].freeze
+
+ # Returns why the LTI shared secret is unsafe, or nil when it can be used
+ def self.lti_api_secret_problem(secret)
+ secret = secret.to_s
+ return 'is not set' if secret.blank?
+ return 'is still the example value' if LTI_SECRET_PLACEHOLDERS.include?(secret)
+ return "must be at least #{LTI_SECRET_MIN_BYTES} bytes" if secret.bytesize < LTI_SECRET_MIN_BYTES
+
+ nil
+ end
+
# ==> Log to stdout
config.log_to_stdout = Application.fetch_boolean_env('DF_LOG_TO_STDOUT')
# Have rails report errors and log messages to the following email address where present
config.email_errors_to = ENV.fetch('DF_EMAIL_ERRORS_TO', nil)
- # ==> JPLAG report directory
- # File server location for storing JPLAG reports. Defaults to `jplag/results`
- # directory under root but is overridden using DF_JPLAG_REPORT_DIR environment
- # variable.
- config.jplag_report_dir = ENV['DF_JPLAG_REPORT_DIR'] || Rails.root.join('jplag/results').to_s
+ # Tunes the comparison sensitivity by adjusting the minimum token required to be
+ # counted as a matching section. A smaller value increases the sensitivity
+ # but might lead to more false-positives
config.jplag_min_tokens = ENV.fetch('DF_JPLAG_MIN_TOKENS', -1)
+
+ # Skips the cluster calculation
config.jplag_skip_cluster_check = ENV['DF_JPLAG_SKIP_CLUSTER_CHECK'].present? && (ENV['DF_JPLAG_SKIP_CLUSTER_CHECK'].to_s.downcase == "true" || ENV['DF_JPLAG_SKIP_CLUSTER_CHECK'].to_i == 1)
+ # The maximum number of comparisons that will be shown in the generated report
+ # if set to -1 all comparisons will be shown
+ config.jplag_max_shown_comparisons = ENV.fetch('DF_JPLAG_MAX_SHOWN_COMPARISONS', 2500)
+
# ==> File size limits
# Sets the global file size limit per upload requirement
# Defaults to 10MB (10,000,000 bytes)
config.max_file_size = ENV.fetch('DF_MAX_FILE_SIZE', 10_000_000)
- # ==> Load credentials from env
- credentials.secret_key_base = ENV.fetch('DF_SECRET_KEY_BASE', Rails.env.production? ? nil : '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97')
- credentials.secret_key_attr = ENV.fetch('DF_SECRET_KEY_ATTR', Rails.env.production? ? nil : 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055')
- credentials.secret_key_devise = ENV.fetch('DF_SECRET_KEY_DEVISE', Rails.env.production? ? nil : 'f4e23c4388dc600e503a09ad057b8271d8fcf4c2cd6723b44f33db638e49075fe96bc545eed9110ded0c5df505625d4e1c838b718349eecf1d39270d0829d5b9')
- credentials.secret_key_aaf = ENV.fetch('DF_SECRET_KEY_AAF', Rails.env.production? ? nil : 'secretsecret12345')
- credentials.secret_key_moss = ENV.fetch('DF_SECRET_KEY_MOSS', nil)
+ # Max files inside an uploaded zip. If denied for "too many files",
+ # remove generated folders (e.g. node_modules/build) or raise this limit.
+ config.zip_entry_limit = ENV.fetch('DF_ZIP_ENTRY_LIMIT', 1_000)
+
+ # Max zip compression ratio. If denied for "compression ratio is too high",
+ # check for repetitive/generated data before raising this zip-bomb guard.
+ config.zip_compression_ratio_limit = ENV.fetch('DF_ZIP_COMPRESSION_RATIO_LIMIT', 100)
+
+ # Max expanded zip size as a multiple of DF_MAX_FILE_SIZE.
+ # Eg. If max file size is 10MB, the uncompressed size can be a maximum of 100MB
+ config.zip_uncompressed_size_multiplier = ENV.fetch('DF_ZIP_UNCOMPRESSED_SIZE_MULTIPLIER', 10)
+
+ # Prefer encrypted Rails credentials, while keeping env vars as a safe fallback.
+ credentials.secret_key_base = Application.fetch_credential_or_env(:secret_key_base, env_key: 'DF_SECRET_KEY_BASE', default: Rails.env.production? ? nil : '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97')
+ credentials.secret_key_attr = Application.fetch_credential_or_env(:secret_key_attr, env_key: 'DF_SECRET_KEY_ATTR', default: Rails.env.production? ? nil : 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055')
+ credentials.secret_key_devise = Application.fetch_credential_or_env(:secret_key_devise, env_key: 'DF_SECRET_KEY_DEVISE', default: Rails.env.production? ? nil : 'f4e23c4388dc600e503a09ad057b8271d8fcf4c2cd6723b44f33db638e49075fe96bc545eed9110ded0c5df505625d4e1c838b718349eecf1d39270d0829d5b9')
+ credentials.secret_key_aaf = Application.fetch_credential_or_env(:aaf, :secret_key, env_key: 'DF_SECRET_KEY_AAF', default: Rails.env.production? ? nil : 'secretsecret12345')
+ credentials.secret_key_moss = Application.fetch_credential_or_env(:moss, :secret_key, env_key: 'DF_SECRET_KEY_MOSS')
+
+ # ==> User identity
+ # Refuse accounts matched by username or email that already belong to a different login id. When off, these are only logged.
+ config.enforce_login_id_match = ENV['DF_ENFORCE_LOGIN_ID_MATCH'].to_s.downcase == 'true'
# ==> LTI settings
# If enabled, mounts the LTI routes and enables LTI authentication.
config.lti_enabled = ENV.fetch('LTI_ENABLED', false).to_s.downcase == "true"
# Shared secret between Ruby on Rails API and the LTI.js API
# LTI.js will send signed JWT tokens using this secret
- config.lti_api_secret = ENV.fetch('LTI_SHARED_API_SECRET', nil)
+ config.lti_api_secret = Application.fetch_credential_or_env(:lti, :shared_api_secret, env_key: 'LTI_SHARED_API_SECRET')
+ # Anyone holding this secret can sign in as any user through /api/auth/lti
+ if config.lti_enabled && (problem = Application.lti_api_secret_problem(config.lti_api_secret))
+ raise "LTI_SHARED_API_SECRET #{problem}. Generate one with `openssl rand -hex 32`, or set LTI_ENABLED=false."
+ end
+ # Where the LTI service connects from: hostnames, IPs or CIDR ranges. Only these can call /api/auth/lti and /api/lti/*
+ config.lti_service_hosts = ENV.fetch('LTI_SERVICE_HOSTS', 'lti').split(',').map(&:strip).reject(&:empty?)
+ if config.lti_enabled && config.lti_service_hosts.empty?
+ raise 'LTI_SERVICE_HOSTS must list where the LTI service connects from when LTI_ENABLED is true, e.g. lti.'
+ end
+
+ # Server-to-server access to the LTI service, used by the unit LMS tab and scheduled LMS syncs
+ config.lti_internal_url = ENV.fetch('LTI_INTERNAL_URL', nil)
+ config.lti_internal_key = Application.fetch_credential_or_env(:lti, :internal_sync_key, env_key: 'LTI_INTERNAL_SYNC_KEY')
# ==> Moderation settings
config.moderation_score_factor = Float(ENV.fetch('MODERATION_SCORE_FACTOR', 1.0))
@@ -106,7 +207,9 @@ def self.fetch_boolean_env(name)
config.institution[:name] = ENV['DF_INSTITUTION_NAME'] if ENV['DF_INSTITUTION_NAME']
config.institution[:email_domain] = ENV['DF_INSTITUTION_EMAIL_DOMAIN'] if ENV['DF_INSTITUTION_EMAIL_DOMAIN']
config.institution[:host] = ENV['DF_INSTITUTION_HOST'] if ENV['DF_INSTITUTION_HOST']
- config.institution[:cookie_domain] = ENV.fetch('DF_COOKIE_DOMAIN', URI.parse(Doubtfire::Application.config.institution[:host]).host)
+ # Blank means host-only cookies, scoped to whichever origin set them.
+ config.institution[:cookie_domain] =
+ ENV.fetch('DF_COOKIE_DOMAIN', URI.parse(Doubtfire::Application.config.institution[:host]).host).presence
config.institution[:product_name] = ENV['DF_INSTITUTION_PRODUCT_NAME'] if ENV['DF_INSTITUTION_PRODUCT_NAME']
config.institution[:has_logo] = (ENV['DF_INSTITUTION_HAS_LOGO'].to_s.downcase == "true" || ENV['DF_INSTITUTION_HAS_LOGO'].to_i == 1) if ENV['DF_INSTITUTION_HAS_LOGO']
@@ -117,6 +220,7 @@ def self.fetch_boolean_env(name)
config.institution[:plagiarism] = ENV['DF_INSTITUTION_PLAGIARISM'] if ENV['DF_INSTITUTION_PLAGIARISM']
# Institution host becomes localhost in development
config.institution[:host] ||= 'http://localhost:4200' if Rails.env.development?
+
config.institution[:settings] = ENV['DF_INSTITUTION_SETTINGS_RB'] if ENV['DF_INSTITUTION_SETTINGS_RB']
config.institution[:ffmpeg] = ENV['DF_FFMPEG_PATH'] || 'ffmpeg'
@@ -232,6 +336,7 @@ def self.fetch_boolean_env(name)
config.autoload_paths <<
Rails.root.join('app') <<
Rails.root.join('app/models/comments') <<
+ Rails.root.join('app/models/communication') <<
Rails.root.join('app/models/turn_it_in') <<
Rails.root.join('app/models/similarity') <<
Rails.root.join('app/models/d2l')
@@ -239,17 +344,38 @@ def self.fetch_boolean_env(name)
config.eager_load_paths <<
Rails.root.join('app') <<
Rails.root.join('app/models/comments') <<
+ Rails.root.join('app/models/communication') <<
Rails.root.join('app/models/turn_it_in') <<
Rails.root.join('app/models/similarity') <<
Rails.root.join('app/models/d2l')
# CORS config
+ # Configure a strict allowlist. Override per environment via:
+ # CORS_ALLOWED_ORIGINS="http://localhost:4200,https://frontend.example.edu"
+ default_cors_origins = [
+ 'http://localhost:4200',
+ Application.cors_origin_for(config.institution[:host])
+ ].uniq
+ allowed_cors_origins = ENV.fetch('CORS_ALLOWED_ORIGINS', default_cors_origins.join(','))
+ .split(',')
+ .map(&:strip)
+ .reject(&:empty?)
+ .uniq
+
+ config.middleware.insert_before Rack::MethodOverride, SentryTunnelMiddleware
+
config.middleware.insert_before Warden::Manager, Rack::Cors do
allow do
- origins '*'
- resource '*', headers: :any, methods: %i(get post put delete options)
+ origins do |source, _env|
+ allowed_cors_origins.include?(source)
+ end
+
+ resource '*',
+ headers: :any,
+ methods: %i[get post put patch delete options head]
end
end
+ config.middleware.use Rack::Attack
config.active_support.to_time_preserves_timezone = :zone
@@ -269,14 +395,17 @@ def self.fetch_boolean_env(name)
config.sm_instance = nil
config.overseer_enabled = ENV['OVERSEER_ENABLED'].present? && ENV['OVERSEER_ENABLED'].to_s.downcase != "false" && ENV['OVERSEER_ENABLED'].to_i != 0
- if (config.overseer_enabled)
- config.docker_config = {
- DOCKER_REGISTRY_URL: ENV.fetch('DOCKER_REGISTRY_URL', nil),
- DOCKER_PROXY_URL: ENV.fetch('DOCKER_PROXY_URL', nil),
- DOCKER_TOKEN: ENV.fetch('DOCKER_TOKEN', nil),
- DOCKER_USER: ENV.fetch('DOCKER_USER', nil)
- }
+ # Enables endpoints to return available storage on the device hosting the API.
+ config.disk_space_endpoint_enabled = %w[true 1 yes].include?(ENV['DISK_SPACE_ENDPOINT_ENABLED']&.downcase)
+
+ config.docker_config = {
+ DOCKER_REGISTRY_URL: ENV.fetch('DOCKER_REGISTRY_URL', nil),
+ DOCKER_PROXY_URL: ENV.fetch('DOCKER_PROXY_URL', nil),
+ DOCKER_TOKEN: ENV.fetch('DOCKER_TOKEN', nil),
+ DOCKER_USER: ENV.fetch('DOCKER_USER', nil)
+ }
+ if (config.overseer_enabled)
# Path to a physical directory on the host used for mounting overseer task work directories.
#
# Example (macOS development):
@@ -311,12 +440,12 @@ def self.fetch_boolean_env(name)
raise 'Overseer configuration error: you must set either OVERSEER_WORKDIR_VOLUME_MOUNT or OVERSEER_FALLBACK_VOLUME_CONTAINER.'
end
- # Enables the endpoint to return how much available storage is left on the device the API is hosted on (often docker volume storage)
- # Used to ensure enough space is available to pull new images for Overseer
- config.disk_space_endpoint_enabled = %w[true 1 yes].include?(ENV['DISK_SPACE_ENDPOINT_ENABLED']&.downcase)
-
config.after_initialize do
- if config.docker_config[:DOCKER_TOKEN] && config.docker_config[:DOCKER_PROXY_URL]
+ docker_login_configured = %i[DOCKER_TOKEN DOCKER_USER DOCKER_PROXY_URL].all? do |key|
+ config.docker_config[key].present?
+ end
+
+ if docker_login_configured
LoginDockerJob.perform_async
end
end
diff --git a/config/database.yml b/config/database.yml
index 06faaed683..81ebb08640 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -1,29 +1,33 @@
development:
- adapter: <%= ENV['DF_DEV_DB_ADAPTER'] %>
- database: <%= ENV['DF_DEV_DB_DATABASE'] %>
- username: <%= ENV['DF_DEV_DB_USERNAME'] %>
- password: <%= ENV['DF_DEV_DB_PASSWORD'] %>
- host: <%= ENV['DF_DEV_DB_HOST'] %>
+ adapter: <%= Rails.application.credentials.dig(:database, :development, :adapter) || ENV['DF_DEV_DB_ADAPTER'] %>
+ database: <%= Rails.application.credentials.dig(:database, :development, :database) || ENV['DF_DEV_DB_DATABASE'] %>
+ username: <%= Rails.application.credentials.dig(:database, :development, :username) || ENV['DF_DEV_DB_USERNAME'] %>
+ password: <%= Rails.application.credentials.dig(:database, :development, :password) || ENV['DF_DEV_DB_PASSWORD'] %>
+ host: <%= Rails.application.credentials.dig(:database, :development, :host) || ENV['DF_DEV_DB_HOST'] %>
min_messages: warning
+ encoding: utf8mb4
+ collation: utf8mb4_general_ci
test:
- adapter: <%= ENV['DF_TEST_DB_ADAPTER'] %>
- database: <%= ENV['DF_TEST_DB_DATABASE'] %>
- username: <%= ENV['DF_TEST_DB_USERNAME'] %>
- password: <%= ENV['DF_TEST_DB_PASSWORD'] %>
- host: <%= ENV['DF_TEST_DB_HOST'] %>
+ adapter: <%= Rails.application.credentials.dig(:database, :test, :adapter) || ENV['DF_TEST_DB_ADAPTER'] %>
+ database: <%= Rails.application.credentials.dig(:database, :test, :database) || ENV['DF_TEST_DB_DATABASE'] %>
+ username: <%= Rails.application.credentials.dig(:database, :test, :username) || ENV['DF_TEST_DB_USERNAME'] %>
+ password: <%= Rails.application.credentials.dig(:database, :test, :password) || ENV['DF_TEST_DB_PASSWORD'] %>
+ host: <%= Rails.application.credentials.dig(:database, :test, :host) || ENV['DF_TEST_DB_HOST'] %>
min_messages: warning
-
+ encoding: utf8mb4
+ collation: utf8mb4_general_ci
+
staging:
- adapter: <%= ENV['DF_STAGING_DB_ADAPTER'] %>
- host: <%= ENV['DF_STAGING_DB_HOST'] %>
- database: <%= ENV['DF_STAGING_DB_DATABASE'] %>
- username: <%= ENV['DF_STAGING_DB_USERNAME'] %>
- password: <%= ENV['DF_STAGING_DB_PASSWORD'] %>
+ adapter: <%= Rails.application.credentials.dig(:database, :staging, :adapter) || ENV['DF_STAGING_DB_ADAPTER'] %>
+ host: <%= Rails.application.credentials.dig(:database, :staging, :host) || ENV['DF_STAGING_DB_HOST'] %>
+ database: <%= Rails.application.credentials.dig(:database, :staging, :database) || ENV['DF_STAGING_DB_DATABASE'] %>
+ username: <%= Rails.application.credentials.dig(:database, :staging, :username) || ENV['DF_STAGING_DB_USERNAME'] %>
+ password: <%= Rails.application.credentials.dig(:database, :staging, :password) || ENV['DF_STAGING_DB_PASSWORD'] %>
production:
- adapter: <%= ENV['DF_PRODUCTION_DB_ADAPTER'] %>
- host: <%= ENV['DF_PRODUCTION_DB_HOST'] %>
- database: <%= ENV['DF_PRODUCTION_DB_DATABASE'] %>
- username: <%= ENV['DF_PRODUCTION_DB_USERNAME'] %>
- password: <%= ENV['DF_PRODUCTION_DB_PASSWORD'] %>
+ adapter: <%= Rails.application.credentials.dig(:database, :production, :adapter) || ENV['DF_PRODUCTION_DB_ADAPTER'] %>
+ host: <%= Rails.application.credentials.dig(:database, :production, :host) || ENV['DF_PRODUCTION_DB_HOST'] %>
+ database: <%= Rails.application.credentials.dig(:database, :production, :database) || ENV['DF_PRODUCTION_DB_DATABASE'] %>
+ username: <%= Rails.application.credentials.dig(:database, :production, :username) || ENV['DF_PRODUCTION_DB_USERNAME'] %>
+ password: <%= Rails.application.credentials.dig(:database, :production, :password) || ENV['DF_PRODUCTION_DB_PASSWORD'] %>
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 7dd6f8112f..d79c75c9a5 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -2,6 +2,11 @@
Doubtfire::Application.configure do
# Settings specified here will take precedence over those in config/application.rb.
+ # Accept requests for the configured institution host, so a local HTTPS
+ # development proxy can forward them. Rails otherwise allows only localhost.
+ institution_host = URI.parse(config.institution[:host].to_s).host
+ config.hosts << institution_host if institution_host.present?
+
# In the development environment your application's code is reloaded any time
# it changes. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
@@ -15,7 +20,9 @@
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
- if ENV['CACHE'] == 'true' || Rails.root.join('tmp/caching-dev.txt').exist?
+ if ENV['CACHE'] == 'true' ||
+ ENV['DF_REDIS_CACHE_URL'].present? ||
+ Rails.root.join('tmp/caching-dev.txt').exist?
skip_first = true
ActiveSupport::Reloader.to_prepare do
if skip_first
@@ -106,7 +113,7 @@
# pdfgen log verbosity
config.pdfgen_quiet = false
- config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z'
- config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_DETERMINISTIC_KEY'] || 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx'
- config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_PRIMARY_KEY'] || '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5'
+ config.active_record.encryption.key_derivation_salt = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :key_derivation_salt, env_key: 'DF_ENCRYPTION_KEY_DERIVATION_SALT', default: 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z')
+ config.active_record.encryption.deterministic_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :deterministic_key, env_key: 'DF_ENCRYPTION_DETERMINISTIC_KEY', default: 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx')
+ config.active_record.encryption.primary_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :primary_key, env_key: 'DF_ENCRYPTION_PRIMARY_KEY', default: '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5')
end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 37b429654a..f3b958c23d 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -7,6 +7,20 @@
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
+ # Use Redis for the shared production cache when configured. Falling back to
+ # an in-process cache avoids FileStore races during concurrent expiry while
+ # keeping production bootable for deployments without Redis.
+ config.cache_store = if ENV['DF_REDIS_CACHE_URL'].present?
+ [:redis_cache_store, {
+ url: ENV.fetch('DF_REDIS_CACHE_URL'),
+ connect_timeout: 1,
+ read_timeout: 1,
+ write_timeout: 1
+ }]
+ else
+ :memory_store
+ end
+
# Disable Rails's static asset server (Apache or nginx will already do this)
config.serve_static_files = true
@@ -50,7 +64,7 @@
config.action_mailer.smtp_settings[:authentication] = nil if %w[no_auth none].include?(config.action_mailer.smtp_settings[:authentication])
end
- config.active_record.encryption.key_derivation_salt = ENV.fetch('DF_ENCRYPTION_KEY_DERIVATION_SALT', nil)
- config.active_record.encryption.deterministic_key = ENV.fetch('DF_ENCRYPTION_DETERMINISTIC_KEY', nil)
- config.active_record.encryption.primary_key = ENV.fetch('DF_ENCRYPTION_PRIMARY_KEY', nil)
+ config.active_record.encryption.key_derivation_salt = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :key_derivation_salt, env_key: 'DF_ENCRYPTION_KEY_DERIVATION_SALT')
+ config.active_record.encryption.deterministic_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :deterministic_key, env_key: 'DF_ENCRYPTION_DETERMINISTIC_KEY')
+ config.active_record.encryption.primary_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :primary_key, env_key: 'DF_ENCRYPTION_PRIMARY_KEY')
end
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 24fcb3d4cc..15e3882e9c 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -37,9 +37,9 @@
# Logging level (:debug, :info, :warn, :error, :fatal)
config.log_level = :warn
- config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z'
- config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx'
- config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5'
+ config.active_record.encryption.key_derivation_salt = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :key_derivation_salt, env_key: 'DF_ENCRYPTION_KEY_DERIVATION_SALT', default: 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z')
+ config.active_record.encryption.deterministic_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :deterministic_key, env_key: 'DF_ENCRYPTION_DETERMINISTIC_KEY', default: 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx')
+ config.active_record.encryption.primary_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :primary_key, env_key: 'DF_ENCRYPTION_PRIMARY_KEY', default: '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5')
# Set turn it in environment
ENV.store('TCA_SIGNING_KEY', 'test')
@@ -57,4 +57,7 @@
ENV.store('D2L_OAUTH_SITE_TOKEN_URL', 'core/connect/token')
ENV.store('D2L_API_HOST', 'https://api.brightspace.com')
ENV.store('D2L_API_VERSION', '1.47')
+
+ # Rack::Test requests connect from 127.0.0.1
+ config.lti_service_hosts = %w[127.0.0.1]
end
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
new file mode 100644
index 0000000000..9ea4caa6f0
--- /dev/null
+++ b/config/initializers/rack_attack.rb
@@ -0,0 +1,39 @@
+module Rack
+ class Attack
+ # Production uses the configured Rails cache: Redis when available,
+ # otherwise an in-process memory store.
+ Rack::Attack.cache.store = if Rails.env.production?
+ Rails.cache
+ else
+ ActiveSupport::Cache::MemoryStore.new
+ end
+
+ # Limit repeated login attempts against the API auth endpoint.
+ throttle('auth/ip', limit: Doubtfire::Application.config.auth_rate_limit,
+ period: Doubtfire::Application.config.auth_rate_period_seconds.seconds) do |req|
+ req.ip if Doubtfire::Application.config.auth_rate_limit_enabled && req.path.start_with?('/api/auth') && req.post?
+ end
+
+ # Return a consistent API-friendly throttling response.
+ self.throttled_responder = lambda do |request|
+ rack_env =
+ if request.respond_to?(:env)
+ request.env
+ elsif request.respond_to?(:[])
+ request
+ else
+ {}
+ end
+
+ match_data = rack_env['rack.attack.match_data'] || {}
+ now = match_data[:epoch_time] || Time.now.to_i
+ retry_after = match_data[:period].to_i - (now % match_data[:period].to_i) if match_data[:period].to_i.positive?
+
+ body = { error: 'Too many login attempts. Please try again shortly.' }.to_json
+ headers = { 'Content-Type' => 'application/json' }
+ headers['Retry-After'] = retry_after.to_s if retry_after
+
+ [429, headers, [body]]
+ end
+ end
+end
diff --git a/config/initializers/rubyzip.rb b/config/initializers/rubyzip.rb
new file mode 100644
index 0000000000..1ba51c5f68
--- /dev/null
+++ b/config/initializers/rubyzip.rb
@@ -0,0 +1,4 @@
+# Aggregate submission downloads can exceed the classic ZIP format's 4 GiB
+# offset limit. Rubyzip leaves ZIP64 output disabled by default, which produces
+# archives with wrapped local-header offsets once that limit is crossed.
+Zip.write_zip64_support = true
diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb
new file mode 100644
index 0000000000..9f5bc7212d
--- /dev/null
+++ b/config/initializers/sentry.rb
@@ -0,0 +1,113 @@
+module OnTrackSentryRedaction
+ FILTERED = "[Filtered]".freeze
+ PII_KEY_PATTERN = /(^|_)(email|first_name|last_name|login_id|name|student_id|username|user_id)\z/i
+ RAILS_LATEX_TASK_WORK_DIR = %r{(tmp/rails-latex/task-\d{8}-\d{4}-)[^/\s]+?(-[^/\s-]+-\d+-\d+(?:-retry)?)(?=/|\s|$)}
+ RAILS_LATEX_PORTFOLIO_WORK_DIR = %r{(tmp/rails-latex/portfolio-\d{8}-\d{4}-)[^/\s]+?(-\d+-\d+(?:-retry)?)(?=/|\s|$)}
+ PORTFOLIO_FAILURE_DETAILS = /(Failed to convert portfolio to PDF - \d+ - )[^\n]+? \([^)]+\)(?=\s+\S+)/
+ SCORM_PATH = %r{(/scorm/[^/?#]+/)[^/?#]+(/)[^/?#]+}i
+
+ module_function
+
+ def scrub_event(event)
+ scrub_request(event.request) if event.respond_to?(:request)
+
+ event.message = scrub_string(event.message) if event.respond_to?(:message) && event.respond_to?(:message=)
+ event.user.replace(scrub_hash(event.user)) if event.respond_to?(:user) && event.user.respond_to?(:replace)
+ event.extra.replace(scrub_hash(event.extra)) if event.respond_to?(:extra) && event.extra.respond_to?(:replace)
+ scrub_contexts(event.contexts) if event.respond_to?(:contexts)
+
+ scrub_exception(event.exception) if event.respond_to?(:exception)
+ scrub_breadcrumbs(event.breadcrumbs) if event.respond_to?(:breadcrumbs)
+
+ event
+ end
+
+ def scrub_request(request)
+ return unless request
+
+ request.url = scrub_string(request.url) if request.respond_to?(:url) && request.respond_to?(:url=)
+ request.headers&.each_key do |key|
+ request.headers[key] = FILTERED if key.casecmp("Username").zero?
+ end
+ end
+
+ def scrub_contexts(contexts)
+ sidekiq = contexts&.[](:sidekiq)
+ return unless sidekiq.respond_to?(:key?)
+
+ # Job args are positional, so PII_KEY_PATTERN cannot tell which ones hold user details.
+ %w[args wrapped_args].each { |key| sidekiq[key] = FILTERED if sidekiq.key?(key) }
+ contexts[:sidekiq] = scrub_hash(sidekiq)
+ end
+
+ def scrub_exception(exception)
+ exception_values = exception&.values
+ exception_values&.each do |value|
+ value.value = scrub_string(value.value) if value.respond_to?(:value) && value.respond_to?(:value=)
+
+ next unless value.respond_to?(:stacktrace)
+
+ value.stacktrace&.frames&.each do |frame|
+ %i[abs_path context_line filename function module pre_context post_context vars].each do |attribute|
+ next unless frame.respond_to?(attribute) && frame.respond_to?(:"#{attribute}=")
+
+ frame.public_send(:"#{attribute}=", scrub_value(frame.public_send(attribute)))
+ end
+ end
+ end
+ end
+
+ def scrub_breadcrumbs(breadcrumbs)
+ breadcrumbs&.each do |breadcrumb|
+ breadcrumb.message = scrub_string(breadcrumb.message) if breadcrumb.respond_to?(:message) && breadcrumb.respond_to?(:message=)
+ breadcrumb.data = scrub_hash(breadcrumb.data) if breadcrumb.respond_to?(:data) && breadcrumb.respond_to?(:data=)
+ end
+ end
+
+ def scrub_value(value)
+ case value
+ when Hash
+ scrub_hash(value)
+ when Array
+ value.map { |item| scrub_value(item) }
+ when String
+ scrub_string(value)
+ else
+ value
+ end
+ end
+
+ def scrub_hash(hash)
+ hash.each_with_object({}) do |(key, value), result|
+ key = key.to_s
+ result[key] = key.match?(PII_KEY_PATTERN) ? FILTERED : scrub_value(value)
+ end
+ end
+
+ def scrub_string(value)
+ return value unless value.is_a?(String)
+
+ value
+ .gsub(RAILS_LATEX_TASK_WORK_DIR, "\\1[username]\\2")
+ .gsub(RAILS_LATEX_PORTFOLIO_WORK_DIR, "\\1[username]\\2")
+ .gsub(PORTFOLIO_FAILURE_DETAILS, "\\1[Filtered] ([username])")
+ .gsub(SCORM_PATH, "\\1[username]\\2[Filtered]")
+ end
+end
+
+if ENV["SENTRY_DSN"].present?
+ Sentry.init do |config|
+ config.dsn = ENV.fetch("SENTRY_DSN", nil)
+ # Rails log breadcrumbs are excluded because app logs can include user details.
+ config.breadcrumbs_logger = [:http_logger]
+ config.environment = ENV.fetch("SENTRY_ENVIRONMENT", Rails.env)
+ config.release = ENV["SENTRY_RELEASE"] if ENV["SENTRY_RELEASE"].present?
+ # Add data like request headers and IP for users, if applicable;
+ # see https://docs.sentry.io/platforms/ruby/data-management/data-collected/ for more info
+ config.send_default_pii = false
+
+ config.before_send = lambda do |event, _hint|
+ OnTrackSentryRedaction.scrub_event(event)
+ end
+ end
+end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index dc00e55825..942c043b5d 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -14,6 +14,11 @@
Sidekiq::Status.configure_server_middleware(config, expiration: 30.minutes.to_i)
Sidekiq::Status.configure_client_middleware(config, expiration: 30.minutes.to_i)
+
+ config.on(:startup) do
+ schedule_file = Rails.root.join('config/schedule.yml')
+ Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(schedule_file)) if File.exist?(schedule_file)
+ end
end
Sidekiq.configure_client do |config|
diff --git a/config/initializers/uri_credential_leak_mitigation.rb b/config/initializers/uri_credential_leak_mitigation.rb
new file mode 100644
index 0000000000..18556a8827
--- /dev/null
+++ b/config/initializers/uri_credential_leak_mitigation.rb
@@ -0,0 +1,22 @@
+require 'uri'
+
+# Mitigates credential leakage when combining a credential-bearing base URI
+# with a relative URI via URI#+ by stripping userinfo from the merged result.
+module UriCredentialLeakMitigation
+ def +(other)
+ combined = super
+ other_uri = other.is_a?(URI::Generic) ? other : URI.parse(other.to_str)
+
+ return combined if other_uri.absolute?
+ return combined unless combined.respond_to?(:user=) || combined.respond_to?(:password=)
+
+ sanitized = combined.dup
+ sanitized.user = nil if sanitized.respond_to?(:user=)
+ sanitized.password = nil if sanitized.respond_to?(:password=)
+ sanitized
+ rescue URI::InvalidComponentError
+ combined
+ end
+end
+
+URI::Generic.prepend(UriCredentialLeakMitigation)
diff --git a/config/no_institution_setting.rb b/config/no_institution_setting.rb
index e47b250583..bd575f334a 100644
--- a/config/no_institution_setting.rb
+++ b/config/no_institution_setting.rb
@@ -1,5 +1,16 @@
class InstitutionSettings
+ # Suggests how each LMS group should map into OnTrack when a convenor pre-fills group mappings.
+ def prefill_lms_group_mappings(_unit, groups)
+ groups.map do |group|
+ {
+ lms_group_id: group[:id],
+ lms_group_name: group[:name],
+ target_type: 'ignore'
+ }
+ end
+ end
+
# Used when importing students from a CSV into a unit. This is called to check if
# the headers in the CSV match an institution system. If this returns true, the import
# will be processed using the lambdas you specify in the import_settings.
@@ -122,6 +133,21 @@ def update_user_from_saml_response(user, user_id_data, response)
user
end
+ # The LTI member field stored as the user's login_id, which should never change for a person
+ def map_lti_member_to_user_id(member)
+ email = member['email']
+ {
+ login_id: member['ext_user_username'].presence || member['user_id'],
+ email: email,
+ username: email.to_s[/(.*)@/, 1]
+ }
+ end
+
+ # Whether a stored login_id is a real institution id. Ids that are not are replaced on the next match.
+ def institution_login_id?(_login_id)
+ true
+ end
+
def update_user_from_lti_response(user, user_id_data, member)
user.login_id = user_id_data[:login_id]
user.email = user_id_data[:email]
diff --git a/config/routes.rb b/config/routes.rb
index ea52a79000..4996574c64 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -5,6 +5,26 @@
get 'api/submission/unit/:id/task_definitions/:task_def_id/download_submissions', to: 'task_downloads#index'
get 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs', to: 'task_submission_pdfs#index'
get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index'
+ post 'api/submission/unit/:id/portfolio/access', to: 'portfolio_download_authorizations#create'
+ post 'api/submission/unit/:id/task_definitions/:task_def_id/download_submissions/access',
+ to: 'task_submission_files_download_authorizations#create'
+ post 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs/access',
+ to: 'task_submission_pdfs_download_authorizations#create'
+ get 'api/units/:unit_id/content/sites/:site_id/files', to: 'unit_content_download_authorizations#serve'
+ get 'api/units/:unit_id/content/sites/:site_id/files/*route',
+ to: 'unit_content_download_authorizations#serve',
+ format: false
+
+ # Caddy-only authorisation subrequests.
+ # These authenticate the user, validates it originates from Caddy, then return
+ # the path of the file to serve using X-OnTrack-* headers.
+ # The file is then served via Caddy
+ get 'api/internal/downloads/submission', to: 'submission_download_authorizations#show'
+ get 'api/internal/downloads/portfolio', to: 'portfolio_download_authorizations#show'
+ get 'api/internal/downloads/task-submission-files', to: 'task_submission_files_download_authorizations#show'
+ get 'api/internal/downloads/task-submission-pdfs', to: 'task_submission_pdfs_download_authorizations#show'
+ get 'api/internal/downloads/pdf-file', to: 'pdf_file_download_authorizations#show'
+ get 'api/internal/downloads/unit-content', to: 'unit_content_download_authorizations#show'
mount ApiRoot => '/'
mount GrapeSwaggerRails::Engine => '/api/docs'
diff --git a/config/schedule.yml b/config/schedule.yml
index b0bd370f02..84a31d9061 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -3,19 +3,44 @@
register_webhooks:
cron: "every day at 5"
class: "TiiRegisterWebHookJob"
+ queue: parallel
progress_turn_it_in_jobs:
cron: "every 30 minutes"
class: "TiiCheckProgressJob"
+ queue: parallel
clean_up_auth_tokens:
cron: "every 30 minutes"
class: "ClearAccessTokensJob"
+ queue: parallel
refresh_moderation_feedback_timestamps:
cron: "every 60 minutes"
class: "RefreshModerationFeedbackTimestampsJob"
+ queue: parallel
+
+aggregate_task_completion_stats:
+ cron: "every day at 11:55pm"
+ class: "AggregateTaskCompletionStatsJob"
+ queue: parallel
+
+poll_communication_set_schedules:
+ cron: "every 5 minutes"
+ class: "PollCommunicationSetSchedulesJob"
+ queue: parallel
+
+notify_discuss_timeout:
+ cron: "every day at 8am"
+ class: "NotifyDiscussTimeoutJob"
+ queue: parallel
+
+sync_lms_integrations:
+ cron: "every day at 3am"
+ class: "SyncLmsIntegrationsJob"
+ queue: parallel
# archive_old_units:
# cron: "every 6 months"
# class: "ArchiveOldUnitsJob"
+# queue: parallel
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 0515ae2186..29ef91a73f 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -1 +1,10 @@
-:concurrency: 1
+# Four parallel workers plus one serial submission worker in the same process.
+:concurrency: 4
+:queues:
+ - parallel
+
+:capsules:
+ :submission:
+ :queues:
+ - default
+ :concurrency: 1
diff --git a/db/migrate/20260327041457_add_enforce_feedback_before_discussed_in_class.rb b/db/migrate/20260327041457_add_enforce_feedback_before_discussed_in_class.rb
new file mode 100644
index 0000000000..ba60168e01
--- /dev/null
+++ b/db/migrate/20260327041457_add_enforce_feedback_before_discussed_in_class.rb
@@ -0,0 +1,5 @@
+class AddEnforceFeedbackBeforeDiscussedInClass < ActiveRecord::Migration[8.0]
+ def change
+ add_column :units, :enforce_feedback_before_discussed_in_class, :boolean, null: false, default: false
+ end
+end
diff --git a/db/migrate/20260604031804_add_student_notified_at_to_overseer_assessments.rb b/db/migrate/20260604031804_add_student_notified_at_to_overseer_assessments.rb
new file mode 100644
index 0000000000..a0b9be1695
--- /dev/null
+++ b/db/migrate/20260604031804_add_student_notified_at_to_overseer_assessments.rb
@@ -0,0 +1,7 @@
+class AddStudentNotifiedAtToOverseerAssessments < ActiveRecord::Migration[8.0]
+ def change
+ add_column :overseer_assessments, :student_notified_at, :datetime
+ add_index :overseer_assessments, [:status, :student_notified_at, :updated_at],
+ name: 'index_overseer_assessments_on_status_notified_updated'
+ end
+end
diff --git a/db/migrate/20260604070032_add_communications_feat.rb b/db/migrate/20260604070032_add_communications_feat.rb
new file mode 100644
index 0000000000..b28e520cc4
--- /dev/null
+++ b/db/migrate/20260604070032_add_communications_feat.rb
@@ -0,0 +1,123 @@
+class AddCommunicationsFeat < ActiveRecord::Migration[8.0]
+ def change
+ create_table :communication_sets do |t|
+ t.references :unit, null: false
+ t.string :name
+ t.boolean :active, null: false, default: true
+ t.timestamps
+ end
+
+ create_table :communication_rules do |t|
+ t.references :communication_set, null: false
+
+ t.integer :position, null: false, default: 0
+ t.string :name
+ # AND | OR
+ t.string :operator # AND | OR
+
+ t.boolean :send_log_to_convenors, null: false, default: false
+
+ t.boolean :active, null: false, default: true
+
+ t.timestamps
+ end
+
+ create_table :communication_conditions do |t|
+ t.string :type, null: false
+ t.references :communication, null: false
+
+ # TargetGradeCondition
+ t.integer :target_grade
+ # t.string :target_grade_comparison # gt|gte|lt|lte|notequal|equal
+
+ # TaskDefinitionStatusCondition
+ t.references :task_definition
+ t.json :task_statuses
+ # t.string :task_status_comparison # equal|notequal
+
+ # LoginStatusCondition
+ t.datetime :last_sign_in_at
+ # t.string :last_sign_in_comparison # before|after
+
+ # TutorialEnrolmentCondition
+ t.references :tutorial
+ # t.string :tutorial_comparison # enrolled|notenrolled
+
+ # TutorialStreamEnrolmentCondition
+ t.references :tutorial_stream
+ # t.string :tutorial_stream_comparison # enrolled|notenrolled
+
+ # CampusCondition
+ t.references :campus
+ # t.string :campus_comparison # enrolled|notenrolled
+
+ # TaskStatusCountCondition
+ t.integer :task_status_count
+ t.integer :task_target_grade
+
+ # SpecConDaysCondition
+ t.integer :spec_con_days
+
+ t.string :operator, null: false
+
+ t.timestamps
+ end
+
+ create_table :communication_actions do |t|
+ t.string :type, null: false
+ t.references :communication_rule, null: false
+
+ # EmailStudentAction / EmailStaffAction
+ t.string :subject
+ t.text :body
+
+ # EmailStaffAction
+ t.boolean :email_tutors, null: false, default: false
+ t.boolean :email_convenors, null: false, default: false
+
+ # ChangeTargetGradeAction
+ t.integer :target_grade
+
+ # TaskCommentAction
+ t.references :task_definition, null: true
+
+ t.timestamps
+ end
+
+ create_table :communication_set_schedules do |t|
+ t.references :communication_set, null: false
+ t.string :name
+ t.boolean :active, null: false, default: true
+
+ # Anchor the schedule to the unit's teaching calendar. The actual start
+ # datetime is resolved through Unit#date_for_week_and_day.
+ t.integer :anchor_week, null: false
+ t.string :anchor_day, null: false
+ t.integer :hour, null: false, default: 8
+ t.integer :minute, null: false, default: 0
+ t.string :timezone, null: false, default: "UTC"
+
+ # Canonical recurrence settings to hydrate IceCube rules from.
+ t.string :recurrence, null: false, default: "none"
+ t.integer :interval, null: false, default: 1
+
+ # Optional limits for recurring schedules.
+ t.integer :repeat_count
+ t.datetime :until_at
+
+ # Serialized payload to rebuild an IceCube schedule without guessing.
+ t.json :ice_cube_schedule
+
+ # Derived state for the worker that enqueues due communication runs.
+ t.datetime :next_run_at
+ t.datetime :last_run_at
+ t.datetime :last_enqueued_at
+
+ t.timestamps
+ end
+
+ add_index :communication_actions, :type
+ add_index :communication_conditions, :type
+ add_index :communication_set_schedules, [:active, :next_run_at]
+ end
+end
diff --git a/db/migrate/20260610065531_create_overflow_task_claim_logs.rb b/db/migrate/20260610065531_create_overflow_task_claim_logs.rb
new file mode 100644
index 0000000000..d02ea8762a
--- /dev/null
+++ b/db/migrate/20260610065531_create_overflow_task_claim_logs.rb
@@ -0,0 +1,17 @@
+class CreateOverflowTaskClaimLogs < ActiveRecord::Migration[8.0]
+ def change
+ create_table :overflow_task_claim_logs do |t|
+ t.references :unit, null: false
+ t.references :task, null: false
+ t.references :claimed_by_unit_role, null: false
+ t.references :claimed_by_user, null: false
+ t.references :original_tutor_user
+ t.references :student_user, null: false
+ t.integer :days_awaiting_feedback, null: false
+ t.datetime :claimed_at, null: false
+ t.timestamps
+ end
+
+ add_index :overflow_task_claim_logs, [:unit_id, :claimed_at]
+ end
+end
diff --git a/db/migrate/20260611053643_create_submission_histories.rb b/db/migrate/20260611053643_create_submission_histories.rb
new file mode 100644
index 0000000000..dee99d13d8
--- /dev/null
+++ b/db/migrate/20260611053643_create_submission_histories.rb
@@ -0,0 +1,257 @@
+# rubocop:disable Rails/SkipsModelValidations
+require 'zip'
+
+class CreateSubmissionHistories < ActiveRecord::Migration[8.0]
+ # Migration-local models provide direct access to the tables as they exist at
+ # this migration point, without using future application callbacks or validations.
+ class MigrationTaskDefinition < ApplicationRecord
+ self.table_name = 'task_definitions'
+ end
+
+ class MigrationOverseerAssessment < ApplicationRecord
+ self.table_name = 'overseer_assessments'
+ end
+
+ class MigrationSubmissionHistory < ApplicationRecord
+ self.table_name = 'submission_histories'
+ end
+
+ class MigrationTask < ApplicationRecord
+ self.table_name = 'tasks'
+ belongs_to :project, class_name: 'CreateSubmissionHistories::MigrationProject'
+ end
+
+ class MigrationProject < ApplicationRecord
+ self.table_name = 'projects'
+ belongs_to :unit, class_name: 'CreateSubmissionHistories::MigrationUnit'
+ belongs_to :user, class_name: 'CreateSubmissionHistories::MigrationUser'
+
+ def student
+ user
+ end
+ end
+
+ class MigrationUnit < ApplicationRecord
+ self.table_name = 'units'
+ end
+
+ class MigrationUser < ApplicationRecord
+ self.table_name = 'users'
+ end
+
+ # `up` applies the migration when moving the database to this version.
+ def up
+ # Store one completed submission-history archive per task and timestamp.
+ create_table :submission_histories do |t|
+ t.references :task, null: false
+ t.string :submission_timestamp, null: false
+
+ t.timestamps
+ end
+
+ add_index :submission_histories,
+ [:task_id, :submission_timestamp],
+ unique: true,
+ name: 'index_submission_histories_on_task_and_timestamp'
+
+ # Enable submission history for existing Overseer task definitions. Preserve
+ # an explicit value if this key has already been added to a requirement.
+ MigrationTaskDefinition.reset_column_information
+ MigrationTaskDefinition.find_each do |task_definition|
+ requirements = JSON.parse(task_definition.upload_requirements.presence || '[]')
+ next unless requirements.is_a?(Array)
+
+ requirements.each do |requirement|
+ next unless requirement.is_a?(Hash)
+ next if requirement.key?('submission_history')
+
+ requirement['submission_history'] = task_definition.assessment_enabled?
+ end
+
+ task_definition.update_columns(upload_requirements: requirements.to_json)
+ rescue JSON::ParserError
+ next
+ end
+
+ # Add the association as nullable first because existing Overseer assessments
+ # do not have a submission-history row yet.
+ add_reference :overseer_assessments, :submission_history, index: false
+
+ # Refresh Active Record's cached columns after creating the table and column.
+ MigrationOverseerAssessment.reset_column_information
+ MigrationSubmissionHistory.reset_column_information
+
+ # Preserve existing Overseer archives by creating a history with the same
+ # task and timestamp, then linking the assessment to that history.
+ MigrationOverseerAssessment.find_each do |assessment|
+ history = MigrationSubmissionHistory.find_or_create_by!(
+ task_id: assessment.task_id,
+ submission_timestamp: assessment.submission_timestamp
+ )
+ assessment.update_columns(submission_history_id: history.id)
+ end
+
+ # Replace each task's timestamp directories with one history.zip. Every
+ # directory and filename is preserved inside the archive.
+ task_ids_by_unit = MigrationTask.left_joins(project: :unit)
+ .where(id: MigrationSubmissionHistory.select(:task_id))
+ .order('units.code, units.id, tasks.id')
+ .pluck('units.id', 'units.code', 'tasks.id')
+ .group_by { |unit_id, unit_code, _task_id| [unit_id, unit_code] }
+ total_units = task_ids_by_unit.size
+ say "Archiving submission histories across #{total_units} units"
+
+ task_ids_by_unit.each_with_index do |((unit_id, unit_code), task_rows), index|
+ unit_label = "#{unit_code.presence || 'unknown'} (ID #{unit_id || 'unknown'})"
+ say_with_time "Archiving submission histories for unit #{unit_label} (#{index + 1}/#{total_units} units, #{task_rows.size} tasks)" do
+ task_rows.each do |_, _, task_id|
+ task = MigrationTask.find_by(id: task_id)
+ archive_existing_history_directories(task) if task
+ end
+ end
+ say "Completed #{index + 1}/#{total_units} units (#{((index + 1) * 100.0 / total_units).round}%)"
+ end
+
+ # Once all existing rows are linked, make the association required and
+ # ensure a submission history cannot belong to multiple Overseer assessments.
+ change_column_null :overseer_assessments, :submission_history_id, false
+ add_index :overseer_assessments, :submission_history_id, unique: true
+ end
+
+ # `down` reverses `up` when rolling the database back from this version.
+ def down
+ # Restore timestamp directories before removing the records that identify
+ # which task archives need to be unpacked.
+ MigrationSubmissionHistory.distinct.pluck(:task_id).each do |task_id|
+ task = MigrationTask.find_by(id: task_id)
+ restore_history_directories(task) if task
+ end
+
+ # Remove the dependent association before removing its referenced table.
+ remove_reference :overseer_assessments, :submission_history
+
+ # Restore upload requirements to their shape before this migration.
+ MigrationTaskDefinition.find_each do |task_definition|
+ requirements = JSON.parse(task_definition.upload_requirements.presence || '[]')
+ next unless requirements.is_a?(Array)
+
+ requirements.each do |requirement|
+ requirement.delete('submission_history') if requirement.is_a?(Hash)
+ end
+
+ task_definition.update_columns(upload_requirements: requirements.to_json)
+ rescue JSON::ParserError
+ next
+ end
+
+ # Submission-history rows are no longer needed after the association and
+ # upload-requirement configuration have been removed.
+ drop_table :submission_histories
+ end
+
+ private
+
+ def archive_existing_history_directories(task)
+ task_path = FileHelper.task_submission_identifier_path(:done, task)
+ return unless Dir.exist?(task_path)
+
+ directories = Dir.children(task_path).filter_map do |name|
+ path = File.join(task_path, name)
+ [name, path] if File.directory?(path)
+ end
+ return if directories.empty?
+
+ archive_path = File.join(task_path, 'history.zip')
+ temporary_path = "#{archive_path}.tmp-#{SecureRandom.hex(8)}"
+
+ Zip::File.open(temporary_path, create: true) do |archive|
+ copy_zip_entries(archive_path, archive) if File.exist?(archive_path)
+ directories.each { |name, path| add_directory_to_zip(archive, name, path) }
+ end
+
+ FileUtils.mv(temporary_path, archive_path)
+ directories.each { |directory| FileUtils.rm_rf(directory.last) }
+ ensure
+ FileUtils.rm_f(temporary_path) if temporary_path
+ end
+
+ def restore_history_directories(task)
+ task_path = FileHelper.task_submission_identifier_path(:done, task)
+ archive_path = File.join(task_path, 'history.zip')
+ return unless File.exist?(archive_path)
+
+ submission_entries = Hash.new { |entries, timestamp| entries[timestamp] = [] }
+ Zip::File.open(archive_path) do |archive|
+ archive.each do |entry|
+ next if entry.name_is_directory?
+
+ timestamp, relative_path = entry.name.split('/', 2)
+ next unless relative_path
+
+ if relative_path.start_with?("#{task.id}/")
+ submission_entries[timestamp] << [relative_path, entry.get_input_stream.read]
+ else
+ destination = File.join(task_path, timestamp, relative_path)
+ FileUtils.mkdir_p(File.dirname(destination))
+ entry.extract(destination) { true }
+ end
+ end
+ end
+
+ submission_entries.each do |timestamp, entries|
+ timestamp_path = File.join(task_path, timestamp)
+ FileUtils.mkdir_p(timestamp_path)
+ Zip::File.open(File.join(timestamp_path, 'submission.zip'), create: true) do |submission_zip|
+ entries.each do |relative_path, contents|
+ submission_zip.get_output_stream(relative_path) { |output| output.write(contents) }
+ end
+ end
+ end
+
+ FileUtils.rm_f(archive_path)
+ end
+
+ def add_directory_to_zip(archive, root_name, path)
+ archive.mkdir("#{root_name}/") unless archive.find_entry("#{root_name}/")
+
+ Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH).sort.each do |source|
+ next if ['.', '..'].include?(File.basename(source))
+
+ relative_path = File.join(root_name, source.delete_prefix("#{path}/"))
+ if File.directory?(source)
+ archive.mkdir("#{relative_path}/") unless archive.find_entry("#{relative_path}/")
+ elsif File.basename(source) == 'submission.zip'
+ add_submission_zip_to_history(archive, root_name, source)
+ else
+ archive.add(relative_path, source)
+ end
+ end
+ end
+
+ def add_submission_zip_to_history(archive, root_name, submission_path)
+ Zip::File.open(submission_path) do |submission_zip|
+ submission_zip.each do |entry|
+ next if entry.name_is_directory?
+
+ archive.get_output_stream(File.join(root_name, entry.name)) do |output|
+ entry.get_input_stream { |input| IO.copy_stream(input, output) }
+ end
+ end
+ end
+ end
+
+ def copy_zip_entries(source_path, destination)
+ Zip::File.open(source_path) do |source|
+ source.each do |entry|
+ if entry.name_is_directory?
+ destination.mkdir(entry.name) unless destination.find_entry(entry.name)
+ else
+ destination.get_output_stream(entry.name) do |output|
+ entry.get_input_stream { |input| IO.copy_stream(input, output) }
+ end
+ end
+ end
+ end
+ end
+end
+# rubocop:enable Rails/SkipsModelValidations
diff --git a/db/migrate/20260618001447_create_engagements.rb b/db/migrate/20260618001447_create_engagements.rb
new file mode 100644
index 0000000000..c66c8a759f
--- /dev/null
+++ b/db/migrate/20260618001447_create_engagements.rb
@@ -0,0 +1,27 @@
+class CreateEngagements < ActiveRecord::Migration[8.0]
+ def change
+ create_table :engagements do |t|
+ t.references :project, null: false, index: true
+ t.references :user, null: false, index: true
+ t.string :engagement_type, null: false
+ t.text :note, null: false
+ t.datetime :occurred_at, null: false
+ t.text :evidence_url
+ t.string :content_type
+ t.string :attachment_extension
+
+ t.timestamps
+ end
+
+ create_table :engagement_comments do |t|
+ t.references :engagement, null: false, index: true
+ t.references :user, null: false, index: true
+ t.references :reply_to, null: true, index: true
+ t.text :comment, null: false
+
+ t.timestamps
+ end
+
+ add_index :engagements, [:project_id, :occurred_at]
+ end
+end
diff --git a/db/migrate/20260618033139_add_grade_values_to_units.rb b/db/migrate/20260618033139_add_grade_values_to_units.rb
new file mode 100644
index 0000000000..1cc8ca1b4c
--- /dev/null
+++ b/db/migrate/20260618033139_add_grade_values_to_units.rb
@@ -0,0 +1,17 @@
+class AddGradeValuesToUnits < ActiveRecord::Migration[8.0]
+ DEFAULT_GRADE_VALUES = [0, 1, 2, 3].to_json
+
+ def up
+ add_column :units, :grade_values, :json
+ execute "UPDATE units SET grade_values = #{connection.quote(DEFAULT_GRADE_VALUES)}"
+
+ change_column_null :task_definition_grade_due_dates, :target_due_date, true
+ add_column :task_definition_grade_due_dates, :start_date, :datetime, null: true unless column_exists?(:task_definition_grade_due_dates, :start_date)
+ end
+
+ def down
+ remove_column :task_definition_grade_due_dates, :start_date if column_exists?(:task_definition_grade_due_dates, :start_date)
+ change_column_null :task_definition_grade_due_dates, :target_due_date, false
+ remove_column :units, :grade_values
+ end
+end
diff --git a/db/migrate/20260625055323_create_task_completion_snapshots.rb b/db/migrate/20260625055323_create_task_completion_snapshots.rb
new file mode 100644
index 0000000000..7b66ae68d4
--- /dev/null
+++ b/db/migrate/20260625055323_create_task_completion_snapshots.rb
@@ -0,0 +1,12 @@
+class CreateTaskCompletionSnapshots < ActiveRecord::Migration[8.0]
+ def change
+ create_table :task_completion_snapshots do |t|
+ t.references :unit, null: false
+ t.string :snapshot_timestamp, null: false
+
+ t.timestamps
+ end
+
+ add_index :task_completion_snapshots, [:unit_id, :snapshot_timestamp], unique: true
+ end
+end
diff --git a/db/migrate/20260709014859_add_rediscuss_status.rb b/db/migrate/20260709014859_add_rediscuss_status.rb
new file mode 100644
index 0000000000..509144a88c
--- /dev/null
+++ b/db/migrate/20260709014859_add_rediscuss_status.rb
@@ -0,0 +1,23 @@
+class AddRediscussStatus < ActiveRecord::Migration[8.0]
+ DESCRIPTION = "You attempted to discuss this task, but it was not adequate. " \
+ "Brush up your knowledge and return for another discussion " \
+ "to get the task signed off.".freeze
+
+ def up
+ status = TaskStatus.find_by(id: 15) || TaskStatus.find_by(name: "Rediscuss")
+
+ if status
+ status.update!(name: "Rediscuss", description: DESCRIPTION)
+ else
+ TaskStatus.create!(id: 15, name: "Rediscuss", description: DESCRIPTION)
+ end
+
+ Rails.cache.delete("task_statuses/15")
+ end
+
+ def down
+ status = TaskStatus.find_by(id: 15, name: "Rediscuss")
+ status&.destroy!
+ Rails.cache.delete("task_statuses/15")
+ end
+end
diff --git a/db/migrate/20260713232535_add_unit_content_management.rb b/db/migrate/20260713232535_add_unit_content_management.rb
new file mode 100644
index 0000000000..076a54b8d6
--- /dev/null
+++ b/db/migrate/20260713232535_add_unit_content_management.rb
@@ -0,0 +1,31 @@
+class AddUnitContentManagement < ActiveRecord::Migration[8.0]
+ def change
+ create_table :unit_content_sites do |t|
+ t.references :unit, null: false
+ t.string :name, null: false
+ t.string :original_filename, null: false
+ t.string :archive_path, null: false
+ t.string :root_dir, null: false, default: '/'
+ t.boolean :is_main, null: false, default: false
+
+ t.timestamps
+ end
+
+ add_index :unit_content_sites, [:unit_id, :name], unique: true
+
+ create_table :unit_content_links do |t|
+ t.references :unit, null: false
+ t.references :unit_content_site, null: false
+ t.string :context_type, null: false
+ t.string :context_key, null: false
+ t.string :route, null: false, default: '/'
+
+ t.timestamps
+ end
+
+ add_index :unit_content_links,
+ [:unit_id, :context_type, :context_key],
+ unique: true,
+ name: 'index_unit_content_links_on_context'
+ end
+end
diff --git a/db/migrate/20260722015317_add_activity_timestamps.rb b/db/migrate/20260722015317_add_activity_timestamps.rb
new file mode 100644
index 0000000000..fe781cc5f8
--- /dev/null
+++ b/db/migrate/20260722015317_add_activity_timestamps.rb
@@ -0,0 +1,6 @@
+class AddActivityTimestamps < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :last_access_at, :datetime
+ add_column :projects, :last_viewed_at, :datetime
+ end
+end
diff --git a/db/migrate/20260722030000_add_activity_days_to_communication_conditions.rb b/db/migrate/20260722030000_add_activity_days_to_communication_conditions.rb
new file mode 100644
index 0000000000..14adb011b5
--- /dev/null
+++ b/db/migrate/20260722030000_add_activity_days_to_communication_conditions.rb
@@ -0,0 +1,5 @@
+class AddActivityDaysToCommunicationConditions < ActiveRecord::Migration[8.0]
+ def change
+ add_column :communication_conditions, :activity_days, :integer
+ end
+end
diff --git a/db/migrate/20260722065239_add_discuss_timeout.rb b/db/migrate/20260722065239_add_discuss_timeout.rb
new file mode 100644
index 0000000000..413de251e5
--- /dev/null
+++ b/db/migrate/20260722065239_add_discuss_timeout.rb
@@ -0,0 +1,14 @@
+class AddDiscussTimeout < ActiveRecord::Migration[8.0]
+ def change
+ add_column :units, :discuss_timeout_enabled, :boolean, null: false, default: false
+ add_column :units, :discuss_timeout_warning_days, :integer, null: false, default: 7
+ add_column :units, :discuss_timeout_expire_days, :integer, null: false, default: 14
+ add_column :tasks, :moved_to_discuss_at, :datetime
+ add_column :tasks, :notified_discuss_warning_at, :datetime
+ add_column :tasks, :notified_discuss_expiry_at, :datetime
+
+ add_index :tasks, [:task_status_id, :moved_to_discuss_at]
+ add_index :tasks, [:task_status_id, :notified_discuss_warning_at]
+ add_index :tasks, [:task_status_id, :notified_discuss_expiry_at]
+ end
+end
diff --git a/db/migrate/20260724015355_add_submitted_portfolio_to_communication_conditions.rb b/db/migrate/20260724015355_add_submitted_portfolio_to_communication_conditions.rb
new file mode 100644
index 0000000000..7deb42a5c5
--- /dev/null
+++ b/db/migrate/20260724015355_add_submitted_portfolio_to_communication_conditions.rb
@@ -0,0 +1,5 @@
+class AddSubmittedPortfolioToCommunicationConditions < ActiveRecord::Migration[8.0]
+ def change
+ add_column :communication_conditions, :submitted_portfolio, :boolean
+ end
+end
diff --git a/db/migrate/20260728033837_create_engagement_projects.rb b/db/migrate/20260728033837_create_engagement_projects.rb
new file mode 100644
index 0000000000..1a32df4b5f
--- /dev/null
+++ b/db/migrate/20260728033837_create_engagement_projects.rb
@@ -0,0 +1,11 @@
+class CreateEngagementProjects < ActiveRecord::Migration[8.0]
+ def change
+ create_table :engagement_projects do |t|
+ t.references :engagement, null: false, index: true
+ t.references :project, null: false, index: true
+ t.timestamps
+ end
+
+ add_index :engagement_projects, [:engagement_id, :project_id], unique: true
+ end
+end
diff --git a/db/migrate/20260812020302_add_campus_ids_to_breaks.rb b/db/migrate/20260812020302_add_campus_ids_to_breaks.rb
new file mode 100644
index 0000000000..23072560a3
--- /dev/null
+++ b/db/migrate/20260812020302_add_campus_ids_to_breaks.rb
@@ -0,0 +1,5 @@
+class AddCampusIdsToBreaks < ActiveRecord::Migration[8.0]
+ def change
+ add_column :breaks, :campus_ids, :json
+ end
+end
diff --git a/db/migrate/20260817055309_add_tutorial_duration.rb b/db/migrate/20260817055309_add_tutorial_duration.rb
new file mode 100644
index 0000000000..33af5374fd
--- /dev/null
+++ b/db/migrate/20260817055309_add_tutorial_duration.rb
@@ -0,0 +1,5 @@
+class AddTutorialDuration < ActiveRecord::Migration[8.0]
+ def change
+ add_column :tutorials, :duration_minutes, :integer, default: 120, null: false
+ end
+end
diff --git a/db/migrate/20260820043724_add_label_and_days_to_breaks.rb b/db/migrate/20260820043724_add_label_and_days_to_breaks.rb
new file mode 100644
index 0000000000..c84bc37e6f
--- /dev/null
+++ b/db/migrate/20260820043724_add_label_and_days_to_breaks.rb
@@ -0,0 +1,21 @@
+class AddLabelAndDaysToBreaks < ActiveRecord::Migration[8.0]
+ def up
+ add_column :breaks, :label, :string
+ add_column :breaks, :number_of_days, :integer
+
+ execute 'UPDATE breaks SET number_of_days = number_of_weeks * 7'
+
+ change_column_null :breaks, :number_of_days, false
+ remove_column :breaks, :number_of_weeks
+ end
+
+ def down
+ add_column :breaks, :number_of_weeks, :integer
+
+ execute 'UPDATE breaks SET number_of_weeks = CEIL(number_of_days / 7)'
+
+ change_column_null :breaks, :number_of_weeks, false
+ remove_column :breaks, :number_of_days
+ remove_column :breaks, :label
+ end
+end
diff --git a/db/migrate/20260820054718_add_group_references_to_communication_conditions.rb b/db/migrate/20260820054718_add_group_references_to_communication_conditions.rb
new file mode 100644
index 0000000000..b1c1a82897
--- /dev/null
+++ b/db/migrate/20260820054718_add_group_references_to_communication_conditions.rb
@@ -0,0 +1,6 @@
+class AddGroupReferencesToCommunicationConditions < ActiveRecord::Migration[8.0]
+ def change
+ add_reference :communication_conditions, :group_set
+ add_reference :communication_conditions, :group
+ end
+end
diff --git a/db/migrate/20260820063351_create_comment_read_cursors.rb b/db/migrate/20260820063351_create_comment_read_cursors.rb
new file mode 100644
index 0000000000..82cdf101a5
--- /dev/null
+++ b/db/migrate/20260820063351_create_comment_read_cursors.rb
@@ -0,0 +1,44 @@
+class CreateCommentReadCursors < ActiveRecord::Migration[8.0]
+ def up
+ create_cursor_table
+ backfill_cursors
+ end
+
+ def down
+ drop_table :comment_read_cursors
+ end
+
+ private
+
+ def create_cursor_table
+ create_table :comment_read_cursors do |t|
+ t.references :task, null: false
+ t.references :user, null: false
+ t.references :last_read_comment, null: false
+ t.datetime :read_at, null: false
+
+ t.timestamps
+ t.index [:task_id, :user_id], unique: true
+ end
+ end
+
+ def backfill_cursors
+ say_with_time 'Backfilling comment read cursors' do
+ execute <<~SQL
+ INSERT INTO comment_read_cursors
+ (task_id, user_id, last_read_comment_id, read_at, created_at, updated_at)
+ SELECT
+ task_comments.task_id,
+ comments_read_receipts.user_id,
+ MAX(comments_read_receipts.task_comment_id),
+ MAX(comments_read_receipts.updated_at),
+ MIN(comments_read_receipts.created_at),
+ MAX(comments_read_receipts.updated_at)
+ FROM comments_read_receipts
+ INNER JOIN task_comments
+ ON task_comments.id = comments_read_receipts.task_comment_id
+ GROUP BY task_comments.task_id, comments_read_receipts.user_id
+ SQL
+ end
+ end
+end
diff --git a/db/migrate/20260820063400_add_attention_audience_to_task_comments.rb b/db/migrate/20260820063400_add_attention_audience_to_task_comments.rb
new file mode 100644
index 0000000000..469a93c0eb
--- /dev/null
+++ b/db/migrate/20260820063400_add_attention_audience_to_task_comments.rb
@@ -0,0 +1,5 @@
+class AddAttentionAudienceToTaskComments < ActiveRecord::Migration[8.0]
+ def change
+ add_column :task_comments, :attention_audience, :integer
+ end
+end
diff --git a/db/migrate/20260902030307_add_pause_week_count_to_breaks.rb b/db/migrate/20260902030307_add_pause_week_count_to_breaks.rb
new file mode 100644
index 0000000000..1548288c03
--- /dev/null
+++ b/db/migrate/20260902030307_add_pause_week_count_to_breaks.rb
@@ -0,0 +1,15 @@
+class AddPauseWeekCountToBreaks < ActiveRecord::Migration[8.0]
+ def up
+ # Defaults to true to preserve the existing behaviour, where every break
+ # pauses the teaching period week count.
+ add_column :breaks, :pause_week_count, :boolean, default: true, null: false
+
+ # Only whole week breaks can pause the week count - clear the flag on any
+ # break that is not a multiple of 7 days so existing records stay valid.
+ execute 'UPDATE breaks SET pause_week_count = FALSE WHERE number_of_days % 7 <> 0'
+ end
+
+ def down
+ remove_column :breaks, :pause_week_count
+ end
+end
diff --git a/db/migrate/20260909002511_add_content_version_to_unit_content_sites.rb b/db/migrate/20260909002511_add_content_version_to_unit_content_sites.rb
new file mode 100644
index 0000000000..4b4ca2111a
--- /dev/null
+++ b/db/migrate/20260909002511_add_content_version_to_unit_content_sites.rb
@@ -0,0 +1,12 @@
+class AddContentVersionToUnitContentSites < ActiveRecord::Migration[8.0]
+ def up
+ return if column_exists?(:unit_content_sites, :content_version)
+
+ add_column :unit_content_sites, :content_version, :string, limit: 64
+ say 'Run `rails unit_content_sites:extract_all` to populate versions and extract archives.'
+ end
+
+ def down
+ remove_column :unit_content_sites, :content_version if column_exists?(:unit_content_sites, :content_version)
+ end
+end
diff --git a/db/migrate/20260916003706_add_lms_integration_to_units.rb b/db/migrate/20260916003706_add_lms_integration_to_units.rb
new file mode 100644
index 0000000000..e865397eed
--- /dev/null
+++ b/db/migrate/20260916003706_add_lms_integration_to_units.rb
@@ -0,0 +1,44 @@
+class AddLmsIntegrationToUnits < ActiveRecord::Migration[8.0]
+ def change
+ create_table :lms_integrations do |t|
+ t.references :unit, null: false, index: { unique: true }
+ # Where course data comes from, so other sources such as a direct Moodle web service can be added later.
+ t.string :source, null: false, default: 'lti'
+ t.bigint :assignment_id
+ t.string :assignment_name
+ t.boolean :fetch_extensions, null: false, default: false
+ t.boolean :auto_sync_students, null: false, default: false
+ t.boolean :withdraw_missing_students, null: false, default: false
+ t.boolean :auto_sync_extensions, null: false, default: false
+ t.boolean :group_mapping_enabled, null: false, default: false
+ t.boolean :skip_ungraded, null: false, default: true
+ t.boolean :send_grade_rationale, null: false, default: false
+ t.boolean :validated, null: false, default: false
+ t.datetime :validated_at
+ # Set by the first failed scheduled sync and cleared by the next one that succeeds
+ t.datetime :auto_sync_failing_since
+ t.text :auto_sync_last_error
+
+ t.timestamps
+ end
+
+ create_table :lms_group_mappings do |t|
+ t.references :lms_integration, null: false
+ t.bigint :lms_group_id, null: false
+ t.string :lms_group_name, null: false
+ t.string :target_type, null: false
+ t.references :group_set
+ t.bigint :group_id, index: true
+ t.references :campus
+ t.references :tutorial_stream
+ t.references :tutorial
+ t.boolean :create_if_missing, null: false, default: false
+
+ t.timestamps
+ end
+
+ add_index :lms_group_mappings,
+ [:lms_integration_id, :lms_group_id],
+ name: 'index_lms_group_mappings_on_integration_and_group'
+ end
+end
diff --git a/db/migrate/20261015040755_add_plain_text_to_communication_actions.rb b/db/migrate/20261015040755_add_plain_text_to_communication_actions.rb
new file mode 100644
index 0000000000..e370a5c179
--- /dev/null
+++ b/db/migrate/20261015040755_add_plain_text_to_communication_actions.rb
@@ -0,0 +1,5 @@
+class AddPlainTextToCommunicationActions < ActiveRecord::Migration[8.0]
+ def change
+ add_column :communication_actions, :plain_text, :boolean, default: false, null: false
+ end
+end
diff --git a/db/migrate/20261023034838_add_last_teaching_date_to_teaching_periods_and_units.rb b/db/migrate/20261023034838_add_last_teaching_date_to_teaching_periods_and_units.rb
new file mode 100644
index 0000000000..ba592251bb
--- /dev/null
+++ b/db/migrate/20261023034838_add_last_teaching_date_to_teaching_periods_and_units.rb
@@ -0,0 +1,17 @@
+class AddLastTeachingDateToTeachingPeriodsAndUnits < ActiveRecord::Migration[8.0]
+ def up
+ add_column :teaching_periods, :last_teaching_date, :date
+ add_column :units, :last_teaching_date, :date
+
+ execute 'UPDATE teaching_periods SET last_teaching_date = DATE(end_date)'
+ execute 'UPDATE units SET last_teaching_date = DATE(end_date)'
+
+ change_column_null :teaching_periods, :last_teaching_date, false
+ change_column_null :units, :last_teaching_date, false
+ end
+
+ def down
+ remove_column :units, :last_teaching_date
+ remove_column :teaching_periods, :last_teaching_date
+ end
+end
diff --git a/db/migrate/20261026014153_add_staff_managed_task_to_task_definitions.rb b/db/migrate/20261026014153_add_staff_managed_task_to_task_definitions.rb
new file mode 100644
index 0000000000..85bdfff67f
--- /dev/null
+++ b/db/migrate/20261026014153_add_staff_managed_task_to_task_definitions.rb
@@ -0,0 +1,5 @@
+class AddStaffManagedTaskToTaskDefinitions < ActiveRecord::Migration[8.0]
+ def change
+ add_column :task_definitions, :staff_managed_task, :boolean, default: false, null: false
+ end
+end
diff --git a/db/migrate/20261026053206_add_content_type_to_staff_notes.rb b/db/migrate/20261026053206_add_content_type_to_staff_notes.rb
new file mode 100644
index 0000000000..aedbe7a38d
--- /dev/null
+++ b/db/migrate/20261026053206_add_content_type_to_staff_notes.rb
@@ -0,0 +1,5 @@
+class AddContentTypeToStaffNotes < ActiveRecord::Migration[8.0]
+ def change
+ add_column :staff_notes, :content_type, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 73fc5ebd58..45c5eafb15 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.0].define(version: 2026_03_22_230239) do
+ActiveRecord::Schema[8.0].define(version: 2026_10_26_053206) do
create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "name", null: false
t.string "abbreviation", null: false
@@ -33,11 +33,15 @@
create_table "breaks", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.datetime "start_date", null: false
- t.integer "number_of_weeks", null: false
t.bigint "teaching_period_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.text "campus_ids", size: :long, collation: "utf8mb4_bin"
+ t.string "label"
+ t.integer "number_of_days", null: false
+ t.boolean "pause_week_count", default: true, null: false
t.index ["teaching_period_id"], name: "index_breaks_on_teaching_period_id"
+ t.check_constraint "json_valid(`campus_ids`)", name: "campus_ids"
end
create_table "campuses", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
@@ -63,6 +67,19 @@
t.index ["tutor_id"], name: "index_chip_usages_on_tutor_id"
end
+ create_table "comment_read_cursors", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "task_id", null: false
+ t.bigint "user_id", null: false
+ t.bigint "last_read_comment_id", null: false
+ t.datetime "read_at", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["last_read_comment_id"], name: "index_comment_read_cursors_on_last_read_comment_id"
+ t.index ["task_id", "user_id"], name: "index_comment_read_cursors_on_task_id_and_user_id", unique: true
+ t.index ["task_id"], name: "index_comment_read_cursors_on_task_id"
+ t.index ["user_id"], name: "index_comment_read_cursors_on_user_id"
+ end
+
create_table "comments_read_receipts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "task_comment_id", null: false
t.bigint "user_id", null: false
@@ -73,6 +90,99 @@
t.index ["user_id"], name: "index_comments_read_receipts_on_user_id"
end
+ create_table "communication_actions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.string "type", null: false
+ t.bigint "communication_rule_id", null: false
+ t.string "subject"
+ t.text "body"
+ t.boolean "email_tutors", default: false, null: false
+ t.boolean "email_convenors", default: false, null: false
+ t.integer "target_grade"
+ t.bigint "task_definition_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "plain_text", default: false, null: false
+ t.index ["communication_rule_id"], name: "index_communication_actions_on_communication_rule_id"
+ t.index ["task_definition_id"], name: "index_communication_actions_on_task_definition_id"
+ t.index ["type"], name: "index_communication_actions_on_type"
+ end
+
+ create_table "communication_conditions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.string "type", null: false
+ t.bigint "communication_id", null: false
+ t.integer "target_grade"
+ t.bigint "task_definition_id"
+ t.text "task_statuses", size: :long, collation: "utf8mb4_bin"
+ t.datetime "last_sign_in_at"
+ t.bigint "tutorial_id"
+ t.bigint "tutorial_stream_id"
+ t.bigint "campus_id"
+ t.integer "task_status_count"
+ t.integer "task_target_grade"
+ t.integer "spec_con_days"
+ t.string "operator", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.integer "activity_days"
+ t.boolean "submitted_portfolio"
+ t.bigint "group_set_id"
+ t.bigint "group_id"
+ t.index ["campus_id"], name: "index_communication_conditions_on_campus_id"
+ t.index ["communication_id"], name: "index_communication_conditions_on_communication_id"
+ t.index ["group_id"], name: "index_communication_conditions_on_group_id"
+ t.index ["group_set_id"], name: "index_communication_conditions_on_group_set_id"
+ t.index ["task_definition_id"], name: "index_communication_conditions_on_task_definition_id"
+ t.index ["tutorial_id"], name: "index_communication_conditions_on_tutorial_id"
+ t.index ["tutorial_stream_id"], name: "index_communication_conditions_on_tutorial_stream_id"
+ t.index ["type"], name: "index_communication_conditions_on_type"
+ t.check_constraint "json_valid(`task_statuses`)", name: "task_statuses"
+ end
+
+ create_table "communication_rules", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "communication_set_id", null: false
+ t.integer "position", default: 0, null: false
+ t.string "name"
+ t.string "operator"
+ t.boolean "send_log_to_convenors", default: false, null: false
+ t.boolean "active", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["communication_set_id"], name: "index_communication_rules_on_communication_set_id"
+ end
+
+ create_table "communication_set_schedules", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "communication_set_id", null: false
+ t.string "name"
+ t.boolean "active", default: true, null: false
+ t.integer "anchor_week", null: false
+ t.string "anchor_day", null: false
+ t.integer "hour", default: 8, null: false
+ t.integer "minute", default: 0, null: false
+ t.string "timezone", default: "UTC", null: false
+ t.string "recurrence", default: "none", null: false
+ t.integer "interval", default: 1, null: false
+ t.integer "repeat_count"
+ t.datetime "until_at"
+ t.text "ice_cube_schedule", size: :long, collation: "utf8mb4_bin"
+ t.datetime "next_run_at"
+ t.datetime "last_run_at"
+ t.datetime "last_enqueued_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["active", "next_run_at"], name: "index_communication_set_schedules_on_active_and_next_run_at"
+ t.index ["communication_set_id"], name: "index_communication_set_schedules_on_communication_set_id"
+ t.check_constraint "json_valid(`ice_cube_schedule`)", name: "ice_cube_schedule"
+ end
+
+ create_table "communication_sets", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "unit_id", null: false
+ t.string "name"
+ t.boolean "active", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["unit_id"], name: "index_communication_sets_on_unit_id"
+ end
+
create_table "d2l_assessment_mappings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "unit_id", null: false
t.string "org_unit_id"
@@ -99,6 +209,44 @@
t.index ["task_definition_id"], name: "index_discussion_prompts_on_task_definition_id"
end
+ create_table "engagement_comments", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "engagement_id", null: false
+ t.bigint "user_id", null: false
+ t.bigint "reply_to_id"
+ t.text "comment", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["engagement_id"], name: "index_engagement_comments_on_engagement_id"
+ t.index ["reply_to_id"], name: "index_engagement_comments_on_reply_to_id"
+ t.index ["user_id"], name: "index_engagement_comments_on_user_id"
+ end
+
+ create_table "engagement_projects", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "engagement_id", null: false
+ t.bigint "project_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["engagement_id", "project_id"], name: "index_engagement_projects_on_engagement_id_and_project_id", unique: true
+ t.index ["engagement_id"], name: "index_engagement_projects_on_engagement_id"
+ t.index ["project_id"], name: "index_engagement_projects_on_project_id"
+ end
+
+ create_table "engagements", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "project_id", null: false
+ t.bigint "user_id", null: false
+ t.string "engagement_type", null: false
+ t.text "note", null: false
+ t.datetime "occurred_at", null: false
+ t.text "evidence_url"
+ t.string "content_type"
+ t.string "attachment_extension"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["project_id", "occurred_at"], name: "index_engagements_on_project_id_and_occurred_at"
+ t.index ["project_id"], name: "index_engagements_on_project_id"
+ t.index ["user_id"], name: "index_engagements_on_user_id"
+ end
+
create_table "feedback_chips", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "type"
t.text "chip_text"
@@ -186,6 +334,49 @@
t.index ["context_id", "context_type"], name: "index_learning_outcomes_on_context_id_and_context_type"
end
+ create_table "lms_group_mappings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "lms_integration_id", null: false
+ t.bigint "lms_group_id", null: false
+ t.string "lms_group_name", null: false
+ t.string "target_type", null: false
+ t.bigint "group_set_id"
+ t.bigint "group_id"
+ t.bigint "campus_id"
+ t.bigint "tutorial_stream_id"
+ t.bigint "tutorial_id"
+ t.boolean "create_if_missing", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["campus_id"], name: "index_lms_group_mappings_on_campus_id"
+ t.index ["group_id"], name: "index_lms_group_mappings_on_group_id"
+ t.index ["group_set_id"], name: "index_lms_group_mappings_on_group_set_id"
+ t.index ["lms_integration_id", "lms_group_id"], name: "index_lms_group_mappings_on_integration_and_group"
+ t.index ["lms_integration_id"], name: "index_lms_group_mappings_on_lms_integration_id"
+ t.index ["tutorial_id"], name: "index_lms_group_mappings_on_tutorial_id"
+ t.index ["tutorial_stream_id"], name: "index_lms_group_mappings_on_tutorial_stream_id"
+ end
+
+ create_table "lms_integrations", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "unit_id", null: false
+ t.string "source", default: "lti", null: false
+ t.bigint "assignment_id"
+ t.string "assignment_name"
+ t.boolean "fetch_extensions", default: false, null: false
+ t.boolean "auto_sync_students", default: false, null: false
+ t.boolean "withdraw_missing_students", default: false, null: false
+ t.boolean "auto_sync_extensions", default: false, null: false
+ t.boolean "group_mapping_enabled", default: false, null: false
+ t.boolean "skip_ungraded", default: true, null: false
+ t.boolean "send_grade_rationale", default: false, null: false
+ t.boolean "validated", default: false, null: false
+ t.datetime "validated_at"
+ t.datetime "auto_sync_failing_since"
+ t.text "auto_sync_last_error"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["unit_id"], name: "index_lms_integrations_on_unit_id", unique: true
+ end
+
create_table "logins", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.datetime "timestamp"
t.bigint "user_id"
@@ -226,6 +417,26 @@
t.index ["task_id"], name: "index_moderated_tasks_on_task_id"
end
+ create_table "overflow_task_claim_logs", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "unit_id", null: false
+ t.bigint "task_id", null: false
+ t.bigint "claimed_by_unit_role_id", null: false
+ t.bigint "claimed_by_user_id", null: false
+ t.bigint "original_tutor_user_id"
+ t.bigint "student_user_id", null: false
+ t.integer "days_awaiting_feedback", null: false
+ t.datetime "claimed_at", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["claimed_by_unit_role_id"], name: "index_overflow_task_claim_logs_on_claimed_by_unit_role_id"
+ t.index ["claimed_by_user_id"], name: "index_overflow_task_claim_logs_on_claimed_by_user_id"
+ t.index ["original_tutor_user_id"], name: "index_overflow_task_claim_logs_on_original_tutor_user_id"
+ t.index ["student_user_id"], name: "index_overflow_task_claim_logs_on_student_user_id"
+ t.index ["task_id"], name: "index_overflow_task_claim_logs_on_task_id"
+ t.index ["unit_id", "claimed_at"], name: "index_overflow_task_claim_logs_on_unit_id_and_claimed_at"
+ t.index ["unit_id"], name: "index_overflow_task_claim_logs_on_unit_id"
+ end
+
create_table "overflow_task_claims", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "task_id", null: false
t.bigint "claimed_by_unit_role_id", null: false
@@ -242,6 +453,10 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "total_steps"
+ t.datetime "student_notified_at"
+ t.bigint "submission_history_id", null: false
+ t.index ["status", "student_notified_at", "updated_at"], name: "index_overseer_assessments_on_status_notified_updated"
+ t.index ["submission_history_id"], name: "index_overseer_assessments_on_submission_history_id", unique: true
t.index ["task_id", "submission_timestamp"], name: "index_overseer_assessments_on_task_id_and_submission_timestamp", unique: true
t.index ["task_id"], name: "index_overseer_assessments_on_task_id"
end
@@ -329,6 +544,7 @@
t.integer "spec_con_days", default: 0, null: false
t.bigint "assessor_id"
t.datetime "portfolio_submission_date"
+ t.datetime "last_viewed_at"
t.index ["assessor_id"], name: "index_projects_on_assessor_id"
t.index ["campus_id"], name: "index_projects_on_campus_id"
t.index ["enrolled"], name: "index_projects_on_enrolled"
@@ -367,12 +583,22 @@
t.bigint "reply_to_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "content_type"
t.index ["project_id"], name: "index_staff_notes_on_project_id"
t.index ["reply_to_id"], name: "index_staff_notes_on_reply_to_id"
t.index ["staff_notes_id"], name: "index_staff_notes_on_staff_notes_id"
t.index ["user_id"], name: "index_staff_notes_on_user_id"
end
+ create_table "submission_histories", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "task_id", null: false
+ t.string "submission_timestamp", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["task_id", "submission_timestamp"], name: "index_submission_histories_on_task_and_timestamp", unique: true
+ t.index ["task_id"], name: "index_submission_histories_on_task_id"
+ end
+
create_table "task_comments", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "task_id", null: false
t.bigint "user_id", null: false
@@ -395,6 +621,7 @@
t.bigint "reply_to_id"
t.bigint "commentable_id"
t.string "commentable_type"
+ t.integer "attention_audience"
t.index ["assessor_id"], name: "index_task_comments_on_assessor_id"
t.index ["commentable_type", "commentable_id"], name: "index_task_comments_on_commentable_type_and_commentable_id"
t.index ["discussion_comment_id"], name: "index_task_comments_on_discussion_comment_id"
@@ -405,6 +632,15 @@
t.index ["user_id"], name: "index_task_comments_on_user_id"
end
+ create_table "task_completion_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "unit_id", null: false
+ t.string "snapshot_timestamp", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["unit_id", "snapshot_timestamp"], name: "idx_on_unit_id_snapshot_timestamp_e923c3ae10", unique: true
+ t.index ["unit_id"], name: "index_task_completion_snapshots_on_unit_id"
+ end
+
create_table "task_definition_grade_due_dates", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "task_definition_id", null: false
t.integer "target_grade", null: false
@@ -450,6 +686,7 @@
t.boolean "use_resources_for_jplag_base_code", default: false, null: false
t.boolean "lock_assessments_to_tutorial_stream", default: false, null: false
t.boolean "requires_discussion", default: false, null: false
+ t.boolean "staff_managed_task", default: false, null: false
t.index ["abbreviation", "unit_id"], name: "index_task_definitions_on_abbreviation_and_unit_id", unique: true
t.index ["group_set_id"], name: "index_task_definitions_on_group_set_id"
t.index ["name", "unit_id"], name: "index_task_definitions_on_name_and_unit_id", unique: true
@@ -546,10 +783,16 @@
t.datetime "target_start_date"
t.datetime "target_due_date"
t.datetime "last_tutor_feedback_at"
+ t.datetime "moved_to_discuss_at"
+ t.datetime "notified_discuss_warning_at"
+ t.datetime "notified_discuss_expiry_at"
t.index ["group_submission_id"], name: "index_tasks_on_group_submission_id"
t.index ["project_id", "task_definition_id"], name: "tasks_uniq_proj_task_def", unique: true
t.index ["project_id"], name: "index_tasks_on_project_id"
t.index ["task_definition_id"], name: "index_tasks_on_task_definition_id"
+ t.index ["task_status_id", "moved_to_discuss_at"], name: "index_tasks_on_task_status_id_and_moved_to_discuss_at"
+ t.index ["task_status_id", "notified_discuss_expiry_at"], name: "index_tasks_on_task_status_id_and_notified_discuss_expiry_at"
+ t.index ["task_status_id", "notified_discuss_warning_at"], name: "index_tasks_on_task_status_id_and_notified_discuss_warning_at"
t.index ["task_status_id"], name: "index_tasks_on_task_status_id"
end
@@ -561,6 +804,7 @@
t.datetime "active_until", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.date "last_teaching_date", null: false
t.index ["period", "year"], name: "index_teaching_periods_on_period_and_year", unique: true
end
@@ -689,6 +933,7 @@
t.integer "capacity", default: -1
t.bigint "campus_id"
t.bigint "tutorial_stream_id"
+ t.integer "duration_minutes", default: 120, null: false
t.index ["abbreviation", "unit_id"], name: "index_tutorials_on_abbreviation_and_unit_id", unique: true
t.index ["campus_id"], name: "index_tutorials_on_campus_id"
t.index ["tutorial_stream_id"], name: "index_tutorials_on_tutorial_stream_id"
@@ -696,6 +941,33 @@
t.index ["unit_role_id"], name: "index_tutorials_on_unit_role_id"
end
+ create_table "unit_content_links", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "unit_id", null: false
+ t.bigint "unit_content_site_id", null: false
+ t.string "context_type", null: false
+ t.string "context_key", null: false
+ t.string "route", default: "/", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["unit_content_site_id"], name: "index_unit_content_links_on_unit_content_site_id"
+ t.index ["unit_id", "context_type", "context_key"], name: "index_unit_content_links_on_context", unique: true
+ t.index ["unit_id"], name: "index_unit_content_links_on_unit_id"
+ end
+
+ create_table "unit_content_sites", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
+ t.bigint "unit_id", null: false
+ t.string "name", null: false
+ t.string "original_filename", null: false
+ t.string "archive_path", null: false
+ t.string "root_dir", default: "/", null: false
+ t.boolean "is_main", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "content_version", limit: 64
+ t.index ["unit_id", "name"], name: "index_unit_content_sites_on_unit_id_and_name", unique: true
+ t.index ["unit_id"], name: "index_unit_content_sites_on_unit_id"
+ end
+
create_table "unit_roles", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "user_id"
t.bigint "tutorial_id"
@@ -743,10 +1015,17 @@
t.boolean "mark_late_submissions_as_assess_in_portfolio", default: false, null: false
t.integer "feedback_warning_threshold_days", default: 5
t.integer "feedback_overflow_threshold_days", default: 7
+ t.boolean "enforce_feedback_before_discussed_in_class", default: false, null: false
+ t.text "grade_values", size: :long, collation: "utf8mb4_bin"
+ t.boolean "discuss_timeout_enabled", default: false, null: false
+ t.integer "discuss_timeout_warning_days", default: 7, null: false
+ t.integer "discuss_timeout_expire_days", default: 14, null: false
+ t.date "last_teaching_date", null: false
t.index ["draft_task_definition_id"], name: "index_units_on_draft_task_definition_id"
t.index ["main_convenor_id"], name: "index_units_on_main_convenor_id"
t.index ["overseer_image_id"], name: "index_units_on_overseer_image_id"
t.index ["teaching_period_id"], name: "index_units_on_teaching_period_id"
+ t.check_constraint "json_valid(`grade_values`)", name: "grade_values"
end
create_table "user_oauth_states", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
@@ -797,6 +1076,7 @@
t.string "tii_eula_version"
t.datetime "tii_eula_date"
t.boolean "tii_eula_version_confirmed", default: false, null: false
+ t.datetime "last_access_at"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["login_id"], name: "index_users_on_login_id", unique: true
t.index ["role_id"], name: "index_users_on_role_id"
diff --git a/gotenberg.Dockerfile b/gotenberg.Dockerfile
new file mode 100644
index 0000000000..6a23edcbbc
--- /dev/null
+++ b/gotenberg.Dockerfile
@@ -0,0 +1,12 @@
+FROM gotenberg/gotenberg:8-libreoffice
+
+USER root
+
+RUN apt-get update \
+ && apt-get install --yes --no-install-recommends curl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY lib/shell/word_document_build.sh /gotenberg/word_document_build.sh
+RUN chmod 755 /gotenberg/word_document_build.sh
+
+USER gotenberg
diff --git a/jplag.Dockerfile b/jplag.Dockerfile
index 1fcf747ce6..52adf9da5d 100644
--- a/jplag.Dockerfile
+++ b/jplag.Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3.23.3
+FROM alpine:3.24.2
ENV JPLAG_VERSION=6.3.0
WORKDIR /jplag
diff --git a/lib/helpers/database_populator.rb b/lib/helpers/database_populator.rb
index 57911a98bc..3b48ef7495 100644
--- a/lib/helpers/database_populator.rb
+++ b/lib/helpers/database_populator.rb
@@ -64,6 +64,7 @@ def initialize(scale = :small)
generate_global_learning_outcomes()
generate_campuses
generate_activity_types
+ generate_overseer_images
end
def generate_teaching_periods
@@ -76,7 +77,7 @@ def generate_teaching_periods
}
tp = TeachingPeriod.create!(data)
- tp.add_break Date.parse('2018-03-30'), 1
+ tp.add_break Date.parse('2018-03-30'), 7
data = {
period: 'T2',
@@ -87,7 +88,7 @@ def generate_teaching_periods
}
tp = TeachingPeriod.create! data
- tp.add_break Date.parse('2018-08-13'), 1
+ tp.add_break Date.parse('2018-08-13'), 7
data = {
period: 'T3',
@@ -98,7 +99,7 @@ def generate_teaching_periods
}
tp = TeachingPeriod.create! data
- tp.add_break Date.parse('2018-12-24'), 2
+ tp.add_break Date.parse('2018-12-24'), 14
end
def generate_global_learning_outcomes
@@ -166,6 +167,18 @@ def generate_activity_types
ActivityType.create! data
end
+ def generate_overseer_images
+ echo_line "--> Generating overseer images"
+
+ overseer_image = OverseerImage.create!(
+ name: 'Bash',
+ tag: 'bash:latest'
+ )
+
+ echo_line "---> Pulling overseer image #{overseer_image.tag}"
+ overseer_image.pull_from_docker
+ end
+
#
# Generate some users. Pass in an optional filter(s) for:
# Role.admin, Role.convenor, Role.tutor, Role.student
diff --git a/lib/shell/latex_build.sh b/lib/shell/latex_build.sh
index c853bdb20f..a729f11414 100644
--- a/lib/shell/latex_build.sh
+++ b/lib/shell/latex_build.sh
@@ -15,7 +15,12 @@ cd work
lualatex -shell-escape -interaction=batchmode -halt-on-error input.tex
RESULT=$?
if [ $RESULT -eq 0 ]; then
- echo "Running lualatex a second time to remove temporary last page..."
+ echo "Running lualatex a second time to remove temporary last page and update references..."
+ lualatex -shell-escape -interaction=batchmode -halt-on-error input.tex
+ RESULT=$?
+fi
+if [ $RESULT -eq 0 ]; then
+ echo "Running lualatex a third time to stabilise page references..."
lualatex -shell-escape -interaction=batchmode -halt-on-error input.tex
RESULT=$?
fi
diff --git a/lib/shell/send_overseer_notifications.sh b/lib/shell/send_overseer_notifications.sh
new file mode 100755
index 0000000000..e5be6b305e
--- /dev/null
+++ b/lib/shell/send_overseer_notifications.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+#Get path to script
+APP_PATH=`echo $0 | awk '{split($0,patharr,"/"); idx=1; while(patharr[idx+1] != "") { if (patharr[idx] != "/") {printf("%s/", patharr[idx]); idx++ }} }'`
+APP_PATH=`cd "$APP_PATH"; pwd`
+
+ROOT_PATH=`cd "$APP_PATH"/../..; pwd`
+
+cd "$ROOT_PATH"
+
+DF_LOG_TO_STDOUT=true rails overseer_notifications:send_failed_assessment_notifications
diff --git a/lib/shell/word_document_build.sh b/lib/shell/word_document_build.sh
new file mode 100755
index 0000000000..f145b14861
--- /dev/null
+++ b/lib/shell/word_document_build.sh
@@ -0,0 +1,106 @@
+#!/bin/sh
+
+set -eu
+
+WORK_ID=${1:-}
+
+if [ -z "$WORK_ID" ]; then
+ echo "Usage: word_document_build.sh " >&2
+ exit 2
+fi
+
+case "$WORK_ID" in
+ *[!A-Za-z0-9_-]*)
+ echo "Invalid Gotenberg work id" >&2
+ exit 2
+ ;;
+esac
+
+WORK_DIR="/workdir/gotenberg/$WORK_ID"
+INPUT_FILE="$WORK_DIR/input.docx"
+OUTPUT_FILE="$WORK_DIR/output.pdf"
+TEMP_OUTPUT="$WORK_DIR/output.pdf.tmp"
+GOTENBERG_URL="http://localhost:3000"
+GOTENBERG_LOG="/tmp/gotenberg.log"
+GOTENBERG_PID=
+
+if [ ! -f "$INPUT_FILE" ]; then
+ echo "Word document input was not found" >&2
+ exit 1
+fi
+
+cleanup() {
+ exit_status=$?
+ trap - EXIT INT TERM
+
+ if [ -n "$GOTENBERG_PID" ] && kill -0 "$GOTENBERG_PID" 2>/dev/null; then
+ kill "$GOTENBERG_PID" 2>/dev/null || true
+ wait "$GOTENBERG_PID" 2>/dev/null || true
+ fi
+
+ rm -f "$TEMP_OUTPUT"
+ exit "$exit_status"
+}
+
+start_gotenberg() {
+ # Docker replaces the image's normal command with this script, so start the
+ # bundled API before making the local conversion request.
+ # Keep the startup banner out of stdout so failures show the real error
+ gotenberg --gotenberg-graceful-shutdown-duration=0s >"$GOTENBERG_LOG" 2>&1 &
+ GOTENBERG_PID=$!
+
+ health_status=0
+ curl \
+ --fail \
+ --silent \
+ --show-error \
+ --retry 30 \
+ --retry-connrefused \
+ --retry-delay 1 \
+ --connect-timeout 1 \
+ --max-time 30 \
+ "$GOTENBERG_URL/health" \
+ >/dev/null 2>&1 || health_status=$?
+
+ if [ "$health_status" -ne 0 ]; then
+ echo "Gotenberg failed to start" >&2
+ tail -n 20 "$GOTENBERG_LOG" >&2 || true
+ exit "$health_status"
+ fi
+}
+
+convert_document() {
+ convert_status=0
+ curl \
+ --fail-with-body \
+ --silent \
+ --show-error \
+ --connect-timeout 5 \
+ --max-time "${WORD_DOCUMENT_CONVERSION_TIMEOUT_SECONDS:-120}" \
+ --request POST \
+ --form "files=@$INPUT_FILE" \
+ --output "$TEMP_OUTPUT" \
+ "$GOTENBERG_URL/forms/libreoffice/convert" || convert_status=$?
+
+ if [ "$convert_status" -ne 0 ]; then
+ # --fail-with-body writes Gotenberg's error response to the output file
+ if [ -s "$TEMP_OUTPUT" ]; then
+ cat "$TEMP_OUTPUT" >&2
+ echo >&2
+ fi
+ grep '"level":"error"' "$GOTENBERG_LOG" >&2 || true
+ exit "$convert_status"
+ fi
+
+ if [ ! -s "$TEMP_OUTPUT" ]; then
+ echo "Gotenberg did not produce a PDF" >&2
+ exit 1
+ fi
+
+ mv "$TEMP_OUTPUT" "$OUTPUT_FILE"
+}
+
+trap cleanup EXIT INT TERM
+
+start_gotenberg
+convert_document
diff --git a/lib/tasks/generate_pdfs.rake b/lib/tasks/generate_pdfs.rake
index f558bd2a1f..5db26d07e7 100644
--- a/lib/tasks/generate_pdfs.rake
+++ b/lib/tasks/generate_pdfs.rake
@@ -140,6 +140,7 @@ namespace :submission do
success = false
begin
# Notify system admin
+ Sentry.capture_exception(e) if defined?(Sentry)
mail = ErrorLogMailer.error_message("Failed portfolio job: #{project.log_details}", "Failed to create portfolio for project #{project.log_details}", e)
mail.deliver if mail.present?
rescue StandardError => e
diff --git a/lib/tasks/init.rake b/lib/tasks/init.rake
index 2023e509a3..4137a32533 100644
--- a/lib/tasks/init.rake
+++ b/lib/tasks/init.rake
@@ -1,4 +1,39 @@
+require 'io/console'
+
namespace :db do
+ def prompt_for_admin_password
+ password = ENV.fetch('DF_INITIAL_ADMIN_PASSWORD', nil)
+ if password.present?
+ unless Devise.password_length.cover?(password.length)
+ raise "DF_INITIAL_ADMIN_PASSWORD must be #{Devise.password_length} characters long"
+ end
+
+ return password
+ end
+
+ console = IO.console
+ if console.nil?
+ raise 'An interactive terminal is required to set the initial admin password. ' \
+ 'Set DF_INITIAL_ADMIN_PASSWORD for non-interactive setup.'
+ end
+
+ loop do
+ password = console.getpass('Enter password for the initial admin account: ')
+ confirmation = console.getpass('Confirm password: ')
+
+ raise 'Password entry was cancelled' if password.nil? || confirmation.nil?
+
+ unless Devise.password_length.cover?(password.length)
+ puts "Password must be #{Devise.password_length} characters long."
+ next
+ end
+
+ return password if password == confirmation
+
+ puts 'Passwords do not match. Try again.'
+ end
+ end
+
#
# Generate roles
#
@@ -42,7 +77,8 @@ namespace :db do
Fail: "You did not successfully demonstrate the required learning in this task.",
"Time Exceeded": "You did not submit or complete the task before the appropriate deadline.",
"Assess in Portfolio": "This task will not be signed off as complete by your tutor, and will be marked directly in your portfolio.",
- "Attention Required": "This task needs to be discussed with your tutor so that you can get back on track."
+ "Attention Required": "This task needs to be discussed with your tutor so that you can get back on track.",
+ Rediscuss: "You attempted to discuss this task, but it was not adequate. Brush up your knowledge and return for another discussion to get the task signed off."
}
statuses.each do |name, desc|
print "."
@@ -73,8 +109,9 @@ namespace :db do
profile[:login_id] ||= username
if AuthenticationHelpers.db_auth?
- profile[:password] = 'password'
- profile[:password_confirmation] = 'password'
+ password = Rails.env.production? ? prompt_for_admin_password : 'password'
+ profile[:password] = password
+ profile[:password_confirmation] = password
end
user = User.create!(profile)
diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake
index f6ba8ebfcb..b4f0cbab37 100644
--- a/lib/tasks/maintenance.rake
+++ b/lib/tasks/maintenance.rake
@@ -1,6 +1,194 @@
require_all 'lib/helpers'
+require 'sidekiq/api'
+# rubocop:disable Metrics/BlockLength
namespace :maintenance do
+ def sidekiq_job_present_in_workers_or_default_queue?(&matcher)
+ Sidekiq::Workers.new.each do |_process_id, _thread_id, work|
+ payload = work['payload'].is_a?(String) ? JSON.parse(work['payload']) : work['payload']
+
+ return true if matcher.call(payload['class'], payload['args'])
+ end
+
+ # The submission jobs checked here all run on the default queue.
+ Sidekiq::Queue.new("default").each do |job|
+ return true if matcher.call(job.klass, job.args)
+ end
+
+ false
+ end
+
+ def accept_submission_job_matches_task?(job_class, job_args, task_id)
+ job_class == 'AcceptSubmissionJob' && job_args.first.to_i == task_id
+ end
+
+ def accept_submission_job_present?(task_id)
+ sidekiq_job_present_in_workers_or_default_queue? do |job_class, job_args|
+ accept_submission_job_matches_task?(job_class, job_args, task_id)
+ end
+ end
+
+ def accept_overseer_job_matches_assessment?(job_class, job_args, overseer_assessment_id)
+ job_class == 'AcceptOverseerJob' && job_args.last.to_i == overseer_assessment_id
+ end
+
+ def accept_overseer_job_present?(overseer_assessment_id)
+ sidekiq_job_present_in_workers_or_default_queue? do |job_class, job_args|
+ accept_overseer_job_matches_assessment?(job_class, job_args, overseer_assessment_id)
+ end
+ end
+
+ def create_submission_history_job_present?(task_id)
+ sidekiq_job_present_in_workers_or_default_queue? do |job_class, job_args|
+ job_class == 'CreateSubmissionHistoryJob' && job_args.first.to_i == task_id
+ end
+ end
+
+ def notify_failed_submission(task, message)
+ if task.project.student.receive_task_notifications
+ begin
+ PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver_now
+ rescue StandardError => e
+ Rails.logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{e.message}"
+ end
+ end
+
+ if defined?(Sentry)
+ Sentry.capture_message(
+ "Cleared abandoned in-process submission for task #{task.id}",
+ level: :info,
+ extra: {
+ task_id: task.id,
+ task_definition: task.task_definition.abbreviation,
+ username: task.project.user.username,
+ detail: message
+ }
+ )
+ end
+
+ begin
+ exception = StandardError.new(message)
+ mail = ErrorLogMailer.error_message('Accept Submission Cleanup', message, exception)
+ mail.deliver_now if mail.present?
+ rescue StandardError => e
+ Rails.logger.error "Failed to send error log to admin for task #{task.id}!\n#{e.message}"
+ end
+ end
+
+ def mark_task_for_resubmission(task, _message)
+ tutor = task.project.tutor_for(task.task_definition)
+
+ task.trigger_transition(trigger: 'fix', by_user: tutor)
+ task.add_text_comment(
+ tutor,
+ "**Automated Comment**: Something went wrong with compiling your submission. Please resubmit the task.",
+ attention_audience: :student
+ )
+ rescue StandardError => e
+ Rails.logger.error "Failed to move task #{task.id} to fix and add automated comment!\n#{e.message}"
+ end
+
+ def mark_task_for_overseer_resubmission(task)
+ tutor = task.project.tutor_for(task.task_definition)
+
+ task.trigger_transition(trigger: 'fix', by_user: tutor)
+ task.add_text_comment(
+ tutor,
+ "**Automated Comment**: Something went wrong while running the automated tests for this submission. Please resubmit the task.",
+ attention_audience: :student
+ )
+ rescue StandardError => e
+ Rails.logger.error "Failed to move task #{task.id} to fix and add Overseer automated comment!\n#{e.message}"
+ end
+
+ def clear_abandoned_submissions!
+ in_process_path = FileHelper.student_work_dir(:in_process)
+ return unless Dir.exist?(in_process_path)
+
+ abandoned_submission_timeout = 10.minutes
+ stale_before = abandoned_submission_timeout.ago
+
+ Dir.foreach(in_process_path) do |entry|
+ next unless entry.match?(/^\d+$/)
+
+ task_path = File.join(in_process_path, entry)
+ next unless File.directory?(task_path)
+ next unless File.mtime(task_path) < stale_before
+
+ task = Task.includes(project: [:user, :unit]).find_by(id: entry.to_i)
+ next if task.nil?
+
+ if accept_submission_job_present?(task.id)
+ Rails.logger.info "Skipping abandoned submission cleanup for task #{task.id} because AcceptSubmissionJob is still running or queued"
+ next
+ end
+
+ message = "Abandoned in-process submission detected for task #{task.log_details}. The stale in-process folder was older than #{abandoned_submission_timeout / 1.minute} minutes with no running or queued AcceptSubmissionJob, has now been cleared, and the task requires resubmission."
+ Rails.logger.error message
+
+ mark_task_for_resubmission(task, message)
+ task.clear_in_process
+ notify_failed_submission(task, message)
+ end
+ end
+
+ def clear_abandoned_overseer_assessments!
+ abandoned_assessment_timeout = 10.minutes
+ stale_before = abandoned_assessment_timeout.ago
+
+ OverseerAssessment
+ .pre_queued
+ .includes(task: [project: :user])
+ .where('created_at < ?', stale_before)
+ .find_each do |assessment|
+ if accept_overseer_job_present?(assessment.id)
+ Rails.logger.info "Skipping abandoned OverseerAssessment cleanup for assessment #{assessment.id} because AcceptOverseerJob is still running or queued"
+ next
+ end
+
+ task = assessment.task
+ message = "Abandoned OverseerAssessment detected for task #{task.log_details}. Assessment #{assessment.id} remained pre_queued for more than #{abandoned_assessment_timeout / 1.minute} minutes with no running or queued AcceptOverseerJob and has been marked failed."
+ Rails.logger.error message
+
+ assessment.update!(status: :failed)
+ mark_task_for_overseer_resubmission(task)
+
+ if defined?(Sentry)
+ Sentry.capture_message(
+ "Marked stale OverseerAssessment failed for task #{task.id}",
+ level: :warning,
+ extra: {
+ task_id: task.id,
+ overseer_assessment_id: assessment.id,
+ task_definition: task.task_definition.abbreviation,
+ username: task.project.user.username,
+ detail: message
+ }
+ )
+ end
+ end
+ end
+
+ def clear_abandoned_submission_history_markers!
+ stale_before = 10.minutes.ago
+ marker_pattern = File.join(FileHelper.root_submission_history_dir, '**', 'pending', '*', 'submission-history')
+
+ Dir.glob(marker_pattern).each do |marker_path|
+ begin
+ marker_mtime = File.mtime(marker_path)
+ rescue Errno::ENOENT
+ next
+ end
+ next unless marker_mtime < stale_before
+
+ task_id = File.basename(File.dirname(marker_path)).to_i
+ next if create_submission_history_job_present?(task_id)
+
+ Rails.logger.error "Clearing abandoned submission history marker for task #{task_id}"
+ FileUtils.rm_f(marker_path)
+ end
+ end
+
desc 'Cleanup temporary files'
task cleanup: [:environment] do
path = FileHelper.tmp_file_dir
@@ -31,6 +219,25 @@ namespace :maintenance do
.find_each(&:destroy!)
AuthToken.destroy_old_tokens
+ BatchFeedbackUpload.remove_expired
+ clear_abandoned_submissions!
+ clear_abandoned_submission_history_markers!
+ clear_abandoned_overseer_assessments!
+ end
+
+ desc 'Clear abandoned in-process submission folders and notify affected users'
+ task clear_abandoned_submissions: [:environment] do
+ clear_abandoned_submissions!
+ end
+
+ desc 'Clear abandoned pre-queued Overseer assessments and request resubmission'
+ task clear_abandoned_overseer_assessments: [:environment] do
+ clear_abandoned_overseer_assessments!
+ end
+
+ desc 'Clear stale submission history markers with no queued or running job'
+ task clear_abandoned_submission_history_markers: [:environment] do
+ clear_abandoned_submission_history_markers!
end
desc 'Remove PDFs from old submissions and archive units'
@@ -74,3 +281,4 @@ namespace :maintenance do
`find #{FileHelper.root_portfolio_dir} -name "*pdf.old" -exec rm {} \;`
end
end
+# rubocop:enable Metrics/BlockLength
diff --git a/lib/tasks/notifications.rake b/lib/tasks/notifications.rake
new file mode 100644
index 0000000000..577b0f3a48
--- /dev/null
+++ b/lib/tasks/notifications.rake
@@ -0,0 +1,7 @@
+namespace :notifications do
+ desc 'Warn students about stale Discuss tasks and expire overdue Discuss tasks'
+ task notify_discuss_timeout: :environment do
+ count = Unit.notify_discuss_timeouts!
+ Rails.logger.info "Discuss timeout notification pass created #{count} comment(s)."
+ end
+end
diff --git a/lib/tasks/overseer_notifications.rake b/lib/tasks/overseer_notifications.rake
new file mode 100644
index 0000000000..2b69ee3f9e
--- /dev/null
+++ b/lib/tasks/overseer_notifications.rake
@@ -0,0 +1,28 @@
+namespace :overseer_notifications do
+ def notify_failed_overseer_assessments!
+ assessments = OverseerAssessment
+ .awaiting_student_failure_notification
+ .includes(task: [{ project: :unit }, :task_definition])
+
+ assessments.group_by(&:project).each do |project, project_assessments|
+ tasks = project_assessments.map(&:task).uniq
+
+ begin
+ mail = PortfolioEvidenceMailer.overseer_assessment_failed(project, tasks)
+ next if mail.blank?
+
+ mail.deliver_now
+ project_assessments.each do |assessment|
+ assessment.update!(student_notified_at: Time.current)
+ end
+ rescue StandardError => e
+ Rails.logger.error "Failed to send overseer assessment email for project #{project.id}!\n#{e.message}"
+ end
+ end
+ end
+
+ desc 'Send overdue overseer assessment failure notifications to students'
+ task send_failed_assessment_notifications: [:environment] do
+ notify_failed_overseer_assessments!
+ end
+end
diff --git a/lib/tasks/unit_content_sites.rake b/lib/tasks/unit_content_sites.rake
new file mode 100644
index 0000000000..0a8f1dffbb
--- /dev/null
+++ b/lib/tasks/unit_content_sites.rake
@@ -0,0 +1,40 @@
+namespace :unit_content_sites do
+ desc 'Populate content versions and extract unit content archives for serving. Safe to re-run; FORCE=1 re-extracts all.'
+ task extract_all: :environment do
+ force = ENV['FORCE'].present?
+ extracted = 0
+ up_to_date = 0
+ failures = []
+
+ UnitContentSite.includes(:unit).find_each do |site|
+ unless site.archive_path.present? && File.file?(site.archive_path)
+ puts "site #{site.id}: archive missing, skipped"
+ next
+ end
+
+ # The version is a hash of the archive and its root dir, so this also
+ # picks up archives that were replaced outside the app.
+ version = UnitContentSite.content_version_for(site.archive_path, site.root_dir)
+
+ if site.content_version == version && File.directory?(site.served_dir) && !force
+ up_to_date += 1
+ next
+ end
+
+ # Set in memory first - the HTML rewrite builds versioned URLs from it -
+ # but only persist once the extraction succeeded. A failure then leaves the
+ # old version stored, so the next run retries instead of assuming success.
+ site.content_version = version
+ site.extract_for_serving!
+ site.save!
+ extracted += 1
+ puts "site #{site.id}: extracted"
+ rescue StandardError => e
+ puts "site #{site.id}: failed - #{e.message}"
+ failures << site.id
+ end
+
+ puts "#{extracted} extracted, #{up_to_date} already up to date, #{failures.length} failed. Nothing deleted."
+ abort "Failed: #{failures.join(', ')}" if failures.any?
+ end
+end
diff --git a/test/api/activity_types_api_test.rb b/test/api/activity_types_api_test.rb
index ca05092413..8c08c1b7af 100644
--- a/test/api/activity_types_api_test.rb
+++ b/test/api/activity_types_api_test.rb
@@ -15,9 +15,9 @@ def test_get_all_activity_types
assert_equal expected_data.count, last_response_body.count
- response_keys = %w(name abbreviation)
+ response_keys = %w[name abbreviation]
- last_response_body.each do | data |
+ last_response_body.each do |data|
expected_data = ActivityType.find(data['id'])
assert_json_matches_model(expected_data, data, response_keys)
end
@@ -28,7 +28,7 @@ def test_get_all_activity_types
def test_admin_can_post_activity_type
# Admin user
admin = FactoryBot.create(:user, :admin)
-
+
# the number of teaching period before post
no_activity_type = ActivityType.count
@@ -42,12 +42,12 @@ def test_admin_can_post_activity_type
# perform the POST
post_json '/api/activity_types', data_to_post
-
- # check if the request get through
+
+ # check if the request get through
assert_equal 201, last_response.status
# check if the details posted match as expected
- response_keys = %w(name abbreviation)
+ response_keys = %w[name abbreviation]
activity_type = ActivityType.find(last_response_body['id'])
assert_json_matches_model(activity_type, last_response_body, response_keys)
@@ -56,14 +56,14 @@ def test_admin_can_post_activity_type
assert_equal data_to_post[:activity_type]['abbreviation'], activity_type.abbreviation
# check if one more activity type is created
- assert_equal no_activity_type +1, ActivityType.count
+ assert_equal no_activity_type + 1, ActivityType.count
end
# 2: Convenor cannot create a new activity type
def test_convenor_cannot_post_activity_type
# Convenor user
convenor = FactoryBot.create(:user, :convenor)
-
+
# the number of teaching period before post
no_activity_type = ActivityType.count
@@ -74,11 +74,11 @@ def test_convenor_cannot_post_activity_type
# auth_token and username added to header
add_auth_header_for(user: convenor)
-
+
# perform the POST
post_json '/api/activity_types', data_to_post
-
- # check if the request get through
+
+ # check if the request get through
assert_equal 403, last_response.status
# check if no more activity type is created
@@ -89,7 +89,7 @@ def test_convenor_cannot_post_activity_type
def test_tutor_cannot_post_activity_type
# Tutor user
tutor = FactoryBot.create(:user, :tutor)
-
+
# the number of teaching period before post
no_activity_type = ActivityType.count
@@ -100,11 +100,11 @@ def test_tutor_cannot_post_activity_type
# auth_token and username added to header
add_auth_header_for(user: tutor)
-
+
# perform the POST
post_json '/api/activity_types', data_to_post
-
- # check if the request get through
+
+ # check if the request get through
assert_equal 403, last_response.status
# check if no more activity type is created
@@ -119,27 +119,27 @@ def test_admin_can_put_activity_types
# The activity type to be replaced
activity_type = FactoryBot.create(:activity_type)
-
- # Data to replace
+
+ # Data to replace
data_to_put = {
activity_type: FactoryBot.build(:activity_type)
}
# auth_token and username added to header
- add_auth_header_for(user:admin)
+ add_auth_header_for(user: admin)
# Update activity_type with data_to_put
put_json "/api/activity_types/#{activity_type.id}", data_to_put
-
- #check if the request get through
+
+ # check if the request get through
assert_equal 200, last_response.status
# check if the details posted match as expected
- response_keys = %w(name abbreviation)
+ response_keys = %w[name abbreviation]
activity_type_updated = activity_type.reload
assert_json_matches_model(activity_type_updated, last_response_body, response_keys)
- # check if the details in the replaced teaching period match as data set to replace
+ # check if the details in the replaced teaching period match as data set to replace
assert_equal data_to_put[:activity_type]['name'], activity_type_updated.name
assert_equal data_to_put[:activity_type]['abbreviation'], activity_type_updated.abbreviation
end
@@ -151,8 +151,8 @@ def test_convenor_cannot_put_activity_types
# The activity type to be replaced
activity_type = FactoryBot.create(:activity_type)
-
- # Data to replace
+
+ # Data to replace
data_to_put = {
activity_type: FactoryBot.build(:activity_type)
}
@@ -162,8 +162,8 @@ def test_convenor_cannot_put_activity_types
# Update activity_type with data_to_put
put_json "/api/activity_types/#{activity_type.id}", data_to_put
-
- #check if the request get through
+
+ # check if the request get through
assert_equal 403, last_response.status
end
@@ -174,8 +174,8 @@ def test_tutor_cannot_put_activity_types
# The activity type to be replaced
activity_type = FactoryBot.create(:activity_type)
-
- # Data to replace
+
+ # Data to replace
data_to_put = {
activity_type: FactoryBot.build(:activity_type)
}
@@ -185,8 +185,8 @@ def test_tutor_cannot_put_activity_types
# Update activity_type with data_to_put
put_json "/api/activity_types/#{activity_type.id}", data_to_put
-
- #check if the request get through
+
+ # check if the request get through
assert_equal 403, last_response.status
end
@@ -209,21 +209,21 @@ def test_student_cannot_post_activity_type
post_json '/api/activity_types', data_to_post
# Check if the post does not get through
- assert_equal 403, last_response.status
+ assert_equal 403, last_response.status
# Check if the number of activity type is the same as initially
assert_equal ActivityType.count, number_of_activity_type
- end
+ end
def test_student_cannot_put_activity_type
# A user with student role which does not have premision to put a activity type
user = FactoryBot.create(:user, :student)
- #Create a new activity type
+ # Create a new activity type
activity_type = FactoryBot.create(:activity_type)
# Number of Activity type before put new activity type
- number_of_activity_type = ActivityType.count
+ number_of_activity_type = ActivityType.count
# Create a dummy activity type
data_to_put = {
@@ -235,9 +235,9 @@ def test_student_cannot_put_activity_type
# Perform PUT, but the student user does not have permissions to put it.
put_json "/api/activity_types/#{activity_type.id}", data_to_put
-
+
# Check if the put does not get through
- assert_equal 403, last_response.status
+ assert_equal 403, last_response.status
# Check if the number of activity type is the same as initially
assert_equal ActivityType.count, number_of_activity_type
@@ -246,42 +246,41 @@ def test_student_cannot_put_activity_type
def test_delete_activity_type
# Create a activity type
activity_type = FactoryBot.create(:activity_type)
-
- #number of activity type before delete
+
+ # number of activity type before delete
number_of_ativity_type = ActivityType.count
-
# auth_token and username added to header
add_auth_header_for(user: User.first)
-
+
# perform the delete
- delete_json "/api/activity_types/#{activity_type.id}"
-
+ delete_json "/api/activity_types/#{activity_type.id}"
+
# Check if the delete get through
assert_equal 200, last_response.status
# Check delete if success
- assert_equal ActivityType.count, number_of_ativity_type-1
+ assert_equal ActivityType.count, number_of_ativity_type - 1
# Check that you can't find the deleted id
- refute ActivityType.exists?(activity_type.id)
+ assert_not ActivityType.exists?(activity_type.id)
end
def test_student_cannot_delete_activity_type
# A user with student role which does not have permision to delete a activity type
user = FactoryBot.build(:user, :student)
-
+
# create a activity type to delete
- activity_type = FactoryBot.create (:activity_type)
-
+ activity_type = FactoryBot.create(:activity_type)
+
# number of activity type before delete
number_of_ativity_type = ActivityType.count
-
+
# auth_token and username added to header
add_auth_header_for(user: user)
# perform the delete
- delete_json "/api/activity_types/#{activity_type.id}"
+ delete_json "/api/activity_types/#{activity_type.id}"
# check if the delete does not get through
assert_equal 403, last_response.status
@@ -290,6 +289,6 @@ def test_student_cannot_delete_activity_type
assert_equal ActivityType.count, number_of_ativity_type
# # Check that you still can find the deleted id
- assert ActivityType.exists?(activity_type.id)
+ assert ActivityType.exists?(activity_type.id)
end
end
diff --git a/test/api/auth_test.rb b/test/api/auth_test.rb
index cc3f737601..b107a20e29 100644
--- a/test/api/auth_test.rb
+++ b/test/api/auth_test.rb
@@ -2,9 +2,18 @@
class AuthTest < ActiveSupport::TestCase
include Rack::Test::Methods
+ include ActiveSupport::Testing::TimeHelpers
include TestHelpers::AuthHelper
include TestHelpers::JsonHelper
+ setup do
+ Rack::Attack.enabled = false
+ end
+
+ teardown do
+ Rack::Attack.enabled = true
+ end
+
def app
Rails.application
end
@@ -17,6 +26,45 @@ def app
# --------------------------------------------------------------------------- #
# POST tests
+ def test_auth_post_is_rate_limited
+ original_setting = Doubtfire::Application.config.auth_rate_limit_enabled
+ Doubtfire::Application.config.auth_rate_limit_enabled = true
+ Rack::Attack.cache.store.clear
+ Rack::Attack.enabled = true
+
+ limit = Doubtfire::Application.config.auth_rate_limit
+ credentials = { username: 'aadmin123', password: 'password' }
+
+ limit.times do
+ post_json '/api/auth.json', credentials
+ assert_equal 401, last_response.status
+ end
+
+ post_json '/api/auth.json', credentials
+ assert_equal 429, last_response.status
+ assert_equal 'Too many login attempts. Please try again shortly.', last_response_body['error']
+ assert_operator last_response.headers['Retry-After'].to_i, :>, 0
+ ensure
+ Doubtfire::Application.config.auth_rate_limit_enabled = original_setting
+ Rack::Attack.cache.store.clear
+ end
+
+ def test_auth_post_is_not_rate_limited_when_disabled
+ original_setting = Doubtfire::Application.config.auth_rate_limit_enabled
+ Doubtfire::Application.config.auth_rate_limit_enabled = false
+ Rack::Attack.cache.store.clear
+ Rack::Attack.enabled = true
+
+ credentials = { username: 'aadmin123', password: 'password' }
+ (Doubtfire::Application.config.auth_rate_limit + 1).times do
+ post_json '/api/auth.json', credentials
+ assert_equal 401, last_response.status
+ end
+ ensure
+ Doubtfire::Application.config.auth_rate_limit_enabled = original_setting
+ Rack::Attack.cache.store.clear
+ end
+
# Test POST for new authentication token
def test_auth_post
data_to_post = {
@@ -50,7 +98,7 @@ def test_auth_post
assert_equal 'general', token.token_type
# User has the token - count of matching tokens for that user is 1
- assert_equal 1, expected_auth.auth_tokens.select{|t| t.authentication_token == actual_auth['auth_token']}.count
+ assert_equal 1, expected_auth.auth_tokens.select { |t| t.authentication_token == actual_auth['auth_token'] }.count
# Check we got a refresh token
assert last_response.cookies['refresh_token'], 'Expect refresh token to be set'
@@ -62,6 +110,52 @@ def test_auth_post
assert_match(/username=#{User.first.username};/, last_response.cookies['username'].to_s, 'Expect username to be set')
end
+ # Signing in again shortly before the refresh token expires must issue a new one, rather than
+ # handing back the old cookie that will expire within hours.
+ def test_auth_post_rotates_refresh_token_near_expiry
+ data_to_post = { username: 'aadmin', password: 'password', remember: true }
+ user = User.find_by!(username: 'aadmin')
+ lifetime = Doubtfire::Application.config.refresh_token_expiry
+ first_sign_in = Time.zone.parse('2026-07-21 10:00:00 UTC')
+
+ first_token = travel_to(first_sign_in) do
+ post_json '/api/auth.json', data_to_post
+ user.auth_tokens.where(token_type: :refresh_token).last
+ end
+
+ # Early in its life, the same refresh token is reused
+ travel_to(first_sign_in + 1.day) do
+ post_json '/api/auth.json', data_to_post
+ assert_equal 201, last_response.status
+ assert_match(/refresh_token=#{first_token.authentication_token};/, last_response.cookies['refresh_token'].to_s)
+ end
+
+ # Hours before it expires, a new refresh token with a full lifetime is issued
+ near_expiry = first_token.auth_token_expiry - 6.hours
+ travel_to(near_expiry) do
+ post_json '/api/auth.json', data_to_post
+ assert_equal 201, last_response.status
+
+ new_token = user.auth_tokens.where(token_type: :refresh_token).last
+ assert_not_equal first_token.authentication_token, new_token.authentication_token
+ assert_in_delta((near_expiry + lifetime).to_i, new_token.auth_token_expiry.to_i, 5)
+ assert_match(/refresh_token=#{new_token.authentication_token};/, last_response.cookies['refresh_token'].to_s)
+ end
+ end
+
+ def test_auth_records_sign_in_and_access_time
+ user = User.find_by!(username: 'aadmin')
+ sign_in_time = Time.zone.parse('2026-07-21 10:00:00 UTC')
+
+ travel_to sign_in_time do
+ post_json '/api/auth.json', username: user.username, password: 'password'
+ end
+
+ assert_equal 201, last_response.status
+ assert_equal sign_in_time, user.reload.last_sign_in_at
+ assert_equal sign_in_time, user.last_access_at
+ end
+
def test_auth_no_remember
data_to_post = {
username: 'aadmin',
@@ -88,8 +182,8 @@ def test_fail_username_auth
actual_auth = last_response_body
# Check response body doesn't return 'user' and 'auth_token' values
- refute actual_auth.key?('user'), 'User not expected if auth fails'
- refute actual_auth.key?('auth_token'), 'Auth token not expected if auth fails'
+ assert_not actual_auth.key?('user'), 'User not expected if auth fails'
+ assert_not actual_auth.key?('auth_token'), 'Auth token not expected if auth fails'
# 401 response code means invalid username / password
assert_equal 401, last_response.status
@@ -108,12 +202,43 @@ def test_fail_password_auth
actual_auth = last_response_body
# Check response body doesn't return 'user' and 'auth_token' values
- refute actual_auth.key?('user'), 'User not expected if auth fails'
- refute actual_auth.key?('auth_token'), 'Auth token not expected if auth fails'
+ assert_not actual_auth.key?('user'), 'User not expected if auth fails'
+ assert_not actual_auth.key?('auth_token'), 'Auth token not expected if auth fails'
assert actual_auth.key? 'error'
end
+ # Blank passwords must still be authenticated rather than bypassing the
+ # password check because ActiveSupport considers them not present.
+ def test_fail_blank_password_auth
+ user = User.find_by!(username: 'aadmin')
+ original_token_count = user.auth_tokens.count
+
+ ['', ' ', "\t\n"].each do |password|
+ post_json '/api/auth.json', username: user.username, password: password
+ actual_auth = last_response_body
+
+ assert_equal 401, last_response.status, "Expected #{password.inspect} to be rejected"
+ assert_equal 'Invalid email or password.', actual_auth['error']
+ assert_not actual_auth.key?('user')
+ assert_not actual_auth.key?('auth_token')
+ end
+
+ assert_equal original_token_count, user.auth_tokens.reload.count
+ end
+
+ def test_blank_password_does_not_create_unknown_user
+ username = "unknown-blank-password-user-#{SecureRandom.hex(8)}"
+
+ ['', ' ', "\t\n"].each do |password|
+ post_json '/api/auth.json', username: username, password: password
+
+ assert_equal 401, last_response.status, "Expected #{password.inspect} to be rejected"
+ assert_equal 'Invalid email or password.', last_response_body['error']
+ assert_not User.exists?(username: username)
+ end
+ end
+
# Test auth with empty request body
def test_fail_empty_request
data_to_post = ""
@@ -123,8 +248,8 @@ def test_fail_empty_request
actual_auth = last_response_body
# Check response body doesn't return 'user' and 'auth_token' values
- refute actual_auth.key?('user'), 'User not expected if auth fails'
- refute actual_auth.key?('auth_token'), 'Auth token not expected if auth fails'
+ assert_not actual_auth.key?('user'), 'User not expected if auth fails'
+ assert_not actual_auth.key?('auth_token'), 'Auth token not expected if auth fails'
# 400 response code means missing username and password
assert_equal 400, last_response.status
@@ -231,6 +356,22 @@ def test_refresh_token
assert_equal last_response_body['auth_token'], new_new_token.authentication_token
end
+ def test_refresh_token_updates_access_but_not_sign_in_time
+ user = FactoryBot.create(:user, last_sign_in_at: 1.day.ago, last_access_at: 1.hour.ago)
+ original_sign_in_time = user.last_sign_in_at
+ token = user.generate_authentication_token!(token_type: :refresh_token)
+ access_time = Time.zone.parse('2026-07-21 11:00:00 UTC')
+
+ set_cookie "username=#{user.username}"
+ set_cookie "refresh_token=#{token.authentication_token}"
+
+ travel_to(access_time) { post '/api/auth/access-token' }
+
+ assert_equal 201, last_response.status
+ assert_equal original_sign_in_time, user.reload.last_sign_in_at
+ assert_equal access_time, user.last_access_at
+ end
+
def test_token_signout_works_with_multiple
user = FactoryBot.create(:user)
# Create 2 auth tokens
diff --git a/test/api/batch_feedback_uploads_api_test.rb b/test/api/batch_feedback_uploads_api_test.rb
new file mode 100644
index 0000000000..40623f0724
--- /dev/null
+++ b/test/api/batch_feedback_uploads_api_test.rb
@@ -0,0 +1,184 @@
+require 'test_helper'
+
+class BatchFeedbackUploadsApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+
+ def app
+ Rails.application
+ end
+
+ def setup
+ @unit = FactoryBot.create(:unit, with_students: false)
+ @task_definition = @unit.task_definitions.first
+ @convenor = @unit.main_convenor_user
+ ImportBatchFeedbackJob.clear
+ end
+
+ def teardown
+ @upload_ids&.each { |id| BatchFeedbackUpload.find(id)&.destroy }
+ end
+
+ def start_upload(size:, user: @convenor)
+ add_auth_header_for(user: user)
+ post '/api/submission/batch_feedback_uploads', {
+ unit_id: @unit.id,
+ task_definition_id: @task_definition.id,
+ filename: 'feedback.zip',
+ size: size
+ }
+ return unless last_response.status == 201
+
+ (@upload_ids ||= []) << last_response_body['id']
+ last_response_body['id']
+ end
+
+ def send_chunk(id, offset, data, sha256: Digest::SHA256.hexdigest(data))
+ chunk = Tempfile.new('chunk')
+ chunk.binmode
+ chunk.write(data)
+ chunk.rewind
+ patch "/api/submission/batch_feedback_uploads/#{id}", {
+ offset: offset,
+ sha256: sha256,
+ chunk: Rack::Test::UploadedFile.new(chunk.path, 'application/octet-stream')
+ }
+ ensure
+ chunk&.close!
+ end
+
+ def test_upload_in_chunks_and_import
+ id = start_upload(size: 10)
+ assert_equal 0, last_response_body['offset']
+ assert_equal BatchFeedbackUpload::CHUNK_SIZE, last_response_body['chunk_size']
+
+ send_chunk(id, 0, 'abcdef')
+ assert_equal 200, last_response.status
+ assert_equal 6, last_response_body['offset']
+
+ get "/api/submission/batch_feedback_uploads/#{id}"
+ assert_equal 6, last_response_body['offset']
+ assert_equal [{ 'size' => 6, 'sha256' => Digest::SHA256.hexdigest('abcdef') }], last_response_body['chunks']
+
+ # A retried chunk is rejected and tells the client where to resume
+ send_chunk(id, 0, 'abcdef')
+ assert_equal 409, last_response.status
+ assert_equal 6, last_response_body['offset']
+
+ send_chunk(id, 6, 'ghij')
+ assert_equal 10, last_response_body['offset']
+
+ post "/api/submission/batch_feedback_uploads/#{id}/complete"
+ assert_equal 201, last_response.status, last_response.body
+
+ assert_equal 1, ImportBatchFeedbackJob.jobs.count
+ unit_id, user_id, task_definition_id, path = ImportBatchFeedbackJob.jobs.first['args']
+ assert_equal [@unit.id, @convenor.id, @task_definition.id], [unit_id, user_id, task_definition_id]
+ assert_equal 'abcdefghij', File.read(path)
+ assert_nil BatchFeedbackUpload.find(id)
+ ensure
+ FileUtils.rm_f(path) if path
+ end
+
+ def test_cannot_complete_partial_upload
+ id = start_upload(size: 10)
+ send_chunk(id, 0, 'abc')
+
+ post "/api/submission/batch_feedback_uploads/#{id}/complete"
+ assert_equal 422, last_response.status
+ assert_equal 0, ImportBatchFeedbackJob.jobs.count
+ assert_equal 3, BatchFeedbackUpload.find(id).offset
+ end
+
+ def test_rejects_chunk_that_does_not_match_its_checksum
+ id = start_upload(size: 10)
+
+ send_chunk(id, 0, 'abcdef', sha256: Digest::SHA256.hexdigest('abcdeX'))
+ assert_equal 422, last_response.status
+ assert_equal 0, BatchFeedbackUpload.find(id).offset
+ end
+
+ def test_stores_a_chunk_once_when_two_requests_race
+ id = start_upload(size: 10)
+ send_chunk(id, 0, 'abc')
+ upload = BatchFeedbackUpload.find(id)
+
+ # A retry that checked the offset before the original request stored its chunk
+ chunk = Tempfile.new('chunk')
+ chunk.write('abc')
+ chunk.flush
+ offset_calls = 0
+ upload.define_singleton_method(:offset) { (offset_calls += 1) == 1 ? 0 : super() }
+ error = assert_raises(BatchFeedbackUpload::OffsetMismatch) do
+ upload.append(0, chunk.path, Digest::SHA256.hexdigest('abc'))
+ end
+
+ assert_equal 3, error.offset
+ assert_equal [{ size: 3, sha256: Digest::SHA256.hexdigest('abc') }], upload.chunks
+ assert_equal 1, Dir.children(upload.chunks_dir).count
+ ensure
+ chunk&.close!
+ end
+
+ def test_rejects_an_upload_with_conflicting_chunks
+ id = start_upload(size: 10)
+ send_chunk(id, 0, 'abc')
+ upload = BatchFeedbackUpload.find(id)
+ File.write(File.join(upload.chunks_dir, format('%020d-%s', 0, Digest::SHA256.hexdigest('xyz'))), 'xyz')
+
+ get "/api/submission/batch_feedback_uploads/#{id}"
+ assert_equal 422, last_response.status
+ end
+
+ def test_rejects_chunk_past_end_of_upload
+ id = start_upload(size: 4)
+
+ send_chunk(id, 0, 'abcdef')
+ assert_equal 422, last_response.status
+ assert_equal 0, BatchFeedbackUpload.find(id).offset
+ end
+
+ def test_only_the_uploader_can_use_an_upload
+ id = start_upload(size: 10)
+
+ other_convenor = FactoryBot.create(:user, :convenor)
+ @unit.employ_staff(other_convenor, Role.convenor)
+ add_auth_header_for(user: other_convenor)
+
+ get "/api/submission/batch_feedback_uploads/#{id}"
+ assert_equal 404, last_response.status
+
+ send_chunk(id, 0, 'abc')
+ assert_equal 404, last_response.status
+ assert_equal 0, BatchFeedbackUpload.find(id).offset
+ end
+
+ def test_student_cannot_start_upload
+ student = FactoryBot.create(:user, :student)
+ @unit.enrol_student(student, nil)
+
+ start_upload(size: 10, user: student)
+ assert_equal 401, last_response.status
+ end
+
+ def test_cancel_upload
+ id = start_upload(size: 10)
+
+ delete "/api/submission/batch_feedback_uploads/#{id}"
+ assert_equal 204, last_response.status
+ assert_nil BatchFeedbackUpload.find(id)
+ end
+
+ def test_remove_expired_uploads
+ stale_id = start_upload(size: 10)
+ fresh_id = start_upload(size: 10)
+ stale = BatchFeedbackUpload.find(stale_id)
+ FileUtils.touch([stale.dir, stale.chunks_dir], mtime: 2.days.ago.to_time)
+
+ BatchFeedbackUpload.remove_expired
+
+ assert_nil BatchFeedbackUpload.find(stale_id)
+ assert_not_nil BatchFeedbackUpload.find(fresh_id)
+ end
+end
diff --git a/test/api/breaks_api_test.rb b/test/api/breaks_api_test.rb
index 97c03d2b6c..802662e257 100644
--- a/test/api/breaks_api_test.rb
+++ b/test/api/breaks_api_test.rb
@@ -5,18 +5,22 @@ class BreaksApiTest < ActiveSupport::TestCase
include TestHelpers::AuthHelper
include TestHelpers::JsonHelper
- #POST TEST
+ # POST TEST
def test_post_breaks
teaching_period = FactoryBot.create(:teaching_period)
+ campus = FactoryBot.create(:campus)
start = teaching_period.start_date + 4.weeks
number_of_break = Break.count
data_to_post = {
start_date: start,
- number_of_weeks: rand(1..3)
+ number_of_days: rand(1..3) * 7,
+ label: 'Mid semester break',
+ campus_ids: [campus.id],
+ pause_week_count: false
}
- add_auth_header_for user: User.first
+ add_auth_header_for user: FactoryBot.create(:user, :admin)
# Perform the POST
post "/api/teaching_periods/#{teaching_period.id}/breaks", data_to_post
@@ -25,13 +29,16 @@ def test_post_breaks
assert_equal 201, last_response.status
# Check if the details posted match as expected
- response_keys = %w(start_date number_of_weeks)
+ response_keys = %w[start_date number_of_days label campus_ids pause_week_count]
the_break = Break.find(last_response_body['id'])
assert_json_matches_model(the_break, last_response_body, response_keys)
# check if the details in the newly created break match as the pre-set data
assert_equal data_to_post[:start_date].to_date, the_break.start_date.to_date
- assert_equal data_to_post[:number_of_weeks], the_break.number_of_weeks
+ assert_equal data_to_post[:number_of_days], the_break.number_of_days
+ assert_equal data_to_post[:label], the_break.label
+ assert_equal data_to_post[:campus_ids], the_break.campus_ids
+ assert_not the_break.pause_week_count
# check if one more break is created
assert_equal Break.count, number_of_break + 1
@@ -41,10 +48,10 @@ def test_post_breaks
# Get breaks in a teaching period
def test_get_breaks
# Create teaching period
- teaching_period = FactoryBot.create(:teaching_period)
- teaching_period.add_break teaching_period.start_date + 1.week, 1
+ teaching_period = FactoryBot.create(:teaching_period)
+ teaching_period.add_break teaching_period.start_date + 1.week, 7
- add_auth_header_for(user: User.first)
+ add_auth_header_for(user: FactoryBot.create(:user, :admin))
# Perform the GET
get "/api/teaching_periods/#{teaching_period.id}/breaks"
@@ -54,9 +61,9 @@ def test_get_breaks
assert_equal expected_data.count, last_response_body.count
assert expected_data.count > 0
- response_keys = %w(id start_date number_of_weeks)
- test_keys = %w(id number_of_weeks)
- last_response_body.each do | data |
+ response_keys = %w[id start_date number_of_days label campus_ids pause_week_count]
+ test_keys = %w[id number_of_days pause_week_count]
+ last_response_body.each do |data|
the_break = Break.find(data['id'])
assert_json_matches_model(the_break, data, test_keys)
assert_equal the_break.start_date.strftime('%Y-%m-%d'), data['start_date']
@@ -66,11 +73,11 @@ def test_get_breaks
def test_delete_breaks
# Create teaching period
- teaching_period = FactoryBot.create(:teaching_period)
- teaching_period.add_break teaching_period.start_date + 1.week, 1
- teaching_period.add_break teaching_period.start_date + 3.week, 1
+ teaching_period = FactoryBot.create(:teaching_period)
+ teaching_period.add_break teaching_period.start_date + 1.week, 7
+ teaching_period.add_break teaching_period.start_date + 3.weeks, 7
- add_auth_header_for(user: User.first)
+ add_auth_header_for(user: FactoryBot.create(:user, :admin))
the_break = Break.last
count = teaching_period.breaks.count
@@ -87,4 +94,50 @@ def test_delete_breaks
# Check if the actual data match as expected
assert last_response_body
end
+
+ def test_post_break_defaults_to_pausing_the_week_count
+ teaching_period = FactoryBot.create(:teaching_period)
+
+ add_auth_header_for user: FactoryBot.create(:user, :admin)
+
+ post "/api/teaching_periods/#{teaching_period.id}/breaks", {
+ start_date: teaching_period.start_date + 4.weeks,
+ number_of_days: 7
+ }
+
+ assert_equal 201, last_response.status
+ assert last_response_body['pause_week_count']
+ assert Break.find(last_response_body['id']).pause_week_count
+ end
+
+ def test_post_break_cannot_pause_the_week_count_over_a_partial_week
+ teaching_period = FactoryBot.create(:teaching_period)
+ number_of_breaks = Break.count
+
+ add_auth_header_for user: FactoryBot.create(:user, :admin)
+
+ post "/api/teaching_periods/#{teaching_period.id}/breaks", {
+ start_date: teaching_period.start_date + 4.weeks,
+ number_of_days: 5,
+ pause_week_count: true
+ }
+
+ assert_not_equal 201, last_response.status
+ assert_equal number_of_breaks, Break.count
+ end
+
+ def test_put_break_updates_pause_week_count
+ teaching_period = FactoryBot.create(:teaching_period)
+ the_break = teaching_period.add_break(teaching_period.start_date + 4.weeks, 7)
+
+ add_auth_header_for user: FactoryBot.create(:user, :admin)
+
+ put "/api/teaching_periods/#{teaching_period.id}/breaks/#{the_break.id}", {
+ pause_week_count: false
+ }
+
+ assert_equal 200, last_response.status
+ assert_not last_response_body['pause_week_count']
+ assert_not the_break.reload.pause_week_count
+ end
end
diff --git a/test/api/campuses_test.rb b/test/api/campuses_test.rb
index 638d490df3..6ca302e812 100644
--- a/test/api/campuses_test.rb
+++ b/test/api/campuses_test.rb
@@ -13,8 +13,8 @@ def test_get_all_campuses
get '/api/campuses'
expected_data = Campus.all
assert_equal expected_data.count, last_response_body.count
- response_keys = %w(name abbreviation)
- last_response_body.each do | data |
+ response_keys = %w[name abbreviation]
+ last_response_body.each do |data|
c = Campus.find(data['id'])
assert_json_matches_model(c, data, response_keys)
end
@@ -23,7 +23,7 @@ def test_get_all_campuses
def test_get_campuses_by_id
campus = FactoryBot.create(:campus, mode: 'timetable')
get "/api/campuses/#{campus.id}"
- response_keys = %w(name abbreviation)
+ response_keys = %w[name abbreviation]
assert_json_matches_model(campus, last_response_body, response_keys)
end
@@ -37,7 +37,7 @@ def test_post_campuses
post_json '/api/campuses', data_to_post
assert_equal 201, last_response.status
- response_keys = %w(name abbreviation)
+ response_keys = %w[name abbreviation]
campus = Campus.find(last_response_body['id'])
assert_json_matches_model(campus, last_response_body, response_keys)
assert_equal 'timetable', campus[:mode], last_response_body.inspect
@@ -67,7 +67,7 @@ def test_put_campuses
# Update campus with id = 1
put_json '/api/campuses/1', data_to_put
assert_equal 200, last_response.status
- response_keys = %w(name abbreviation)
+ response_keys = %w[name abbreviation]
first_campus = Campus.first
assert_json_matches_model(first_campus, last_response_body, response_keys)
assert_equal 'timetable', first_campus[:mode]
@@ -93,18 +93,18 @@ def test_delete_campuses
# auth_token and username added to header
add_auth_header_for(user: User.first)
- #Perform the delete
+ # Perform the delete
delete_json "/api/campuses/#{campus.id}"
- #check the request went through
+ # check the request went through
assert_equal 200, last_response.status
- get '/api/campuses' #get number of campuses
+ get '/api/campuses' # get number of campuses
# check if current number of campuses = original number of campuses
assert_equal initial_num_of_campus - 1, last_response_body.count
# check campus no longer exists
- refute Campus.exists?(campus.id)
+ assert_not Campus.exists?(campus.id)
end
def test_student_delete_campus
diff --git a/test/api/comments/comment_test.rb b/test/api/comments/comment_test.rb
index 1e1fc558a1..bb96e1d305 100644
--- a/test/api/comments/comment_test.rb
+++ b/test/api/comments/comment_test.rb
@@ -2,6 +2,7 @@
class CommentTest < ActiveSupport::TestCase
include Rack::Test::Methods
+ include ActiveSupport::Testing::TimeHelpers
include TestHelpers::AuthHelper
include TestHelpers::JsonHelper
include TestHelpers::TestFileHelper
@@ -33,8 +34,8 @@ def test_get_comments
assert_equal 200, last_response.status, last_response_body
assert_equal 3, last_response_body.length, last_response_body.inspect
- keys = %w(id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time)
- keys_test = %w(id comment reply_to_id)
+ keys = %w[id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time]
+ keys_test = %w[id comment reply_to_id]
last_response_body.each do |resp|
assert_json_limit_keys_to_exactly keys, resp
@@ -49,14 +50,14 @@ def test_get_comments
assert_equal 200, last_response.status, last_response_body
assert_equal 3, last_response_body.length, last_response_body.inspect
- keys = %w(id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time)
- keys_test = %w(id comment reply_to_id)
+ keys = %w[id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time]
+ keys_test = %w[id comment reply_to_id]
last_response_body.each do |resp|
assert_json_limit_keys_to_exactly keys, resp
comment = TaskComment.find(resp['id'])
assert_json_matches_model comment, resp, keys_test
- refute resp['is_new'], resp.inspect
+ assert_not resp['is_new'], resp.inspect
end
task.add_text_comment(user, 'Response')
@@ -93,7 +94,7 @@ def test_student_post_comment
}
# check each is the same
- assert_json_matches_model expected_response, last_response_body, %w(comment has_attachment type is_new)
+ assert_json_matches_model expected_response, last_response_body, %w[comment has_attachment type is_new]
assert_json_matches_model expected_response[:author], last_response_body['author'], ['id']
assert_json_matches_model expected_response[:recipient], last_response_body['recipient'], ['id']
end
@@ -132,7 +133,7 @@ def test_replying_to_comments
comment_data = { comment: 'Responding!', reply_to_id: TaskComment.last.id }
post_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", comment_data
assert_equal 201, last_response.status
- assert_json_matches_model expected_response, last_response_body, %w(comment type reply_to_id)
+ assert_json_matches_model expected_response, last_response_body, %w[comment type reply_to_id]
expected_response = {
'comment' => 'Responding again!',
@@ -152,7 +153,7 @@ def test_replying_to_comments
assert_equal 201, last_response.status
# check each is the same
- assert_json_matches_model expected_response, last_response_body, %w(comment type reply_to_id)
+ assert_json_matches_model expected_response, last_response_body, %w[comment type reply_to_id]
end
def test_student_post_reply_to_invalid_comment
@@ -221,6 +222,100 @@ def test_student_reply_to_themselve_in_different_task_same_project
assert_equal 404, last_response.status
end
+ def test_student_can_edit_own_comment_within_10_minutes
+ project = Project.first
+ user = project.student
+ task_definition = project.unit.task_definitions.first
+
+ add_auth_header_for(user: user)
+ post_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", comment: 'Original comment'
+ assert_equal 201, last_response.status
+
+ comment_id = last_response_body['id']
+
+ travel_to 9.minutes.from_now do
+ put_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment_id}", comment: 'Edited comment'
+ assert_equal 200, last_response.status, last_response.body
+ end
+
+ assert_equal 'Edited comment', TaskComment.find(comment_id).read_attribute(:comment)
+ assert_equal 'Edited comment', last_response_body['comment']
+ end
+
+ def test_student_cannot_edit_own_comment_after_10_minutes
+ project = Project.first
+ user = project.student
+ task_definition = project.unit.task_definitions.first
+
+ add_auth_header_for(user: user)
+ post_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", comment: 'Original comment'
+ assert_equal 201, last_response.status
+
+ comment_id = last_response_body['id']
+
+ travel_to 11.minutes.from_now do
+ put_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment_id}", comment: 'Too late'
+ assert_equal 403, last_response.status, last_response.body
+ end
+
+ assert_equal 'Original comment', TaskComment.find(comment_id).read_attribute(:comment)
+ end
+
+ def test_student_cannot_edit_other_users_comment
+ project = Project.first
+ task_definition = project.unit.task_definitions.first
+ tutor = project.tutor_for(task_definition)
+ user = project.student
+ task = project.task_for_task_definition(task_definition)
+ comment = task.add_text_comment(tutor, 'Tutor comment')
+
+ add_auth_header_for(user: user)
+ put_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment.id}", comment: 'Edited by student'
+
+ assert_equal 403, last_response.status, last_response.body
+ assert_equal 'Tutor comment', comment.reload.read_attribute(:comment)
+ end
+
+ def test_special_task_comments_cannot_be_deleted
+ project = FactoryBot.create(:project)
+ task_definition = project.unit.task_definitions.first
+ task = project.task_for_task_definition(task_definition)
+ tutor = project.tutor_for(task_definition)
+ student = project.student
+
+ submission_history = FactoryBot.create(:submission_history, task: task)
+ overseer_assessment = FactoryBot.create(
+ :overseer_assessment,
+ task: task,
+ submission_history: submission_history,
+ submission_timestamp: submission_history.submission_timestamp,
+ status: :failed
+ )
+
+ protected_comments = [
+ task.add_status_comment(student, TaskStatus.ready_for_feedback),
+ task.add_discussed_comment(tutor),
+ task.add_feedback_review_request_comment(student),
+ AssessmentComment.create!(
+ task: task,
+ user: tutor,
+ recipient: student,
+ comment: 'Automated tests failed',
+ commentable: overseer_assessment
+ )
+ ]
+
+ protected_comments.each do |comment|
+ add_auth_header_for(user: comment.user)
+ delete_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment.id}"
+ assert_equal 403, last_response.status, "Expected #{comment.class.name} delete to be rejected"
+ end
+
+ deleted_comment_types = protected_comments.reject { |comment| TaskComment.exists?(comment.id) }.map(&:content_type)
+
+ assert_empty deleted_comment_types, "Expected protected comment types to remain after delete attempt: #{deleted_comment_types.join(', ')}"
+ end
+
def test_student_reply_to_other_student_in_same_group
unit = FactoryBot.create :unit
@@ -248,7 +343,7 @@ def test_student_reply_to_other_student_in_same_group
due_date: Time.zone.now + 1.week,
abbreviation: 'TaskSwitchIndGrp',
restrict_status_updates: false,
- upload_requirements: [ { 'key' => 'file0', 'name' => 'Shape Class', 'type' => 'code' } ],
+ upload_requirements: [{ 'key' => 'file0', 'name' => 'Shape Class', 'type' => 'code' }],
plagiarism_warn_pct: 0.8,
is_graded: false,
max_quality_pts: 0,
@@ -491,7 +586,7 @@ def test_read_receipts_for_task_status_comments
due_date: Time.zone.now + 2.weeks,
abbreviation: 'test_read_receipts_for_task_status_comments',
restrict_status_updates: false,
- upload_requirements: [ ],
+ upload_requirements: [],
plagiarism_warn_pct: 0.8,
is_graded: false,
max_quality_pts: 0)
@@ -511,8 +606,8 @@ def test_read_receipts_for_task_status_comments
task = project.task_for_task_definition(td)
assert_equal TaskStatus.ready_for_feedback, task.task_status
- refute task.comments.last.new_for?(user)
- refute task.comments.last.new_for?(project.tutor_for(td))
+ assert_not task.comments.last.new_for?(user)
+ assert_not task.comments.last.new_for?(project.tutor_for(td))
td.destroy!
end
@@ -558,6 +653,7 @@ def test_project_plan_task_comments_dont_show_in_inbox
inbox = unit.tasks_for_task_inbox(unit.tutors.first)
assert_not_includes inbox.map(&:id), task_new.id, "Task should not be in tutors inbox"
+ assert task_new.comments.last.attention_none?, "Plan comment should not require attention"
assert_not task_new.comments.last.new_for?(user), "Comment should be marked read by student"
assert_not task_new.comments.last.new_for?(project.tutor_for(td)), "Comment should be read by tutor"
diff --git a/test/api/comments/extension_test.rb b/test/api/comments/extension_test.rb
index 7c707ab798..ad0de7c0d0 100644
--- a/test/api/comments/extension_test.rb
+++ b/test/api/comments/extension_test.rb
@@ -15,22 +15,22 @@ def test_extension_application
user = project.student
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: project.tutorial_enrolments.first.tutorial.tutorial_stream,
- name: 'status task change',
- description: 'status task change test',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 1.day,
- abbreviation: 'LESS1WEEKEXTTEST',
- restrict_status_updates: false,
- upload_requirements: [ ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: project.tutorial_enrolments.first.tutorial.tutorial_stream,
+ name: 'status task change',
+ description: 'status task change test',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 1.day,
+ abbreviation: 'LESS1WEEKEXTTEST',
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
data_to_post = {
weeks_requested: '1',
@@ -44,7 +44,7 @@ def test_extension_application
post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post
response = last_response_body
assert_equal 201, last_response.status
- assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}."
+ assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}."
# Request a 2 week extension on the day
td.due_date = Time.zone.now + 2.weeks
@@ -57,7 +57,7 @@ def test_extension_application
post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post
response = last_response_body
assert_equal 201, last_response.status
- assert response["weeks_requested"] == 2, "Error: Weeks requested weeks should be 2, found #{response["weeks_requested"]}."
+ assert response["weeks_requested"] == 2, "Error: Weeks requested weeks should be 2, found #{response['weeks_requested']}."
# Add auth_token and username to header
add_auth_header_for(user: user)
@@ -68,7 +68,6 @@ def test_extension_application
response = last_response_body
assert_equal 403, last_response.status, "Error: Allowed too long of a request to be applied."
-
# Add auth_token and username to header
add_auth_header_for(user: user)
@@ -90,22 +89,22 @@ def test_extension_application
other_tutor = unit.main_convenor_user
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'status task change',
- description: 'status task change test',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 1.day,
- abbreviation: 'LESS1WEEKEXTTEST',
- restrict_status_updates: false,
- upload_requirements: [ ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'status task change',
+ description: 'status task change test',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 1.day,
+ abbreviation: 'LESS1WEEKEXTTEST',
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
main_tutor = project.tutor_for(td)
@@ -121,17 +120,17 @@ def test_extension_application
post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post
response = last_response_body
assert_equal 201, last_response.status
- assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}."
+ assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}."
tc = TaskComment.find(response['id'])
# Check it is not read by the main tutor
- refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create"
+ assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create"
assert tc.read_by?(user), "Error: Should be read by student on create"
# Check that reading by main tutor does not read the task
tc.read_by? main_tutor
- refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it"
+ assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it"
# Check it is read after grant by another user
tc.assess_extension other_tutor, true
@@ -148,22 +147,22 @@ def test_disallow_student_extensions
other_tutor = unit.main_convenor_user
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'status task change',
- description: 'status task change test',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 1.day,
- abbreviation: 'LESS1WEEKEXTTEST',
- restrict_status_updates: false,
- upload_requirements: [ ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'status task change',
+ description: 'status task change test',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 1.day,
+ abbreviation: 'LESS1WEEKEXTTEST',
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
main_tutor = project.tutor_for(td)
@@ -182,7 +181,7 @@ def test_disallow_student_extensions
post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post
response = last_response_body
assert_equal 201, last_response.status
- assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}."
+ assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}."
tc = ExtensionComment.find(response['id'])
@@ -197,22 +196,22 @@ def test_disallow_student_extensions
def test_extension_on_resubmit
unit = FactoryBot.create(:unit, extension_weeks_on_resubmit_request: 2)
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'Task past due - for revert',
- description: 'Task past due',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now + 1.day,
- due_date: Time.zone.now + 1.day + 3.weeks,
- abbreviation: 'TaskPastDueForRevert',
- restrict_status_updates: false,
- upload_requirements: [ ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Task past due - for revert',
+ description: 'Task past due',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now + 1.day,
+ due_date: Time.zone.now + 1.day + 3.weeks,
+ abbreviation: 'TaskPastDueForRevert',
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
data_to_post = {
@@ -245,7 +244,7 @@ def test_extension_on_resubmit
end
def test_extension_in_inbox
- unit = FactoryBot.create(:unit, auto_apply_extension_before_deadline: false, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 2, staff_count: 2, task_count:0)
+ unit = FactoryBot.create(:unit, auto_apply_extension_before_deadline: false, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 2, staff_count: 2, task_count: 0)
project = unit.projects.first
user = project.student
tutor = unit.main_convenor_user
@@ -253,22 +252,22 @@ def test_extension_in_inbox
assert project.enrolled
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'status task change',
- description: 'status task change test',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 2.week,
- abbreviation: 'LESS1WEEKEXTTEST',
- restrict_status_updates: false,
- upload_requirements: [ ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'status task change',
+ description: 'status task change test',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 2.weeks,
+ abbreviation: 'LESS1WEEKEXTTEST',
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
data_to_post = {
@@ -284,7 +283,7 @@ def test_extension_in_inbox
post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post
response = last_response_body
assert_equal 201, last_response.status
- assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}."
+ assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}."
unit.reload
task = unit.tasks.last
@@ -308,27 +307,27 @@ def test_extension_in_inbox
end
def test_flexible_dates
- unit = FactoryBot.create(:unit, student_count: 1, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 1, staff_count: 1, task_count:0)
+ unit = FactoryBot.create(:unit, student_count: 1, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 1, staff_count: 1, task_count: 0)
project = unit.projects.first
user = project.student
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'Flexible Dates Test',
- description: 'Flexible Dates Test',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now + 1.day,
- due_date: Time.zone.now + 1.day + 3.weeks,
- abbreviation: 'FLEXTEST',
- restrict_status_updates: false,
- upload_requirements: [ ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Flexible Dates Test',
+ description: 'Flexible Dates Test',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now + 1.day,
+ due_date: Time.zone.now + 1.day + 3.weeks,
+ abbreviation: 'FLEXTEST',
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
data_to_post = {
@@ -354,7 +353,7 @@ def test_flexible_dates
assert_equal(-1, task.extensions, "Error: Task should have -1 extensions set")
# Check cannot make extension request
- post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", {weeks_requested: 1, comment: "Test Extension Request"}
+ post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", { weeks_requested: 1, comment: "Test Extension Request" }
assert_equal 403, last_response.status, last_response_body
td.destroy!
diff --git a/test/api/comments/scorm_extension_test.rb b/test/api/comments/scorm_extension_test.rb
index f206b8f0f5..ddcd5221eb 100644
--- a/test/api/comments/scorm_extension_test.rb
+++ b/test/api/comments/scorm_extension_test.rb
@@ -40,6 +40,18 @@ def test_scorm_extension_request
data_to_post = {
comment: 'I need more attempts please'
}
+ extension_count = ScormExtensionComment.count
+
+ clear_auth_header
+ header 'username', user.username
+ post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post
+ assert_equal 419, last_response.status
+ assert_equal extension_count, ScormExtensionComment.count
+
+ add_auth_header_for(user: user, auth_token: 'invalid')
+ post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post
+ assert_equal 419, last_response.status
+ assert_equal extension_count, ScormExtensionComment.count
add_auth_header_for(user: user)
@@ -113,12 +125,12 @@ def test_read_by_main_tutor
tc = TaskComment.find(last_response_body["id"])
# Check it is not read by the main tutor
- refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create"
+ assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create"
assert tc.read_by?(user), "Error: Should be read by student on create"
# Check that reading by main tutor does not read the task
tc.read_by? main_tutor
- refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it"
+ assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it"
# Check it is read after grant by another user
tc.assess_scorm_extension other_tutor, true
@@ -215,6 +227,12 @@ def test_scorm_extension_assessment
granted: true
}
+ clear_auth_header
+ header 'username', main_tutor.username
+ put_json "/api/projects/#{project.id}/task_def_id/#{td.id}/assess_scorm_extension/#{tc.id}", data_to_put
+ assert_equal 419, last_response.status
+ assert_not tc.reload.assessed?
+
add_auth_header_for(user: user)
# When the user is unauthorised
diff --git a/test/api/comments/status_test.rb b/test/api/comments/status_test.rb
index 9c2e729b12..02d5e55d5d 100644
--- a/test/api/comments/status_test.rb
+++ b/test/api/comments/status_test.rb
@@ -15,28 +15,28 @@ def test_status_comments
unit = project.unit
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- abbreviation: 'test_status_comments',
- name: 'test_status_comments',
- description: 'test_status_comments',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now - 1.week,
- due_date: Time.zone.now + 1.day,
- restrict_status_updates: false,
- upload_requirements: [ ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ abbreviation: 'test_status_comments',
+ name: 'test_status_comments',
+ description: 'test_status_comments',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now - 1.week,
+ due_date: Time.zone.now + 1.day,
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
tutor = project.tutor_for(td)
data_to_post = {
- trigger: 'ready_for_feedback',
+ trigger: 'ready_for_feedback'
}
# Add auth_token and username to header
diff --git a/test/api/communication_rules_api_test.rb b/test/api/communication_rules_api_test.rb
new file mode 100644
index 0000000000..ca24a0ae4a
--- /dev/null
+++ b/test/api/communication_rules_api_test.rb
@@ -0,0 +1,327 @@
+require 'test_helper'
+
+class CommunicationRulesApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+
+ def app
+ Rails.application
+ end
+
+ def test_create_and_update_relative_activity_condition
+ admin = FactoryBot.create(:user, :admin)
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0)
+ communication_set = unit.communication_sets.create!(name: 'Activity', active: true)
+ rule = communication_set.communication_rules.create!(name: 'Inactive students', operator: 'and', position: 0)
+ add_auth_header_for(user: admin)
+
+ post_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions",
+ communication_condition: {
+ type: 'LoginStatusCondition',
+ operator: 'more_than',
+ activity_days: 7
+ }
+
+ assert_equal 201, last_response.status
+ condition_response = last_response_body
+ assert_equal 'LoginStatusCondition', condition_response['type']
+ assert_equal 'more_than', condition_response['operator']
+ assert_equal 7, condition_response['activity_days']
+
+ put_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions/#{condition_response['id']}",
+ communication_condition: {
+ type: 'UnitViewedStatusCondition',
+ operator: 'within_last',
+ activity_days: 3
+ }
+
+ assert_equal 200, last_response.status
+ condition_response = last_response_body
+ assert_equal 'UnitViewedStatusCondition', condition_response['type']
+ assert_equal 'within_last', condition_response['operator']
+ assert_equal 3, condition_response['activity_days']
+
+ viewed_at = 1.day.ago.change(usec: 0)
+ project = FactoryBot.create(:project, unit: unit, last_viewed_at: viewed_at)
+ project.user.update!(last_sign_in_at: 2.days.ago.change(usec: 0))
+
+ get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}"
+
+ assert_equal 200, last_response.status
+ assert_equal 1, last_response_body['eligible_student_count']
+ assert_not last_response_body.key?('previews')
+
+ get "/api/units/#{unit.id}/communication_rules/#{rule.id}/preview"
+
+ assert_equal 200, last_response.status
+ assert_equal rule.id, last_response_body['rule_id']
+ assert_equal 1, last_response_body['eligible_student_count']
+ student = last_response_body['students'].first
+ assert_equal project.id, student['project_id']
+ assert_equal project.user.username, student['username']
+ assert_equal false, student['has_portfolio']
+ assert_equal viewed_at.iso8601(3), Time.zone.parse(student['last_viewed_at']).iso8601(3)
+ assert_equal project.user.last_sign_in_at.iso8601(3), Time.zone.parse(student['last_sign_in_at']).iso8601(3)
+ end
+
+ def test_rule_preview_ignores_earlier_rules_in_the_set
+ admin = FactoryBot.create(:user, :admin)
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0)
+ communication_set = unit.communication_sets.create!(name: 'Spec con', active: true)
+ first_rule = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0)
+ second_rule = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1)
+
+ [first_rule, second_rule].each do |rule|
+ rule.communication_conditions.create!(
+ type: 'SpecConCondition',
+ operator: 'greater_than_or_equal_to',
+ spec_con_days: 3
+ )
+ end
+
+ matching_project = FactoryBot.create(:project, unit: unit, spec_con_days: 4)
+ FactoryBot.create(:project, unit: unit, spec_con_days: 1)
+ add_auth_header_for(user: admin)
+
+ # Both rules report the same student -- the second is no longer filtered by the first.
+ [first_rule, second_rule].each do |rule|
+ get "/api/units/#{unit.id}/communication_rules/#{rule.id}/preview"
+
+ assert_equal 200, last_response.status
+ assert_equal 2, last_response_body['eligible_student_count']
+ assert_equal([matching_project.id], last_response_body['students'].map { |student| student['project_id'] })
+ end
+ end
+
+ def test_rules_are_returned_ordered_by_position
+ admin = FactoryBot.create(:user, :admin)
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0)
+ communication_set = unit.communication_sets.create!(name: 'Ordering', active: true)
+
+ # Created out of order on purpose -- id order must not decide the response order.
+ third = communication_set.communication_rules.create!(name: 'Third', operator: 'and', position: 2)
+ first = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0)
+ second = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1)
+ expected = [first.id, second.id, third.id]
+ add_auth_header_for(user: admin)
+
+ get "/api/units/#{unit.id}/communication_sets"
+ assert_equal 200, last_response.status
+ assert_equal(expected, last_response_body.first['rules'].map { |rule| rule['id'] })
+
+ get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}"
+ assert_equal 200, last_response.status
+ assert_equal(expected, last_response_body['rules'].map { |rule| rule['id'] })
+
+ get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}/rules"
+ assert_equal 200, last_response.status
+ assert_equal(expected, last_response_body.map { |rule| rule['id'] })
+
+ # And the order follows position after a drag-and-drop style reorder.
+ put_json "/api/units/#{unit.id}/communication_rules/#{third.id}", communication_rule: { position: 0 }
+ put_json "/api/units/#{unit.id}/communication_rules/#{first.id}", communication_rule: { position: 1 }
+ put_json "/api/units/#{unit.id}/communication_rules/#{second.id}", communication_rule: { position: 2 }
+
+ get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}"
+ assert_equal 200, last_response.status
+ assert_equal([third.id, first.id, second.id], last_response_body['rules'].map { |rule| rule['id'] })
+ end
+
+ def test_update_rule_position_reorders_the_set
+ admin = FactoryBot.create(:user, :admin)
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0)
+ communication_set = unit.communication_sets.create!(name: 'Ordering', active: true)
+ first_rule = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0)
+ second_rule = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1)
+ add_auth_header_for(user: admin)
+
+ put_json "/api/units/#{unit.id}/communication_rules/#{second_rule.id}", communication_rule: { position: 0 }
+ assert_equal 200, last_response.status
+
+ put_json "/api/units/#{unit.id}/communication_rules/#{first_rule.id}", communication_rule: { position: 1 }
+ assert_equal 200, last_response.status
+
+ assert_equal [second_rule.id, first_rule.id], communication_set.reload.communication_rules.map(&:id)
+ end
+
+ def test_delete_rule_removes_it_and_resequences_remaining_rules
+ admin = FactoryBot.create(:user, :admin)
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0)
+ communication_set = unit.communication_sets.create!(name: 'Ordering', active: true)
+ first_rule = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0)
+ second_rule = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1)
+ third_rule = communication_set.communication_rules.create!(name: 'Third', operator: 'and', position: 2)
+ add_auth_header_for(user: admin)
+
+ delete "/api/units/#{unit.id}/communication_rules/#{second_rule.id}"
+
+ assert_equal 204, last_response.status
+ assert_not CommunicationRule.exists?(second_rule.id)
+ assert_equal [[first_rule.id, 0], [third_rule.id, 1]],
+ communication_set.reload.communication_rules.pluck(:id, :position)
+ end
+
+ def test_create_and_update_portfolio_submitted_condition
+ admin = FactoryBot.create(:user, :admin)
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0)
+ communication_set = unit.communication_sets.create!(name: 'Portfolio', active: true)
+ rule = communication_set.communication_rules.create!(name: 'Portfolio submitted', operator: 'and', position: 0)
+ add_auth_header_for(user: admin)
+
+ post_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions",
+ communication_condition: {
+ type: 'PortfolioSubmittedCondition',
+ operator: 'equal_to',
+ submitted_portfolio: true
+ }
+
+ assert_equal 201, last_response.status
+ condition_response = last_response_body
+ assert_equal true, condition_response['submitted_portfolio']
+
+ put_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions/#{condition_response['id']}",
+ communication_condition: {
+ submitted_portfolio: false
+ }
+
+ assert_equal 200, last_response.status
+ assert_equal false, last_response_body['submitted_portfolio']
+ assert_equal false, rule.communication_conditions.find(condition_response['id']).submitted_portfolio
+ end
+
+ def test_copying_a_set_and_importing_it_into_another_unit
+ admin = FactoryBot.create(:user, :admin)
+ source_unit = transferable_unit('T1.1')
+ target_unit = transferable_unit('T1.1')
+ communication_set = transferable_set(source_unit)
+ add_auth_header_for(user: admin)
+
+ get "/api/units/#{source_unit.id}/communication_sets/#{communication_set.id}/export"
+
+ assert_equal 200, last_response.status
+ document = last_response_body
+
+ assert_equal 'ontrack.communication_set', document['format']
+
+ post_json "/api/units/#{target_unit.id}/communication_sets/import", document: document
+
+ assert_equal 201, last_response.status
+ assert_equal true, last_response_body['executable']
+ assert_equal(['Chase T1.1'], last_response_body['rules'].map { |rule| rule['name'] })
+ end
+
+ def test_importing_without_the_referenced_task_flags_the_rule_and_blocks_execution
+ admin = FactoryBot.create(:user, :admin)
+ source_unit = transferable_unit('T1.1')
+ target_unit = transferable_unit('Different')
+ communication_set = transferable_set(source_unit)
+ add_auth_header_for(user: admin)
+
+ get "/api/units/#{source_unit.id}/communication_sets/#{communication_set.id}/export"
+ post_json "/api/units/#{target_unit.id}/communication_sets/import", document: last_response_body
+
+ assert_equal 201, last_response.status
+ assert_equal false, last_response_body['executable']
+ assert_equal([true], last_response_body['rules'].map { |rule| rule['unresolved'] })
+
+ imported_id = last_response_body['id']
+
+ post_json "/api/units/#{target_unit.id}/communication_sets/#{imported_id}/execute", {}
+
+ assert_equal 409, last_response.status
+ assert_equal(['Chase T1.1'], last_response_body['unresolved_rules'].map { |rule| rule['name'] })
+ end
+
+ def test_repointing_a_condition_clears_the_unresolved_flag_it_returns
+ admin = FactoryBot.create(:user, :admin)
+ source_unit = transferable_unit('T1.1')
+ target_unit = transferable_unit('Different')
+ add_auth_header_for(user: admin)
+
+ get "/api/units/#{source_unit.id}/communication_sets/#{transferable_set(source_unit).id}/export"
+ post_json "/api/units/#{target_unit.id}/communication_sets/import", document: last_response_body
+
+ imported_id = last_response_body['id']
+ rule = last_response_body['rules'].first
+ condition = rule['conditions'].first
+
+ assert_equal true, condition['unresolved']
+
+ put_json "/api/units/#{target_unit.id}/communication_rules/#{rule['id']}/conditions/#{condition['id']}",
+ communication_condition: {
+ type: 'TaskDefinitionStatusCondition',
+ operator: 'equal_to',
+ task_definition_id: target_unit.task_definitions.first.id,
+ task_statuses: ['not_started']
+ }
+
+ assert_equal 200, last_response.status
+ assert_equal false, last_response_body['unresolved']
+
+ get "/api/units/#{target_unit.id}/communication_sets/#{imported_id}"
+
+ assert_equal true, last_response_body['executable']
+ assert_equal([false], last_response_body['rules'].map { |item| item['unresolved'] })
+ end
+
+ def test_importing_a_rule_document_as_a_set_is_rejected
+ admin = FactoryBot.create(:user, :admin)
+ unit = transferable_unit('T1.1')
+ rule = transferable_set(unit).communication_rules.first
+ add_auth_header_for(user: admin)
+
+ get "/api/units/#{unit.id}/communication_rules/#{rule.id}/export"
+
+ assert_equal 'ontrack.communication_rule', last_response_body['format']
+
+ post_json "/api/units/#{unit.id}/communication_sets/import", document: last_response_body
+
+ assert_equal 400, last_response.status
+ assert_match 'ontrack.communication_set', last_response_body['error']
+ end
+
+ private
+
+ def transferable_unit(abbreviation)
+ unit = FactoryBot.create(
+ :unit,
+ with_students: false,
+ task_count: 0,
+ stream_count: 0,
+ tutorials: 0,
+ outcome_count: 0,
+ staff_count: 1,
+ campus_count: 1
+ )
+
+ unit.task_definitions.create!(
+ name: "Task #{abbreviation}",
+ abbreviation: abbreviation,
+ description: 'Test task',
+ weighting: 1,
+ target_grade: 0,
+ start_date: unit.start_date,
+ target_date: unit.start_date + 1.week,
+ upload_requirements: []
+ )
+
+ unit.reload
+ end
+
+ def transferable_set(unit)
+ communication_set = unit.communication_sets.create!(name: 'Nudges', active: true)
+ rule = communication_set.communication_rules.create!(name: 'Chase T1.1', operator: 'and', position: 0)
+
+ rule.communication_conditions.create!(
+ type: 'TaskDefinitionStatusCondition',
+ operator: 'equal_to',
+ task_definition: unit.task_definitions.first,
+ task_statuses: ['not_started']
+ )
+
+ rule.communication_actions.create!(type: 'EmailStudentAction', subject: 'Get started', body: 'Hi')
+
+ communication_set
+ end
+end
diff --git a/test/api/csv_test.rb b/test/api/csv_test.rb
index 39845dcd38..74cd05849b 100644
--- a/test/api/csv_test.rb
+++ b/test/api/csv_test.rb
@@ -23,7 +23,7 @@ def test_download_csv_all_task_definitions_unit
assert_equal 200, last_response.status
# Check for file
- assert_equal "attachment; filename=COS10001-Tasks.csv",last_response.headers["content-disposition"]
+ assert_equal "attachment; filename=COS10001-Tasks.csv", last_response.headers["content-disposition"]
end
def test_download_csv_all_task_definitions_unit_with_empty_unit_id
@@ -39,10 +39,9 @@ def test_download_csv_all_task_definitions_unit_with_empty_unit_id
assert_equal 404, last_response.status
end
- #3: Testing for unit ID error with incorrect user ID
- #GET /api/csv/task_definitions
+ # 3: Testing for unit ID error with incorrect user ID
+ # GET /api/csv/task_definitions
def test_download_csv_all_task_definitions_unit_with_incorrect_unit_id
-
unit_id_to_test = '999'
# auth_token and username added to header
@@ -55,10 +54,9 @@ def test_download_csv_all_task_definitions_unit_with_incorrect_unit_id
assert_equal 404, last_response.status
end
- #4: Testing for unit ID error with string user ID
- #GET /api/csv/task_definitions
+ # 4: Testing for unit ID error with string user ID
+ # GET /api/csv/task_definitions
def test_download_csv_all_task_definitions_unit_with_string_unit_id
-
unit_id_to_test = 'string'
# auth_token and username added to header
@@ -71,10 +69,9 @@ def test_download_csv_all_task_definitions_unit_with_string_unit_id
assert_equal 400, last_response.status
end
- #5: Testing for authentication failure with incorrect token
- #GET /api/csv/task_definitions
+ # 5: Testing for authentication failure with incorrect token
+ # GET /api/csv/task_definitions
def test_download_csv_all_task_definitions_unit_with_incorrect_auth_token
-
unit_id_to_test = 'string'
# auth_token and username added to header
@@ -87,17 +84,16 @@ def test_download_csv_all_task_definitions_unit_with_incorrect_auth_token
assert_equal 419, last_response.status
end
- #6: Testing for authentication failure with empty token
- #GET /api/csv/task_definitions
+ # 6: Testing for authentication failure with empty token
+ # GET /api/csv/task_definitions
def test_download_csv_all_task_definitions_unit_with_empty_auth_token
-
unit_id_to_test = 'string'
# auth_token and username added to header
add_auth_header_for(user: User.first)
# Overwrite header for empty auth_token
- header 'auth_token',''
+ header 'auth_token', ''
# perform the get
get "/api/csv/task_definitions?unit_id=#{unit_id_to_test}"
@@ -106,7 +102,7 @@ def test_download_csv_all_task_definitions_unit_with_empty_auth_token
assert_equal 419, last_response.status
end
- #####--------------POST tests - Upload CSV of task definitions to the provided unit------------######
+ # ####--------------POST tests - Upload CSV of task definitions to the provided unit------------######
# 7: Testing for CSV upload all task definitions for the given unit
# POST /api/csv/task_definitions
@@ -181,7 +177,7 @@ def test_csv_upload_normalises_bad_upload_requirement_file_keys
# Ensure our test csv has unordered or duplicate file keys
# This scenario can happen when moving upload requirements around from different tasks
expected_upload_requirements_by_task = {
- '1.1P' => '[{"key":"file1","name":"HelloWorld.pas","type":"code"},{"key":"file1","name":"Screenshot","type":"image"}]',
+ '1.1P' => '[{"key":"file1","name":"HelloWorld.cpp","type":"code"},{"key":"file1","name":"Screenshot","type":"image"}]',
'1.2P' => '[{"key":"file0","name":"PictureDrawing.pas","type":"code"},{"key":"file5","name":"Screenshot","type":"image"}]',
'1.3P' => '[{"key":"file0","name":"PictureDrawing.pas","type":"code"},{"key":"file3","name":"Screenshot","type":"image"},{"key":"file2","name":"Screenshot","type":"image"}]'
}
@@ -221,7 +217,6 @@ def test_csv_upload_normalises_bad_upload_requirement_file_keys
# 8: Testing for CSV upload failure due to incorrect auth token
# POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_incorrect_auth_token
-
data_to_post = {
unit_id: '1',
file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv')
@@ -236,10 +231,9 @@ def test_csv_upload_all_task_definitions_unit_incorrect_auth_token
assert_equal 419, last_response.status
end
- #9: Testing for CSV upload failure due to empty auth token
- #POST /api/csv/task_definitions
+ # 9: Testing for CSV upload failure due to empty auth token
+ # POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_empty_auth_token
-
data_to_post = {
unit_id: '1',
file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv')
@@ -249,7 +243,7 @@ def test_csv_upload_all_task_definitions_unit_empty_auth_token
add_auth_header_for(user: User.first)
# Overwrite header for empty auth_token
- header 'auth_token',''
+ header 'auth_token', ''
# perform the POST
post "/api/csv/task_definitions", data_to_post
@@ -257,10 +251,9 @@ def test_csv_upload_all_task_definitions_unit_empty_auth_token
assert_equal 419, last_response.status
end
- #10: Testing for CSV upload failure due to string unit ID
- #POST /api/csv/task_definitions
+ # 10: Testing for CSV upload failure due to string unit ID
+ # POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_string_unit_id
-
data_to_post = {
unit_id: 'string',
file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv')
@@ -275,10 +268,9 @@ def test_csv_upload_all_task_definitions_unit_string_unit_id
assert_equal 400, last_response.status
end
- #11: Testing for CSV upload failure due to empty unit ID
- #POST /api/csv/task_definitions
+ # 11: Testing for CSV upload failure due to empty unit ID
+ # POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_empty_unit_id
-
data_to_post = {
unit_id: '',
file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv')
@@ -293,10 +285,9 @@ def test_csv_upload_all_task_definitions_unit_empty_unit_id
assert_equal 404, last_response.status
end
- #12: Testing for CSV upload of xlsx file type
- #POST /api/csv/task_definitions
+ # 12: Testing for CSV upload of xlsx file type
+ # POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_xlsx
-
unit = FactoryBot.create(:unit, with_students: false)
data_to_post = {
@@ -319,10 +310,9 @@ def test_csv_upload_all_task_definitions_unit_xlsx
assert_equal tdc + 1, unit.task_definitions.count
end
- #13: Testing for CSV upload failure due to incorrect file type (PDF)
- #POST /api/csv/task_definitions
+ # 13: Testing for CSV upload failure due to incorrect file type (PDF)
+ # POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_incorrect_file_pdf
-
data_to_post = {
unit_id: '1',
file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.pdf')
@@ -337,13 +327,12 @@ def test_csv_upload_all_task_definitions_unit_incorrect_file_pdf
assert_equal 403, last_response.status
end
- #14: Testing for CSV upload failure due to no file
- #POST /api/csv/task_definitions
+ # 14: Testing for CSV upload failure due to no file
+ # POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_no_file
-
data_to_post = {
unit_id: '1',
- file: '',
+ file: ''
}
# auth_token and username added to header
@@ -355,10 +344,9 @@ def test_csv_upload_all_task_definitions_unit_no_file
assert_equal 400, last_response.status
end
- #15: Testing for CSV upload failure due to non-existant unit id
- #POST /api/csv/task_definitions
+ # 15: Testing for CSV upload failure due to non-existant unit id
+ # POST /api/csv/task_definitions
def test_csv_upload_all_task_definitions_unit_incorrect_unit_id
-
data_to_post = {
unit_id: '9999',
file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv')
@@ -373,12 +361,11 @@ def test_csv_upload_all_task_definitions_unit_incorrect_unit_id
assert_equal 404, last_response.status
end
- #####--------------GET tests - Download CSV of all students in this unit------------######
+ # ####--------------GET tests - Download CSV of all students in this unit------------######
- #16: Testing for CSV download of all the students in a unit
- #GET /api/csv/units/{id}
+ # 16: Testing for CSV download of all the students in a unit
+ # GET /api/csv/units/{id}
def test_download_csv_all_students_in_unit
-
unit_id_to_test = '1'
# auth_token and username added to header
@@ -391,13 +378,12 @@ def test_download_csv_all_students_in_unit
assert_equal 200, last_response.status
# Check for file
- assert_equal "attachment; filename=COS10001-Students.csv",last_response.headers["content-disposition"]
+ assert_equal "attachment; filename=COS10001-Students.csv", last_response.headers["content-disposition"]
end
- #18: Testing for unit ID error with string unit ID
- #GET /api/csv/units/{id}
+ # 18: Testing for unit ID error with string unit ID
+ # GET /api/csv/units/{id}
def test_download_csv_all_students_in_unit_with_string_unit_id
-
unit_id_to_test = 'string'
# auth_token and username added to header
@@ -410,10 +396,9 @@ def test_download_csv_all_students_in_unit_with_string_unit_id
assert_equal 404, last_response.status
end
- #19: Testing for unit ID error with incorrect (non-existant) user ID
- #GET /api/csv/units/{id}
+ # 19: Testing for unit ID error with incorrect (non-existant) user ID
+ # GET /api/csv/units/{id}
def test_download_csv_all_students_in_unit_with_incorrect_unit_id
-
unit_id_to_test = '999'
# auth_token and username added to header
@@ -426,10 +411,9 @@ def test_download_csv_all_students_in_unit_with_incorrect_unit_id
assert_equal 404, last_response.status
end
- #20: Testing for authentication failure with incorrect token
- #GET /api/csv/units/{id}
+ # 20: Testing for authentication failure with incorrect token
+ # GET /api/csv/units/{id}
def test_download_csv_all_students_in_unit_with_incorrect_auth_token
-
unit_id_to_test = '1'
# auth_token and username added to header
@@ -442,17 +426,16 @@ def test_download_csv_all_students_in_unit_with_incorrect_auth_token
assert_equal 419, last_response.status
end
- #21: Testing for authentication failure with empty token
- #GET /api/csv/units/{id}
+ # 21: Testing for authentication failure with empty token
+ # GET /api/csv/units/{id}
def test_download_csv_all_students_in_unit_with_empty_auth_token
-
unit_id_to_test = '1'
# auth_token and username added to header
add_auth_header_for(user: User.first)
- #Override header for empty auth_token
- header 'auth_token',''
+ # Override header for empty auth_token
+ header 'auth_token', ''
# perform the get
get "/api/csv/units/#{unit_id_to_test}"
@@ -461,7 +444,7 @@ def test_download_csv_all_students_in_unit_with_empty_auth_token
assert_equal 419, last_response.status
end
- #####--------------POST tests - Upload CSV of all the students in a unit------------######
+ # ####--------------POST tests - Upload CSV of all the students in a unit------------######
# 22: Testing for CSV upload of all the students in a unit
# POST /api/csv/units/{id}
@@ -490,8 +473,8 @@ def test_csv_upload_all_students_in_unit
end
end
- #23: Testing for CSV upload failure due to incorrect auth token
- #POST /api/csv/units/{id}
+ # 23: Testing for CSV upload failure due to incorrect auth token
+ # POST /api/csv/units/{id}
def test_csv_upload_all_students_in_unit_incorrect_auth_token
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
@@ -508,8 +491,8 @@ def test_csv_upload_all_students_in_unit_incorrect_auth_token
assert_equal 419, last_response.status
end
- #24: Testing for CSV upload failure due to empty auth token
- #POST /api/csv/units/{id}
+ # 24: Testing for CSV upload failure due to empty auth token
+ # POST /api/csv/units/{id}
def test_csv_upload_all_students_in_unit_empty_auth_token
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
data_to_post = {
@@ -521,7 +504,7 @@ def test_csv_upload_all_students_in_unit_empty_auth_token
add_auth_header_for(user: User.first)
# Override the header for empty auth_token
- header 'auth_token',''
+ header 'auth_token', ''
# perform the POST
post "/api/csv/units/#{unit.id}", data_to_post
@@ -529,8 +512,8 @@ def test_csv_upload_all_students_in_unit_empty_auth_token
assert_equal 419, last_response.status
end
- #25: Testing for CSV upload failure due to string unit ID
- #POST /api/csv/units/{id}
+ # 25: Testing for CSV upload failure due to string unit ID
+ # POST /api/csv/units/{id}
def test_csv_upload_all_students_in_unit_string_unit_id
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/COS10001-Students.csv')
@@ -545,8 +528,8 @@ def test_csv_upload_all_students_in_unit_string_unit_id
assert_equal 404, last_response.status
end
- #28: Testing for CSV upload failure due to incorrect file type (PDF)
- #POST /api/csv/units/{id}
+ # 28: Testing for CSV upload failure due to incorrect file type (PDF)
+ # POST /api/csv/units/{id}
def test_csv_upload_all_students_in_unit_incorrect_file_pdf
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
@@ -563,8 +546,8 @@ def test_csv_upload_all_students_in_unit_incorrect_file_pdf
assert_equal 403, last_response.status
end
- #29: Testing for CSV upload failure due to no file
- #POST /api/csv/units/{id}
+ # 29: Testing for CSV upload failure due to no file
+ # POST /api/csv/units/{id}
def test_csv_upload_all_students_in_unit_no_file
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
@@ -581,10 +564,9 @@ def test_csv_upload_all_students_in_unit_no_file
assert_equal 400, last_response.status
end
- #30: Testing for CSV upload failure due to non-existant unit id
- #POST /api/csv/units/{id}
+ # 30: Testing for CSV upload failure due to non-existant unit id
+ # POST /api/csv/units/{id}
def test_csv_upload_all_students_in_unit_incorrect_unit_id
-
unit_id_to_test = '999'
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/COS10001-Students.csv')
@@ -598,10 +580,10 @@ def test_csv_upload_all_students_in_unit_incorrect_unit_id
assert_equal 404, last_response.status
end
- #####--------------POST tests - Upload CSV with the students to un-enrol from the unit------------######
+ # ####--------------POST tests - Upload CSV with the students to un-enrol from the unit------------######
- #31: Testing for CSV upload of all the students in a unit
- #POST /api/csv/units/{id}/withdraw
+ # 31: Testing for CSV upload of all the students in a unit
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
@@ -626,8 +608,8 @@ def test_csv_upload_students_un_enroll_in_unit
assert_equal false, Project.where(user_id: user_id_check).last.enrolled
end
- #32: Testing for CSV upload failure due to incorrect auth token
- #POST /api/csv/units/{id}/withdraw
+ # 32: Testing for CSV upload failure due to incorrect auth token
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_incorrect_auth_token
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
@@ -653,15 +635,13 @@ def test_csv_upload_students_un_enroll_in_unit_incorrect_auth_token
assert_equal true, unit.projects.last.enrolled
end
- #33: Testing for CSV upload failure due to empty auth token
- #POST /api/csv/units/{id}/withdraw
+ # 33: Testing for CSV upload failure due to empty auth token
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_empty_auth_token
-
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
response = unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv'
assert_equal 1, unit.projects.count, response
-
unit_id_to_test = '1'
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/COS10001-Students.csv')
@@ -670,8 +650,8 @@ def test_csv_upload_students_un_enroll_in_unit_empty_auth_token
# auth_token and username added to header
add_auth_header_for(user: User.first)
- #Override header for empty auth_token
- header 'auth_token',''
+ # Override header for empty auth_token
+ header 'auth_token', ''
user_id_check = unit.projects.last.user_id
@@ -685,10 +665,9 @@ def test_csv_upload_students_un_enroll_in_unit_empty_auth_token
assert_equal true, unit.projects.last.enrolled
end
- #34: Testing for CSV upload failure due to string unit ID
- #POST /api/csv/units/{id}/withdraw
+ # 34: Testing for CSV upload failure due to string unit ID
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_string_unit_id
-
# Adding user to withdraw from unit
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv'
@@ -714,10 +693,9 @@ def test_csv_upload_students_un_enroll_in_unit_string_unit_id
unit.destroy
end
- #35: Testing for CSV upload failure due to empty unit ID
- #POST /api/csv/units/{id}/withdraw
+ # 35: Testing for CSV upload failure due to empty unit ID
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_empty_unit_id
-
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv'
@@ -741,10 +719,9 @@ def test_csv_upload_students_un_enroll_in_unit_empty_unit_id
assert_equal true, Project.where(user_id: user_id_check).last.enrolled
end
- #36: Testing for CSV uploadof XLSX
- #POST /api/csv/units/{id}/withdraw
+ # 36: Testing for CSV uploadof XLSX
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_xlsx
-
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv'
@@ -768,14 +745,12 @@ def test_csv_upload_students_un_enroll_in_unit_xlsx
assert_equal 1, last_response_body['success'].count, last_response_body
end
- #37: Testing for CSV upload failure due to incorrect file type (PDF)
- #POST /api/csv/units/{id}/withdraw
+ # 37: Testing for CSV upload failure due to incorrect file type (PDF)
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_incorrect_file_pdf
-
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv'
-
unit_id_to_test = unit.id
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/COS10001-Students.pdf')
@@ -799,7 +774,6 @@ def test_csv_upload_students_un_enroll_in_unit_incorrect_file_pdf
# 38: Testing for CSV upload failure due to no file
# POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_no_file
-
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv'
@@ -823,14 +797,13 @@ def test_csv_upload_students_un_enroll_in_unit_no_file
assert_equal true, Project.where(user_id: user_id_check).last.enrolled
end
- #39: Testing for CSV upload failure due to non-existant unit id
- #POST /api/csv/units/{id}/withdraw
+ # 39: Testing for CSV upload failure due to non-existant unit id
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_students_un_enroll_in_unit_incorrect_unit_id
-
unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0)
unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv'
- # auth_token and username added to header
+ # auth_token and username added to header
add_auth_header_for(user: User.first)
unit_id_to_test = '999'
@@ -878,13 +851,12 @@ def test_download_csv_all_student_tasks_in_unit
end
end
- #41: Testing for unit ID error with empty user ID
- #GET /api/csv/units/{id}/task_completion
+ # 41: Testing for unit ID error with empty user ID
+ # GET /api/csv/units/{id}/task_completion
def test_download_csv_all_student_tasks_in_unit_with_empty_unit_id
-
unit_id_to_test = ''
- # auth_token and username added to header
+ # auth_token and username added to header
add_auth_header_for(user: User.first)
# perform the get
@@ -894,10 +866,9 @@ def test_download_csv_all_student_tasks_in_unit_with_empty_unit_id
assert_equal 404, last_response.status
end
- #42: Testing for unit ID error with string user ID
- #GET /api/csv/units/{id}/task_completion
+ # 42: Testing for unit ID error with string user ID
+ # GET /api/csv/units/{id}/task_completion
def test_download_csv_all_student_tasks_in_unit_with_string_unit_id
-
unit_id_to_test = 'string'
# auth_token and username added to header
@@ -910,10 +881,9 @@ def test_download_csv_all_student_tasks_in_unit_with_string_unit_id
assert_equal 404, last_response.status
end
- #43: Testing for unit ID error with incorrect (non-existant) user ID
- #GET /api/csv/units/{id}/task_completion
+ # 43: Testing for unit ID error with incorrect (non-existant) user ID
+ # GET /api/csv/units/{id}/task_completion
def test_download_csv_all_student_tasks_in_unit_with_incorrect_unit_id
-
unit_id_to_test = '999'
# auth_token and username added to header
@@ -926,10 +896,9 @@ def test_download_csv_all_student_tasks_in_unit_with_incorrect_unit_id
assert_equal 404, last_response.status
end
- #44: Testing for authentication failure with incorrect token
- #GET /api/csv/units/{id}/task_completion
+ # 44: Testing for authentication failure with incorrect token
+ # GET /api/csv/units/{id}/task_completion
def test_download_csv_all_student_tasks_in_unit_with_incorrect_auth_token
-
unit_id_to_test = '1'
# Add authentication token to header
@@ -942,10 +911,9 @@ def test_download_csv_all_student_tasks_in_unit_with_incorrect_auth_token
assert_equal 419, last_response.status
end
- #45: Testing for authentication failure with empty token
- #GET /api/csv/units/{id}/task_completion
+ # 45: Testing for authentication failure with empty token
+ # GET /api/csv/units/{id}/task_completion
def test_download_csv_all_student_tasks_in_unit_with_empty_auth_token
-
unit_id_to_test = '1'
# Add authentication token to header
@@ -989,10 +957,63 @@ def test_download_csv_stats_tutor_assessed
end
end
- #47: Testing for unit ID error with empty user ID
- #GET /api/csv/units/{id}/tutor_assessments
- def test_download_csv_stats_tutor_assessed_with_empty_unit_id
+ # GET /api/csv/units/{id}/overflow_task_claims
+ def test_download_csv_overflow_task_claims
+ Sidekiq::Testing.inline! do
+ unit = create(:unit, student_count: 1, task_count: 1, stream_count: 0, tutorials: 1)
+ convenor = unit.main_convenor.user
+ tutor_user = create(:user, :tutor, first_name: 'ATutor', last_name: 'Tutor')
+ tutor_role = unit.employ_staff(tutor_user, Role.tutor)
+ tutorial = unit.tutorials.first
+ tutorial.update!(unit_role: tutor_role)
+
+ project = unit.active_projects.first
+ student = project.student
+ student.update!(username: 'student-one', student_id: 's1234567')
+
+ task_definition = unit.task_definitions.first
+ task_definition.update!(abbreviation: 'T1')
+
+ task = project.task_for_task_definition(task_definition)
+ claimed_at = Time.zone.parse('2026-04-01 10:30:00 UTC')
+ create(
+ :overflow_task_claim_log,
+ unit_id: unit.id,
+ task_id: task.id,
+ claimed_by_unit_role_id: tutor_role.id,
+ claimed_by_user_id: tutor_user.id,
+ original_tutor_user_id: tutor_user.id,
+ student_user_id: student.id,
+ days_awaiting_feedback: 12,
+ claimed_at: claimed_at
+ )
+ add_auth_header_for(user: convenor)
+
+ get "/api/csv/units/#{unit.id}/overflow_task_claims"
+
+ assert_equal 200, last_response.status
+ assert_equal unit.overflow_task_claims_csv, last_response_body['result']
+
+ rows = CSV.parse(last_response_body['result'], headers: true)
+ assert_equal 1, rows.length
+ assert_equal 'ATutor Tutor', rows[0]['Tutor who claimed']
+ assert_equal tutor_role.id.to_s, rows[0]['Claiming Unit Role ID']
+ assert_equal 'ATutor Tutor', rows[0]['Original Tutor']
+ assert_equal 'student-one', rows[0]['Student Username']
+ assert_equal 's1234567', rows[0]['Student ID']
+ assert_equal task.id.to_s, rows[0]['Task ID']
+ assert_equal 'T1', rows[0]['Task Definition']
+ assert_equal '12', rows[0]['Days Awaiting Feedback']
+ assert_equal claimed_at.to_s, rows[0]['Timestamp']
+ assert_equal claimed_at, Time.zone.parse(rows[0]['Timestamp'])
+ Sidekiq::Testing.fake!
+ end
+ end
+
+ # 47: Testing for unit ID error with empty user ID
+ # GET /api/csv/units/{id}/tutor_assessments
+ def test_download_csv_stats_tutor_assessed_with_empty_unit_id
unit_id_to_test = ''
# Add authentication token to header
@@ -1005,10 +1026,9 @@ def test_download_csv_stats_tutor_assessed_with_empty_unit_id
assert_equal 404, last_response.status
end
- #48: Testing for unit ID error with string user ID
- #GET /api/csv/units/{id}/tutor_assessments
+ # 48: Testing for unit ID error with string user ID
+ # GET /api/csv/units/{id}/tutor_assessments
def test_download_csv_stats_tutor_assessed_with_string_unit_id
-
unit_id_to_test = 'string'
# Add authentication token to header
@@ -1020,10 +1040,9 @@ def test_download_csv_stats_tutor_assessed_with_string_unit_id
assert_equal 404, last_response.status
end
- #49: Testing for unit ID error with incorrect (non-existant) user ID
- #GET /api/csv/units/{id}/tutor_assessments
+ # 49: Testing for unit ID error with incorrect (non-existant) user ID
+ # GET /api/csv/units/{id}/tutor_assessments
def test_download_csv_stats_tutor_assessed_with_incorrect_unit_id
-
unit_id_to_test = '999'
# Add authentication token to header
@@ -1036,10 +1055,9 @@ def test_download_csv_stats_tutor_assessed_with_incorrect_unit_id
assert_equal 404, last_response.status
end
- #50: Testing for authentication failure with incorrect token
- #GET /api/csv/units/{id}/tutor_assessments
+ # 50: Testing for authentication failure with incorrect token
+ # GET /api/csv/units/{id}/tutor_assessments
def test_download_csv_stats_tutor_assessed_with_incorrect_auth_token
-
unit_id_to_test = '1'
# Add authentication token to header
@@ -1052,17 +1070,16 @@ def test_download_csv_stats_tutor_assessed_with_incorrect_auth_token
assert_equal 419, last_response.status
end
- #51: Testing for authentication failure with empty token
- #GET /api/csv/units/{id}/tutor_assessments
+ # 51: Testing for authentication failure with empty token
+ # GET /api/csv/units/{id}/tutor_assessments
def test_download_csv_stats_tutor_assessed_with_empty_auth_token
-
unit_id_to_test = '1'
# Add authentication token to header
add_auth_header_for(user: User.first)
- #Override header for empty auth_token
- header 'auth_token',''
+ # Override header for empty auth_token
+ header 'auth_token', ''
# perform the get
get "/api/csv/units/#{unit_id_to_test}/tutor_assessments"
@@ -1071,12 +1088,11 @@ def test_download_csv_stats_tutor_assessed_with_empty_auth_token
assert_equal 419, last_response.status
end
- #####--------------GET tests - Download CSV of all users------------######
+ # ####--------------GET tests - Download CSV of all users------------######
- #52: Testing for CSV download of stats related to number of tasks assessed by each tutor
- #GET /api/csv/users
+ # 52: Testing for CSV download of stats related to number of tasks assessed by each tutor
+ # GET /api/csv/users
def test_download_csv_all_users
-
# Add authentication token to header
add_auth_header_for(user: User.first)
@@ -1087,13 +1103,12 @@ def test_download_csv_all_users
assert_equal 200, last_response.status
# Check for file
- assert_equal "attachment; filename=doubtfire_users.csv",last_response.headers["content-disposition"]
+ assert_equal "attachment; filename=doubtfire_users.csv", last_response.headers["content-disposition"]
end
- #53: Testing for authentication failure with incorrect token
- #GET /api/csv/users
+ # 53: Testing for authentication failure with incorrect token
+ # GET /api/csv/users
def test_download_csv_all_users_with_incorrect_auth_token
-
# Add authentication token to header
add_auth_header_for(username: 'aadmin', auth_token: 'wrong_token')
@@ -1104,15 +1119,14 @@ def test_download_csv_all_users_with_incorrect_auth_token
assert_equal 419, last_response.status
end
- #54: Testing for authentication failure with empty token
- #GET /api/csv/users
+ # 54: Testing for authentication failure with empty token
+ # GET /api/csv/users
def test_download_csv_all_users_with_empty_auth_token
-
# Add authentication token to header
add_auth_header_for(user: User.first)
- #Override header for empty auth_token
- header 'auth_token',''
+ # Override header for empty auth_token
+ header 'auth_token', ''
# perform the get
get "/api/csv/users"
@@ -1121,12 +1135,11 @@ def test_download_csv_all_users_with_empty_auth_token
assert_equal 419, last_response.status
end
- #####--------------POST tests - Upload CSV of users------------######
+ # ####--------------POST tests - Upload CSV of users------------######
- #55: Testing for CSV upload of users
- #POST /api/csv/users
+ # 55: Testing for CSV upload of users
+ # POST /api/csv/users
def test_csv_upload_users
-
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv')
}
@@ -1142,10 +1155,9 @@ def test_csv_upload_users
assert_equal 'test.case@doubtfire.com', User.last.email
end
- #56: Testing for CSV upload failure due to incorrect auth token
- #POST /api/csv/units/{id}/withdraw
+ # 56: Testing for CSV upload failure due to incorrect auth token
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_users_incorrect_auth_token
-
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv')
}
@@ -1160,19 +1172,18 @@ def test_csv_upload_users_incorrect_auth_token
assert_equal 419, last_response.status
end
- #57: Testing for CSV upload failure due to empty auth token
- #POST /api/csv/units/{id}/withdraw
+ # 57: Testing for CSV upload failure due to empty auth token
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_users_empty_auth_token
-
data_to_post = {
- file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv'),
+ file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv')
}
# Add authentication token to header
add_auth_header_for(user: User.first)
- #Override header for empty auth_token
- header 'auth_token',''
+ # Override header for empty auth_token
+ header 'auth_token', ''
# perform the POST to withdraw user from the unit
post "/api/csv/users", data_to_post
@@ -1181,13 +1192,12 @@ def test_csv_upload_users_empty_auth_token
assert_equal 419, last_response.status
end
- #58: Testing for CSV upload of XLSX
- #POST /api/csv/units/{id}/withdraw
+ # 58: Testing for CSV upload of XLSX
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_users_xlsx
-
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/doubtfire_users.xlsx')
- }
+ }
# Add authentication token to header
add_auth_header_for(user: User.first)
@@ -1199,10 +1209,9 @@ def test_csv_upload_users_xlsx
assert_equal 1, last_response_body['ignored'].count, last_response_body
end
- #59: Testing for CSV upload failure due to incorrect file type (PDF)
- #POST /api/csv/units/{id}/withdraw
+ # 59: Testing for CSV upload failure due to incorrect file type (PDF)
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_users_incorrect_file_pdf
-
data_to_post = {
file: upload_file_csv('test_files/csv_test_files/doubtfire_users.pdf')
}
@@ -1217,10 +1226,9 @@ def test_csv_upload_users_incorrect_file_pdf
assert_equal 403, last_response.status
end
- #60: Testing for CSV upload failure due to no file
- #POST /api/csv/units/{id}/withdraw
+ # 60: Testing for CSV upload failure due to no file
+ # POST /api/csv/units/{id}/withdraw
def test_csv_upload_users_no_file
-
data_to_post = {
file: ''
}
@@ -1243,20 +1251,20 @@ def test_upload_unit_feedback_chips
ulos = [
FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: unit.id, abbreviation: 'ULO1'),
- FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: unit.id, abbreviation: 'ULO2'),
+ FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: unit.id, abbreviation: 'ULO2')
]
other_ulos = [
- FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: other_unit.id, abbreviation: 'ULO1'),
+ FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: other_unit.id, abbreviation: 'ULO1')
]
td = FactoryBot.create(:task_definition, unit: unit, abbreviation: 'P1', outcome_count: 0)
tlos = [
FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: td.id, abbreviation: 'TLO1'),
- FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: td.id, abbreviation: 'TLO2'),
+ FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: td.id, abbreviation: 'TLO2')
]
other_td = FactoryBot.create(:task_definition, unit: other_unit, abbreviation: 'P1', outcome_count: 0)
other_tlos = [
FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: other_td.id, abbreviation: 'TLO1'),
- FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: other_td.id, abbreviation: 'TLO2'),
+ FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: other_td.id, abbreviation: 'TLO2')
]
admin = FactoryBot.create(:user, :admin)
diff --git a/test/api/d2l_test.rb b/test/api/d2l_test.rb
index 37eadc7ab3..2837702088 100644
--- a/test/api/d2l_test.rb
+++ b/test/api/d2l_test.rb
@@ -351,14 +351,14 @@ def test_post_grades
headers: { 'Content-Type' => 'application/json;charset=UTF-8' }
)
- assert_equal p1, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => p1.student.student_id, "UserName" => p1.student.username, "Email" => p1.student.email } )
- assert_equal p2, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => p2.student.username, "Email" => p2.student.email } )
- assert_equal p3, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => p3.student.email } )
- assert_nil D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => 'BLAH' } )
+ assert_equal p1, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => p1.student.student_id, "UserName" => p1.student.username, "Email" => p1.student.email })
+ assert_equal p2, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => p2.student.username, "Email" => p2.student.email })
+ assert_equal p3, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => p3.student.email })
+ assert_nil D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => 'BLAH' })
p1_put_request = stub_request(:put, "https://api.brightspace.com/d2l/api/le/1.47/12345/grades/98701/values/12345")
.with(
- body: {"{\"GradeObjectType\":1,\"PointsNumerator\":50}" => nil}
+ body: { "{\"GradeObjectType\":1,\"PointsNumerator\":50}" => nil }
).to_return(
status: 200,
headers: {
@@ -370,7 +370,7 @@ def test_post_grades
p2_put_request = stub_request(:put, "https://api.brightspace.com/d2l/api/le/1.47/12345/grades/98701/values/12346")
.with(
- body: { "{\"GradeObjectType\":1,\"PointsNumerator\":60}" => nil },
+ body: { "{\"GradeObjectType\":1,\"PointsNumerator\":60}" => nil }
).to_return(status: 200, headers: {})
D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345')
diff --git a/test/api/engagements_api_test.rb b/test/api/engagements_api_test.rb
new file mode 100644
index 0000000000..357c87339c
--- /dev/null
+++ b/test/api/engagements_api_test.rb
@@ -0,0 +1,496 @@
+require 'test_helper'
+
+class EngagementsApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include ActiveSupport::Testing::TimeHelpers
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+ include TestHelpers::TestFileHelper
+
+ def app
+ Rails.application
+ end
+
+ def setup
+ @unit = FactoryBot.create(:unit, with_students: false)
+ @student = FactoryBot.create(:user, :student)
+ @project = @unit.enrol_student(@student, nil)
+ @tutor = FactoryBot.create(:user, :tutor)
+ @unit.employ_staff(@tutor, Role.tutor)
+ @convenor = @unit.main_convenor_user
+ end
+
+ def engagement_params(overrides = {})
+ {
+ engagement_type: 'attendance',
+ note: 'Attended the weekly discussion.',
+ occurred_at: Time.zone.now.iso8601
+ }.merge(overrides)
+ end
+
+ def create_engagement(user: @tutor, overrides: {})
+ add_auth_header_for(user: user)
+ post_json "/api/projects/#{@project.id}/engagements", engagement_params(overrides)
+ assert_equal 201, last_response.status, last_response.body
+ Engagement.find(last_response_body['id'])
+ end
+
+ def test_tutor_can_create_and_student_can_read_engagements
+ later = create_engagement(overrides: { engagement_type: 'forum', occurred_at: 1.day.from_now.iso8601 })
+ earlier = create_engagement(overrides: { engagement_type: 'negative', occurred_at: 1.day.ago.iso8601 })
+
+ add_auth_header_for(user: @student)
+ get "/api/projects/#{@project.id}/engagements"
+
+ assert_equal 200, last_response.status
+ assert_equal [earlier.id, later.id], (last_response_body.map { |engagement| engagement['id'] })
+ assert_equal 'negative', last_response_body.first['engagement_type']
+ assert_equal @tutor.id, last_response_body.first.dig('user', 'id')
+ end
+
+ def test_tutor_can_create_one_engagement_for_multiple_students
+ other_student = FactoryBot.create(:user, :student)
+ other_project = @unit.enrol_student(other_student, nil)
+
+ engagement = create_engagement(
+ overrides: { project_ids: [@project.id, other_project.id] }
+ )
+
+ assert_equal [engagement.id], @project.engagements.pluck(:id)
+ assert_equal [engagement.id], other_project.shared_engagements.pluck(:id)
+ assert_equal [other_project.id], engagement.additional_projects.pluck(:id)
+
+ add_auth_header_for(user: other_student)
+ get "/api/projects/#{other_project.id}/engagements/#{engagement.id}"
+ assert_equal [@student.id, other_student.id].sort,
+ last_response_body['students'].pluck('id').sort
+ end
+
+ def test_records_automatic_class_discussion_with_tutorial_context
+ tutorial = @unit.tutorials.first
+ tutorial.campus.update!(timezone: 'UTC')
+ @project.enrol_in(tutorial)
+ tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00')
+ add_auth_header_for(user: @tutor)
+ task_definition = @unit.task_definitions.first
+
+ assert_difference 'Engagement.count', 1 do
+ travel_to(Time.zone.parse('2026-07-20 10:30:00 UTC')) do
+ post_json(
+ "/api/projects/#{@project.id}/engagements/class_discussion",
+ {
+ task_status_updates: [
+ {
+ task_definition_id: task_definition.id,
+ from_status: 'ready_for_feedback',
+ to_status: 'complete'
+ }
+ ]
+ }
+ )
+ end
+ end
+
+ assert_equal 201, last_response.status
+ engagement = @project.engagements.last
+ assert_equal 'Discussion', engagement.engagement_type
+ assert_equal(
+ "Updated task statuses during tutorial. #{task_definition.abbreviation}: Ready for Feedback → Complete.",
+ engagement.note
+ )
+ assert_equal @tutor, engagement.user
+ end
+
+ def test_uses_default_timezone_for_tutorial_without_campus
+ tutorial = @unit.tutorials.first
+ tutorial.update!(campus: nil, meeting_day: 'Monday', meeting_time: '10:00')
+ @project.enrol_in(tutorial)
+ add_auth_header_for(user: @tutor)
+
+ travel_to(Time.zone.parse('2026-07-20 10:30:00')) do
+ post_json(
+ "/api/projects/#{@project.id}/engagements/class_discussion",
+ { task_status_updates: [] }
+ )
+ end
+
+ assert_equal 201, last_response.status
+ assert_equal(
+ 'Class discussion during tutorial; no task statuses were updated.',
+ @project.engagements.last.note
+ )
+ end
+
+ def test_records_class_discussion_without_status_updates_outside_tutorial
+ add_auth_header_for(user: @tutor)
+
+ assert_difference 'Engagement.count', 1 do
+ post_json(
+ "/api/projects/#{@project.id}/engagements/class_discussion",
+ { task_status_updates: [] }
+ )
+ end
+
+ assert_equal 201, last_response.status
+ assert_equal(
+ 'Class discussion outside of tutorial; no task statuses were updated.',
+ @project.engagements.last.note
+ )
+ end
+
+ def test_debounces_automatic_class_discussion_engagements
+ add_auth_header_for(user: @tutor)
+ path = "/api/projects/#{@project.id}/engagements/class_discussion"
+ task_definitions = @unit.task_definitions.first(2)
+
+ assert_difference 'Engagement.count', 1 do
+ post_json(
+ path,
+ {
+ task_status_updates: [
+ {
+ task_definition_id: task_definitions.first.id,
+ from_status: 'ready_for_feedback',
+ to_status: 'complete'
+ }
+ ]
+ }
+ )
+ post_json(
+ path,
+ {
+ task_status_updates: [
+ {
+ task_definition_id: task_definitions.second.id,
+ from_status: 'discuss',
+ to_status: 'rediscuss'
+ }
+ ]
+ }
+ )
+ end
+
+ assert_equal false, last_response_body['recorded']
+ note = @project.engagements.last.note
+ assert_includes note, "#{task_definitions.first.abbreviation}: Ready for Feedback → Complete."
+ assert_includes note, "#{task_definitions.second.abbreviation}: Discuss → Rediscuss."
+ end
+
+ def test_student_cannot_record_automatic_class_discussion
+ add_auth_header_for(user: @student)
+
+ assert_no_difference 'Engagement.count' do
+ post_json(
+ "/api/projects/#{@project.id}/engagements/class_discussion",
+ { task_status_updates: [] }
+ )
+ end
+
+ assert_equal 403, last_response.status
+ end
+
+ def test_student_and_unrelated_tutor_cannot_create_engagements
+ add_auth_header_for(user: @student)
+ post_json "/api/projects/#{@project.id}/engagements", engagement_params
+ assert_equal 403, last_response.status
+
+ unrelated_tutor = FactoryBot.create(:user, :tutor)
+ add_auth_header_for(user: unrelated_tutor)
+ post_json "/api/projects/#{@project.id}/engagements", engagement_params
+ assert_equal 403, last_response.status
+ end
+
+ def test_only_author_with_current_teaching_access_can_edit
+ engagement = create_engagement
+ other_tutor = FactoryBot.create(:user, :tutor)
+ @unit.employ_staff(other_tutor, Role.tutor)
+
+ add_auth_header_for(user: other_tutor)
+ put_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}",
+ { note: 'Changed by another tutor.' }
+ )
+ assert_equal 403, last_response.status
+
+ add_auth_header_for(user: @tutor)
+ put_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}",
+ { note: 'Corrected note.', occurred_at: 2.days.ago.iso8601 }
+ )
+ assert_equal 200, last_response.status
+ assert_equal 'Corrected note.', engagement.reload.note
+
+ @unit.unit_role_for(@tutor).destroy!
+ assert Engagement.exists?(engagement.id)
+
+ put_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}",
+ { note: 'Changed after role removal.' }
+ )
+ assert_equal 403, last_response.status
+ assert_equal 'Corrected note.', engagement.reload.note
+ end
+
+ def test_tutor_can_delete_their_own_engagement
+ engagement = create_engagement
+
+ add_auth_header_for(user: @tutor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 200, last_response.status
+ assert_nil Engagement.find_by(id: engagement.id)
+ end
+
+ def test_tutor_cannot_delete_another_staff_members_engagement
+ engagement = create_engagement(user: @convenor)
+
+ add_auth_header_for(user: @tutor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 403, last_response.status
+ assert Engagement.exists?(engagement.id)
+ end
+
+ def test_convenor_can_delete_any_engagement
+ engagement = create_engagement
+
+ add_auth_header_for(user: @convenor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 200, last_response.status
+ assert_nil Engagement.find_by(id: engagement.id)
+ end
+
+ def test_student_cannot_delete_an_engagement
+ engagement = create_engagement
+
+ add_auth_header_for(user: @student)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 403, last_response.status
+ assert Engagement.exists?(engagement.id)
+ end
+
+ def test_tutor_cannot_delete_an_engagement_after_the_delete_window
+ engagement = create_engagement
+
+ travel_to (Engagement::DELETE_WINDOW + 1.minute).from_now do
+ add_auth_header_for(user: @tutor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 403, last_response.status
+ assert Engagement.exists?(engagement.id)
+
+ # The convenor has a longer window, so they can still delete it
+ add_auth_header_for(user: @convenor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 200, last_response.status
+ assert_nil Engagement.find_by(id: engagement.id)
+ end
+ end
+
+ def test_convenor_cannot_delete_an_engagement_after_the_convenor_delete_window
+ engagement = create_engagement
+
+ travel_to (Engagement::CONVENOR_DELETE_WINDOW + 1.minute).from_now do
+ add_auth_header_for(user: @convenor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 403, last_response.status
+ end
+
+ assert Engagement.exists?(engagement.id)
+ end
+
+ def test_student_and_teaching_staff_can_comment
+ engagement = create_engagement
+
+ add_auth_header_for(user: @student)
+ post_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments",
+ { comment: 'I have added the supporting context.' }
+ )
+ assert_equal 201, last_response.status
+ assert_equal @student.id, last_response_body.dig('user', 'id')
+
+ add_auth_header_for(user: @tutor)
+ post_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments",
+ { comment: 'Thanks, this clarifies the evidence.' }
+ )
+ assert_equal 201, last_response.status
+
+ unrelated_student = FactoryBot.create(:user, :student)
+ add_auth_header_for(user: unrelated_student)
+ post_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments",
+ { comment: 'Unrelated comment.' }
+ )
+ assert_equal 403, last_response.status
+
+ add_auth_header_for(user: @student)
+ get "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal 200, last_response.status
+ assert_equal 2, last_response_body['comments'].length
+ assert_equal(
+ ['I have added the supporting context.', 'Thanks, this clarifies the evidence.'],
+ last_response_body['comments'].map { |comment| comment['comment'] }
+ )
+ end
+
+ def test_comment_can_reply_to_comment_in_same_engagement
+ engagement = create_engagement
+
+ add_auth_header_for(user: @student)
+ post_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments",
+ { comment: 'Original comment.' }
+ )
+ assert_equal 201, last_response.status
+ original_comment_id = last_response_body['id']
+
+ add_auth_header_for(user: @tutor)
+ post_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments",
+ { comment: 'Reply comment.', reply_to_id: original_comment_id }
+ )
+ assert_equal 201, last_response.status
+ assert_equal original_comment_id, last_response_body['reply_to_id']
+
+ get "/api/projects/#{@project.id}/engagements/#{engagement.id}"
+ assert_equal original_comment_id, last_response_body['comments'].last['reply_to_id']
+ end
+
+ def test_comment_cannot_reply_to_comment_in_another_engagement
+ engagement = create_engagement
+ other_engagement = create_engagement(overrides: { note: 'Another engagement.' })
+ original_comment = engagement.engagement_comments.create!(
+ user: @student,
+ comment: 'Comment on the first engagement.'
+ )
+
+ add_auth_header_for(user: @tutor)
+ post_json(
+ "/api/projects/#{@project.id}/engagements/#{other_engagement.id}/comments",
+ { comment: 'Invalid reply.', reply_to_id: original_comment.id }
+ )
+ assert_equal 404, last_response.status
+ end
+
+ def test_comment_author_can_edit_within_ten_minutes
+ engagement = create_engagement
+ comment = engagement.engagement_comments.create!(user: @student, comment: 'Original comment.')
+
+ add_auth_header_for(user: @student)
+ put_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{comment.id}",
+ { comment: 'Updated comment.' }
+ )
+
+ assert_equal 200, last_response.status
+ assert_equal 'Updated comment.', comment.reload.comment
+ end
+
+ def test_comment_cannot_be_edited_after_ten_minutes_or_by_another_user
+ engagement = create_engagement
+ comment = engagement.engagement_comments.create!(user: @student, comment: 'Original comment.')
+
+ add_auth_header_for(user: @tutor)
+ put_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{comment.id}",
+ { comment: 'Tutor edit.' }
+ )
+ assert_equal 403, last_response.status
+
+ comment.update_column(:created_at, 11.minutes.ago)
+ add_auth_header_for(user: @student)
+ put_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{comment.id}",
+ { comment: 'Late edit.' }
+ )
+ assert_equal 403, last_response.status
+ assert_equal 'Original comment.', comment.reload.comment
+ end
+
+ def test_comment_author_and_convenor_can_delete
+ engagement = create_engagement
+ student_comment = engagement.engagement_comments.create!(
+ user: @student,
+ comment: 'Student comment.'
+ )
+
+ add_auth_header_for(user: @tutor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{student_comment.id}"
+ assert_equal 403, last_response.status
+
+ add_auth_header_for(user: @student)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{student_comment.id}"
+ assert_equal 200, last_response.status
+ assert_not EngagementComment.exists?(student_comment.id)
+
+ tutor_comment = engagement.engagement_comments.create!(user: @tutor, comment: 'Tutor comment.')
+ add_auth_header_for(user: @convenor)
+ delete "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{tutor_comment.id}"
+ assert_equal 200, last_response.status
+ assert_not EngagementComment.exists?(tutor_comment.id)
+ end
+
+ def test_rejects_file_and_url_together
+ add_auth_header_for(user: @tutor)
+ data = engagement_params(
+ evidence_url: 'https://example.com/evidence',
+ attachment: upload_file('test_files/submissions/boo.png', 'image/png')
+ )
+
+ post "/api/projects/#{@project.id}/engagements", data
+
+ assert_equal 400, last_response.status
+ assert_equal 0, @project.engagements.count
+ end
+
+ def test_image_attachment_can_be_retrieved_and_replaced_with_url
+ add_auth_header_for(user: @tutor)
+ data = engagement_params(
+ attachment: upload_file('test_files/submissions/Deakin_Logo.jpeg', 'image/jpeg')
+ )
+
+ post "/api/projects/#{@project.id}/engagements", data
+ assert_equal 201, last_response.status, last_response.body
+
+ engagement = Engagement.find(last_response_body['id'])
+ assert engagement.attachment?
+ assert File.exist?(engagement.attachment_path)
+
+ add_auth_header_for(user: @student)
+ get "/api/projects/#{@project.id}/engagements/#{engagement.id}/attachment"
+ assert_equal 200, last_response.status
+
+ add_auth_header_for(user: @tutor)
+ old_attachment_path = engagement.attachment_path
+ put_json(
+ "/api/projects/#{@project.id}/engagements/#{engagement.id}",
+ { evidence_url: 'https://example.com/replacement' }
+ )
+ assert_equal 200, last_response.status
+
+ engagement.reload
+ assert_not engagement.attachment?
+ assert_not File.exist?(old_attachment_path)
+ assert_equal 'https://example.com/replacement', engagement.evidence_url
+ end
+
+ def test_pdf_attachment_is_accepted_and_removed_with_project
+ add_auth_header_for(user: @tutor)
+ data = engagement_params(
+ attachment: upload_file('test_files/submissions/00_question.pdf', 'application/pdf')
+ )
+
+ post "/api/projects/#{@project.id}/engagements", data
+ assert_equal 201, last_response.status, last_response.body
+
+ engagement = Engagement.find(last_response_body['id'])
+ assert_equal 'pdf', engagement.content_type
+ attachment_path = engagement.attachment_path
+ assert File.exist?(attachment_path)
+
+ @project.tutorial_enrolments.destroy_all
+ @project.destroy!
+
+ assert_nil Engagement.find_by(id: engagement.id)
+ assert_not File.exist?(attachment_path)
+ end
+end
diff --git a/test/api/feedback/feedback_chip_api_consolidated_test.rb b/test/api/feedback/feedback_chip_api_consolidated_test.rb
index a0222bb92d..2ea1b88e14 100644
--- a/test/api/feedback/feedback_chip_api_consolidated_test.rb
+++ b/test/api/feedback/feedback_chip_api_consolidated_test.rb
@@ -112,7 +112,7 @@ def test_auth_for_create_edit_global_feedback_chips
}
data_to_put = {
- chip_text: 'Updated text',
+ chip_text: 'Updated text'
}
FactoryBot.create(:feedback_group_chip, learning_outcome_id: learning_outcome.id)
@@ -253,7 +253,6 @@ def test_auth_for_delete_feedback_chips
auditor = FactoryBot.create(:user, :auditor)
student = unit.students.first
-
learning_outcomes = [
{
learning_outcome: unit.learning_outcomes.first,
diff --git a/test/api/feedback/learning_outcome_api_test.rb b/test/api/feedback/learning_outcome_api_test.rb
index 0dcf445623..8ad22e4075 100644
--- a/test/api/feedback/learning_outcome_api_test.rb
+++ b/test/api/feedback/learning_outcome_api_test.rb
@@ -43,13 +43,13 @@ def test_auth_for_create_edit_unit_learning_outcome
data_to_post = {
abbreviation: 'ULO9',
short_description: 'learning outcome short description',
- full_outcome_description: 'Long description of learning outcome',
+ full_outcome_description: 'Long description of learning outcome'
}
data_to_put = {
abbreviation: 'NLO9',
short_description: 'new learning outcome short description',
- full_outcome_description: 'New long description of learning outcome',
+ full_outcome_description: 'New long description of learning outcome'
}
users_can.each do |user|
@@ -100,7 +100,7 @@ def test_get_global_outcomes
last_response_body.each do |outcome|
model = LearningOutcome.find(outcome['id'])
- assert_json_matches_model model, outcome, %w(id context_id context_type abbreviation short_description full_outcome_description)
+ assert_json_matches_model model, outcome, %w[id context_id context_type abbreviation short_description full_outcome_description]
end
end
@@ -195,6 +195,29 @@ def test_update_global_outcome
assert_json_matches_model learning_outcome, last_response_body, %w[id context_id context_type abbreviation short_description full_outcome_description]
end
+ def test_tutors_and_convenors_cannot_manage_global_outcomes
+ learning_outcome = FactoryBot.create(:learning_outcome, context_id: nil, context_type: nil,
+ short_description: 'Original')
+ new_abbreviation = FactoryBot.build(:learning_outcome).abbreviation
+ new_outcome = { abbreviation: new_abbreviation, short_description: 'New outcome' }
+
+ [FactoryBot.create(:user, :tutor), FactoryBot.create(:user, :convenor)].each do |user|
+ add_auth_header_for user: user
+
+ post_json 'api/global/outcomes', new_outcome
+ assert_equal 403, last_response.status
+ assert_nil LearningOutcome.global_outcomes.find_by(abbreviation: new_abbreviation)
+
+ put_json "api/global/outcomes/#{learning_outcome.id}", short_description: 'Changed'
+ assert_equal 403, last_response.status
+ assert_equal 'Original', learning_outcome.reload.short_description
+
+ delete "api/global/outcomes/#{learning_outcome.id}"
+ assert_equal 403, last_response.status
+ assert LearningOutcome.exists?(learning_outcome.id)
+ end
+ end
+
def test_create_task_definition_learning_outcome
task_definition = FactoryBot.create(:task_definition, outcome_count: 0)
data_to_post = {
@@ -404,6 +427,17 @@ def test_unit_rollover
assert_equal chip.learning_outcome_id, new_td_lo.id, 'Each feedback chip should have the correct learning outcome id'
end
+ copied_group_chip = new_td_lo.feedback_group_chips.find_by(chip_text: group_chip_td_lo.chip_text)
+ copied_template_chip = new_td_lo.feedback_template_chips.find_by(chip_text: template_chip_td_lo.chip_text)
+
+ assert_not_nil copied_group_chip, 'The task learning outcome feedback group should be copied'
+ assert_not_nil copied_template_chip, 'The task learning outcome feedback template should be copied'
+ assert_equal copied_group_chip, copied_template_chip.parent_chip
+ assert_equal template_chip_td_lo.description, copied_template_chip.description
+ assert_equal template_chip_td_lo.task_status, copied_template_chip.task_status
+ assert_equal template_chip_td_lo.summary_text, copied_template_chip.summary_text
+ assert_equal template_chip_td_lo.comment_text, copied_template_chip.comment_text
+
new_link = LearningOutcomeLink.find_by(source_id: new_lo1.id, target_id: global_learning_outcome.id)
assert_not_nil new_link, 'New learning outcome 1 should be linked to the global learning outcome'
diff --git a/test/api/group_sets_api_test.rb b/test/api/group_sets_api_test.rb
index 52d2b66939..09204e9762 100644
--- a/test/api/group_sets_api_test.rb
+++ b/test/api/group_sets_api_test.rb
@@ -116,8 +116,8 @@ def test_post_add_a_group_to_a_group_set_of_a_unit_with_authorization
unit_id: new_unit.id,
group_set_id: new_group_set.id,
group: {
- name:new_group.name,
- tutorial_id:new_tutorial.id
+ name: new_group.name,
+ tutorial_id: new_tutorial.id
}
}
@@ -128,14 +128,14 @@ def test_post_add_a_group_to_a_group_set_of_a_unit_with_authorization
# check if the POST get through
assert_equal 201, last_response.status
- #check response
- response_keys = %w(name tutorial_id group_set_id locked)
+ # check response
+ response_keys = %w[name tutorial_id group_set_id locked]
responseGroup = Group.find(last_response_body['id'])
assert_json_matches_model(responseGroup, last_response_body, response_keys)
assert_equal new_unit.id, responseGroup.group_set.unit.id
assert_equal new_group.name, responseGroup.name
- assert_equal new_group_set.id,responseGroup.group_set.id
- assert_equal new_tutorial.id,responseGroup.group_set.unit.tutorials.first.id
+ assert_equal new_group_set.id, responseGroup.group_set.id
+ assert_equal new_tutorial.id, responseGroup.group_set.unit.tutorials.first.id
end
def test_get_all_groups_in_unit_without_authorization
@@ -165,8 +165,8 @@ def test_get_all_groups_in_unit_with_authorization
assert_equal new_group.group_set.groups.count, last_response_body.count
# Check response
- response_keys = %w(id name)
- last_response_body.each do | data |
+ response_keys = %w[id name]
+ last_response_body.each do |data|
grp = Group.find(data['id'])
assert_json_matches_model(grp, data, response_keys)
end
@@ -205,11 +205,11 @@ def test_get_groups_in_a_group_set_with_authorization
get "/api/units/#{new_unit.id}/group_sets/#{new_group_set.id}/groups"
# Check returning number of groups
- assert_equal new_group_set.groups.all.count,last_response_body.count
+ assert_equal new_group_set.groups.all.count, last_response_body.count
# Check response
- response_keys = %w(id name)
- last_response_body.each do | data |
+ response_keys = %w[id name]
+ last_response_body.each do |data|
grp = Group.find(data['id'])
assert_json_matches_model(grp, data, response_keys)
end
@@ -217,10 +217,9 @@ def test_get_groups_in_a_group_set_with_authorization
end
def test_groups_unlocked_upon_creation
-
unit = FactoryBot.create :unit
unit.save!
- group_set = GroupSet.create!({name: 'test_groups_unlocked_upon_creation', unit: unit})
+ group_set = GroupSet.create!({ name: 'test_groups_unlocked_upon_creation', unit: unit })
group_set.save!
# A group should be unlocked upon creation.
@@ -228,8 +227,8 @@ def test_groups_unlocked_upon_creation
group: {
name: 'test_groups_unlocked_upon_creation',
tutorial_id: unit.tutorials.first.id,
- capacity_adjustment: 0,
- },
+ capacity_adjustment: 0
+ }
}
add_auth_header_for(user: unit.main_convenor_user)
post "/api/units/#{unit.id}/group_sets/#{group_set.id}/groups", data
@@ -243,9 +242,9 @@ def test_groups_unlocked_upon_creation
def test_groups_lockable_only_by_staff
unit = FactoryBot.create :unit
unit.save!
- group_set = GroupSet.create!({name: 'test_groups_lockable_only_by_staff', unit: unit, allow_students_to_manage_groups: true })
+ group_set = GroupSet.create!({ name: 'test_groups_lockable_only_by_staff', unit: unit, allow_students_to_manage_groups: true })
group_set.save!
- group = Group.create!({group_set: group_set, name: 'test_groups_lockable_only_by_staff', tutorial: unit.tutorials.first })
+ group = Group.create!({ group_set: group_set, name: 'test_groups_lockable_only_by_staff', tutorial: unit.tutorials.first })
group.save!
group.add_member(unit.active_projects[0])
diff --git a/test/api/groups_api_test.rb b/test/api/groups_api_test.rb
index 9c71389519..8a53134868 100644
--- a/test/api/groups_api_test.rb
+++ b/test/api/groups_api_test.rb
@@ -10,42 +10,42 @@ def app
Rails.application
end
-# def test_get_groups
-# # The GET we are testing
-# unit_id = rand(1..Unit.all.length)
+ # def test_get_groups
+ # # The GET we are testing
+ # unit_id = rand(1..Unit.all.length)
-# end
+ # end
def test_group_submission_with_extensions
unit = FactoryBot.create :unit
- group_set = GroupSet.create!({name: 'test_group_submission_with_extensions', unit: unit})
+ group_set = GroupSet.create!({ name: 'test_group_submission_with_extensions', unit: unit })
group_set.save!
- group = Group.create!({group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first})
+ group = Group.create!({ group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first })
group.add_member(unit.active_projects[0])
group.add_member(unit.active_projects[1])
group.add_member(unit.active_projects[2])
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'Task to switch from ind to group after submission',
- description: 'test def',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 1.week,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 1.week,
- abbreviation: 'TaskSwitchIndGrp',
- restrict_status_updates: false,
- upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0,
- group_set: group_set
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Task to switch from ind to group after submission',
+ description: 'test def',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 1.week,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 1.week,
+ abbreviation: 'TaskSwitchIndGrp',
+ restrict_status_updates: false,
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0,
+ group_set: group_set
+ })
assert td.save!
data_to_post = {
@@ -77,8 +77,8 @@ def test_group_submission_with_extensions
group.reload
group.projects.each do |proj|
- task = proj.task_for_task_definition(td)
- assert_equal TaskStatus.ready_for_feedback, task.task_status
+ task = proj.task_for_task_definition(td)
+ assert_equal TaskStatus.ready_for_feedback, task.task_status
end
# ensure groupset has groups to destroy
@@ -91,34 +91,34 @@ def test_group_submission_with_extensions
def test_comment_on_group_task_without_group
unit = FactoryBot.create :unit
- group_set = GroupSet.create!({name: 'test_comment_without_group', unit: unit})
+ group_set = GroupSet.create!({ name: 'test_comment_without_group', unit: unit })
group_set.save!
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'Task to switch from ind to group after submission',
- description: 'test def',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 1.week,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 1.week,
- abbreviation: 'TaskSwitchIndGrp',
- restrict_status_updates: false,
- upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0,
- group_set: group_set
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Task to switch from ind to group after submission',
+ description: 'test def',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 1.week,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 1.week,
+ abbreviation: 'TaskSwitchIndGrp',
+ restrict_status_updates: false,
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0,
+ group_set: group_set
+ })
assert td.save!
project = unit.projects.first
comment_data = { comment: "Hello World" }
- # Add username and auth_token to Header
+ # Add username and auth_token to Header
add_auth_header_for(user: project.student)
post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/comments", comment_data
@@ -132,33 +132,33 @@ def test_comment_on_group_task_without_group
def test_pdf_comment_on_group_task
unit = FactoryBot.create :unit
- group_set = GroupSet.create!({name: 'test_comment_without_group', unit: unit})
+ group_set = GroupSet.create!({ name: 'test_comment_without_group', unit: unit })
group_set.save!
- group = Group.create!({group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first})
+ group = Group.create!({ group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first })
group.add_member(unit.active_projects[0])
group.add_member(unit.active_projects[1])
group.add_member(unit.active_projects[2])
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'Task to switch from ind to group after submission',
- description: 'test def',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 1.week,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 1.week,
- abbreviation: 'TaskSwitchIndGrp',
- restrict_status_updates: false,
- upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0,
- group_set: group_set
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Task to switch from ind to group after submission',
+ description: 'test def',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 1.week,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 1.week,
+ abbreviation: 'TaskSwitchIndGrp',
+ restrict_status_updates: false,
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0,
+ group_set: group_set
+ })
assert td.save!
project = unit.projects.first
@@ -181,27 +181,27 @@ def test_pdf_comment_on_group_task
def test_pdf_comment_on_group_task_without_group
unit = FactoryBot.create :unit
- group_set = GroupSet.create!({name: 'test_comment_without_group', unit: unit})
+ group_set = GroupSet.create!({ name: 'test_comment_without_group', unit: unit })
group_set.save!
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'Task to switch from ind to group after submission',
- description: 'test def',
- weighting: 4,
- target_grade: 0,
- start_date: Time.zone.now - 1.week,
- target_date: Time.zone.now - 1.day,
- due_date: Time.zone.now + 1.week,
- abbreviation: 'TaskSwitchIndGrp',
- restrict_status_updates: false,
- upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0,
- group_set: group_set
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Task to switch from ind to group after submission',
+ description: 'test def',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 1.week,
+ target_date: Time.zone.now - 1.day,
+ due_date: Time.zone.now + 1.week,
+ abbreviation: 'TaskSwitchIndGrp',
+ restrict_status_updates: false,
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0,
+ group_set: group_set
+ })
assert td.save!
project = unit.projects.first
@@ -307,7 +307,7 @@ def test_create_group
end
def test_group_student_count
- unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 2}]
+ unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 2 }]
assert_equal 2, unit.groups.first.group_memberships.count
# Add username and auth_token to Header
@@ -320,7 +320,7 @@ def test_group_student_count
end
def test_group_switch_tutorial
- unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}]
+ unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }]
gs = unit.group_sets.first
gs.update keep_groups_in_same_class: true, allow_students_to_manage_groups: true
@@ -334,13 +334,13 @@ def test_group_switch_tutorial
tutorial = FactoryBot.create :tutorial, unit: unit, campus: nil
- refute p1.enrolled_in? tutorial
- refute p2.enrolled_in? tutorial
+ assert_not p1.enrolled_in? tutorial
+ assert_not p2.enrolled_in? tutorial
# Add username and auth_token to Header
add_auth_header_for(user: unit.main_convenor_user)
- put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: {tutorial_id: tutorial.id} }
+ put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: { tutorial_id: tutorial.id } }
assert 201, last_response.status
@@ -355,7 +355,7 @@ def test_group_switch_tutorial
end
def test_group_switch_tutorial_no_student_management
- unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}]
+ unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }]
gs = unit.group_sets.first
gs.update keep_groups_in_same_class: true, allow_students_to_manage_groups: false
@@ -369,11 +369,11 @@ def test_group_switch_tutorial_no_student_management
tutorial = FactoryBot.create :tutorial, unit: unit, campus: nil
- refute p1.enrolled_in? tutorial
- refute p2.enrolled_in? tutorial
+ assert_not p1.enrolled_in? tutorial
+ assert_not p2.enrolled_in? tutorial
add_auth_header_for(user: unit.main_convenor_user)
- put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: {tutorial_id: tutorial.id} }
+ put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: { tutorial_id: tutorial.id } }
assert 201, last_response.status
@@ -388,7 +388,7 @@ def test_group_switch_tutorial_no_student_management
end
def test_group_switch_tutorial_unenrolled_students
- unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}]
+ unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }]
gs = unit.group_sets.first
gs.update keep_groups_in_same_class: true, allow_students_to_manage_groups: false, capacity: 2
@@ -404,16 +404,16 @@ def test_group_switch_tutorial_unenrolled_students
tutorial = FactoryBot.create :tutorial, unit: unit, campus: nil
- refute p1.enrolled_in? tutorial
- refute p2.enrolled_in? tutorial
+ assert_not p1.enrolled_in? tutorial
+ assert_not p2.enrolled_in? tutorial
p2.update(enrolled: false)
p2.reload
- refute group1.at_capacity?
+ assert_not group1.at_capacity?
add_auth_header_for(user: unit.main_convenor_user)
- put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: {tutorial_id: tutorial.id} }
+ put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: { tutorial_id: tutorial.id } }
assert 201, last_response.status
@@ -428,17 +428,17 @@ def test_group_switch_tutorial_unenrolled_students
assert group1.valid?, group1.errors.full_messages
assert p1.enrolled_in? tutorial
- refute p2.enrolled_in? tutorial
+ assert_not p2.enrolled_in? tutorial
# check we can reenrol the student
- refute group1.at_capacity?
+ assert_not group1.at_capacity?
assert p2.update(enrolled: true)
- refute group1.at_capacity? # they are not in the right tutorial
+ assert_not group1.at_capacity? # they are not in the right tutorial
assert_equal 1, group1.projects.count
end
def test_locked_groups
- unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}], task_count: 0
+ unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }], task_count: 0
gs = unit.group_sets.first
group1 = gs.groups.first
diff --git a/test/api/lms_integration_api_test.rb b/test/api/lms_integration_api_test.rb
new file mode 100644
index 0000000000..e32eea446d
--- /dev/null
+++ b/test/api/lms_integration_api_test.rb
@@ -0,0 +1,211 @@
+require 'test_helper'
+
+class LmsIntegrationApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+
+ setup do
+ @original_lti_enabled = Doubtfire::Application.config.lti_enabled
+ @original_lti_internal_url = Doubtfire::Application.config.lti_internal_url
+ @original_lti_internal_key = Doubtfire::Application.config.lti_internal_key
+ Doubtfire::Application.config.lti_enabled = true
+ Doubtfire::Application.config.lti_internal_url = 'http://lti.test'
+ Doubtfire::Application.config.lti_internal_key = 'internal-test-key'
+ end
+
+ teardown do
+ Doubtfire::Application.config.lti_enabled = @original_lti_enabled
+ Doubtfire::Application.config.lti_internal_url = @original_lti_internal_url
+ Doubtfire::Application.config.lti_internal_key = @original_lti_internal_key
+ end
+
+ def test_convenor_can_retry_grade_line_item_setup
+ unit = FactoryBot.create(:unit, with_students: false)
+ add_auth_header_for(user: unit.main_convenor_user)
+ stub_link(unit)
+ retry_request = stub_request(:post, internal_url(unit, 'grade-line-item'))
+ .with(headers: { 'X-Internal-Key' => 'internal-test-key' })
+ .to_return(
+ status: 200,
+ body: grade_line_item_status(configured: true).to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ post "/api/units/#{unit.id}/lms/grade_line_item"
+
+ assert_equal 201, last_response.status, last_response.inspect
+ assert last_response_body['configured']
+ assert_requested retry_request
+ end
+
+ def test_student_cannot_retry_grade_line_item_setup
+ unit = FactoryBot.create(:unit, with_students: false)
+ add_auth_header_for(user: FactoryBot.create(:user, :student))
+
+ post "/api/units/#{unit.id}/lms/grade_line_item"
+
+ assert_equal 403, last_response.status, last_response.inspect
+ end
+
+ def test_retry_grade_line_item_propagates_lti_error
+ unit = FactoryBot.create(:unit, with_students: false)
+ add_auth_header_for(user: unit.main_convenor_user)
+ stub_link(unit)
+ stub_request(:post, internal_url(unit, 'grade-line-item')).to_return(
+ status: 422,
+ body: { error: 'Enable Assignment and Grade Services, relaunch OnTrack and retry.' }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ post "/api/units/#{unit.id}/lms/grade_line_item"
+
+ assert_equal 422, last_response.status, last_response.inspect
+ assert_equal 'Enable Assignment and Grade Services, relaunch OnTrack and retry.',
+ last_response_body['error']
+ end
+
+ def test_grade_sync_is_not_queued_without_a_configured_line_item
+ unit = FactoryBot.create(:unit, with_students: false)
+ add_auth_header_for(user: unit.main_convenor_user)
+ stub_link(unit)
+ stub_request(:get, internal_url(unit, 'grade-line-item')).to_return(
+ status: 200,
+ body: grade_line_item_status(configured: false).to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ post "/api/units/#{unit.id}/lms/sync_grades"
+
+ assert_equal 422, last_response.status, last_response.inspect
+ assert_equal 'Grade sync is not configured for this LMS course.', last_response_body['error']
+ assert_empty SyncLmsGradesJob.jobs
+ end
+
+ def test_settings_patch_only_changes_the_given_toggles
+ unit = FactoryBot.create(:unit, with_students: false)
+ integration = unit.create_lms_integration!(group_mapping_enabled: true, validated: true, validated_at: Time.zone.now)
+ integration.lms_group_mappings.create!(lms_group_id: 7, lms_group_name: 'Lab 1', target_type: 'ignore')
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ patch "/api/units/#{unit.id}/lms/settings", { auto_sync_students: true }
+
+ assert_equal 200, last_response.status, last_response.inspect
+ integration.reload
+ assert integration.auto_sync_students?
+ assert integration.group_mapping_enabled?
+ assert integration.validated?
+ assert_equal 1, integration.lms_group_mappings.count
+ end
+
+ def test_settings_patch_keeps_validation_when_extensions_turn_off
+ unit = FactoryBot.create(:unit, with_students: false)
+ integration = unit.create_lms_integration!(fetch_extensions: true, auto_sync_extensions: true, validated: true, validated_at: Time.zone.now)
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ patch "/api/units/#{unit.id}/lms/settings", { fetch_extensions: false }
+
+ assert_equal 200, last_response.status, last_response.inspect
+ integration.reload
+ assert_not integration.fetch_extensions?
+ assert_not integration.auto_sync_extensions?
+ assert integration.validated?
+ end
+
+ def test_settings_patch_invalidates_when_extensions_turn_on
+ unit = FactoryBot.create(:unit, with_students: false)
+ integration = unit.create_lms_integration!(validated: true, validated_at: Time.zone.now)
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ patch "/api/units/#{unit.id}/lms/settings", { fetch_extensions: true }
+
+ assert_equal 200, last_response.status, last_response.inspect
+ assert_not integration.reload.validated?
+ end
+
+ def test_settings_patch_saves_the_assignment_and_invalidates
+ unit = FactoryBot.create(:unit, with_students: false)
+ integration = unit.create_lms_integration!(fetch_extensions: true, assignment_id: 1, assignment_name: 'Old', validated: true, validated_at: Time.zone.now)
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ patch "/api/units/#{unit.id}/lms/settings", { assignment_id: 2, assignment_name: 'Portfolio' }
+
+ assert_equal 200, last_response.status, last_response.inspect
+ integration.reload
+ assert_equal 2, integration.assignment_id
+ assert_equal 'Portfolio', integration.assignment_name
+ assert_not integration.validated?
+ end
+
+ def test_student_cannot_patch_settings
+ unit = FactoryBot.create(:unit)
+ add_auth_header_for(user: unit.active_projects.first.student)
+
+ patch "/api/units/#{unit.id}/lms/settings", { auto_sync_students: true }
+
+ assert_equal 403, last_response.status, last_response.inspect
+ end
+
+ def test_grade_sync_preview_is_queued_without_a_configured_line_item
+ unit = FactoryBot.create(:unit, with_students: false)
+ add_auth_header_for(user: unit.main_convenor_user)
+ stub_link(unit)
+
+ post "/api/units/#{unit.id}/lms/sync_grades", { preview_only: true }
+
+ assert_equal 201, last_response.status, last_response.inspect
+ assert_equal([[unit.id, true]], SyncLmsGradesJob.jobs.map { |job| job['args'] })
+ ensure
+ SyncLmsGradesJob.clear
+ end
+
+ def test_student_import_needs_validated_mappings_when_the_plugin_is_available
+ unit = FactoryBot.create(:unit, with_students: false)
+ unit.create_lms_integration!(group_mapping_enabled: true)
+ add_auth_header_for(user: unit.main_convenor_user)
+ stub_link(unit, course_data_available: true)
+
+ post "/api/units/#{unit.id}/lms/import_students", { preview_only: true }
+
+ assert_equal 422, last_response.status, last_response.inspect
+ assert_empty ImportLmsStudentsJob.jobs
+ end
+
+ def test_student_import_ignores_unvalidated_mappings_without_the_plugin
+ unit = FactoryBot.create(:unit, with_students: false)
+ unit.create_lms_integration!(group_mapping_enabled: true)
+ add_auth_header_for(user: unit.main_convenor_user)
+ stub_link(unit, course_data_available: false)
+
+ post "/api/units/#{unit.id}/lms/import_students", { preview_only: true }
+
+ assert_equal 201, last_response.status, last_response.inspect
+ assert_equal 1, ImportLmsStudentsJob.jobs.length
+ ensure
+ ImportLmsStudentsJob.clear
+ end
+
+ private
+
+ def internal_url(unit, path)
+ "http://lti.test/lti/api/internal/units/#{unit.id}/#{path}"
+ end
+
+ def stub_link(unit, course_data_available: false)
+ stub_request(:get, internal_url(unit, 'link')).to_return(
+ status: 200,
+ body: { linked: true, contextId: 'course-1', courseDataAvailable: course_data_available }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ def grade_line_item_status(configured:)
+ return { configured: false, visibility: 'unknown' } unless configured
+
+ {
+ configured: true,
+ visibility: 'unknown',
+ lineItem: { id: 'https://moodle.test/lineitem/1', label: 'OnTrack', scoreMaximum: 100 }
+ }
+ end
+end
diff --git a/test/api/lti_api_test.rb b/test/api/lti_api_test.rb
index 607c30a3b8..4605f90b4c 100644
--- a/test/api/lti_api_test.rb
+++ b/test/api/lti_api_test.rb
@@ -7,6 +7,28 @@ class LtiApiTest < ActiveSupport::TestCase
include TestHelpers::AuthHelper
include TestHelpers::JsonHelper
+ setup do
+ Rack::Attack.enabled = false
+ end
+
+ teardown do
+ Rack::Attack.enabled = true
+ end
+
+ LTI_ROUTE_PURPOSES = {
+ '/api/auth/lti' => 'auth',
+ '/api/lti/link' => 'link',
+ '/api/lti/enrol' => 'enrol',
+ '/api/lti/enrol/bulk' => 'enrol_bulk',
+ '/api/lti/grades' => 'grades',
+ '/api/lti/app-handoff' => 'app_handoff'
+ }.freeze
+
+ # A fresh token, as the LTI service signs one per request
+ def lti_token(purpose, exp: Time.now.to_i + 30, **claims)
+ JWT.encode({ purpose: purpose, exp: exp, jti: SecureRandom.uuid, **claims }, Doubtfire::Application.config.lti_api_secret, 'HS256')
+ end
+
def test_ensure_jwt_secret_is_valid
# Simply validate that our ENV var is not nil
secret_key = Doubtfire::Application.config.lti_api_secret
@@ -19,7 +41,8 @@ def test_invalid_jwt
"/api/lti/link",
"/api/lti/enrol",
"/api/lti/enrol/bulk",
- "/api/lti/grades"
+ "/api/lti/grades",
+ "/api/lti/app-handoff"
]
# Test tokens without jti or expiration
@@ -69,8 +92,6 @@ def test_invalid_jwt
end
def test_invalid_unit
- secret_key = Doubtfire::Application.config.lti_api_secret
-
routes_expecting_unit = [
"/api/lti/link",
"/api/lti/enrol",
@@ -78,39 +99,22 @@ def test_invalid_unit
"/api/lti/grades"
]
- payload_missing_unit_id = {
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }
- token_missing_unit_id = JWT.encode(payload_missing_unit_id, secret_key, 'HS256')
-
add_auth_header_for(user: FactoryBot.create(:user, :convenor))
- # Ensure we get a 401 error if unit_id is missing from the token
routes_expecting_unit.each do |url|
- post url, { ltik: token_missing_unit_id }
+ # Ensure we get a 400 error if unit_id is missing from the token
+ post url, { ltik: lti_token(LTI_ROUTE_PURPOSES[url]) }
assert_equal 400, last_response.status, "Expected 400 from #{url} with missing unit_id #{last_response_body}"
assert_equal "Invalid LTI token.", last_response_body['error']
- end
-
- # Ensure we get a 404 if the unit (id) does not exist
- payload_invalid_unit_id = {
- unit_id: 9_999_999, # Unit should not exist
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }
- token_invalid_unit_id = JWT.encode(payload_invalid_unit_id, secret_key, 'HS256')
- routes_expecting_unit.each do |url|
- post url, { ltik: token_invalid_unit_id }
+ # Ensure we get a 404 if the unit (id) does not exist
+ post url, { ltik: lti_token(LTI_ROUTE_PURPOSES[url], unit_id: 9_999_999) }
assert_equal 404, last_response.status, "Expected 404 from #{url} with invalid unit #{last_response_body}"
assert_equal "Unit does not exist.", last_response_body['error']
end
end
def test_lti_authentication
- secret_key = Doubtfire::Application.config.lti_api_secret
-
username = 'user12345'
member = {
user_id: '3',
@@ -119,16 +123,11 @@ def test_lti_authentication
family_name: 'Last name 2',
email: "#{username}@doubtfire.com",
ext_user_username: 'student_test_lti3',
+ lis_person_sourcedid: 'student-source-12345',
roles: ['Learner']
}
- token = JWT.encode({
- member: member,
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }, secret_key, 'HS256')
-
# Retrieve the temporary auth token and username
- post '/api/auth/lti', { ltik: token }
+ post '/api/auth/lti', { ltik: lti_token('auth', member: member) }
assert_equal 201, last_response.status
assert last_response_body.key?('username')
assert last_response_body.key?('auth_token')
@@ -152,6 +151,83 @@ def test_lti_authentication
assert_equal member[:name], user.nickname
assert_equal member[:email], user.email
assert_equal username, user.username
+ assert_equal member[:lis_person_sourcedid], user.student_id
+ end
+
+ def test_lti_launch_fills_only_a_missing_student_id
+ user = FactoryBot.create(:user, :student)
+ user.update!(student_id: nil)
+ member = launch_member(user, SecureRandom.uuid).merge(lis_person_sourcedid: 'student-source-67890')
+
+ post '/api/auth/lti', { ltik: lti_token('auth', member: member) }
+ assert_equal 201, last_response.status
+ assert_equal 'student-source-67890', user.reload.student_id
+
+ member[:lis_person_sourcedid] = 'student-source-changed'
+ post '/api/auth/lti', { ltik: lti_token('auth', member: member) }
+ assert_equal 201, last_response.status
+ assert_equal 'student-source-67890', user.reload.student_id
+ end
+
+ def test_lti_launch_does_not_claim_another_users_student_id
+ existing_id = FactoryBot.create(:user, :student).student_id
+ user = FactoryBot.create(:user, :student)
+ user.update!(student_id: nil)
+
+ post '/api/auth/lti', {
+ ltik: lti_token('auth', member: launch_member(user, SecureRandom.uuid).merge(lis_person_sourcedid: existing_id))
+ }
+
+ assert_equal 201, last_response.status
+ assert_nil user.reload.student_id
+ end
+
+ def test_lti_launch_links_login_id_without_renaming_an_existing_user
+ user = FactoryBot.create(:user, :student)
+ username = user.username
+ login_id = SecureRandom.uuid
+
+ post '/api/auth/lti', { ltik: lti_token('auth', member: launch_member(user, login_id)) }
+ assert_equal 201, last_response.status
+ assert_equal username, last_response_body['username']
+
+ user.reload
+ assert_equal login_id, user.login_id
+ assert_equal username, user.username
+ end
+
+ def test_lti_launch_keeps_an_existing_login_id
+ login_id = SecureRandom.uuid
+ user = FactoryBot.create(:user, :student, login_id: login_id)
+
+ post '/api/auth/lti', { ltik: lti_token('auth', member: launch_member(user, SecureRandom.uuid)) }
+ assert_equal 201, last_response.status
+
+ assert_equal login_id, user.reload.login_id
+ end
+
+ def test_lti_launch_refuses_a_different_login_id_when_enforced
+ user = FactoryBot.create(:user, :student, login_id: SecureRandom.uuid)
+ config = Doubtfire::Application.config
+ config.enforce_login_id_match = true
+
+ post '/api/auth/lti', { ltik: lti_token('auth', member: launch_member(user, SecureRandom.uuid)) }
+ assert_equal 403, last_response.status
+ assert_nil last_response_body['auth_token']
+ ensure
+ config.enforce_login_id_match = false
+ end
+
+ def launch_member(user, login_id)
+ {
+ user_id: '31',
+ name: user.name,
+ given_name: user.first_name,
+ family_name: user.last_name,
+ email: user.email,
+ ext_user_username: login_id,
+ roles: ['Learner']
+ }
end
def test_convenor_can_link_requested_unit
@@ -183,39 +259,31 @@ def test_convenor_can_link_requested_unit
assert_equal the_unit.id, unit.id
end
- payload = {
- unit_id: unit.id,
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }
-
- secret_key = Doubtfire::Application.config.lti_api_secret
- token = JWT.encode(payload, secret_key, 'HS256')
-
users_can = [
convenor,
FactoryBot.create(:user, :admin)
]
# Test to ensure convenor and admins can link the unit
- users_can.each do |_user|
- post '/api/lti/link', { ltik: token }
+ users_can.each do |user|
+ add_auth_header_for(user: user)
+ post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: user.email) }
assert_equal 200, last_response.status, last_response_body
end
- # Test to ensure that convenors cant link a unit they can not already enrol students in
- payload_invalid_unit = {
- unit_id: Unit.first.id,
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }
-
- token_invalid_unit = JWT.encode(payload_invalid_unit, secret_key, 'HS256')
-
- post '/api/lti/link', { ltik: token_invalid_unit }
+ # Test to ensure that convenors cant link a unit they do not manage
+ add_auth_header_for(user: convenor)
+ post '/api/lti/link', { ltik: lti_token('link', unit_id: Unit.first.id, email: convenor.email) }
assert_equal 403, last_response.status, last_response_body
assert_equal "Not authorised to link this unit.", last_response_body['error'], last_response_body
+ # Tutoring the unit is not enough, even with a system convenor role
+ unit_tutor = FactoryBot.create(:user, :convenor)
+ unit.employ_staff(unit_tutor, Role.tutor)
+ add_auth_header_for(user: unit_tutor)
+ post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: unit_tutor.email) }
+ assert_equal 403, last_response.status, last_response_body
+
users_cant = [
FactoryBot.create(:user, :student),
FactoryBot.create(:user, :tutor)
@@ -224,12 +292,45 @@ def test_convenor_can_link_requested_unit
# Ensure that students and tutors cant link the unit
users_cant.each do |user|
add_auth_header_for(user: user)
- post '/api/lti/link', { ltik: token }
+ post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: user.email) }
assert_equal 403, last_response.status, last_response_body
end
unit.destroy
end
+ def test_lti_routes_reject_requests_not_from_the_lti_service
+ convenor = FactoryBot.create(:user, :convenor)
+ add_auth_header_for(user: convenor)
+ token = lti_token('link', unit_id: 1, email: convenor.email)
+
+ # X-Forwarded-For is what request.ip trusts from private peers, so it must not help
+ [{}, { 'HTTP_X_FORWARDED_FOR' => '127.0.0.1' }].each do |forwarded|
+ %w[/api/auth/lti /api/lti/link /api/lti/enrol /api/lti/enrol/bulk /api/lti/grades /api/lti/app-handoff].each do |url|
+ post url, { ltik: token }, { 'REMOTE_ADDR' => '172.20.0.9' }.merge(forwarded)
+ assert_equal 403, last_response.status, "#{url} #{forwarded} #{last_response_body}"
+ assert_equal 'Only the LTI service can make this request.', last_response_body['error']
+ end
+ end
+ end
+
+ def test_link_rejects_a_different_launch_user
+ convenor = FactoryBot.create(:user, :convenor)
+ unit = FactoryBot.create(:unit, with_students: false)
+ unit.employ_staff(convenor, Role.convenor)
+ add_auth_header_for(user: convenor)
+
+ [FactoryBot.create(:user, :student).email, ''].each do |email|
+ post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: email) }
+ assert_equal 403, last_response.status, last_response_body
+ assert_equal 'This OnTrack session does not belong to the LMS user who launched OnTrack. Relaunch OnTrack from the LMS.', last_response_body['error']
+ end
+
+ post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: convenor.email.upcase) }
+ assert_equal 200, last_response.status, last_response_body
+ ensure
+ unit&.destroy
+ end
+
def test_correct_roles_are_enrolled
users = [
FactoryBot.create(:user, :student),
@@ -251,42 +352,28 @@ def test_correct_roles_are_enrolled
unit = FactoryBot.create(:unit, with_students: false)
- payload = {
- unit_id: unit.id,
- member: {
- user_id: '2',
- name: 'Nickname',
- given_name: 'First name',
- family_name: 'Last name',
- email: 'email@doubtfire.com',
- ext_user_username: 'student_test_lti',
- roles: ['Learner']
- },
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
+ member = {
+ user_id: '2',
+ name: 'Nickname',
+ given_name: 'First name',
+ family_name: 'Last name',
+ email: 'email@doubtfire.com',
+ ext_user_username: 'student_test_lti',
+ roles: ['Learner']
}
- secret_key = Doubtfire::Application.config.lti_api_secret
- token = JWT.encode(payload, secret_key, 'HS256')
-
roles_cant_be_enrolled.each do |role|
- payload[:member][:roles] = [role]
-
- token = JWT.encode(payload, secret_key, 'HS256')
-
- add_auth_header_for(user: users.sample)
- post '/api/lti/enrol', { ltik: token }
+ user = users.sample
+ add_auth_header_for(user: user)
+ post '/api/lti/enrol', { ltik: lti_token('enrol', unit_id: unit.id, email: user.email, member: member.merge(roles: [role])) }
assert_equal 204, last_response.status
end
roles_can_be_enrolled.each do |role|
- payload[:member][:roles] = [role]
-
- token = JWT.encode(payload, secret_key, 'HS256')
-
- add_auth_header_for(user: users.sample) # or whichever user you want as caller
- post '/api/lti/enrol', { ltik: token }
+ user = users.sample
+ add_auth_header_for(user: user)
+ post '/api/lti/enrol', { ltik: lti_token('enrol', unit_id: unit.id, email: user.email, member: member.merge(roles: [role])) }
assert_equal 201, last_response.status
id = last_response_body['id']
@@ -354,14 +441,9 @@ def test_enrol_students_bulk
email: 'email5@doubtfire.com',
ext_user_username: 'student_test_lti4'
}
- ],
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
+ ]
}
- secret_key = Doubtfire::Application.config.lti_api_secret
- token = JWT.encode(payload, secret_key, 'HS256')
-
convenor = FactoryBot.create(:user, :convenor)
unit.employ_staff(convenor, Role.convenor)
@@ -373,7 +455,7 @@ def test_enrol_students_bulk
expected_ignore_count = 2
assert_equal expected_enrolled_projects_count + expected_error_count + expected_ignore_count, payload[:members].count
- post '/api/lti/enrol/bulk', { ltik: token }
+ post '/api/lti/enrol/bulk', { ltik: lti_token('enrol_bulk', **payload) }
assert_equal 201, last_response.status
job = last_response_body
@@ -386,13 +468,14 @@ def test_enrol_students_bulk
assert_equal expected_success_count, results['success'].count, results
assert_equal expected_error_count, results['errors'].count, results
assert_equal expected_ignore_count, results['ignored'].count, results
+ assert_equal 'student_test_lti1', User.find_by(email: 'email1@doubtfire.com').login_id
student = FactoryBot.create(:user, :student)
unit.enrol_student(student, nil)
# Ensure students cant access this route
add_auth_header_for(user: student)
- post '/api/lti/enrol/bulk', { ltik: token }
+ post '/api/lti/enrol/bulk', { ltik: lti_token('enrol_bulk', **payload) }
assert_equal 403, last_response.status
Sidekiq::Testing.fake!
end
@@ -460,34 +543,16 @@ def test_get_grades_for_members
convenor
]
- secret_key = Doubtfire::Application.config.lti_api_secret
-
- # Ensure we get an error if we dont pass student_emails fiel
- token_missing_emails = JWT.encode({
- unit_id: unit.id,
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }, secret_key, 'HS256')
-
users_can.each do |user|
add_auth_header_for(user: user)
- post '/api/lti/grades', { ltik: token_missing_emails }
+
+ # Ensure we get an error if we dont pass student_emails fiel
+ post '/api/lti/grades', { ltik: lti_token('grades', unit_id: unit.id) }
assert_equal 400, last_response.status
assert_equal "Student emails field does not exist.", last_response_body['error']
- end
-
- # Ensure we get an error if we dont pass in an array
-
- token_non_array = JWT.encode({
- unit_id: unit.id,
- student_emails: "not-an-array",
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }, secret_key, 'HS256')
- users_can.each do |user|
- add_auth_header_for(user: user)
- post '/api/lti/grades', { ltik: token_non_array }
+ # Ensure we get an error if we dont pass in an array
+ post '/api/lti/grades', { ltik: lti_token('grades', unit_id: unit.id, student_emails: 'not-an-array') }
assert_equal 400, last_response.status
assert_equal "Student emails must be an array.", last_response_body['error']
end
@@ -500,22 +565,18 @@ def test_get_grades_for_members
student3.email,
student4.email,
student5.email
- ],
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
+ ]
}
- token = JWT.encode(payload, secret_key, 'HS256')
-
users_cant.each do |user|
add_auth_header_for(user: user)
- post '/api/lti/grades', { ltik: token }
+ post '/api/lti/grades', { ltik: lti_token('grades', **payload) }
assert_equal 403, last_response.status
end
users_can.each do |user|
add_auth_header_for(user: user)
- post '/api/lti/grades', { ltik: token }
+ post '/api/lti/grades', { ltik: lti_token('grades', **payload) }
assert_equal 201, last_response.status
assert_equal unit.projects.count, last_response_body.count
last_response_body.each do |email, grade|
@@ -534,44 +595,15 @@ def test_valid_member_data
convenor = FactoryBot.create(:user, :convenor)
unit.employ_staff(convenor, Role.convenor)
- secret_key = Doubtfire::Application.config.lti_api_secret
-
- token_missing_member = JWT.encode({
- unit_id: unit.id,
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }, secret_key, 'HS256')
-
- token_invalid_member_object = JWT.encode({
- unit_id: unit.id,
- member: "not-a-hash",
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }, secret_key, 'HS256')
-
- token_missing_member_fields = JWT.encode({
- unit_id: unit.id,
- member: {
- user_id: nil
- },
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }, secret_key, 'HS256')
-
- token_member_invalid_email = JWT.encode({
- unit_id: unit.id,
- member: {
- user_id: '3',
- name: 'Nickname 2',
- given_name: 'First name 2',
- family_name: 'Last name 2',
- email: nil,
- ext_user_username: 'student_test_lti3',
- roles: ['Learner']
- },
- exp: Time.now.to_i + 30,
- jti: SecureRandom.uuid
- }, secret_key, 'HS256')
+ member_invalid_email = {
+ user_id: '3',
+ name: 'Nickname 2',
+ given_name: 'First name 2',
+ family_name: 'Last name 2',
+ email: nil,
+ ext_user_username: 'student_test_lti3',
+ roles: ['Learner']
+ }
add_auth_header_for(user: convenor)
urls = [
@@ -580,21 +612,117 @@ def test_valid_member_data
]
urls.each do |url|
- post url, { ltik: token_missing_member }
+ purpose = LTI_ROUTE_PURPOSES[url]
+
+ post url, { ltik: lti_token(purpose, unit_id: unit.id) }
assert_equal 400, last_response.status
assert last_response_body['error'].start_with?('Invalid LTI token.'), last_response_body['error']
- post url, { ltik: token_invalid_member_object }
+ post url, { ltik: lti_token(purpose, unit_id: unit.id, member: 'not-a-hash') }
assert_equal 400, last_response.status
assert last_response_body['error'].start_with?('Missing required fields:'), last_response_body['error']
- post url, { ltik: token_missing_member_fields }
+ post url, { ltik: lti_token(purpose, unit_id: unit.id, member: { user_id: nil }) }
assert_equal 400, last_response.status
assert last_response_body['error'].start_with?('Missing required fields:'), last_response_body['error']
- post url, { ltik: token_member_invalid_email }
+ post url, { ltik: lti_token(purpose, unit_id: unit.id, member: member_invalid_email) }
assert_equal 400, last_response.status
assert last_response_body['error'].start_with?('Missing required fields:'), last_response_body['error']
end
end
+
+ def test_app_handoff_issues_single_use_login_token
+ user = FactoryBot.create(:user, :student)
+ add_auth_header_for(user: user)
+
+ post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: user.email.upcase) }
+ assert_equal 201, last_response.status, last_response_body
+ assert_equal user.username, last_response_body['username']
+ login_token = last_response_body['auth_token']
+
+ clear_auth_header
+ post '/api/auth', { username: user.username, auth_token: login_token }
+ assert_equal 201, last_response.status, last_response_body
+ assert_equal user.id, last_response_body['user']['id']
+
+ post '/api/auth', { username: user.username, auth_token: login_token }
+ assert_equal 419, last_response.status
+ end
+
+ def test_app_handoff_requires_authentication
+ user = FactoryBot.create(:user, :student)
+
+ post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: user.email) }
+ assert_equal 419, last_response.status
+ end
+
+ def test_app_handoff_rejects_other_lti_tokens
+ user = FactoryBot.create(:user, :student)
+ add_auth_header_for(user: user)
+
+ post '/api/lti/app-handoff', { ltik: lti_token(nil, email: user.email) }
+ assert_equal 403, last_response.status
+ assert_equal 'Invalid LTI token.', last_response_body['error']
+ end
+
+ def test_app_handoff_rejects_a_different_launch_user
+ user = FactoryBot.create(:user, :student)
+ other_user = FactoryBot.create(:user, :student)
+ add_auth_header_for(user: user)
+
+ [other_user.email, ''].each do |email|
+ post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: email) }
+ assert_equal 403, last_response.status
+ assert_nil last_response_body['auth_token']
+ end
+ end
+
+ def test_lti_tokens_cannot_be_replayed
+ user = FactoryBot.create(:user, :student)
+ add_auth_header_for(user: user)
+ token = lti_token('app_handoff', email: user.email)
+
+ post '/api/lti/app-handoff', { ltik: token }
+ assert_equal 201, last_response.status, last_response_body
+
+ post '/api/lti/app-handoff', { ltik: token }
+ assert_equal 403, last_response.status
+ assert_equal 'Invalid LTI token.', last_response_body['error']
+ end
+
+ def test_lti_tokens_only_work_for_their_purpose
+ convenor = FactoryBot.create(:user, :convenor)
+ add_auth_header_for(user: convenor)
+ member = { user_id: '9', name: 'Staff', given_name: 'Staff', family_name: 'Member', email: convenor.email, roles: ['Instructor'] }
+
+ # e.g. an enrolment token carries a member, so it must not also work as a sign in
+ LTI_ROUTE_PURPOSES.each do |url, purpose|
+ (LTI_ROUTE_PURPOSES.values - [purpose]).each do |other_purpose|
+ post url, { ltik: lti_token(other_purpose, unit_id: 1, email: convenor.email, member: member) }
+ assert_equal 403, last_response.status, "#{other_purpose} token at #{url}: #{last_response_body}"
+ assert_equal 'Invalid LTI token.', last_response_body['error']
+ end
+ end
+ end
+
+ def test_lti_tokens_must_expire_within_a_minute
+ user = FactoryBot.create(:user, :student)
+ add_auth_header_for(user: user)
+
+ post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: user.email, exp: Time.now.to_i + 3600) }
+ assert_equal 403, last_response.status
+ assert_equal 'Invalid LTI token.', last_response_body['error']
+ end
+
+ def test_enrol_rejects_a_different_launch_user
+ unit = FactoryBot.create(:unit, with_students: false)
+ user = FactoryBot.create(:user, :student)
+ add_auth_header_for(user: user)
+ instructor = { user_id: '9', name: 'Staff', given_name: 'Staff', family_name: 'Member', email: 'staff@doubtfire.com', roles: ['Instructor'] }
+
+ post '/api/lti/enrol', { ltik: lti_token('enrol', unit_id: unit.id, email: instructor[:email], member: instructor) }
+ assert_equal 403, last_response.status, last_response_body
+ assert_nil unit.unit_role_for(user), 'The signed-in user must not be employed from another launch'
+ end
end
diff --git a/test/api/marking_sessions_api_test.rb b/test/api/marking_sessions_api_test.rb
index 7a0615f7db..f5079fd36d 100644
--- a/test/api/marking_sessions_api_test.rb
+++ b/test/api/marking_sessions_api_test.rb
@@ -223,6 +223,8 @@ def test_assesment_activities
assert_equal project.id, last_activity.project.id
assert_equal "delete-comment", last_activity.action
+ project.task_for_task_definition(td).add_text_comment(tutor, 'Manual tutor feedback')
+
# Test asessment
put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete' }
assert_equal 200, last_response.status
@@ -364,7 +366,7 @@ def test_marking_sessions_tutorial_split
# unit = Unit.first
project = unit.projects.first
- task = project.tasks.first
+ task = project.task_for_task_definition(unit.task_definitions.first)
while current_time <= end_time
travel_to current_time do
diff --git a/test/api/overseer_image_api_test.rb b/test/api/overseer_image_api_test.rb
index 65025a8803..dff6571204 100644
--- a/test/api/overseer_image_api_test.rb
+++ b/test/api/overseer_image_api_test.rb
@@ -26,7 +26,7 @@ def test_get_all_overseer_images
assert_equal 200, last_response.status
assert_equal expected_data.count, last_response_body.count, last_response_body
- response_keys = %w[name tag pulled_image_text pulled_image_status last_pulled_date]
+ response_keys = %w[name tag pulled_image_text pulled_image_status]
last_response_body.each do |data|
expected_data = OverseerImage.find(data['id'])
@@ -288,7 +288,7 @@ def test_non_admin_cannot_delete_overseer_image
]
# create a overseer image to delete
- overseer_image = FactoryBot.create (:overseer_image)
+ overseer_image = FactoryBot.create(:overseer_image)
# number of overseer image before delete
number_of_images = OverseerImage.count
diff --git a/test/api/projects_api_test.rb b/test/api/projects_api_test.rb
index 30407838e6..375e02853c 100644
--- a/test/api/projects_api_test.rb
+++ b/test/api/projects_api_test.rb
@@ -4,6 +4,7 @@
class ProjectsApiTest < ActiveSupport::TestCase
include Rack::Test::Methods
+ include ActiveSupport::Testing::TimeHelpers
include TestHelpers::AuthHelper
include TestHelpers::JsonHelper
include TestHelpers::TestFileHelper
@@ -62,8 +63,8 @@ def test_projects_returns_correct_data
assert_json_limit_keys_to_exactly keys, data
- assert_json_matches_model(project, data, %w(campus_id target_grade campus_id))
- assert_json_matches_model(project.unit, data['unit'], %w(id code name active))
+ assert_json_matches_model(project, data, %w[campus_id target_grade campus_id])
+ assert_json_matches_model(project.unit, data['unit'], %w[id code name active])
assert_json_matches_model project, data, key_test
end
@@ -86,6 +87,103 @@ def test_get_project_response_is_correct
assert_json_matches_model project, last_response_body, key_test
end
+ def test_get_project_records_when_student_viewed_it
+ user = FactoryBot.create(:user, :student, enrol_in: 1)
+ project = user.projects.first
+ viewed_at = Time.zone.parse('2026-07-21 12:00:00 UTC')
+ add_auth_header_for(user: user)
+
+ travel_to(viewed_at) { get "/api/projects/#{project.id}" }
+
+ assert_equal 200, last_response.status
+ assert_equal viewed_at, project.reload.last_viewed_at
+ end
+
+ def test_get_project_does_not_record_staff_view_as_student_view
+ project = FactoryBot.create(:project)
+ admin = FactoryBot.create(:user, :admin)
+ add_auth_header_for(user: admin)
+
+ get "/api/projects/#{project.id}"
+
+ assert_equal 200, last_response.status
+ assert_nil project.reload.last_viewed_at
+ end
+
+ def test_get_project_can_record_attendance_during_enrolled_tutorial
+ project, tutor, tutorial = attendance_test_data
+ attended_at = Time.zone.parse('2026-07-20 10:30:00 UTC')
+ tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00')
+ add_auth_header_for(user: tutor)
+
+ assert_difference 'Engagement.count', 1 do
+ travel_to(attended_at) { get "/api/projects/#{project.id}?record_attendance=true" }
+ end
+
+ assert_equal 200, last_response.status
+ attendance = project.engagements.last
+ assert_equal tutor, attendance.user
+ assert_equal 'Attendance', attendance.engagement_type
+ assert_equal 'Attended tutorial and QR scanned.', attendance.note
+ assert_equal attended_at, attendance.occurred_at
+ end
+
+ def test_get_project_does_not_record_attendance_outside_enrolled_tutorial
+ project, tutor, tutorial = attendance_test_data
+ tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00')
+ add_auth_header_for(user: tutor)
+
+ assert_no_difference 'Engagement.count' do
+ travel_to(Time.zone.parse('2026-07-20 12:00:00 UTC')) do
+ get "/api/projects/#{project.id}?record_attendance=true"
+ end
+ end
+
+ assert_equal 200, last_response.status
+ end
+
+ def test_get_project_debounces_attendance_for_fifteen_minutes
+ project, tutor, tutorial = attendance_test_data
+ tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00')
+ add_auth_header_for(user: tutor)
+
+ assert_difference 'Engagement.count', 1 do
+ travel_to(Time.zone.parse('2026-07-20 10:30:00 UTC')) do
+ get "/api/projects/#{project.id}?record_attendance=true"
+ end
+ end
+
+ assert_no_difference 'Engagement.count' do
+ travel_to(Time.zone.parse('2026-07-20 10:44:00 UTC')) do
+ get "/api/projects/#{project.id}?record_attendance=true"
+ end
+ end
+
+ assert_difference 'Engagement.count', 1 do
+ travel_to(Time.zone.parse('2026-07-20 10:46:00 UTC')) do
+ get "/api/projects/#{project.id}?record_attendance=true"
+ end
+ end
+ end
+
+ def test_get_project_rejects_attendance_trigger_without_unit_teaching_access
+ project, = attendance_test_data
+ add_auth_header_for(user: project.student)
+
+ assert_no_difference 'Engagement.count' do
+ get "/api/projects/#{project.id}?record_attendance=true"
+ end
+
+ assert_equal 403, last_response.status
+
+ add_auth_header_for(user: FactoryBot.create(:user, :admin))
+ assert_no_difference 'Engagement.count' do
+ get "/api/projects/#{project.id}?record_attendance=true"
+ end
+
+ assert_equal 403, last_response.status
+ end
+
def test_projects_works_with_inactive_units
user = FactoryBot.create(:user, :student, enrol_in: 2)
Unit.last.update(active: false)
@@ -107,8 +205,8 @@ def test_projects_works_with_inactive_units
project = user.projects.find(data['id'])
assert project.present?, data.inspect
- assert_json_matches_model(project, data, %w(campus_id target_grade campus_id))
- assert_json_matches_model(project.unit, data['unit'], %w(code id name active))
+ assert_json_matches_model(project, data, %w[campus_id target_grade campus_id])
+ assert_json_matches_model(project.unit, data['unit'], %w[code id name active])
end
end
@@ -129,7 +227,7 @@ def test_submitted_grade_cant_change_after_submission
assert_equal 200, last_response.status, last_response_body
assert_equal user.projects.find(project.id).submitted_grade, 2
- keys = %w(campus_id target_grade submitted_grade compile_portfolio portfolio_available uses_draft_learning_summary)
+ keys = %w[campus_id target_grade submitted_grade compile_portfolio portfolio_available uses_draft_learning_summary]
assert_json_limit_keys_to_exactly keys, last_response_body
assert_json_matches_model project, last_response_body, keys
@@ -192,4 +290,19 @@ def test_download_portfolio
ensure
FileUtils.rm_f(project.portfolio_path)
end
+
+ private
+
+ def attendance_test_data
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0)
+ tutorial = unit.tutorials.first
+ tutorial.campus.update!(timezone: 'UTC')
+ student = FactoryBot.create(:user, :student)
+ project = unit.enrol_student(student, tutorial.campus)
+ project.enrol_in(tutorial)
+ tutor = FactoryBot.create(:user, :tutor)
+ unit.employ_staff(tutor, Role.tutor)
+
+ [project, tutor, tutorial]
+ end
end
diff --git a/test/api/scorm_api_test.rb b/test/api/scorm_api_test.rb
index 22287d1888..fb563ed4c9 100644
--- a/test/api/scorm_api_test.rb
+++ b/test/api/scorm_api_test.rb
@@ -96,10 +96,9 @@ def test_username_with_dot_fails_without_fix
user.update!(username: "user.name")
td = FactoryBot.create(:task_definition,
- unit: unit,
- tutorial_stream: unit.tutorial_streams.first,
- scorm_enabled: true
- )
+ unit: unit,
+ tutorial_stream: unit.tutorial_streams.first,
+ scorm_enabled: true)
td.add_scorm_data(test_file_path("numbas.zip"), copy: true)
td.save!
diff --git a/test/api/settings_test.rb b/test/api/settings_test.rb
index 2a922d1c93..6eba5b78a0 100644
--- a/test/api/settings_test.rb
+++ b/test/api/settings_test.rb
@@ -2,47 +2,47 @@
require 'json'
class SettingTest < ActiveSupport::TestCase
- include Rack::Test::Methods
- include TestHelpers::AuthHelper
- include TestHelpers::JsonHelper
-
- def app
- Rails.application
- end
-
- # Get config details
- def test_get_config_details
- expected_product_name = Doubtfire::Application.config.institution[:product_name]
-
- # Perform the GET
- get '/api/settings'
-
- # Set returned details
- returned_mes = last_response_body['externalName']
-
- # Check if the call succeeds
- assert_equal 200, last_response.status
- # Check returned details match as expected
- assert_equal expected_product_name, returned_mes
- end
-
- # Get privacy policy details
- def test_get_privacy_policy_details
- expected_privacy = Doubtfire::Application.config.institution[:privacy]
- expected_plagiarism = Doubtfire::Application.config.institution[:plagiarism]
-
- # Perform the GET
- get '/api/settings/privacy'
-
- # Set two returned details
- returned_privacy = last_response_body['privacy']
- returned_plagiarism = last_response_body['plagiarism']
-
- # Check if the call succeeds
- assert_equal 200, last_response.status
-
- # Check returned details match as expected
- assert_equal expected_privacy, returned_privacy
- assert_equal expected_plagiarism, returned_plagiarism
- end
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+
+ def app
+ Rails.application
+ end
+
+ # Get config details
+ def test_get_config_details
+ expected_product_name = Doubtfire::Application.config.institution[:product_name]
+
+ # Perform the GET
+ get '/api/settings'
+
+ # Set returned details
+ returned_mes = last_response_body['externalName']
+
+ # Check if the call succeeds
+ assert_equal 200, last_response.status
+ # Check returned details match as expected
+ assert_equal expected_product_name, returned_mes
+ end
+
+ # Get privacy policy details
+ def test_get_privacy_policy_details
+ expected_privacy = Doubtfire::Application.config.institution[:privacy]
+ expected_plagiarism = Doubtfire::Application.config.institution[:plagiarism]
+
+ # Perform the GET
+ get '/api/settings/privacy'
+
+ # Set two returned details
+ returned_privacy = last_response_body['privacy']
+ returned_plagiarism = last_response_body['plagiarism']
+
+ # Check if the call succeeds
+ assert_equal 200, last_response.status
+
+ # Check returned details match as expected
+ assert_equal expected_privacy, returned_privacy
+ assert_equal expected_plagiarism, returned_plagiarism
+ end
end
diff --git a/test/api/staff_managed_task_api_test.rb b/test/api/staff_managed_task_api_test.rb
new file mode 100644
index 0000000000..d6d2552719
--- /dev/null
+++ b/test/api/staff_managed_task_api_test.rb
@@ -0,0 +1,55 @@
+require 'test_helper'
+
+class StaffManagedTaskApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+
+ def app
+ Rails.application
+ end
+
+ def test_convenor_can_enable_staff_managed_task
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 1)
+ task_definition = unit.task_definitions.first
+
+ assert_not task_definition.staff_managed_task
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ put_json "/api/units/#{unit.id}/task_definitions/#{task_definition.id}",
+ { task_def: { staff_managed_task: true } }
+
+ assert_equal 200, last_response.status, last_response_body
+ assert task_definition.reload.staff_managed_task
+ assert_equal true, last_response_body['staff_managed_task']
+ end
+
+ def test_students_cannot_change_status_or_upload_to_staff_managed_task
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 1)
+ task_definition = unit.task_definitions.first
+ task_definition.update!(staff_managed_task: true)
+ project = unit.active_projects.first
+ task = project.task_for_task_definition(task_definition)
+ task_url = "/api/projects/#{project.id}/task_def_id/#{task_definition.id}"
+ original_status = task.task_status
+
+ add_auth_header_for(user: project.student)
+ put task_url, { trigger: 'need_help' }
+ assert_equal 403, last_response.status, last_response_body
+ assert_equal original_status, task.reload.task_status
+
+ post "#{task_url}/submission", {}
+ assert_equal 403, last_response.status, last_response_body
+ assert_nil task.reload.file_uploaded_at
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ put task_url, { trigger: 'working_on_it' }
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal TaskStatus.working_on_it, task.reload.task_status
+
+ add_auth_header_for(user: project.student)
+ put task_url, { trigger: 'not_started' }
+ assert_equal 403, last_response.status, last_response_body
+ assert_equal TaskStatus.working_on_it, task.reload.task_status
+ end
+end
diff --git a/test/api/staff_notes_api_test.rb b/test/api/staff_notes_api_test.rb
index 300d30eb77..47a343d57a 100644
--- a/test/api/staff_notes_api_test.rb
+++ b/test/api/staff_notes_api_test.rb
@@ -93,6 +93,144 @@ def test_tutor_can_create_staff_notes
assert_equal response_staff_note.id, second_staff_note.reply_to_id
end
+ def test_tutor_can_post_and_view_image_attachment_but_student_cannot
+ unit = FactoryBot.create(:unit, code: 'COS10001')
+ student = FactoryBot.create(:user, :student)
+ project = unit.enrol_student(student, nil)
+ tutor = FactoryBot.create(:user, :tutor)
+ unit.employ_staff(tutor, Role.tutor)
+
+ add_auth_header_for(user: tutor)
+ post "/api/projects/#{project.id}/staff_notes", {
+ note: '',
+ attachment: upload_file('test_files/submissions/Deakin_Logo.jpeg', 'image/jpeg')
+ }
+ assert_equal 201, last_response.status, last_response.body
+
+ response_note = last_response_body
+ note = StaffNote.find(response_note['id'])
+ assert_equal true, response_note['has_attachment']
+ assert_equal 'image', response_note['attachment_type']
+ assert_equal 'image/jpeg', note.content_type
+ assert File.file?(note.attachment_path)
+ attachment_path = note.attachment_path
+
+ get "/api/projects/#{project.id}/staff_notes/#{note.id}/attachment"
+ assert_equal 200, last_response.status
+ assert_match(%r{image/}, last_response.headers['Content-Type'])
+
+ add_auth_header_for(user: student)
+ get "/api/projects/#{project.id}/staff_notes/#{note.id}/attachment"
+ assert_equal 403, last_response.status
+
+ add_auth_header_for(user: tutor)
+ delete "/api/projects/#{project.id}/staff_notes/#{note.id}"
+ assert_equal 200, last_response.status
+ assert_not File.exist?(attachment_path)
+ end
+
+ def test_tutor_can_post_pdf_and_invalid_attachment_is_rejected
+ unit = FactoryBot.create(:unit, code: 'COS10001')
+ student = FactoryBot.create(:user, :student)
+ project = unit.enrol_student(student, nil)
+ tutor = FactoryBot.create(:user, :tutor)
+ unit.employ_staff(tutor, Role.tutor)
+
+ add_auth_header_for(user: tutor)
+ post "/api/projects/#{project.id}/staff_notes", {
+ attachment: upload_file('test_files/submissions/valid.pdf', 'application/pdf')
+ }
+ assert_equal 201, last_response.status, last_response.body
+ assert_equal 'pdf', last_response_body['attachment_type']
+ assert_equal true, last_response_body['has_attachment']
+ assert_equal 'application/pdf', project.staff_notes.last.content_type
+
+ post "/api/projects/#{project.id}/staff_notes", {
+ attachment: upload_file('test_files/csv_test_files/COS10001-Students.csv', 'text/csv')
+ }
+ assert_equal 400, last_response.status
+ assert_equal 1, project.staff_notes.count
+
+ project.staff_notes.last.destroy!
+ end
+
+ def test_only_convenors_and_admins_can_upload_staff_notes_csv
+ unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false)
+ admin_unit = FactoryBot.create(:unit, code: 'COS10002', with_students: false)
+ student = FactoryBot.create(:user, :student, login_id: 'staff-note-student')
+ tutor = FactoryBot.create(:user, :tutor)
+ convenor = FactoryBot.create(:user, :convenor)
+ admin = FactoryBot.create(:user, :admin)
+ unit.enrol_student(student, nil)
+ admin_unit.enrol_student(student, nil)
+ unit.employ_staff(tutor, Role.tutor)
+ unit.employ_staff(convenor, Role.convenor)
+
+ csv_file = Tempfile.new(['staff-notes-permissions', '.csv'])
+ csv_file.write("username,comment\n#{student.username},Permission test note\n")
+ csv_file.rewind
+
+ Sidekiq::Testing.inline! do
+ [[convenor, unit], [admin, admin_unit]].each do |user, upload_unit|
+ add_auth_header_for(user: user)
+ post "/api/csv/units/#{upload_unit.id}/staff_notes", file: Rack::Test::UploadedFile.new(csv_file.path, 'text/csv')
+ assert_equal 201, last_response.status, "#{user.role.name}: #{last_response.body}"
+ end
+
+ [tutor, student].each do |user|
+ add_auth_header_for(user: user)
+ post "/api/csv/units/#{unit.id}/staff_notes", file: Rack::Test::UploadedFile.new(csv_file.path, 'text/csv')
+ assert_equal 403, last_response.status, user.role.name
+ end
+ ensure
+ Sidekiq::Testing.fake!
+ end
+ ensure
+ csv_file&.close
+ csv_file&.unlink
+ end
+
+ def test_staff_notes_csv_imports_by_username_or_login_id
+ unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false)
+ convenor = FactoryBot.create(:user, :convenor)
+ first_student = FactoryBot.create(:user, :student, login_id: 'staff-note-login-1')
+ second_student = FactoryBot.create(:user, :student, login_id: 'staff-note-login-2')
+ first_project = unit.enrol_student(first_student, nil)
+ second_project = unit.enrol_student(second_student, nil)
+ unit.employ_staff(convenor, Role.convenor)
+
+ csv_file = Tempfile.new(['staff-notes-import', '.csv'])
+ csv_file.write(CSV.generate do |csv|
+ csv << %w[username login_id comment]
+ csv << [first_student.username, nil, 'Username note']
+ csv << [nil, second_student.login_id, 'Login ID note']
+ csv << [first_student.username, first_student.login_id, 'Duplicate note']
+ csv << [first_student.username, first_student.login_id, 'Duplicate note']
+ csv << [first_student.username, second_student.login_id, 'Mismatched identifiers note']
+ end)
+ csv_file.rewind
+
+ Sidekiq::Testing.inline! do
+ add_auth_header_for(user: convenor)
+ post "/api/csv/units/#{unit.id}/staff_notes", file: Rack::Test::UploadedFile.new(csv_file.path, 'text/csv')
+
+ assert_equal 201, last_response.status, last_response.body
+ result = JSON.parse(last_response_body['result'])
+ assert_equal 3, result['success'].size
+ assert_equal 1, result['ignored'].size
+ assert_equal 1, result['errors'].size
+ assert_equal 'Staff note already exists', result['ignored'].first['message']
+ assert_equal 'Username and login_id do not match the same user', result['errors'].first['message']
+ assert_equal ['Username note', 'Duplicate note'], first_project.staff_notes.reload.pluck(:note)
+ assert_equal ['Login ID note'], second_project.staff_notes.reload.pluck(:note)
+ ensure
+ Sidekiq::Testing.fake!
+ end
+ ensure
+ csv_file&.close
+ csv_file&.unlink
+ end
+
def test_tutor_can_edit_staff_notes
unit = FactoryBot.create(:unit, code: 'COS10001')
diff --git a/test/api/statistics_api_test.rb b/test/api/statistics_api_test.rb
new file mode 100644
index 0000000000..573f2d639e
--- /dev/null
+++ b/test/api/statistics_api_test.rb
@@ -0,0 +1,45 @@
+require 'test_helper'
+
+class StatisticsApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+
+ def app
+ Rails.application
+ end
+
+ def test_get_statistics
+ now = Time.current
+ FactoryBot.create(:user, last_access_at: now - 2.minutes)
+ FactoryBot.create(:user, last_access_at: now - 10.minutes)
+ FactoryBot.create(:user, last_access_at: now - 45.minutes)
+ FactoryBot.create(:user, last_access_at: now - 12.hours)
+ FactoryBot.create(:user, last_access_at: now - 3.days)
+
+ add_auth_header_for(user: FactoryBot.create(:user, :admin))
+ get '/api/admin/statistics'
+
+ assert_equal 200, last_response.status
+ assert_equal User.where(last_access_at: (now - 5.minutes)..).count,
+ last_response_body.dig('activeUsers', 'fiveMinutes')
+ assert_equal User.where(last_access_at: (now - 15.minutes)..).count,
+ last_response_body.dig('activeUsers', 'fifteenMinutes')
+ assert_equal User.where(last_access_at: (now - 30.minutes)..).count,
+ last_response_body.dig('activeUsers', 'thirtyMinutes')
+ assert_equal User.where(last_access_at: (now - 1.hour)..).count,
+ last_response_body.dig('activeUsers', 'oneHour')
+ assert_equal User.where(last_access_at: (now - 24.hours)..).count,
+ last_response_body.dig('activeUsers', 'twentyFourHours')
+ assert_equal User.where(last_access_at: (now - 7.days)..).count,
+ last_response_body.dig('activeUsers', 'sevenDays')
+ assert_equal User.count, last_response_body['totalUsers']
+ end
+
+ def test_get_statistics_rejects_students
+ add_auth_header_for(user: FactoryBot.create(:user, :student))
+ get '/api/admin/statistics'
+
+ assert_equal 403, last_response.status
+ end
+end
diff --git a/test/api/students_api_test.rb b/test/api/students_api_test.rb
index 24c0cf5ff5..093f19ce04 100644
--- a/test/api/students_api_test.rb
+++ b/test/api/students_api_test.rb
@@ -20,16 +20,16 @@ def test_get_students_with_authorization
get "/api/students/?unit_id=#{newUnit.id}"
# check returning number of students
- assert_equal newUnit.active_projects.all.count,last_response_body.count
+ assert_equal newUnit.active_projects.all.count, last_response_body.count
# check the response
- last_response_body.each do | data |
+ last_response_body.each do |data|
pro = newUnit.active_projects.find(data['id'])
std = pro.student
- assert_equal data['student']['id'],std['id']
- assert_equal data['student']['first_name'],std['first_name']
- assert_equal data['student']['last_name'],std['last_name']
- assert_equal data['student']['email'],std['email']
+ assert_equal data['student']['id'], std['id']
+ assert_equal data['student']['first_name'], std['first_name']
+ assert_equal data['student']['last_name'], std['last_name']
+ assert_equal data['student']['email'], std['email']
end
assert_equal 200, last_response.status
end
diff --git a/test/api/submission_history_api_test.rb b/test/api/submission_history_api_test.rb
new file mode 100644
index 0000000000..0a99ac5ad5
--- /dev/null
+++ b/test/api/submission_history_api_test.rb
@@ -0,0 +1,62 @@
+require 'test_helper'
+
+class SubmissionHistoryApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+ include TestHelpers::JsonHelper
+
+ def app
+ Rails.application
+ end
+
+ def test_group_member_can_view_submission_history_and_overseer_assessment
+ setup_group_submission
+ history = FactoryBot.create(:submission_history, task: @tasks.first.reload)
+ assessment = FactoryBot.create(
+ :overseer_assessment,
+ task: @tasks.first,
+ submission_history: history,
+ submission_timestamp: history.submission_timestamp
+ )
+ add_auth_header_for(user: @projects.second.student)
+
+ get submission_histories_path(@projects.second)
+
+ assert_equal 200, last_response.status, last_response.body
+ assert_equal [history.id], last_response_body.pluck('id')
+
+ get overseer_assessments_path(@projects.second)
+
+ assert_equal 200, last_response.status, last_response.body
+ assert_equal [assessment.id], last_response_body.pluck('id')
+ end
+
+ private
+
+ def setup_group_submission
+ unit = FactoryBot.create(:unit, student_count: 3, task_count: 1)
+ @projects = unit.active_projects.first(3)
+ @task_definition = unit.task_definitions.first
+ group_set = FactoryBot.create(:group_set, unit: unit)
+ @task_definition.update!(group_set: group_set)
+ @group = FactoryBot.create(:group, group_set: group_set, tutorial: unit.tutorials.first)
+ @tasks = @projects.first(2).map do |project|
+ @group.add_member(project)
+ project.task_for_task_definition(@task_definition)
+ end
+ @group.create_submission(
+ @tasks.first,
+ 'Group submission',
+ @tasks.map { |task| { project: task.project, pct: 50, pts: 3 } }
+ )
+ @tasks
+ end
+
+ def submission_histories_path(project)
+ "/api/projects/#{project.id}/task_def_id/#{@task_definition.id}/submission_histories"
+ end
+
+ def overseer_assessments_path(project)
+ "/api/projects/#{project.id}/task_def_id/#{@task_definition.id}/submissions/timestamps"
+ end
+end
diff --git a/test/api/tasks_api_test.rb b/test/api/tasks_api_test.rb
index 4d2f996016..6df05674b9 100644
--- a/test/api/tasks_api_test.rb
+++ b/test/api/tasks_api_test.rb
@@ -11,6 +11,52 @@ def app
Rails.application
end
+ def test_staff_can_request_feedback_using_existing_submission
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 1)
+ task_definition = unit.task_definitions.first
+ task_definition.update!(upload_requirements: [{ 'key' => 'file0', 'name' => 'Work', 'type' => 'document' }])
+ project = unit.active_projects.first
+ task = project.task_for_task_definition(task_definition)
+ submitted_at = 2.days.ago
+ task.submit(project.user, submitted_at)
+ task.update!(task_status: TaskStatus.assess_in_portfolio)
+ pdf_path = task.final_pdf_path
+ path = "/api/projects/#{project.id}/task_def_id/#{task_definition.id}"
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ put path, trigger: 'ready_for_feedback', reuse_existing_submission: true
+ assert_equal 409, last_response.status
+ assert_equal TaskStatus.assess_in_portfolio, task.reload.task_status
+
+ FileUtils.mkdir_p(File.dirname(pdf_path))
+ FileUtils.cp(Rails.root.join('test_files/submissions/1.2P.pdf'), pdf_path)
+
+ submission_count = task.task_submissions.count
+ original_submission_date = task.submission_date
+ original_pdf = File.binread(pdf_path)
+ add_auth_header_for(user: project.user)
+ put path, trigger: 'ready_for_feedback', reuse_existing_submission: true
+ assert_equal 403, last_response.status
+ assert_equal TaskStatus.assess_in_portfolio, task.reload.task_status
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ put path, trigger: 'ready_for_feedback', reuse_existing_submission: true
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal TaskStatus.ready_for_feedback, task.reload.task_status
+ assert_equal original_submission_date, task.submission_date
+ assert_equal submission_count, task.task_submissions.count
+ assert_equal original_pdf, File.binread(pdf_path)
+
+ task.update!(task_status: TaskStatus.redo)
+ put path, trigger: 'ready_for_feedback', reuse_existing_submission: true
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal TaskStatus.ready_for_feedback, task.reload.task_status
+ assert_equal original_submission_date, task.submission_date
+ assert_equal submission_count, task.task_submissions.count
+ ensure
+ FileUtils.rm_f(pdf_path) if pdf_path
+ end
+
def test_task_get
# The GET we are testing
unit = FactoryBot.create(:unit, perform_submissions: true)
@@ -564,96 +610,132 @@ def test_download_task_pdf
end
def test_cant_submit_until_prerequisites_submitted
- Sidekiq::Testing.inline! do
- # Create a unit and two task definitions
- unit = FactoryBot.create(:unit, student_count: 1, task_count: 2)
- td1 = unit.task_definitions.first
- td2 = unit.task_definitions.second
- project = unit.active_projects.first
+ # Create a unit and two task definitions
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 2)
+ td1 = unit.task_definitions.first
+ td2 = unit.task_definitions.second
+ project = unit.active_projects.first
- task = project.task_for_task_definition(td2)
+ task = project.task_for_task_definition(td2)
- td1.update(
- upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
- target_grade: 0, # Pass
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now + 1.week
- )
+ td1.update(
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
+ target_grade: 0, # Pass
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now + 1.week
+ )
- td2.update(
- upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
- target_grade: 3, # HD
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now + 1.week
- )
+ td2.update(
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
+ target_grade: 3, # HD
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now + 1.week
+ )
- # Create a prerequisite on the second taskDef that adds the first taskDef as a prereq
- prereq = TaskPrerequisite.create!(
- task_definition: td2, # Before you can submit td2...
- prerequisite: td1, # You need to submit td1
- task_status_id: TaskStatus.ready_for_feedback.id
- )
+ # Create a prerequisite on the second taskDef that adds the first taskDef as a prereq
+ prereq = TaskPrerequisite.create!(
+ task_definition: td2, # Before you can submit td2...
+ prerequisite: td1, # You need to submit td1
+ task_status_id: TaskStatus.ready_for_feedback.id
+ )
- assert prereq.valid?
+ assert prereq.valid?
- # Add username and auth_token to Header
- add_auth_header_for(user: project.user)
+ # Add username and auth_token to Header
+ add_auth_header_for(user: project.user)
- data_to_post = {
- trigger: 'ready_for_feedback'
- }
+ data_to_post = {
+ trigger: 'ready_for_feedback'
+ }
- data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
+ data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
- # Attempt to make a submission that has an unsubmitted prerequisite
- post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
- assert_equal 409, last_response.status, last_response_body
- task = project.task_for_task_definition(td2)
- # Ensure the submission was denied
- assert_equal TaskStatus.not_started, task.task_status
- assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been submitted"
+ # Attempt to make a submission that has an unsubmitted prerequisite
+ post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
+ assert_equal 409, last_response.status, last_response_body
+ task = project.task_for_task_definition(td2)
+ # Ensure the submission was denied
+ assert_equal TaskStatus.not_started, task.task_status
+ assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been submitted"
- prereq.update(task_status_id: TaskStatus.discuss.id)
- post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
- assert_equal 409, last_response.status, last_response_body
- task.reload
- # Ensure the submission was denied
- assert_equal TaskStatus.not_started, task.task_status
- assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been discussed"
+ prereq.update(task_status_id: TaskStatus.discuss.id)
+ post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
+ assert_equal 409, last_response.status, last_response_body
+ task.reload
+ # Ensure the submission was denied
+ assert_equal TaskStatus.not_started, task.task_status
+ assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been discussed"
- prereq.update(task_status_id: TaskStatus.demonstrate.id)
- post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
- assert_equal 409, last_response.status, last_response_body
- task.reload
- # Ensure the submission was denied
- assert_equal TaskStatus.not_started, task.task_status
- assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been demonstrated"
+ prereq.update(task_status_id: TaskStatus.demonstrate.id)
+ post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
+ assert_equal 409, last_response.status, last_response_body
+ task.reload
+ # Ensure the submission was denied
+ assert_equal TaskStatus.not_started, task.task_status
+ assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been demonstrated"
- prereq.update(task_status_id: TaskStatus.complete.id)
- post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
- assert_equal 409, last_response.status, last_response_body
- task.reload
- # Ensure the submission was denied
- assert_equal TaskStatus.not_started, task.task_status
- assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been completed"
+ prereq.update(task_status_id: TaskStatus.complete.id)
+ post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
+ assert_equal 409, last_response.status, last_response_body
+ task.reload
+ # Ensure the submission was denied
+ assert_equal TaskStatus.not_started, task.task_status
+ assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been completed"
- prereq.update(task_status_id: TaskStatus.ready_for_feedback.id)
+ prereq.update(task_status_id: TaskStatus.ready_for_feedback.id)
- # Make a submission to the prerequsite task
- post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission", data_to_post
- assert_equal 201, last_response.status, last_response_body
- task1 = project.task_for_task_definition(td1)
- assert_equal TaskStatus.ready_for_feedback, task1.task_status
+ # Use a direct status change here to avoid waiting for submission processing.
+ task1 = project.task_for_task_definition(td1)
+ task1.submit(project.user)
+ assert_equal TaskStatus.ready_for_feedback, task1.task_status
- # Re-attempt to make a submission (Prerequisite status is ready for feedback, expecting complete)
- post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
- assert_equal 201, last_response.status, last_response_body
- task.reload
- assert_equal TaskStatus.ready_for_feedback, task.task_status
+ # Re-attempt to make a submission (Prerequisite status is ready for feedback, expecting complete)
+ post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
+ assert_equal 201, last_response.status, last_response_body
+ task.reload
+ assert_equal TaskStatus.ready_for_feedback, task.task_status
- prereq.destroy
- unit.destroy
- end
+ prereq.destroy
+ unit.destroy
+ end
+
+ def test_cant_set_upload_free_task_ready_for_feedback_until_prerequisites_met
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 2)
+ prerequisite_definition, task_definition = unit.task_definitions.first(2)
+ prerequisite_definition.update!(target_grade: 0)
+ task_definition.update!(target_grade: 3, upload_requirements: [])
+ project = unit.active_projects.first
+ task = project.task_for_task_definition(task_definition)
+ prerequisite_task = project.task_for_task_definition(prerequisite_definition)
+ prerequisite = TaskPrerequisite.create!(
+ task_definition: task_definition,
+ prerequisite: prerequisite_definition,
+ task_status_id: TaskStatus.ready_for_feedback.id
+ )
+
+ add_auth_header_for(user: project.user)
+ path = "/api/projects/#{project.id}/task_def_id/#{task_definition.id}"
+
+ put path, { trigger: 'ready_for_feedback' }
+ assert_equal 409, last_response.status
+ assert_equal "Cannot submit this task until prerequisite '#{prerequisite_definition.abbreviation}' has been submitted",
+ last_response_body['error']
+ assert_equal TaskStatus.not_started, task.reload.task_status
+
+ prerequisite.update!(task_status_id: TaskStatus.complete.id)
+ prerequisite_task.update!(task_status: TaskStatus.ready_for_feedback)
+
+ put path, { trigger: 'ready_for_feedback' }
+ assert_equal 409, last_response.status
+ assert_equal "Cannot submit this task until prerequisite '#{prerequisite_definition.abbreviation}' has been completed",
+ last_response_body['error']
+ assert_equal TaskStatus.not_started, task.reload.task_status
+
+ prerequisite_task.update!(task_status: TaskStatus.complete)
+
+ put path, { trigger: 'ready_for_feedback' }
+ assert_equal 200, last_response.status
+ assert_equal TaskStatus.ready_for_feedback, task.reload.task_status
end
def test_prerequisites_task_status
@@ -678,13 +760,6 @@ def test_prerequisites_task_status
project = unit.active_projects.first
- # Add username and auth_token to Header
- add_auth_header_for(user: project.user)
-
- data_to_post = {
- trigger: 'ready_for_feedback'
- }
-
# Create a prerequisite on the second taskDef that adds the first taskDef as a prereq
prereq = TaskPrerequisite.create!(
task_definition: td2, # Before you can submit td2...
@@ -697,78 +772,53 @@ def test_prerequisites_task_status
tests = [
{
prerequisite_status: TaskStatus.ready_for_feedback,
- required_status: TaskStatus.ready_for_feedback,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.ready_for_feedback
},
{
prerequisite_status: TaskStatus.discuss,
- required_status: TaskStatus.discuss,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.discuss
},
{
prerequisite_status: TaskStatus.demonstrate,
- required_status: TaskStatus.demonstrate,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.demonstrate
},
{
prerequisite_status: TaskStatus.discuss,
- required_status: TaskStatus.demonstrate,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.demonstrate
},
{
prerequisite_status: TaskStatus.demonstrate,
- required_status: TaskStatus.discuss,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.discuss
},
{
prerequisite_status: TaskStatus.complete,
- required_status: TaskStatus.complete,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.complete
},
{
prerequisite_status: TaskStatus.complete,
- required_status: TaskStatus.ready_for_feedback,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.ready_for_feedback
},
{
prerequisite_status: TaskStatus.discuss,
- required_status: TaskStatus.ready_for_feedback,
- expected_status: 201,
- expected_error: nil
+ required_status: TaskStatus.ready_for_feedback
}
]
- Sidekiq::Testing.inline! do
- prereq_task = project.task_for_task_definition(td1)
- task = project.task_for_task_definition(td2)
- data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
-
- tests.each do |test|
- prereq_task.update(task_status_id: test[:prerequisite_status].id)
- task.update(task_status_id: TaskStatus.not_started.id)
-
- post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post
- assert_equal test[:expected_status], last_response.status, last_response_body
- task.reload
- if test[:expected_status] == 201
- # Ensure submission was accepted
- assert_equal TaskStatus.ready_for_feedback, task.task_status
- else
- # Ensure submission was denied
- assert_equal TaskStatus.not_started, task.task_status
- end
- end
+ prereq_task = project.task_for_task_definition(td1)
+ task = project.task_for_task_definition(td2)
- prereq.destroy
- unit.destroy
+ tests.each do |test|
+ prereq.update!(task_status_id: test[:required_status].id)
+ prereq_task.update!(task_status_id: test[:prerequisite_status].id)
+ task.update!(task_status_id: TaskStatus.not_started.id, submission_date: nil)
+
+ task.submit(project.user)
+ task.reload
+ assert_equal TaskStatus.ready_for_feedback, task.task_status
end
+
+ prereq.destroy
+ unit.destroy
end
def test_check_in_comment
@@ -859,88 +909,141 @@ def test_requires_discussion_blocks_complete_until_discussed_comment_added
assert_instance_of TaskDiscussedComment, discussed_comment
assert_equal 'discussed_in_class', discussed_comment.content_type
+ task.add_text_comment(tutor, 'Manual tutor feedback')
+
put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete' }
assert_equal 200, last_response.status
task.reload
assert_equal TaskStatus.complete, task.task_status
end
+ def test_complete_with_discussed_only_keeps_comment_when_transition_succeeds
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 0)
+ td = TaskDefinition.create!({
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Discussion required task',
+ description: 'Task that requires discussion before complete',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now + 1.week,
+ abbreviation: 'DiscussReqAtomicTask',
+ restrict_status_updates: false,
+ requires_discussion: true,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
+
+ project = unit.active_projects.first
+ task = project.task_for_task_definition(td)
+ tutor = unit.tutors.first
+
+ add_auth_header_for(user: tutor)
+
+ put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete', discussed: true }
+ assert_equal 403, last_response.status
+ task.reload
+ assert_not_equal TaskStatus.complete, task.task_status
+ assert_not task.has_discussed_in_class_comment?
+
+ task.add_text_comment(tutor, 'Manual tutor feedback')
+
+ put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete', discussed: true }
+ assert_equal 200, last_response.status
+ task.reload
+ assert_equal TaskStatus.complete, task.task_status
+ assert task.has_discussed_in_class_comment?
+ end
+
def test_require_comment_for_feedback_submission_assess_in_portfolio
- Sidekiq::Testing.inline! do
- unit = FactoryBot.create(:unit, student_count: 1, task_count: 2)
- td1 = unit.task_definitions.first
- project = unit.active_projects.first
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 2)
+ td1 = unit.task_definitions.first
+ project = unit.active_projects.first
- task = project.task_for_task_definition(td1)
+ td1.update!(
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
+ target_grade: 0, # Pass
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now + 1.week,
+ assess_in_portfolio_only: false
+ )
- td1.update(
- upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }],
- target_grade: 0, # Pass
- start_date: Time.zone.now - 2.weeks,
- target_date: Time.zone.now + 1.week,
- assess_in_portfolio_only: false
- )
+ task = project.task_for_task_definition(td1)
- add_auth_header_for(user: project.user)
+ add_auth_header_for(user: project.user)
- # Make a submission where a comment isn't required
- post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission",
- with_file('test_files/submissions/program.cs', 'application/json', {
- trigger: 'ready_for_feedback'
- })
- assert_equal 201, last_response.status, last_response_body
- task.reload
- assert_equal TaskStatus.ready_for_feedback, task.task_status
+ # Use a direct submit here so the test can focus on the comment requirement.
+ task.submit(project.user)
+ task.reload
+ assert_equal TaskStatus.ready_for_feedback, task.task_status
- task.update(task_status: TaskStatus.not_started)
+ task.update!(task_status: TaskStatus.not_started, submission_date: nil)
- td1.update(assess_in_portfolio_only: true)
+ td1.update(assess_in_portfolio_only: true)
- # Make a submission where a comment is required
- post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission",
- with_file('test_files/submissions/program.cs', 'application/json', {
- trigger: 'ready_for_feedback'
- })
- assert_equal 422, last_response.status, last_response_body
- task.reload
- assert_equal TaskStatus.not_started, task.task_status
+ # Make a submission where a comment is required
+ post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission",
+ with_file('test_files/submissions/program.cs', 'application/json', {
+ trigger: 'ready_for_feedback'
+ })
+ assert_equal 422, last_response.status, last_response_body
+ task.reload
+ assert_equal TaskStatus.not_started, task.task_status
- comment = 'I would like feedback with my code..'
+ comment = 'I would like feedback with my code..'
- # Make a submission with comment
- post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission",
- with_file('test_files/submissions/program.cs', 'application/json', {
- trigger: 'ready_for_feedback',
- comment: comment
- })
- assert_equal 201, last_response.status, last_response_body
- task.reload
- assert_equal TaskStatus.ready_for_feedback, task.task_status
+ # Make a submission with comment
+ post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission",
+ with_file('test_files/submissions/program.cs', 'application/json', {
+ trigger: 'ready_for_feedback',
+ comment: comment
+ })
+ assert_equal 201, last_response.status, last_response_body
+ task.reload
+ assert_equal TaskStatus.ready_for_feedback, task.task_status
- status_comment = task.comments.last
- text_comment = task.comments.second_to_last
+ status_comment = task.comments.last
+ text_comment = task.comments.second_to_last
- assert_not status_comment.nil?
- assert_not text_comment.nil?
+ assert_not status_comment.nil?
+ assert_not text_comment.nil?
- assert_equal TaskStatus.ready_for_feedback.name, status_comment.comment
- assert_equal comment, text_comment.comment
- end
+ assert_equal TaskStatus.ready_for_feedback.name, status_comment.comment
+ assert_equal comment, text_comment.comment
end
def test_resubmission_doesnt_change_submission_date
Sidekiq::Testing.inline! do
- unit = FactoryBot.create(:unit, task_count: 2, student_count: 0)
+ unit = FactoryBot.create(
+ :unit,
+ with_students: false,
+ student_count: 0,
+ task_count: 0,
+ tutorials: 0,
+ stream_count: 0,
+ staff_count: 0,
+ campus_count: 0,
+ outcome_count: 0
+ )
tutor = FactoryBot.create(:user, :tutor)
unit_role = unit.employ_staff(tutor, Role.tutor)
tutorial_stream = FactoryBot.create(:tutorial_stream, unit: unit)
tutorial = FactoryBot.create(:tutorial, unit: unit, tutorial_stream: tutorial_stream, campus: nil, unit_role: unit_role)
-
- td = unit.task_definitions.first
-
- td.update!(due_date: Time.zone.today + 1.day, tutorial_stream: tutorial_stream)
- assert_not td.nil?
+ td = FactoryBot.create(
+ :task_definition,
+ unit: unit,
+ tutorial_stream: tutorial_stream,
+ target_grade: 0,
+ outcome_count: 0
+ )
+ td.update!(
+ target_date: Time.zone.today + 1.week,
+ due_date: Time.zone.today + 1.week
+ )
student1 = FactoryBot.create(:user, :student)
student2 = FactoryBot.create(:user, :student)
@@ -951,111 +1054,82 @@ def test_resubmission_doesnt_change_submission_date
project1.enrol_in(tutorial)
project2.enrol_in(tutorial)
+ task1 = project1.task_for_task_definition(td)
+ task2 = project2.task_for_task_definition(td)
+
tasks = unit.tasks_for_task_inbox(tutor, false)
assert tasks.to_a.empty?
# Submit a task before the due date (student 1)
- add_auth_header_for(user: student1)
- data_to_post = {
- trigger: 'ready_for_feedback'
- }
- data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
- post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post
- assert_equal 201, last_response.status, last_response_body
+ task1.submit(student1)
travel 10.minutes
# Submit a task before the due date (student 2)
- add_auth_header_for(user: student2)
- data_to_post = {
- trigger: 'ready_for_feedback'
- }
- data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
- post "/api/projects/#{project2.id}/task_def_id/#{td.id}/submission", data_to_post
- assert_equal 201, last_response.status, last_response_body
+ task2.submit(student2)
tasks = unit.tasks_for_task_inbox(tutor, false)
+ assert_equal TaskStatus.ready_for_feedback, task1.task_status
+ assert_equal TaskStatus.ready_for_feedback, task2.task_status
+
assert_equal 2, tasks.to_a.count
assert_equal project1.id, tasks.first.project.id, "First task in inbox should be project1's task"
assert_equal project2.id, tasks.second.project.id, "Second task in inbox should be project2's task"
- task1 = project1.task_for_task_definition(td)
- task2 = project2.task_for_task_definition(td)
-
- assert_equal TaskStatus.ready_for_feedback, task1.task_status
- assert_equal TaskStatus.ready_for_feedback, task2.task_status
-
assert task2.submission_date > task1.submission_date
+ original_submission_date = task1.submission_date
# Submit the task again, ensure the submission_date hasn't changed (student1)
travel 10.minutes
- # Submit a task before the due date (student 1)
- add_auth_header_for(user: student1)
- data_to_post = {
- trigger: 'ready_for_feedback'
- }
- data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
- post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post
- assert_equal 201, last_response.status, last_response_body
+ task1.submit(student1)
tasks = unit.tasks_for_task_inbox(tutor, false)
assert_equal project1.id, tasks.first.project.id, "First task in inbox should be project1's task"
assert_equal project2.id, tasks.second.project.id, "Second task in inbox should be project2's task"
- task1 = project1.task_for_task_definition(td)
- task2 = project2.task_for_task_definition(td)
+ task1.reload
assert task2.submission_date > task1.submission_date
- assert TaskStatus.ready_for_feedback, task1.task_status
+ assert_equal original_submission_date, task1.submission_date
+ assert_equal TaskStatus.ready_for_feedback, task1.task_status
- # Submit the task again after the duedate, ensure the submission_date hasn't changed (student1)
+ # Submit the task again later, ensure the submission_date hasn't changed (student1)
travel 2.days
- # Submit a task before the due date (student 1)
- add_auth_header_for(user: student1)
- data_to_post = {
- trigger: 'ready_for_feedback'
- }
- data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
- post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post
- assert_equal 201, last_response.status, last_response_body
+ task1.submit(student1)
tasks = unit.tasks_for_task_inbox(tutor, false)
assert_equal project1.id, tasks.first.project.id, "First task in inbox should be project1's task"
assert_equal project2.id, tasks.second.project.id, "Second task in inbox should be project2's task"
- task1 = project1.task_for_task_definition(td)
- task2 = project2.task_for_task_definition(td)
+ task1.reload
assert task2.submission_date > task1.submission_date
- assert TaskStatus.ready_for_feedback, task1.task_status
+ assert_equal original_submission_date, task1.submission_date
+ assert_equal TaskStatus.ready_for_feedback, task1.task_status
task1.update(task_status_id: TaskStatus.fix_and_resubmit.id)
# Submit the task again, now expecting submission date to update
travel 10.minutes
- # Submit a task before the due date (student 1)
- add_auth_header_for(user: student1)
- data_to_post = {
- trigger: 'ready_for_feedback'
- }
- data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post)
- post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post
- assert_equal 201, last_response.status, last_response_body
+ task1.submit(student1)
+ task1.reload
+ assert_equal TaskStatus.ready_for_feedback, task1.task_status
tasks = unit.tasks_for_task_inbox(tutor, false)
+ assert_operator tasks.to_a.count, :>=, 2, "Expected 2 or more tasks in tutors inbox"
assert_equal project2.id, tasks.first.project.id, "First task in inbox should be project1's task"
assert_equal project1.id, tasks.second.project.id, "Second task in inbox should be project2's task"
- task1 = project1.task_for_task_definition(td)
- task2 = project2.task_for_task_definition(td)
+ task1.reload
assert task1.submission_date > task2.submission_date
+ assert task1.submission_date > original_submission_date
end
end
@@ -1110,14 +1184,93 @@ def test_task_target_date_permissions
task.reload
assert_equal new_start_date, task.target_start_date
assert_equal new_end_date, task.target_due_date
+ assert task.comments.last.attention_none?
put "/api/projects/#{project1.id}/reset_target_dates"
assert_equal 200, last_response.status
task.reload
assert_nil task.target_start_date
assert_nil task.target_due_date
+ assert task.comments.last.attention_none?
unit.update!(allow_flexible_dates: false)
end
+ def test_claim_overflow_task_creates_analytics_log
+ travel_to Time.zone.parse('2026-04-15 10:30:00 UTC') do
+ unit = create(
+ :unit,
+ student_count: 1,
+ unenrolled_student_count: 0,
+ part_enrolled_student_count: 0,
+ inactive_student_count: 0,
+ task_count: 1,
+ stream_count: 0,
+ tutorials: 1
+ )
+ original_tutor = create(:user, :tutor)
+ original_tutor_role = unit.employ_staff(original_tutor, Role.tutor)
+ unit.tutorials.first.update!(unit_role: original_tutor_role)
+
+ claiming_tutor = create(:user, :tutor)
+ claiming_role = unit.employ_staff(claiming_tutor, Role.tutor)
+ claiming_role.update!(can_mark_overflow_tasks: true)
+
+ project = unit.active_projects.first
+ task_definition = unit.task_definitions.first
+ task = project.task_for_task_definition(task_definition)
+ task.update!(submission_date: 12.days.ago)
+
+ add_auth_header_for(user: claiming_tutor)
+
+ assert_difference('OverflowTaskClaim.count', 1) do
+ assert_difference('OverflowTaskClaimLog.count', 1) do
+ post "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/claim_overflow_task"
+ end
+ end
+
+ assert_equal 201, last_response.status
+
+ claim_log = OverflowTaskClaimLog.order(:id).last
+ assert_equal unit, claim_log.unit
+ assert_equal task, claim_log.task
+ assert_equal claiming_role, claim_log.claimed_by_unit_role
+ assert_equal claiming_tutor, claim_log.claimed_by_user
+ assert_equal original_tutor, claim_log.original_tutor_user
+ assert_equal project.student, claim_log.student_user
+ assert_equal 12, claim_log.days_awaiting_feedback
+ assert_equal Time.zone.now, claim_log.claimed_at
+ end
+ end
+
+ def test_student_cannot_grade_own_task
+ unit = FactoryBot.create(:unit, student_count: 1, task_count: 0)
+ td = TaskDefinition.create!({
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Graded task',
+ description: 'Task graded by staff',
+ weighting: 4,
+ target_grade: 0,
+ start_date: Time.zone.now - 2.weeks,
+ target_date: Time.zone.now + 1.week,
+ abbreviation: 'GradedTask',
+ restrict_status_updates: false,
+ upload_requirements: [],
+ plagiarism_warn_pct: 0.8,
+ is_graded: true,
+ max_quality_pts: 0
+ })
+
+ project = unit.active_projects.first
+ top_grade = unit.grade_definitions.map { |definition| definition['value'] }.max
+
+ add_auth_header_for(user: project.student)
+
+ put "/api/projects/#{project.id}/task_def_id/#{td.id}", { grade: top_grade }
+
+ assert_equal 403, last_response.status, last_response_body
+ assert_nil Task.find_by(project: project, task_definition: td)&.grade
+ end
+
end
diff --git a/test/api/teaching_period_api_test.rb b/test/api/teaching_period_api_test.rb
index 14d9009292..2c73b10632 100644
--- a/test/api/teaching_period_api_test.rb
+++ b/test/api/teaching_period_api_test.rb
@@ -19,10 +19,10 @@ def test_get_teaching_periods
assert_equal expected_data.count, last_response_body.count
# What are the keys we expect in the data that match the model - so we can check these
- response_keys = %w(start_date year period end_date active_until)
+ response_keys = %w[start_date year period end_date active_until]
# Loop through all of the responses
- last_response_body.each do | data |
+ last_response_body.each do |data|
# Find the matching teaching period, by id from response
tp = TeachingPeriod.find(data['id'])
# Match json with object
@@ -32,7 +32,7 @@ def test_get_teaching_periods
# Get a teaching period's details
def test_get_a_teaching_periods_details
- #create a dummy variable
+ # create a dummy variable
expected_tp = FactoryBot.create(:teaching_period)
# perform the GET
@@ -49,37 +49,78 @@ def test_get_a_teaching_periods_details
assert_equal actual_tp['end_date'].to_date, expected_tp.end_date.to_date
end
+ def test_get_teaching_period_details_with_units_without_user
+ tp = FactoryBot.create(:teaching_period)
+ unit = FactoryBot.create(:unit, teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
+
+ get "/api/teaching_periods/#{tp.id}"
+
+ assert_equal 200, last_response.status
+ assert_equal([unit.id], last_response_body['units'].map { |u| u['id'] })
+ assert_nil last_response_body['units'].first['my_role']
+ end
+
+ def test_get_teaching_period_details_ignores_username_without_token
+ tp = FactoryBot.create(:teaching_period)
+ unit = FactoryBot.create(:unit, teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
+
+ header 'username', unit.main_convenor_user.username
+ get "/api/teaching_periods/#{tp.id}"
+
+ assert_equal 200, last_response.status
+ assert_nil last_response_body['units'].first['my_role']
+ end
+
+ def test_get_teaching_period_details_includes_role_when_authenticated
+ tp = FactoryBot.create(:teaching_period)
+ unit = FactoryBot.create(:unit, teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ get "/api/teaching_periods/#{tp.id}"
+
+ assert_equal 200, last_response.status
+ assert_equal 'Convenor', last_response_body['units'].first['my_role']
+ end
+
# PUT tests
# Update break from teaching period
def test_update_break_from_teaching_period
tp = TeachingPeriod.first
to_update = tp.breaks.first
+ campus = FactoryBot.create(:campus)
+ new_start_date = to_update.start_date + 1.day
# Add username and auth_token to Header
- add_auth_header_for(user: User.first)
+ add_auth_header_for(user: FactoryBot.create(:user, :admin))
# The api call we are testing
- put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", { number_of_weeks: 5 }
+ put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", {
+ start_date: new_start_date,
+ number_of_days: 35,
+ campus_ids: [campus.id]
+ }
to_update.reload
- assert_equal 5, to_update.number_of_weeks
+ assert_equal 35, to_update.number_of_days
+ assert_equal [campus.id], to_update.campus_ids
+ assert_equal new_start_date.to_date, to_update.start_date.to_date
end
def test_update_break_must_be_from_teaching_period
tp = TeachingPeriod.first
to_update = TeachingPeriod.last.breaks.first
- num_weeks = to_update.number_of_weeks
+ num_days = to_update.number_of_days
# Add username and auth_token to Header
add_auth_header_for(user: User.first)
# The api call we are testing
- put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", { number_of_weeks: num_weeks + 1 }
+ put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", { number_of_days: num_days + 7 }
assert_equal 404, last_response.status
to_update.reload
- assert_equal num_weeks, to_update.number_of_weeks
+ assert_equal num_days, to_update.number_of_days
end
# Replace a teaching period
@@ -88,8 +129,10 @@ def test_put_teaching_period
tp = FactoryBot.create(:teaching_period)
# data to replace
+ replacement = FactoryBot.build(:teaching_period)
+ replacement.last_teaching_date = replacement.end_date.to_date
data_to_put = {
- teaching_period: FactoryBot.build(:teaching_period)
+ teaching_period: replacement
}
# Add username and auth_token to Header
@@ -99,20 +142,22 @@ def test_put_teaching_period
put_json "/api/teaching_periods/#{tp.id}", data_to_put
# check if the POST get through
- assert_equal 200, last_response.status
+ assert_equal 200, last_response.status, last_response_body
# check if the details posted match as expected
- response_keys = %w(period year start_date end_date active_until)
+ response_keys = %w[period year start_date end_date active_until]
tp_updated = tp.reload
assert_json_matches_model(tp_updated, last_response_body, response_keys)
+ assert_equal tp_updated.last_teaching_date.iso8601, last_response_body['last_teaching_date']
# check if the details in the replaced teaching period match as data set to replace
assert_equal data_to_put[:teaching_period]['period'], tp_updated.period
assert_equal data_to_put[:teaching_period]['active_until'].to_date, tp_updated.active_until.to_date
assert_equal data_to_put[:teaching_period]['start_date'].to_date, tp_updated.start_date.to_date
assert_equal data_to_put[:teaching_period]['end_date'].to_date, tp_updated.end_date.to_date
+ assert_equal data_to_put[:teaching_period]['last_teaching_date'], tp_updated.last_teaching_date
end
-
+
# Put teaching period using unauthorised account
def test_student_cannot_put_teaching_period
# A user with student role which does not have permission to put a teaching period
@@ -124,7 +169,7 @@ def test_student_cannot_put_teaching_period
# Number of teaching period before put new teaching period
number_of_tp = TeachingPeriod.count
- # Create a dummy teaching period
+ # Create a dummy teaching period
data_to_put = {
teaching_period: FactoryBot.build(:teaching_period)
}
@@ -163,7 +208,7 @@ def test_post_teaching_period
assert_equal 201, last_response.status
# check if the details posted match as expected
- response_keys = %w(period year start_date end_date active_until)
+ response_keys = %w[period year start_date end_date active_until]
teaching_period = TeachingPeriod.find(last_response_body['id'])
assert_json_matches_model(teaching_period, last_response_body, response_keys)
@@ -181,7 +226,7 @@ def test_post_teaching_period
# Delete a teaching period
def test_delete_teaching_period
# create a dummy teaching period
- teaching_period = FactoryBot.create (:teaching_period)
+ teaching_period = FactoryBot.create(:teaching_period)
id_of_tp = teaching_period.id
# number of teaching periods before delete
@@ -197,10 +242,10 @@ def test_delete_teaching_period
assert_equal 200, last_response.status
# Check if the number of teaching period reduces by 1
- assert_equal TeachingPeriod.count, number_of_tp -1
+ assert_equal TeachingPeriod.count, number_of_tp - 1
# Check that you can't find the deleted id
- refute TeachingPeriod.exists?(id_of_tp)
+ assert_not TeachingPeriod.exists?(id_of_tp)
end
# Delete a teaching period using unauthorised account
@@ -209,7 +254,7 @@ def test_student_cannot_delete_teaching_period
user = FactoryBot.build(:user, :student)
# Teaching period to delete
- teaching_period = FactoryBot.create (:teaching_period)
+ teaching_period = FactoryBot.create(:teaching_period)
id_of_tp = teaching_period.id
# Number of teaching periods before deletion
diff --git a/test/api/tii/tii_action_api_test.rb b/test/api/tii/tii_action_api_test.rb
index 8a34265e96..9226eba46d 100644
--- a/test/api/tii/tii_action_api_test.rb
+++ b/test/api/tii/tii_action_api_test.rb
@@ -159,7 +159,7 @@ def test_trigger_action_via_put
TiiActionJob.clear
add_auth_header_for(user: User.where(role: Role.admin).first)
action = TiiActionUploadSubmission.last
- put_json "/api/tii_actions/#{action.id}", { 'action': 'retry' }
+ put_json "/api/tii_actions/#{action.id}", { action: 'retry' }
assert_equal 200, last_response.status
assert_equal 1, TiiActionJob.jobs.size
@@ -174,13 +174,13 @@ def test_trigger_action_via_put_multiple
# Ensure retry is false
action.update(retry: false)
- refute action.retry
+ assert_not action.retry
- put_json "/api/tii_actions/#{action.id}", { 'action': 'retry' }
+ put_json "/api/tii_actions/#{action.id}", { action: 'retry' }
assert_equal 200, last_response.status
# Ensure 2nd put does not cause action to be scheduled twice
- put_json "/api/tii_actions/#{action.id}", { 'action': 'retry' }
+ put_json "/api/tii_actions/#{action.id}", { action: 'retry' }
assert_equal 403, last_response.status
assert_equal 1, TiiActionJob.jobs.size
diff --git a/test/api/tii/tii_group_attachment_api_test.rb b/test/api/tii/tii_group_attachment_api_test.rb
index b716db1a7e..41c18671c6 100644
--- a/test/api/tii/tii_group_attachment_api_test.rb
+++ b/test/api/tii/tii_group_attachment_api_test.rb
@@ -15,8 +15,7 @@ def app
setup do
@task_def = FactoryBot.create(:task_definition,
- unit: FactoryBot.create(:unit, with_students: false, task_count: 0)
- )
+ unit: FactoryBot.create(:unit, with_students: false, task_count: 0))
@unit = @task_def.unit
@@ -34,6 +33,26 @@ def app
)
end
+ def test_group_attachments_require_authentication
+ clear_auth_header
+ path = "/api/units/#{@unit.id}/task_definitions/#{@task_def.id}/tii_group_attachments"
+ attachment = @task_def.tii_group_attachments.first
+
+ get path
+ assert_equal 419, last_response.status, last_response_body
+
+ put_json "#{path}/#{attachment.id}", { action: 'upload' }
+ assert_equal 419, last_response.status, last_response_body
+
+ delete "#{path}/#{attachment.id}"
+ assert_equal 419, last_response.status, last_response_body
+ assert TiiGroupAttachment.exists?(attachment.id)
+
+ add_auth_header_for(user: @unit.main_convenor_user, auth_token: 'invalid')
+ get path
+ assert_equal 419, last_response.status, last_response_body
+ end
+
def test_only_main_convenor_can_access_group_attachments_for_task_definition
tutor = FactoryBot.create(:user, :tutor)
@unit.employ_staff tutor, Role.tutor
@@ -80,7 +99,7 @@ def test_can_get_group_attachments_for_task_definition
get "/api/units/#{@unit.id}/task_definitions/#{@task_def.id}/tii_group_attachments"
assert_equal 200, last_response.status, last_response_body
assert_equal 2, last_response_body.length
- assert_json_limit_keys_to_exactly(%w(id group_attachment_id filename status), last_response_body.first)
+ assert_json_limit_keys_to_exactly(%w[id group_attachment_id filename status], last_response_body.first)
end
def test_can_trigger_upload_group_attachments_for_task_definition
diff --git a/test/api/tii/tii_hook_test.rb b/test/api/tii/tii_hook_test.rb
index fbba5991ff..9092472e00 100644
--- a/test/api/tii/tii_hook_test.rb
+++ b/test/api/tii/tii_hook_test.rb
@@ -31,9 +31,9 @@ def test_submission_webhook
)
# destroy will trigger delete of submission
- delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/).
- with(tii_headers).
- to_return(status: 200, body: "", headers: {})
+ delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838})
+ .with(tii_headers)
+ .to_return(status: 200, body: "", headers: {})
data = TCAClient::SubmissionCompleteWebhookRequest.new(
"id" => "e884f478-9757-41c7-80da-37b94ebb2838",
@@ -45,10 +45,10 @@ def test_submission_webhook
"word_count" => 145,
"character_count" => 760,
"created_time" => "2017-08-30T22:13:41Z",
- "capabilities" => [
- "INDEX",
- "VIEWER",
- "SIMILARITY"
+ "capabilities" => %w[
+ INDEX
+ VIEWER
+ SIMILARITY
],
"metadata" => {
"custom" => "{\"Type\":\"Final Paper\"}"
@@ -65,11 +65,11 @@ def test_submission_webhook
header "X-Turnitin-EventType", "SUBMISSION_COMPLETE"
# Callback will trigger similarity request
- stub_request(:put, "https://#{ENV['TCA_HOST']}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity").
- with(tii_headers).
- to_return(
- { status: 200, body: TCAClient::SimilarityMetadata.new(status: 'PROCESSING').to_hash.to_json, headers: {}},
- )
+ stub_request(:put, "https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity")
+ .with(tii_headers)
+ .to_return(
+ { status: 200, body: TCAClient::SimilarityMetadata.new(status: 'PROCESSING').to_hash.to_json, headers: {} }
+ )
post_json '/api/tii_hook', data
@@ -105,9 +105,9 @@ def test_similarity_webhook
)
# destroy will trigger delete of submission
- delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/).
- with(tii_headers).
- to_return(status: 200, body: "", headers: {})
+ delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838})
+ .with(tii_headers)
+ .to_return(status: 200, body: "", headers: {})
data = TCAClient::SimilarityCompleteWebhookRequest.new(
"submission_id" => "e884f478-9757-41c7-80da-37b94ebb2838",
@@ -178,9 +178,9 @@ def test_similarity_webhook
)
# destroy will trigger delete of submission
- delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/).
- with(tii_headers).
- to_return(status: 200, body: "", headers: {})
+ delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838})
+ .with(tii_headers)
+ .to_return(status: 200, body: "", headers: {})
data = TCAClient::SimilarityCompleteWebhookRequest.new(
"submission_id" => "e884f478-9757-41c7-80da-37b94ebb2838",
@@ -264,9 +264,9 @@ def test_pdf_status_webhook
)
# destroy will trigger delete of submission
- delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/).
- with(tii_headers).
- to_return(status: 200, body: "", headers: {})
+ delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838})
+ .with(tii_headers)
+ .to_return(status: 200, body: "", headers: {})
data = TCAClient::PDFStatusWebhookRequest.new(
"status" => "SUCCESS",
@@ -277,9 +277,9 @@ def test_pdf_status_webhook
}
)
- download_pdf_request = stub_request(:get, "https://#{ENV['TCA_HOST']}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity/pdf/123312").
- with(tii_headers).
- to_return(status: 200, body: File.read(test_file_path('submissions/1.2P.pdf')), headers: {})
+ download_pdf_request = stub_request(:get, "https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity/pdf/123312")
+ .with(tii_headers)
+ .to_return(status: 200, body: File.read(test_file_path('submissions/1.2P.pdf')), headers: {})
# puts data.to_json
@@ -299,7 +299,7 @@ def test_pdf_status_webhook
assert File.exist?(subm.similarity_pdf_path)
task.unit.destroy!
- refute File.exist?(subm.similarity_pdf_path)
+ assert_not File.exist?(subm.similarity_pdf_path)
end
# Test the group_attachment webhook
@@ -318,15 +318,15 @@ def test_group_attachment_webhook
grp_attachment.update(group_attachment_id: "16c45fbe-25f5-458b-9a4c-c3deeaff8af4")
- delete_stub = stub_request(:delete, %r[https://#{ENV['TCA_HOST']}/api/v1/groups/#{td.tii_group_id}/attachments/.*]).
- with(tii_headers).
- to_return(status: 200, body: "", headers: {})
+ delete_stub = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/groups/#{td.tii_group_id}/attachments/.*})
+ .with(tii_headers)
+ .to_return(status: 200, body: "", headers: {})
data = TCAClient::GroupAttachmentResponse.new(
- "id": grp_attachment.group_attachment_id,
- "title": "large2",
- "status": "COMPLETE",
- "template": true
+ id: grp_attachment.group_attachment_id,
+ title: "large2",
+ status: "COMPLETE",
+ template: true
)
# puts data.to_json
diff --git a/test/api/tii/turn_it_in_api_test.rb b/test/api/tii/turn_it_in_api_test.rb
new file mode 100644
index 0000000000..daa9ad41ba
--- /dev/null
+++ b/test/api/tii/turn_it_in_api_test.rb
@@ -0,0 +1,40 @@
+require 'test_helper'
+
+class TurnItInApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+
+ def app
+ Rails.application
+ end
+
+ def test_accept_eula_requires_a_valid_token
+ user = FactoryBot.create(:user)
+ path = "/api/tii_eula/users/#{user.id}/accept"
+
+ clear_auth_header
+ header 'username', user.username
+ put path
+ assert_equal 419, last_response.status
+ assert_nil user.reload.tii_eula_date
+
+ add_auth_header_for(user: user, auth_token: 'invalid')
+ put path
+ assert_equal 419, last_response.status
+ assert_nil user.reload.tii_eula_date
+ end
+
+ def test_accept_eula_only_for_the_authenticated_user
+ user = FactoryBot.create(:user)
+ other_user = FactoryBot.create(:user)
+ add_auth_header_for(user: user)
+
+ put "/api/tii_eula/users/#{other_user.id}/accept"
+ assert_equal 403, last_response.status
+ assert_nil other_user.reload.tii_eula_date
+
+ put "/api/tii_eula/users/#{user.id}/accept"
+ assert_equal 200, last_response.status
+ assert_not_nil user.reload.tii_eula_date
+ end
+end
diff --git a/test/api/tutorial_stream_api_test.rb b/test/api/tutorial_stream_api_test.rb
index b3f1385848..e33d5414b4 100644
--- a/test/api/tutorial_stream_api_test.rb
+++ b/test/api/tutorial_stream_api_test.rb
@@ -19,9 +19,9 @@ def test_create_checks_uniqueness
add_auth_header_for(user: unit.main_convenor_user)
assert_equal 0, unit.tutorial_streams.count
- post_json "/api/units/#{unit.id}/tutorial_streams", {activity_type_abbr: activity_type.abbreviation}
+ post_json "/api/units/#{unit.id}/tutorial_streams", { activity_type_abbr: activity_type.abbreviation }
assert_equal 201, last_response.status
- post_json "/api/units/#{unit.id}/tutorial_streams", {activity_type_abbr: activity_type.abbreviation}
+ post_json "/api/units/#{unit.id}/tutorial_streams", { activity_type_abbr: activity_type.abbreviation }
assert_equal 201, last_response.status
unit.reload
@@ -31,7 +31,7 @@ def test_create_checks_uniqueness
assert_equal 1, unit.tutorial_streams.count
assert_equal 200, last_response.status
- post_json "/api/units/#{unit.id}/tutorial_streams", {activity_type_abbr: activity_type.abbreviation}
+ post_json "/api/units/#{unit.id}/tutorial_streams", { activity_type_abbr: activity_type.abbreviation }
assert_equal 201, last_response.status
end
end
diff --git a/test/api/unit_contents_api_test.rb b/test/api/unit_contents_api_test.rb
new file mode 100644
index 0000000000..3f4b4a1709
--- /dev/null
+++ b/test/api/unit_contents_api_test.rb
@@ -0,0 +1,67 @@
+require 'test_helper'
+
+class UnitContentsApiTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+
+ def app
+ Rails.application
+ end
+
+ def setup
+ @unit = FactoryBot.create(:unit, with_students: false, stream_count: 0)
+ @archive = Tempfile.new(['unit-content-download', '.zip'])
+ write_zip(@archive.path, 'index.html' => 'Unit content
')
+ @site = @unit.unit_content_sites.create!(
+ name: 'Course site',
+ original_filename: 'course site.zip',
+ archive_path: @archive.path,
+ root_dir: '/'
+ )
+ end
+
+ def teardown
+ @archive.close
+ super
+ end
+
+ def test_convenor_can_download_content_site_archive
+ add_auth_header_for(user: @unit.main_convenor_user)
+
+ get "/api/units/#{@unit.id}/content/sites/#{@site.id}/archive"
+
+ assert_equal 200, last_response.status
+ assert_equal 'application/zip', last_response.content_type
+ assert_equal 'attachment; filename="course_site.zip"', last_response.headers['Content-Disposition']
+ assert_equal File.binread(@archive.path), last_response.body
+ end
+
+ def test_content_site_response_includes_the_cache_version
+ add_auth_header_for(user: @unit.main_convenor_user)
+
+ get "/api/units/#{@unit.id}/content/sites"
+
+ assert_equal 200, last_response.status
+ assert_equal @site.content_version, JSON.parse(last_response.body).first['content_version']
+ end
+
+ def test_student_cannot_download_content_site_archive
+ student = FactoryBot.create(:user, :student)
+ @unit.enrol_student(student, nil)
+ add_auth_header_for(user: student)
+
+ get "/api/units/#{@unit.id}/content/sites/#{@site.id}/archive"
+
+ assert_equal 403, last_response.status
+ end
+
+ private
+
+ def write_zip(path, entries)
+ Zip::File.open(path, Zip::File::CREATE) do |zip|
+ entries.each do |entry_name, contents|
+ zip.get_output_stream(entry_name) { |stream| stream.write(contents) }
+ end
+ end
+ end
+end
diff --git a/test/api/unit_roles_test.rb b/test/api/unit_roles_test.rb
index 5e24941fd0..b50949c86b 100644
--- a/test/api/unit_roles_test.rb
+++ b/test/api/unit_roles_test.rb
@@ -11,7 +11,6 @@ def app
# GET /api/units_roles
def test_get_unit_roles
-
# Add username and auth_token to Header
add_auth_header_for(user: User.first)
@@ -82,10 +81,10 @@ def test_delete_unit_role
assert_equal 200, last_response.status
# check if the number of unit roles reduces by 1
- assert_equal UnitRole.count, number_of_ur -1
+ assert_equal UnitRole.count, number_of_ur - 1
# Check that you can't find the deleted id
- refute UnitRole.exists?(id_of_ur)
+ assert_not UnitRole.exists?(id_of_ur)
end
# Delete a teaching period using unauthorised account
@@ -142,7 +141,117 @@ def test_delete_main_convenor
delete "/api/unit_roles/#{initial_id}"
assert_equal 200, last_response.status, last_response.inspect
- refute UnitRole.where(id: initial_id).present?
+ assert_not UnitRole.where(id: initial_id).present?
+ end
+
+ def test_delete_unit_role_with_assigned_tutorials_requires_reassignment
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1
+ tutor_user = FactoryBot.create :user, :tutor
+ tutor_role = unit.employ_staff tutor_user, Role.tutor
+ tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: tutor_role
+
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ delete "/api/unit_roles/#{tutor_role.id}"
+
+ assert_equal 400, last_response.status
+ assert_equal tutor_role.id, tutorial.reload.unit_role_id
+ assert UnitRole.exists?(tutor_role.id)
+ end
+
+ def test_delete_unit_role_with_assigned_tutorials_reassigns_and_deletes
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1
+ tutor_user = FactoryBot.create :user, :tutor
+ replacement_user = FactoryBot.create :user, :tutor
+ tutor_role = unit.employ_staff tutor_user, Role.tutor
+ replacement_role = unit.employ_staff replacement_user, Role.tutor
+ tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: tutor_role
+
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ delete "/api/unit_roles/#{tutor_role.id}", { reassign_to_unit_role_id: replacement_role.id }
+
+ assert_equal 200, last_response.status
+ assert_equal replacement_role.id, tutorial.reload.unit_role_id
+ assert_not UnitRole.exists?(tutor_role.id)
+ end
+
+ def test_delete_unit_role_with_assigned_tutorials_rejects_invalid_reassignment_target
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1
+ other_unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1
+ tutor_user = FactoryBot.create :user, :tutor
+ tutor_role = unit.employ_staff tutor_user, Role.tutor
+ invalid_role = other_unit.staff.first
+ tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: tutor_role
+
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ delete "/api/unit_roles/#{tutor_role.id}", { reassign_to_unit_role_id: invalid_role.id }
+
+ assert_equal 400, last_response.status
+ assert_equal tutor_role.id, tutorial.reload.unit_role_id
+ assert UnitRole.exists?(tutor_role.id)
+ end
+
+ def test_delete_main_convenor_with_reassignment_rolls_back_tutorial_updates
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1
+ replacement_user = FactoryBot.create :user, :convenor
+ replacement_role = unit.employ_staff replacement_user, Role.convenor
+ tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: unit.main_convenor
+ initial_main_convenor_id = unit.main_convenor_id
+
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ delete "/api/unit_roles/#{initial_main_convenor_id}", { reassign_to_unit_role_id: replacement_role.id }
+
+ assert_equal 400, last_response.status, last_response.inspect
+ assert_equal initial_main_convenor_id, tutorial.reload.unit_role_id
+ assert UnitRole.exists?(initial_main_convenor_id)
+ assert UnitRole.exists?(replacement_role.id)
+ end
+
+ def test_admin_not_in_unit_can_update_unit_role
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 0
+
+ admin_user = FactoryBot.create :user, :admin
+ tutor_user = FactoryBot.create :user, :convenor
+ tutor_role = unit.employ_staff tutor_user, Role.tutor
+
+ # The admin is not employed within the unit
+ assert_nil unit.unit_role_for(admin_user)
+
+ add_auth_header_for(user: admin_user)
+
+ # Promote the staff member to convenor
+ put_json "/api/unit_roles/#{tutor_role.id}", { unit_role: { role_id: Role.convenor.id } }
+
+ assert_equal 200, last_response.status, last_response.inspect
+ assert_equal Role.convenor.id, tutor_role.reload.role_id
+
+ # ... and mark them as observer only
+ put_json "/api/unit_roles/#{tutor_role.id}", { unit_role: { role_id: Role.convenor.id, observer_only: true } }
+
+ assert_equal 200, last_response.status, last_response.inspect
+ assert tutor_role.reload.observer_only
+
+ unit.destroy
+ end
+
+ def test_tutor_not_in_unit_cannot_update_unit_role
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 0
+
+ other_tutor_user = FactoryBot.create :user, :tutor
+ tutor_user = FactoryBot.create :user, :convenor
+ tutor_role = unit.employ_staff tutor_user, Role.tutor
+
+ add_auth_header_for(user: other_tutor_user)
+
+ put_json "/api/unit_roles/#{tutor_role.id}", { unit_role: { role_id: Role.convenor.id } }
+
+ assert_equal 403, last_response.status, last_response.inspect
+ assert_equal Role.tutor.id, tutor_role.reload.role_id
+
+ unit.destroy
end
def test_observer_unit_role
@@ -265,4 +374,27 @@ def test_observer_unit_role
assert_not_equal 403, last_response.status, "#{route}: (#{last_response_body})"
end
end
+
+ def test_student_cannot_moderate_feedback
+ unit = FactoryBot.create :unit, with_students: true, task_count: 1, outcome_count: 0, staff_count: 0
+ project = unit.active_projects.first
+ task = project.task_for_task_definition(unit.task_definitions.first)
+ task.save! if task.new_record?
+
+ # Tutors without a mentor have a nil mentor, as do users without a unit role
+ tutor_role = unit.unit_role_for(project.tutor_for(task.task_definition))
+ tutor_role.update!(mentor: nil)
+
+ moderated_task = ModeratedTask.create!(task: task, task_definition: task.task_definition, state: :open, moderation_type: :escalation)
+
+ add_auth_header_for(user: project.student)
+ post "/api/unit_roles/#{tutor_role.id}/moderation/#{task.id}", { action: 'overturn' }
+
+ assert_equal 400, last_response.status, last_response_body
+ assert moderated_task.reload.open?
+ assert_nil moderated_task.outcome
+ assert_nil TutorFeedbackScore.find_by(unit_role: tutor_role, task_definition: task.task_definition)
+
+ unit.destroy
+ end
end
diff --git a/test/api/units/feedback_test.rb b/test/api/units/feedback_test.rb
index 1b51591544..bc4387f1bc 100644
--- a/test/api/units/feedback_test.rb
+++ b/test/api/units/feedback_test.rb
@@ -145,6 +145,34 @@ def test_tasks_for_task_inbox
unit.destroy
end
+ def test_convenor_can_view_inbox_as_tutor
+ unit = FactoryBot.create(:unit, perform_submissions: true, unenrolled_student_count: 0, part_enrolled_student_count: 0, tutorials: 2, staff_count: 2, student_count: 2)
+
+ convenor = unit.main_convenor_user
+ tutor = FactoryBot.create(:user, :tutor)
+ tutor_role = unit.employ_staff(tutor, Role.tutor)
+ unit.tutorials.first.assign_tutor(tutor)
+
+ add_auth_header_for(user: convenor)
+ get "/api/units/#{unit.id}/tasks/inbox?my_students_only=true&view_as_unit_role_id=#{tutor_role.id}"
+
+ assert_equal 200, last_response.status, last_response_body
+ expected_ids = unit.tasks_for_task_inbox(tutor, true).map(&:task_id)
+ assert_equal expected_ids, last_response_body.pluck('id')
+
+ add_auth_header_for(user: tutor)
+ get "/api/units/#{unit.id}/tasks/inbox?view_as_unit_role_id=#{unit.unit_role_for(convenor).id}"
+ assert_equal 403, last_response.status
+
+ other_unit = FactoryBot.create(:unit, unenrolled_student_count: 0, part_enrolled_student_count: 0, student_count: 0)
+ add_auth_header_for(user: convenor)
+ get "/api/units/#{unit.id}/tasks/inbox?view_as_unit_role_id=#{other_unit.unit_roles.first.id}"
+ assert_equal 404, last_response.status
+ ensure
+ unit&.destroy
+ other_unit&.destroy
+ end
+
def test_task_similarity_inbox
unit = FactoryBot.create(:unit, perform_submissions: true, unenrolled_student_count: 0, part_enrolled_student_count: 0, tutorials: 2, staff_count: 2)
diff --git a/test/api/units/task_definitions_api_test.rb b/test/api/units/task_definitions_api_test.rb
index 00967f32b0..30656e2381 100644
--- a/test/api/units/task_definitions_api_test.rb
+++ b/test/api/units/task_definitions_api_test.rb
@@ -12,19 +12,19 @@ def app
end
def all_task_def_keys
- [
- 'name',
- 'description',
- 'target_grade',
- 'group_set_id',
- 'start_date',
- 'target_date',
- 'due_date',
- 'abbreviation',
- 'restrict_status_updates',
- 'plagiarism_warn_pct',
- 'is_graded',
- 'max_quality_pts'
+ %w[
+ name
+ description
+ target_grade
+ group_set_id
+ start_date
+ target_date
+ due_date
+ abbreviation
+ restrict_status_updates
+ plagiarism_warn_pct
+ is_graded
+ max_quality_pts
]
end
@@ -35,21 +35,21 @@ def test_task_definition_cud
data_to_post = {
task_def: {
- tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation,
- name: 'New Task Def',
- description: 'First task def',
- weighting: 4,
- target_grade: 1,
- group_set_id: unit.group_sets.first.id,
- start_date: unit.start_date,
- target_date: unit.start_date + 7.days,
- due_date: unit.start_date + 21.days,
- abbreviation: 'P1.1',
- restrict_status_updates: false,
- upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]',
- plagiarism_warn_pct: 80,
- is_graded: false,
- max_quality_pts: 0
+ tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation,
+ name: 'New Task Def',
+ description: 'First task def',
+ weighting: 4,
+ target_grade: 1,
+ group_set_id: unit.group_sets.first.id,
+ start_date: unit.start_date,
+ target_date: unit.start_date + 7.days,
+ due_date: unit.start_date + 21.days,
+ abbreviation: 'P1.1',
+ restrict_status_updates: false,
+ upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]',
+ plagiarism_warn_pct: 80,
+ is_graded: false,
+ max_quality_pts: 0
}
}
@@ -69,21 +69,21 @@ def test_task_definition_cud
data_to_put = {
task_def: {
- tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation,
- name: 'New Task Def 1',
- description: 'First task def 1',
- weighting: 2,
- target_grade: 2,
- group_set_id: nil,
- start_date: unit.start_date + 2.days,
- target_date: unit.start_date + 9.days,
- due_date: unit.start_date + 23.days,
- abbreviation: 'P1.2',
- restrict_status_updates: true,
- upload_requirements: [ { "key": "file0", "name": "Other Class", "type": "document" } ].to_json,
- plagiarism_warn_pct: 80,
- is_graded: false,
- max_quality_pts: 0
+ tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation,
+ name: 'New Task Def 1',
+ description: 'First task def 1',
+ weighting: 2,
+ target_grade: 2,
+ group_set_id: nil,
+ start_date: unit.start_date + 2.days,
+ target_date: unit.start_date + 9.days,
+ due_date: unit.start_date + 23.days,
+ abbreviation: 'P1.2',
+ restrict_status_updates: true,
+ upload_requirements: [{ key: "file0", name: "Other Class", type: "document" }].to_json,
+ plagiarism_warn_pct: 80,
+ is_graded: false,
+ max_quality_pts: 0
}
}
@@ -117,6 +117,18 @@ def test_post_invalid_file_tasksheet
assert last_response_body.key?('error')
end
+ def test_update_rejects_unsupported_similarity_language
+ unit = FactoryBot.create(:unit, task_count: 1)
+ task_definition = unit.task_definitions.first
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ put_json "/api/units/#{unit.id}/task_definitions/#{task_definition.id}",
+ { task_def: { similarity_language: 'java; touch /tmp/injected' } }
+
+ assert_equal 400, last_response.status, last_response_body
+ assert_nil task_definition.reload.similarity_language
+ end
+
def test_post_tasksheet
test_unit = Unit.first
test_task_definition = TaskDefinition.first
@@ -181,30 +193,30 @@ def test_post_task_resources
]
# Save will trigger TII integration
- create_tii_group_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*]).
- with(tii_headers).
- with(body: %r[.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*]).
- to_return(status: 200, body: "", headers: {})
-
- post_attachment_stub = stub_request(:post, %r[https://localhost/api/v1/groups/.*/attachments]).
- with(tii_headers).
- with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}").
- to_return(
- status: 200,
- body: TCAClient::AddGroupAttachmentResponse.new(
- id: SecureRandom.uuid
- ).to_json,
- headers: {}
- )
-
- upload_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*/attachments/.*/original]).
- with(tii_headers).
- with(headers: {'Content-Type'=>'binary/octet-stream'}).
- to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {})
-
- delete_stub = stub_request(:delete, %r[https://localhost/api/v1/groups/.*/attachments/.*]).
- with(tii_headers).
- to_return(status: 200, body: "", headers: {})
+ create_tii_group_stub = stub_request(:put, %r{https://localhost/api/v1/groups/.*})
+ .with(tii_headers)
+ .with(body: /.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*/)
+ .to_return(status: 200, body: "", headers: {})
+
+ post_attachment_stub = stub_request(:post, %r{https://localhost/api/v1/groups/.*/attachments})
+ .with(tii_headers)
+ .with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}")
+ .to_return(
+ status: 200,
+ body: TCAClient::AddGroupAttachmentResponse.new(
+ id: SecureRandom.uuid
+ ).to_json,
+ headers: {}
+ )
+
+ upload_stub = stub_request(:put, %r{https://localhost/api/v1/groups/.*/attachments/.*/original})
+ .with(tii_headers)
+ .with(headers: { 'Content-Type' => 'binary/octet-stream' })
+ .to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {})
+
+ delete_stub = stub_request(:delete, %r{https://localhost/api/v1/groups/.*/attachments/.*})
+ .with(tii_headers)
+ .to_return(status: 200, body: "", headers: {})
td.save!
@@ -215,6 +227,7 @@ def test_post_task_resources
assert_requested upload_stub, times: 1
assert_requested delete_stub, times: 0
+ TaskPrerequisite.where(prerequisite_id: td.id).destroy_all
td.destroy!
assert_requested delete_stub, times: 1
end
@@ -241,21 +254,21 @@ def test_post_scorm
def test_submission_creates_folders
unit = Unit.first
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'test_submission_creates_folders',
- description: 'test def',
- weighting: 4,
- target_grade: 0,
- start_date: unit.start_date + 1.week,
- target_date: unit.start_date + 2.weeks,
- abbreviation: 'test_submission_creates_folders',
- restrict_status_updates: false,
- upload_requirements: [ { "key" => "file0", "name" => "Shape Class", "type" => "document" } ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'test_submission_creates_folders',
+ description: 'test def',
+ weighting: 4,
+ target_grade: 0,
+ start_date: unit.start_date + 1.week,
+ target_date: unit.start_date + 2.weeks,
+ abbreviation: 'test_submission_creates_folders',
+ restrict_status_updates: false,
+ upload_requirements: [{ "key" => "file0", "name" => "Shape Class", "type" => "document" }],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
data_to_post = {
@@ -295,21 +308,21 @@ def test_submission_creates_folders
def test_change_to_group_after_submissions
unit = Unit.first
td = TaskDefinition.new({
- unit_id: unit.id,
- tutorial_stream: unit.tutorial_streams.first,
- name: 'Task to switch from ind to group after submission',
- description: 'test def',
- weighting: 4,
- target_grade: 0,
- start_date: unit.start_date + 1.week,
- target_date: unit.start_date + 2.weeks,
- abbreviation: 'TaskSwitchIndGrp',
- restrict_status_updates: false,
- upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'document' } ],
- plagiarism_warn_pct: 0.8,
- is_graded: false,
- max_quality_pts: 0
- })
+ unit_id: unit.id,
+ tutorial_stream: unit.tutorial_streams.first,
+ name: 'Task to switch from ind to group after submission',
+ description: 'test def',
+ weighting: 4,
+ target_grade: 0,
+ start_date: unit.start_date + 1.week,
+ target_date: unit.start_date + 2.weeks,
+ abbreviation: 'TaskSwitchIndGrp',
+ restrict_status_updates: false,
+ upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'document' }],
+ plagiarism_warn_pct: 0.8,
+ is_graded: false,
+ max_quality_pts: 0
+ })
td.save!
data_to_post = {
@@ -335,13 +348,13 @@ def test_change_to_group_after_submissions
# Change it to a group task
- group_set = GroupSet.create!({name: 'test group set', unit: unit})
+ group_set = GroupSet.create!({ name: 'test group set', unit: unit })
group_set.save!
td.group_set = group_set
- assert !td.save
+ assert_not td.save
- task.reload()
+ task.reload
task.task_definition = td
path = task.zip_file_path_for_done_task
assert path
@@ -836,8 +849,8 @@ def test_task_related_to_task_def_when_multiple_projects_tasks_and_tutorials
end
def test_change_draft_learning_summary_upload_requirements
- unit = FactoryBot.create :unit, student_count:1, task_count:0
- upload_reqs = [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}]
+ unit = FactoryBot.create :unit, student_count: 1, task_count: 0
+ upload_reqs = [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }]
task_def = FactoryBot.create(:task_definition, unit: unit, upload_requirements: upload_reqs)
# Set draft learning summary task defintion
@@ -857,7 +870,7 @@ def test_change_draft_learning_summary_upload_requirements
# Test change upload requirements to a non-document upload
data_to_put = {
task_def: {
- upload_requirements: [{"key": "file0","name": "Code file","type": "code"}].to_json
+ upload_requirements: [{ key: "file0", name: "Code file", type: "code" }].to_json
}
}
@@ -937,6 +950,27 @@ def test_task_prerequisites
end
end
+ def test_cannot_delete_task_definition_used_as_a_prerequisite
+ unit = FactoryBot.create(:unit, task_count: 0)
+ prerequisite = FactoryBot.create(:task_definition, unit: unit, target_grade: 0)
+ dependent = FactoryBot.create(:task_definition, unit: unit, target_grade: 0)
+ task_prerequisite = TaskPrerequisite.create!(
+ task_definition: dependent,
+ prerequisite: prerequisite,
+ task_status_id: TaskStatus.complete.id
+ )
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ delete "/api/units/#{unit.id}/task_definitions/#{prerequisite.id}"
+
+ assert_equal 403, last_response.status, last_response_body
+ assert_equal "Cannot delete task definition while it is used as a prerequisite. Remove the prerequisite links first.",
+ last_response_body['error']
+ assert TaskDefinition.exists?(prerequisite.id)
+ assert TaskDefinition.exists?(dependent.id)
+ assert TaskPrerequisite.exists?(task_prerequisite.id)
+ end
+
def test_download_student_submission_jobs
unit = FactoryBot.create(:unit, student_count: 1, task_count: 2)
diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb
index 9ff3ed5d03..cb6e0dadd6 100644
--- a/test/api/units_api_test.rb
+++ b/test/api/units_api_test.rb
@@ -127,19 +127,19 @@ def test_units_post_other_main_convenor_not_permitted_for_student
def create_unit
{
- name:'Intro to Social Skills',
- code:'JRRW40003',
- start_date:'2016-05-14T00:00:00.000Z',
- end_date:'2017-05-14T00:00:00.000Z'
+ name: 'Intro to Social Skills',
+ code: 'JRRW40003',
+ start_date: '2016-05-14T00:00:00.000Z',
+ end_date: '2017-05-14T00:00:00.000Z'
}
end
- def test_post_create_unit_custom_token(token='abcdef')
+ def test_post_create_unit_custom_token(token = 'abcdef')
count = Unit.all.length
unit = create_unit
data_to_post = {
- unit: unit
+ unit: unit
}
# Add username and auth_token to Header
@@ -147,7 +147,7 @@ def test_post_create_unit_custom_token(token='abcdef')
# Override the header for empty auth_token
if token == ''
- header 'auth_token',''
+ header 'auth_token', ''
end
post_json '/api/units', data_to_post
@@ -189,7 +189,7 @@ def test_add_tutorial_to_unit
assert_equal 201, last_response.status, last_response_body
# Check there is a new tutorial
assert_equal count_tutorials + 1, Tutorial.all.length, last_response_body
- assert_json_matches_model tutorial, last_response_body, ["abbreviation", "capacity", "meeting_location", "meeting_day", "meeting_time"]
+ assert_json_matches_model tutorial, last_response_body, %w[abbreviation capacity meeting_location meeting_day meeting_time]
unit.destroy
end
@@ -261,8 +261,8 @@ def test_permissions_on_get
assert_equal 200, last_response.status
assert last_response_body.map { |r| r['id'] }.include? test_unit_inside.id
- refute last_response_body.map { |r| r['id'] }.include? test_unit_before.id
- refute last_response_body.map { |r| r['id'] }.include? test_unit_after.id
+ assert_not last_response_body.map { |r| r['id'] }.include? test_unit_before.id
+ assert_not last_response_body.map { |r| r['id'] }.include? test_unit_after.id
# Test convenor can not get all
add_auth_header_for(user: aconvenor)
@@ -342,7 +342,7 @@ def test_unit_output
assert actual_unit.key?("tutorials"), actual_unit.inspect
# assert actual_unit.key?("tutorial_enrolments"), actual_unit.inspect
assert actual_unit.key?("task_definitions"), actual_unit.inspect
- #TODO: expand tests to check details returned
+ # TODO: expand tests to check details returned
assert actual_unit.key?("staff"), actual_unit.inspect
assert_equal expected_unit.staff.count, actual_unit["staff"].count, actual_unit["staff"].inspect
@@ -380,7 +380,7 @@ def test_unit_output
assert actual_unit.key?("groups"), actual_unit.inspect
assert_equal expected_unit.groups.count, actual_unit["groups"].count, actual_unit["groups"].inspect
actual_unit["groups"].each do |group|
- keys = %w(id name tutorial_id group_set_id student_count capacity_adjustment locked)
+ keys = %w[id name tutorial_id group_set_id student_count capacity_adjustment locked]
assert_json_limit_keys_to_exactly keys, group
assert_json_matches_model Group.find(group['id']), group, keys
end
@@ -421,9 +421,8 @@ def test_units_get_has_streams
assert_equal 3, actual_unit['tutorial_streams'].count
end
- #Test GET for getting the unit details of current user
+ # Test GET for getting the unit details of current user
def test_units_current
-
# Add username and auth_token to Header
add_auth_header_for(user: User.first)
@@ -431,17 +430,16 @@ def test_units_current
assert_equal 200, last_response.status
end
-
- #Test PUT for updating unit details with valid id
+ # Test PUT for updating unit details with valid id
def test_units_put
original = FactoryBot.create(:unit, with_students: false)
- unit={}
+ unit = {}
unit['name'] = 'Intro to python'
unit['code'] = 'JRSW40004'
unit['description'] = 'new language'
unit['start_date'] = '2018-12-14T00:00:00.000Z'
- unit['end_date']='2019-05-14T00:00:00.000Z'
+ unit['end_date'] = '2019-05-14T00:00:00.000Z'
unit['active'] = false
unit['auto_apply_extension_before_deadline'] = false
unit['send_notifications'] = false
@@ -456,16 +454,71 @@ def test_units_put
put_json "/api/units/#{original.id}", data_to_put
assert_equal 200, last_response.status
- assert_json_matches_model original.reload, unit, %w( name code description start_date end_date active auto_apply_extension_before_deadline send_notifications )
+ assert_json_matches_model original.reload, unit, %w[name code description start_date end_date active auto_apply_extension_before_deadline send_notifications]
+ end
+
+ def test_units_put_rejects_unsupported_name_and_code_characters
+ unit = FactoryBot.create(:unit, with_students: false, name: 'Original-Unit #1 (Core)', code: 'ABC1001-ABC1002')
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ ['={', 'Unit+Name', 'Unit@Name'].each do |name|
+ put_json "/api/units/#{unit.id}", { unit: { name: name } }
+
+ assert_equal 400, last_response.status, last_response_body
+ assert_equal 'Original-Unit #1 (Core)', unit.reload.name
+ end
+
+ put_json "/api/units/#{unit.id}", { unit: { code: 'ABC1001-ABC1002=' } }
+
+ assert_equal 400, last_response.status, last_response_body
+ assert_equal 'ABC1001-ABC1002', unit.reload.code
+ end
+
+ def test_units_put_allows_hash_in_unit_code
+ unit = FactoryBot.create(:unit, with_students: false, code: 'ABC1001-ABC1002')
+ add_auth_header_for(user: unit.main_convenor_user)
+
+ put_json "/api/units/#{unit.id}", { unit: { code: 'ABC1001-ABC1002C#' } }
+
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal 'ABC1001-ABC1002C#', unit.reload.code
end
- #Test PUT for updating unit details with empty name
+ def test_system_administrator_enrolled_as_student_can_update_unit
+ unit = FactoryBot.create(:unit, with_students: false)
+ administrator = FactoryBot.create(:user, :admin)
+ unit.enrol_student(administrator, Campus.first)
+
+ assert_equal Role.student, unit.role_for(administrator)
+
+ add_auth_header_for(user: administrator)
+ put_json "/api/units/#{unit.id}", { unit: { name: 'Updated by student administrator' } }
+
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal 'Updated by student administrator', unit.reload.name
+ end
+
+ def test_system_administrator_employed_as_tutor_can_update_unit
+ unit = FactoryBot.create(:unit, with_students: false)
+ administrator = FactoryBot.create(:user, :admin)
+ unit.employ_staff(administrator, Role.tutor)
+
+ assert_equal Role.tutor, unit.role_for(administrator)
+
+ add_auth_header_for(user: administrator)
+ put_json "/api/units/#{unit.id}", { unit: { name: 'Updated by tutor administrator' } }
+
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal 'Updated by tutor administrator', unit.reload.name
+ end
+
+ # Test PUT for updating unit details with empty name
def test_put_update_unit_empty_name
unit = Unit.first
unit[:name] = ''
data_to_put = {
- unit: unit
+ unit: unit
}
# Add username and auth_token to Header
@@ -475,10 +528,10 @@ def test_put_update_unit_empty_name
assert_equal 400, last_response.status
end
- #Test PUT for updating unit details with invalid id
+ # Test PUT for updating unit details with invalid id
def test_put_update_unit_invalid_id
data_to_put = {
- unit: { name: 'test'}
+ unit: { name: 'test' }
}
# Add username and auth_token to Header
@@ -516,7 +569,7 @@ def test_fail_units_get_by_id
assert_equal 404, last_response.status
end
- def test_put_update_unit_custom_token()
+ def test_put_update_unit_custom_token
unit = Unit.first
token = 'abcdef'
data_to_put = {
@@ -576,9 +629,9 @@ def test_update_main_convenor
def test_draft_learning_summary_upload_requirements
unit = FactoryBot.create :unit, student_count: 1, task_count: 0
- task_def_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{'key' => 'file0','name' => 'Code file','type' => 'code'}])
- task_def_doc = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}])
- task_def_doc_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}, {'key' => 'file1','name' => 'Code file','type' => 'code'}])
+ task_def_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{ 'key' => 'file0', 'name' => 'Code file', 'type' => 'code' }])
+ task_def_doc = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }])
+ task_def_doc_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }, { 'key' => 'file1', 'name' => 'Code file', 'type' => 'code' }])
# Test with a task containing non document upload requirement
data_to_put = {
@@ -622,4 +675,174 @@ def test_draft_learning_summary_upload_requirements
unit.reload
assert_equal task_def_doc.id, unit.draft_task_definition_id
end
+
+ def test_get_task_completion_snapshots
+ unit = FactoryBot.create :unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 1, campus_count: 1
+ tutorial = unit.tutorials.first
+ task_definition = unit.task_definitions_by_grade.first
+
+ older_snapshot = TaskCompletionSnapshot.create!(
+ unit: unit,
+ snapshot_timestamp: Time.zone.parse('2026-04-01 10:00:00').to_i.to_s
+ )
+
+ mid_snapshot = TaskCompletionSnapshot.create!(
+ unit: unit,
+ snapshot_timestamp: Time.zone.parse('2026-04-02 10:00:00').to_i.to_s
+ )
+
+ latest_snapshot = TaskCompletionSnapshot.create!(
+ unit: unit,
+ snapshot_timestamp: Time.zone.parse('2026-04-03 10:00:00').to_i.to_s
+ )
+
+ older_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.not_started.id]))
+ mid_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id, TaskStatus.complete.id]))
+ latest_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id, TaskStatus.complete.id, TaskStatus.complete.id]))
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ header 'Host', 'localhost'
+ get "/api/units/#{unit.id}/stats/task_completion_snapshots", { limit: 2 }
+
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal 2, last_response_body.length
+
+ assert_equal latest_snapshot.snapshot_date.to_s, last_response_body[0]['snapshot_date'].to_date.to_s
+ assert_equal mid_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s
+
+ latest_stats = last_response_body[0]['stats']
+ assert_equal 3, latest_stats[tutorial.campus.name][tutorial.abbreviation][task_definition.abbreviation]['complete']
+ assert_equal 3, last_response_body[0]['student_count']
+ assert_equal 3, last_response_body[0]['campus_student_counts'][tutorial.campus.name]
+ assert_equal 3, last_response_body[0]['target_grade_student_counts']['0']
+ assert_equal 3, last_response_body[0]['target_grade_campus_student_counts']['0'][tutorial.campus.name]
+ assert_equal 3, last_response_body[0]['status_population_stats']['all'][tutorial.campus.name]['__all__'][task_definition.abbreviation]['complete']
+
+ assert_not_equal older_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s
+ end
+
+ def test_get_task_completion_snapshots_filters_by_date
+ unit = FactoryBot.create :unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 1, campus_count: 1
+ tutorial = unit.tutorials.first
+ task_definition = unit.task_definitions_by_grade.first
+
+ TaskCompletionSnapshot.create!(
+ unit: unit,
+ snapshot_timestamp: Time.zone.parse('2026-03-30 10:00:00').to_i.to_s
+ )
+
+ included_snapshot = TaskCompletionSnapshot.create!(
+ unit: unit,
+ snapshot_timestamp: Time.zone.parse('2026-04-02 10:00:00').to_i.to_s
+ )
+
+ TaskCompletionSnapshot.create!(
+ unit: unit,
+ snapshot_timestamp: Time.zone.parse('2026-04-05 10:00:00').to_i.to_s
+ )
+
+ unit.task_completion_snapshots.find_each do |snapshot|
+ snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id]))
+ end
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ header 'Host', 'localhost'
+ get "/api/units/#{unit.id}/stats/task_completion_snapshots", {
+ start_date: Date.new(2026, 4, 1),
+ end_date: Date.new(2026, 4, 3)
+ }
+
+ assert_equal 200, last_response.status, last_response_body
+ assert_equal 1, last_response_body.length
+ assert_equal included_snapshot.snapshot_date.to_s, last_response_body[0]['snapshot_date'].to_date.to_s
+ end
+
+ def test_get_task_completion_snapshots_not_authorised
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0
+ TaskCompletionSnapshot.create!(
+ unit: unit,
+ snapshot_timestamp: Time.zone.now.to_i.to_s
+ )
+
+ add_auth_header_for(user: User.where(role: Role.student).first)
+ header 'Host', 'localhost'
+ get "/api/units/#{unit.id}/stats/task_completion_snapshots"
+
+ assert_equal 403, last_response.status
+ end
+
+ def test_post_capture_task_completion_snapshot
+ Sidekiq::Testing.inline! do
+ unit = FactoryBot.create :unit
+
+ count_before = TaskCompletionSnapshot.where(unit: unit).count
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ header 'Host', 'localhost'
+ post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture"
+
+ assert_equal 201, last_response.status, last_response_body
+ assert_not_nil last_response_body['id']
+
+ snapshot = TaskCompletionSnapshot.where(unit: unit).order(snapshot_timestamp: :desc).first
+ assert_not_nil snapshot
+ assert_equal count_before + 1, TaskCompletionSnapshot.where(unit: unit).count
+
+ assert_equal Date.current.to_s, snapshot.snapshot_date.to_s
+ assert_not_empty snapshot.load_stats
+ assert File.exist?(snapshot.snapshot_file_path)
+ ensure
+ Sidekiq::Testing.fake!
+ end
+ end
+
+ def test_post_capture_task_completion_snapshot_not_authorised
+ unit = FactoryBot.create :unit, with_students: false, task_count: 0
+
+ add_auth_header_for(user: User.where(role: Role.student).first)
+ header 'Host', 'localhost'
+ post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture"
+
+ assert_equal 403, last_response.status
+ end
+
+ private
+
+ def build_task_completion_snapshot_csv(tutorial, task_definition, statuses)
+ headers = [
+ 'Student ID',
+ 'Username',
+ 'Student Name',
+ 'Campus',
+ 'Target Grade',
+ 'Email',
+ 'Portfolio',
+ 'Grade',
+ 'Rationale',
+ 'Assessor',
+ 'Tutorial',
+ task_definition.abbreviation
+ ]
+
+ CSV.generate do |csv|
+ csv << headers
+
+ statuses.each_with_index do |status, index|
+ csv << [
+ "#{index + 1}",
+ "student-#{index + 1}",
+ "Student #{index + 1}",
+ tutorial.campus.abbreviation,
+ tutorial.unit.grade_label(0),
+ "student-#{index + 1}@example.com",
+ 'false',
+ '',
+ '',
+ '',
+ tutorial.abbreviation,
+ status
+ ]
+ end
+ end
+ end
end
diff --git a/test/api/users_test.rb b/test/api/users_test.rb
index 05b21395c5..e4f86d6e8d 100644
--- a/test/api/users_test.rb
+++ b/test/api/users_test.rb
@@ -36,7 +36,6 @@ def create_user
# Get users' details
def test_get_users
-
# Add username and auth_token to Header
add_auth_header_for(user: User.first)
@@ -53,7 +52,7 @@ def test_get_users
response_keys = %w[first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup]
# Loop through all of the responses
- last_response_body.each do | data |
+ last_response_body.each do |data|
# Find the matching user, by id from response
user = User.find(data['id'])
# Match json with object
@@ -76,12 +75,11 @@ def test_get_a_users_details
assert_equal 200, last_response.status
# Check the returned details match as expected
- response_keys = %w(first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup)
+ response_keys = %w[first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup]
assert_json_matches_model(expected_user, returned_user, response_keys)
end
def test_get_convenors
-
# Add username and auth_token to Header
add_auth_header_for(user: User.first)
@@ -90,7 +88,6 @@ def test_get_convenors
end
def test_get_tutors
-
# Add username and auth_token to Header
add_auth_header_for(user: User.first)
@@ -99,7 +96,7 @@ def test_get_tutors
end
def test_get_no_token
- models = %w(users users/tutors users/convenors)
+ models = %w[users users/tutors users/convenors]
models.each do |m|
get "/api/#{m}"
@@ -108,12 +105,12 @@ def test_get_no_token
end
def test_get_invalid_token
- models = %w(users users/tutors users/convenors)
+ models = %w[users users/tutors users/convenors]
- models.each { |m|
+ models.each do |m|
get "/api/#{m}?auth_token=1234"
assert_equal 419, last_response.status
- }
+ end
end
# ========================================================================
@@ -163,7 +160,7 @@ def test_post_create_same_user_different_email
user = create_user
data_to_post = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -186,7 +183,7 @@ def test_post_create_same_user_different_username
user = create_user
data_to_post = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -208,14 +205,14 @@ def test_post_create_user_invalid_email
pre_count = User.all.length
user = create_user
- invalid_emails = %w(qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com)
+ invalid_emails = %w[qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com]
invalid_emails.each do |email|
# Assign invalid email
user[:email] = email
data_to_post = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -251,14 +248,14 @@ def test_post_create_user_empty_required_fields
end
end
- def test_post_create_user_custom_role(role='asdasd')
+ def test_post_create_user_custom_role(role = 'asdasd')
pre_count = User.all.length
user = create_user
user[:system_role] = role
data_to_post = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -274,12 +271,12 @@ def test_post_create_user_role_root
test_post_create_user_custom_role 'root'
end
- def test_post_create_user_custom_token(token='asdasd')
+ def test_post_create_user_custom_token(token = 'asdasd')
pre_count = User.all.length
user = create_user
data_to_post = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -287,7 +284,7 @@ def test_post_create_user_custom_token(token='asdasd')
# Override header to empty auth_token
if token == ''
- header 'auth_token',''
+ header 'auth_token', ''
end
post_json '/api/users', data_to_post
@@ -325,7 +322,7 @@ def test_put_update_user_existing_email
user[:email] = User.third.email
data_to_put = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -338,14 +335,14 @@ def test_put_update_user_existing_email
def test_put_update_user_invalid_email
user = User.second
- invalid_emails = %w(qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com)
+ invalid_emails = %w[qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com]
invalid_emails.each do |email|
# Assign invalid email
user[:email] = email
data_to_put = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -361,7 +358,7 @@ def test_put_update_user_empty_email
user[:email] = ''
data_to_put = {
- user: user
+ user: user
}
# Add username and auth_token to Header
@@ -371,19 +368,19 @@ def test_put_update_user_empty_email
assert_equal 400, last_response.status
end
- def test_put_update_user_custom_token(token='asdasd')
+ def test_put_update_user_custom_token(token = 'asdasd')
user = User.second
user[:email] = ''
data_to_put = {
- user: user
+ user: user
}
# Add username and auth_token to Header
add_auth_header_for(user: User.first, auth_token: token)
if token == ''
- header 'auth_token',token
+ header 'auth_token', token
end
put_json '/api/users/2', data_to_put
diff --git a/test/api/webcal_api_test.rb b/test/api/webcal_api_test.rb
index ae7f6c37ac..21c40e8a65 100644
--- a/test/api/webcal_api_test.rb
+++ b/test/api/webcal_api_test.rb
@@ -77,7 +77,7 @@ def app
assert_equal current_guid, last_response_body['guid']
end
- test 'Ical endpoint is public and serves webcal with corect content type' do
+ test 'Ical endpoint is public and serves webcal with correct content type' do
add_auth_header_for user: @student
# Enable webcal, get GUID
put_json '/api/webcal', { webcal: { enabled: true } }
@@ -89,6 +89,9 @@ def app
# Ensure correct content type
assert_equal 200, last_response.status
assert_equal 'text/calendar', last_response['Content-Type']
+ assert last_response.body.start_with?("BEGIN:VCALENDAR\r\n")
+ assert_includes last_response.body, "\r\nEND:VCALENDAR\r\n"
+ assert_not last_response.body.start_with?('"')
end
test 'Reminder must be specified with both time & unit' do
@@ -106,7 +109,7 @@ def app
assert 400, last_response.status
# Specify both time & unit
- Webcal.valid_time_units.each_with_index { |u, i|
+ Webcal.valid_time_units.each_with_index do |u, i|
t = i + 1
put_json '/api/webcal', { webcal: { reminder: { time: t, unit: u } } }
@@ -115,7 +118,7 @@ def app
assert_not_nil last_response_body['reminder']
assert_equal t, last_response_body['reminder']['time']
assert_equal u, last_response_body['reminder']['unit']
- }
+ end
end
test 'Can update unit exclusions' do
diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb
index 989ed8df84..d7392b44bc 100644
--- a/test/application_system_test_case.rb
+++ b/test/application_system_test_case.rb
@@ -3,7 +3,7 @@
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
- register_spec_type(self) do |desc, *addl|
+ register_spec_type(self) do |_desc, *addl|
addl.include? :system
end
end
diff --git a/test/config/cors_test.rb b/test/config/cors_test.rb
new file mode 100644
index 0000000000..6f372f655c
--- /dev/null
+++ b/test/config/cors_test.rb
@@ -0,0 +1,47 @@
+require 'test_helper'
+require 'rack/mock'
+
+class CorsTest < ActiveSupport::TestCase
+ test 'normalizes configured institution hosts into origins' do
+ assert_equal 'https://ontrack.example.edu', Doubtfire::Application.cors_origin_for('ontrack.example.edu')
+ assert_equal 'https://ontrack.example.edu', Doubtfire::Application.cors_origin_for('https://ontrack.example.edu')
+ assert_equal 'http://localhost:4200', Doubtfire::Application.cors_origin_for('http://localhost:4200')
+ end
+
+ test 'allows the frontend authentication headers and patch method' do
+ response_headers = preflight(
+ origin: 'http://localhost:4200',
+ method: 'PATCH',
+ headers: 'Auth-Token,Username,Content-Type'
+ )
+
+ assert_equal 'http://localhost:4200', response_headers['access-control-allow-origin']
+ assert_includes response_headers['access-control-allow-methods'], 'PATCH'
+ assert_equal 'Auth-Token,Username,Content-Type', response_headers['access-control-allow-headers']
+ end
+
+ test 'rejects origins outside the allowlist' do
+ response_headers = preflight(
+ origin: 'https://untrusted.example.edu',
+ method: 'GET',
+ headers: 'Authorization'
+ )
+
+ assert_nil response_headers['access-control-allow-origin']
+ end
+
+ private
+
+ def preflight(origin:, method:, headers:)
+ env = Rack::MockRequest.env_for(
+ 'http://www.example.com/api/settings',
+ method: 'OPTIONS',
+ 'HTTP_ORIGIN' => origin,
+ 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => method,
+ 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => headers
+ )
+
+ _status, response_headers, = Rails.application.call(env)
+ response_headers
+ end
+end
diff --git a/test/config/custom_institution_setting_test.rb b/test/config/custom_institution_setting_test.rb
new file mode 100644
index 0000000000..e73fa3961d
--- /dev/null
+++ b/test/config/custom_institution_setting_test.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class CustomInstitutionSettingTest < ActiveSupport::TestCase
+ setup do
+ @settings = Doubtfire::Application.config.institution_settings
+ skip 'Custom Moodle group mapping settings are not installed' unless @settings.respond_to?(:moodle_tutorial_details)
+
+ @burwood = Campus.find_by(abbreviation: 'BU') || FactoryBot.create(:campus, name: 'Burwood', abbreviation: 'BU')
+ @geelong = Campus.find_by(abbreviation: 'GE') || FactoryBot.create(:campus, name: 'Geelong', abbreviation: 'GE')
+ @online = Campus.find_by(abbreviation: 'OL') || FactoryBot.create(:campus, name: 'Online', abbreviation: 'OL')
+ campus_details = {
+ 'BU' => { suffix: 'bu', names: %w[BU Burwood] },
+ 'GE' => { suffix: 'ge', names: %w[GE Geelong] },
+ 'OL' => { suffix: 'ol', names: %w[OL Online] }
+ }
+ @settings.define_singleton_method(:moodle_campus_details) { campus_details }
+ @unit = FactoryBot.create(:unit, with_students: false)
+
+ workshop = ActivityType.where(name: 'Workshop').first_or_create!(abbreviation: 'WRK')
+ seminar = ActivityType.where(name: 'Seminar').first_or_create!(abbreviation: 'SEM')
+ @workshop_stream = FactoryBot.create(:tutorial_stream, unit: @unit, activity_type: workshop)
+ @seminar_stream = FactoryBot.create(:tutorial_stream, unit: @unit, activity_type: seminar)
+ end
+
+ test 'pre-fills local and regional timetable activities' do
+ suggestions = @settings.prefill_lms_group_mappings(
+ @unit,
+ [
+ { id: 1, name: 'Allocate+ COS10001 Workshop 07_OnCampus BU_Building.B2.123 Tue 10:00 (24)' },
+ { id: 2, name: 'Allocate+ COS10001 Workshop 08 GE_Room_2.15 Fri 14:30 (30)' }
+ ]
+ )
+
+ local = suggestions.first[:tutorial_draft]
+ assert_equal ['workshop-07-bu', @burwood.id, @workshop_stream.id, 'Building.B2.123', 'Tuesday', '10:00', 24],
+ local.values_at(
+ :abbreviation,
+ :campus_id,
+ :tutorial_stream_id,
+ :meeting_location,
+ :meeting_day,
+ :meeting_time,
+ :capacity
+ )
+
+ regional = suggestions.second[:tutorial_draft]
+ assert_equal 'workshop-08-ge', regional[:abbreviation]
+ assert_equal @geelong.id, regional[:campus_id]
+ assert_equal 'Room_2.15', regional[:meeting_location]
+ end
+
+ test 'makes repeated activity abbreviations unique and ignores unrelated groups' do
+ suggestions = @settings.prefill_lms_group_mappings(
+ @unit,
+ [
+ { id: 1, name: 'Allocate+ COS10001 Seminar 03_OnCampus-P1 OL_Site.Room_5 Mon 09:00' },
+ { id: 2, name: 'Allocate+ COS10001 Seminar 03_OnCampus-P2 OL_Site.Room_5 Thu 15:00' },
+ { id: 3, name: 'Allocate+ COS10001 Workshop 09 GE_Room_4 Wed 11:00 (20)' },
+ { id: 4, name: 'Allocate+ COS10001 Workshop 09 GE_Room_4 Fri 13:00 (20)' },
+ { id: 5, name: 'Course participants' }
+ ]
+ )
+
+ abbreviations = suggestions.filter_map { |suggestion| suggestion.dig(:tutorial_draft, :abbreviation) }
+ assert_equal %w[seminar-03-p1-ol seminar-03-p2-ol workshop-09-wed-1100-ge workshop-09-fri-1300-ge],
+ abbreviations
+ assert_equal @online.id, suggestions.first.dig(:tutorial_draft, :campus_id)
+ assert_equal @seminar_stream.id, suggestions.first.dig(:tutorial_draft, :tutorial_stream_id)
+ assert_equal 'Site.Room_5', suggestions.first.dig(:tutorial_draft, :meeting_location)
+ assert_equal 'ignore', suggestions.last[:target_type]
+ end
+
+ test 'selects an existing tutorial instead of returning a draft' do
+ existing = FactoryBot.create(
+ :tutorial,
+ unit: @unit,
+ tutorial_stream: @workshop_stream,
+ campus: @burwood,
+ abbreviation: 'workshop-07-bu'
+ )
+ suggestion = @settings.prefill_lms_group_mappings(
+ @unit,
+ [{ id: 1, name: 'Allocate+ COS10001 Workshop 07_OnCampus BU_Building.B2.123 Tue 10:00 (24)' }]
+ ).first
+
+ assert_equal existing.id, suggestion[:tutorial_id]
+ assert_equal @workshop_stream.id, suggestion[:tutorial_stream_id]
+ assert_nil suggestion[:tutorial_draft]
+ end
+end
diff --git a/test/config/deakin_config_test.rb b/test/config/deakin_config_test.rb
index 25da492eec..8c8c16c936 100644
--- a/test/config/deakin_config_test.rb
+++ b/test/config/deakin_config_test.rb
@@ -10,11 +10,11 @@ class DeakinConfigTest < ActiveSupport::TestCase
ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL'] = 'https://test.com/enrolment'
ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL'] = 'https://test.com/timetable'
- require "#{Rails.root}/config/deakin"
+ require "#{Rails.root.join('config/deakin')}"
Doubtfire::Application.config.institution_settings = DeakinInstitutionSettings.new
# Rename and adjust abbreviation of all other campuses... so we can make sure they have the right details
- Campus.update_all "name = #{db_concat("'Test-'", "name")}, abbreviation = #{db_concat("'-'", "abbreviation")}"
+ Campus.update_all "name = #{db_concat("'Test-'", 'name')}, abbreviation = #{db_concat("'-'", 'abbreviation')}"
FactoryBot.create(:campus, name: 'Test Sync Campus', abbreviation: 'T')
FactoryBot.create(:campus, name: 'Online', abbreviation: 'C')
@@ -29,13 +29,13 @@ def test_sync_deakin_unit
# Setup enrolments stub
raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json"))
- enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/).to_return(body: raw_enrolment_file, status: 200)
+ enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/).to_return(body: raw_enrolment_file, status: 200)
raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json"))
- timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/).to_return(body: raw_timetable_file, status: 200)
+ timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/).to_return(body: raw_timetable_file, status: 200)
raw_timetable_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json"))
- timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200)
+ timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200)
tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020)
unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
@@ -43,7 +43,7 @@ def test_sync_deakin_unit
assert_equal 0, unit.tutorials.count
assert_equal 0, unit.projects.count
- result = unit.sync_enrolments()
+ result = unit.sync_enrolments
assert_equal 3, unit.projects.count, result # 3 students and others skipped
assert_equal 1, unit.tutorials.count, result # campus
@@ -60,13 +60,13 @@ def test_sync_deakin_unit_without_timetable
# Setup enrolments stubs
raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json"))
- enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/).to_return(body: raw_enrolment_file, status: 200)
+ enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/).to_return(body: raw_enrolment_file, status: 200)
raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json"))
- timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/).to_return(body: raw_timetable_file, status: 200)
+ timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/).to_return(body: raw_timetable_file, status: 200)
raw_timetable_cls_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json"))
- timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/).to_return(body: raw_timetable_cls_activity_file, status: 200)
+ timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/).to_return(body: raw_timetable_cls_activity_file, status: 200)
tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020)
unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
@@ -90,7 +90,7 @@ def test_sync_deakin_retry_requests
# Setup enrolments stubs
raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json"))
- enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/)
+ enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/)
.to_return([
{ body: "Too many requests", status: 429 },
{ body: "Internal server error", status: 500 },
@@ -98,20 +98,20 @@ def test_sync_deakin_retry_requests
])
raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json"))
- timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/).
- to_return([
- { body: "Too many requests", status: 429 },
- { body: "Internal server error", status: 500 },
- { body: raw_timetable_file, status: 200 }
- ])
+ timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/)
+ .to_return([
+ { body: "Too many requests", status: 429 },
+ { body: "Internal server error", status: 500 },
+ { body: raw_timetable_file, status: 200 }
+ ])
raw_timetable_cls_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json"))
- timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/).
- to_return([
- { body: "Too many requests", status: 429 },
- { body: "Internal server error", status: 500 },
- { body: raw_timetable_cls_activity_file, status: 200 }
- ])
+ timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/)
+ .to_return([
+ { body: "Too many requests", status: 429 },
+ { body: "Internal server error", status: 500 },
+ { body: raw_timetable_cls_activity_file, status: 200 }
+ ])
tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020)
unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
@@ -134,13 +134,13 @@ def test_sync_deakin_multi_unit
raw_enrolment_file_3 = File.new(test_file_path("deakin/enrol_multi_1.json"))
raw_enrolment_file_4 = File.new(test_file_path("deakin/enrol_multi_2.json"))
- enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/).
- to_return([
- { body: raw_enrolment_file_1, status: 200 },
- { body: raw_enrolment_file_2, status: 200 },
- { body: raw_enrolment_file_3, status: 200 },
- { body: raw_enrolment_file_4, status: 200 }
- ])
+ enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/)
+ .to_return([
+ { body: raw_enrolment_file_1, status: 200 },
+ { body: raw_enrolment_file_2, status: 200 },
+ { body: raw_enrolment_file_3, status: 200 },
+ { body: raw_enrolment_file_4, status: 200 }
+ ])
tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2024)
unit = FactoryBot.create(:unit, code: 'SIT724/SIT746', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
@@ -169,13 +169,13 @@ def test_sync_deakin_unit_disabled
# Setup enrolments stubs
raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json"))
- enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/).to_return(body: raw_enrolment_file, status: 200)
+ enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/).to_return(body: raw_enrolment_file, status: 200)
raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json"))
- timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/).to_return(body: raw_timetable_file, status: 200)
+ timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/).to_return(body: raw_timetable_file, status: 200)
raw_timetable_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json"))
- timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200)
+ timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200)
tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020)
unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0)
@@ -184,7 +184,7 @@ def test_sync_deakin_unit_disabled
unit.save
# unit.update enable_sync_timetable: false
- unit.sync_enrolments()
+ unit.sync_enrolments
assert_equal 0, unit.tutorials.count # none created
diff --git a/test/config/lti_secret_test.rb b/test/config/lti_secret_test.rb
new file mode 100644
index 0000000000..fd52cccaf8
--- /dev/null
+++ b/test/config/lti_secret_test.rb
@@ -0,0 +1,14 @@
+require 'test_helper'
+
+class LtiSecretTest < ActiveSupport::TestCase
+ test 'rejects missing, example and short LTI secrets' do
+ assert_equal 'is not set', Doubtfire::Application.lti_api_secret_problem(nil)
+ assert_equal 'is not set', Doubtfire::Application.lti_api_secret_problem(' ')
+ assert_equal 'is still the example value', Doubtfire::Application.lti_api_secret_problem('your-secret-lti-shared-api-secret')
+ assert_equal 'must be at least 32 bytes', Doubtfire::Application.lti_api_secret_problem('a' * 31)
+ end
+
+ test 'accepts a generated LTI secret' do
+ assert_nil Doubtfire::Application.lti_api_secret_problem(SecureRandom.hex(32))
+ end
+end
diff --git a/test/config/sentry_redaction_test.rb b/test/config/sentry_redaction_test.rb
new file mode 100644
index 0000000000..fa0e1564eb
--- /dev/null
+++ b/test/config/sentry_redaction_test.rb
@@ -0,0 +1,57 @@
+require "test_helper"
+
+class SentryRedactionTest < ActiveSupport::TestCase
+ test "scrubs exception values from Sentry's exception interface" do
+ exception_value = Sentry::SingleExceptionInterface.new(
+ exception: RuntimeError.new("tmp/rails-latex/task-20260724-1100-student-name-task-1-2/file.tex"),
+ mechanism: nil
+ )
+ exception = Sentry::ExceptionInterface.new(exceptions: [exception_value])
+
+ OnTrackSentryRedaction.scrub_exception(exception)
+
+ assert_equal(
+ "tmp/rails-latex/task-20260724-1100-[username]-task-1-2/file.tex (RuntimeError)",
+ exception_value.value
+ )
+ end
+
+ test "scrubs student details from unit-labelled LaTeX work directories" do
+ task_path = "tmp/rails-latex/task-20260724-1100-COURSE1234-student-name-task-1-2/file.tex"
+ portfolio_path = "tmp/rails-latex/portfolio-20260724-1100-COURSE1234-student-name-1-2/file.tex"
+
+ assert_equal(
+ "tmp/rails-latex/task-20260724-1100-[username]-task-1-2/file.tex",
+ OnTrackSentryRedaction.scrub_string(task_path)
+ )
+ assert_equal(
+ "tmp/rails-latex/portfolio-20260724-1100-[username]-1-2/file.tex",
+ OnTrackSentryRedaction.scrub_string(portfolio_path)
+ )
+ end
+
+ test "filters Sidekiq job args from Sentry's contexts" do
+ contexts = { sidekiq: { "class" => "ImportBatchFeedbackJob", "args" => [20, "student@example.com"], "jid" => "abc" } }
+
+ OnTrackSentryRedaction.scrub_contexts(contexts)
+
+ assert_equal({ "class" => "ImportBatchFeedbackJob", "args" => "[Filtered]", "jid" => "abc" }, contexts[:sidekiq])
+ end
+
+ test "scrubs student details from portfolio PDF failure exceptions" do
+ exception_value = Sentry::SingleExceptionInterface.new(
+ exception: PdfGeneration::ProjectCompilePortfolioModule::LatexError.new(
+ "Failed to convert portfolio to PDF - 1234 - Test Example (example1234) COURSE123"
+ ),
+ mechanism: nil
+ )
+ exception = Sentry::ExceptionInterface.new(exceptions: [exception_value])
+
+ OnTrackSentryRedaction.scrub_exception(exception)
+
+ assert_equal(
+ "Failed to convert portfolio to PDF - 1234 - [Filtered] ([username]) COURSE123 (PdfGeneration::ProjectCompilePortfolioModule::LatexError)",
+ exception_value.value
+ )
+ end
+end
diff --git a/test/config/uri_credential_leak_mitigation_test.rb b/test/config/uri_credential_leak_mitigation_test.rb
new file mode 100644
index 0000000000..0219b6f6f8
--- /dev/null
+++ b/test/config/uri_credential_leak_mitigation_test.rb
@@ -0,0 +1,22 @@
+require 'test_helper'
+
+class UriCredentialLeakMitigationTest < ActiveSupport::TestCase
+ test 'strips credentials when merging a relative URI object' do
+ base = URI.parse('https://user:password@internal.example/service')
+
+ assert_equal 'https://internal.example/next', (base + URI.parse('/next')).to_s
+ end
+
+ test 'strips credentials when merging a relative string' do
+ base = URI.parse('https://user:password@internal.example/service')
+
+ assert_equal 'https://internal.example/next', base.public_send(:+, '/next').to_s
+ end
+
+ test 'does not alter absolute URI merges' do
+ base = URI.parse('https://user:password@internal.example/service')
+
+ assert_equal 'https://other:secret@external.example/next',
+ (base + URI.parse('https://other:secret@external.example/next')).to_s
+ end
+end
diff --git a/test/controllers/download_authorization_boundary_test.rb b/test/controllers/download_authorization_boundary_test.rb
new file mode 100644
index 0000000000..d026aaf603
--- /dev/null
+++ b/test/controllers/download_authorization_boundary_test.rb
@@ -0,0 +1,484 @@
+require 'test_helper'
+
+class DownloadAuthorizationBoundaryTest < ActiveSupport::TestCase
+ include Rack::Test::Methods
+ include TestHelpers::AuthHelper
+
+ CADDY_SECRET = 'download-authorization-test-secret'.freeze
+ INTERNAL_REQUESTS = {
+ '/api/internal/downloads/submission' => '/api/projects/1/task_def_id/1/submission',
+ '/api/internal/downloads/portfolio' => '/api/submission/unit/1/portfolio',
+ '/api/internal/downloads/task-submission-files' =>
+ '/api/submission/unit/1/task_definitions/1/download_submissions',
+ '/api/internal/downloads/task-submission-pdfs' =>
+ '/api/submission/unit/1/task_definitions/1/student_pdfs',
+ '/api/internal/downloads/pdf-file' => '/api/units/1/task_definitions/1/task_pdf',
+ '/api/internal/downloads/unit-content' => '/api/units/1/content/sites/1/files/index.html'
+ }.freeze
+
+ def app
+ Rails.application
+ end
+
+ setup do
+ @original_caddy_secret = Doubtfire::Application.config.caddy_download_auth_secret
+ @original_student_work_dir = Doubtfire::Application.config.student_work_dir
+ @original_archive_dir = Doubtfire::Application.config.archive_dir
+ @student_work_dir = Dir.mktmpdir('download-authorization-boundary')
+
+ Doubtfire::Application.config.caddy_download_auth_secret = CADDY_SECRET
+ Doubtfire::Application.config.student_work_dir = @student_work_dir
+ Doubtfire::Application.config.archive_dir = File.join(@student_work_dir, 'archive')
+ clear_cookies
+ clear_request_headers
+ end
+
+ teardown do
+ Doubtfire::Application.config.caddy_download_auth_secret = @original_caddy_secret
+ Doubtfire::Application.config.student_work_dir = @original_student_work_dir
+ Doubtfire::Application.config.archive_dir = @original_archive_dir
+ FileUtils.rm_rf(@student_work_dir)
+ end
+
+ def test_internal_download_endpoints_reject_missing_or_incorrect_caddy_secret
+ INTERNAL_REQUESTS.each do |endpoint, original_uri|
+ request_internal_download(endpoint, original_uri, secret: nil)
+ assert_equal 404, last_response.status, "#{endpoint} accepted a missing Caddy secret"
+
+ request_internal_download(endpoint, original_uri, secret: 'incorrect-secret')
+ assert_equal 404, last_response.status, "#{endpoint} accepted an incorrect Caddy secret"
+ end
+ end
+
+ def test_blank_server_secret_disables_every_internal_download_endpoint
+ Doubtfire::Application.config.caddy_download_auth_secret = nil
+
+ INTERNAL_REQUESTS.each do |endpoint, original_uri|
+ request_internal_download(endpoint, original_uri)
+ assert_equal 404, last_response.status, "#{endpoint} remained enabled without a server secret"
+ end
+ end
+
+ def test_trusted_internal_download_endpoints_still_require_user_authentication
+ INTERNAL_REQUESTS.each do |endpoint, original_uri|
+ request_internal_download(endpoint, original_uri)
+ assert_equal 401, last_response.status, "#{endpoint} did not require user authentication"
+ end
+ end
+
+ def test_internal_download_endpoints_reject_unrecognised_original_uris
+ user = FactoryBot.create(:user)
+ add_auth_header_for(user: user)
+
+ INTERNAL_REQUESTS.each_key do |endpoint|
+ header 'X-OnTrack-File', '../../etc/passwd'
+ request_internal_download(endpoint, '/etc/passwd')
+ assert_equal 404, last_response.status, "#{endpoint} accepted an unrecognised original URI"
+ assert_nil last_response.headers['X-OnTrack-File']
+ end
+ end
+
+ def test_submission_download_returns_only_an_authorised_relative_path
+ unit, project, task_definition, task = create_student_task
+ submission_path = task.final_pdf_path
+ write_file(submission_path, '%PDF submission')
+
+ add_auth_header_for(user: project.student)
+ header 'X-OnTrack-File', '../../etc/passwd'
+ request_internal_download(
+ '/api/internal/downloads/submission',
+ "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission"
+ )
+
+ assert_equal 200, last_response.status
+ assert_safe_relative_file_header(submission_path)
+ assert_equal 'application/pdf', last_response.headers['X-OnTrack-Content-Type']
+
+ clear_auth_header
+ add_auth_header_for(user: FactoryBot.create(:user, :student))
+ request_internal_download(
+ '/api/internal/downloads/submission',
+ "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission"
+ )
+
+ assert_equal 403, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ ensure
+ unit&.destroy
+ end
+
+ def test_submission_download_rejects_a_task_definition_from_another_unit
+ unit, project, = create_student_task
+ other_unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0)
+ other_task_definition = other_unit.task_definitions.first
+ add_auth_header_for(user: project.student)
+
+ request_internal_download(
+ '/api/internal/downloads/submission',
+ "/api/projects/#{project.id}/task_def_id/#{other_task_definition.id}/submission"
+ )
+
+ assert_equal 404, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ ensure
+ unit&.destroy
+ other_unit&.destroy
+ end
+
+ def test_download_rejects_a_symlink_that_escapes_student_work
+ unit, project, task_definition, task = create_student_task
+
+ Tempfile.create('outside-student-work') do |outside_file|
+ outside_file.write('not student work')
+ outside_file.flush
+ submission_path = task.final_pdf_path
+ FileUtils.mkdir_p(File.dirname(submission_path))
+ FileUtils.rm_f(submission_path)
+ File.symlink(outside_file.path, submission_path)
+
+ add_auth_header_for(user: project.student)
+ request_internal_download(
+ '/api/internal/downloads/submission',
+ "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission"
+ )
+
+ assert_equal 404, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ end
+ ensure
+ unit&.destroy
+ end
+
+ def test_pdf_download_enforces_record_scope_and_permissions
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0)
+ task_definition = unit.task_definitions.first
+ task_sheet_path = task_definition.task_sheet(false)
+ write_file(task_sheet_path, '%PDF task sheet')
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ request_internal_download(
+ '/api/internal/downloads/pdf-file',
+ "/api/units/#{unit.id}/task_definitions/#{task_definition.id}/task_pdf"
+ )
+
+ assert_equal 200, last_response.status
+ assert_safe_relative_file_header(task_sheet_path)
+
+ clear_auth_header
+ add_auth_header_for(user: FactoryBot.create(:user, :student))
+ request_internal_download(
+ '/api/internal/downloads/pdf-file',
+ "/api/units/#{unit.id}/task_definitions/#{task_definition.id}/task_pdf"
+ )
+
+ assert_equal 403, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ ensure
+ unit&.destroy
+ end
+
+ def test_jplag_report_download_requires_permission_to_download_jplag_reports
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0)
+ task_definition = unit.task_definitions.first
+ report_path = FileHelper.task_jplag_report_path(unit, task_definition)
+ write_file(report_path, 'PK jplag report')
+ report_uri = "/api/units/#{unit.id}/task_definitions/#{task_definition.id}/jplag_report"
+
+ add_auth_header_for(user: unit.main_convenor_user)
+ request_internal_download('/api/internal/downloads/pdf-file', report_uri)
+
+ assert_equal 200, last_response.status
+ assert_safe_relative_file_header(report_path)
+ assert_equal 'application/octet-stream', last_response.headers['X-OnTrack-Content-Type']
+ assert_includes last_response.headers['X-OnTrack-Content-Disposition'],
+ "#{unit.code}-#{task_definition.abbreviation}-jplag-report.jplag"
+
+ clear_auth_header
+ add_auth_header_for(user: FactoryBot.create(:user, :student))
+ request_internal_download('/api/internal/downloads/pdf-file', report_uri)
+
+ assert_equal 403, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ ensure
+ unit&.destroy
+ end
+
+ def test_unit_content_download_requires_content_cookie_and_confines_the_file
+ unit, site = create_content_site
+ content_token = unit.main_convenor_user.generate_content_authentication_token!
+ set_content_credentials(unit.main_convenor_user, content_token)
+
+ request_internal_download(
+ '/api/internal/downloads/unit-content',
+ "/api/units/#{unit.id}/content/sites/#{site.id}/files/index.html"
+ )
+
+ assert_equal 200, last_response.status
+ assert_safe_relative_file_header(File.join(site.served_dir, 'index.html'))
+
+ Tempfile.create('outside-unit-content') do |outside_file|
+ outside_file.write('outside content')
+ outside_file.flush
+ served_file = File.join(site.served_dir, 'index.html')
+ FileUtils.rm_f(served_file)
+ File.symlink(outside_file.path, served_file)
+
+ request_internal_download(
+ '/api/internal/downloads/unit-content',
+ "/api/units/#{unit.id}/content/sites/#{site.id}/files/index.html"
+ )
+
+ assert_equal 404, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ end
+ ensure
+ unit&.destroy
+ end
+
+ def test_unit_content_file_paths_are_url_encoded_for_the_file_server
+ unit, site = create_content_site
+ content_token = unit.main_convenor_user.generate_content_authentication_token!
+ set_content_credentials(unit.main_convenor_user, content_token)
+
+ # Caddy rewrites the request to X-OnTrack-File, so an unencoded '?' would
+ # truncate the path and an unencoded '%' would decode to a different file.
+ {
+ 'query?name.txt' => 'query%3Fname.txt',
+ '100%20done.txt' => '100%2520done.txt',
+ 'with space.txt' => 'with%20space.txt'
+ }.each do |name, expected|
+ write_file(File.join(site.served_dir, name), 'content')
+
+ request_internal_download(
+ '/api/internal/downloads/unit-content',
+ "/api/units/#{unit.id}/content/sites/#{site.id}/files/#{ERB::Util.url_encode(name)}"
+ )
+
+ assert_equal 200, last_response.status, "#{name} was not served"
+ assert_equal expected, last_response.headers['X-OnTrack-File'].split('/').last
+ end
+ ensure
+ unit&.destroy
+ end
+
+ def test_native_download_cookies_are_scoped_to_their_own_download
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0)
+ user = unit.main_convenor_user
+ task_definition = unit.task_definitions.first
+ downloads = native_downloads(unit, task_definition, user)
+
+ downloads.each do |download|
+ write_file(download[:file_path], 'archive')
+ clear_cookies
+ clear_request_headers
+ add_auth_header_for(user: user)
+ post download[:access_path]
+
+ assert_equal 204, last_response.status, "failed to issue #{download[:cookie_name]}"
+ set_cookie = set_cookie_line(download[:cookie_name])
+ assert_match(/path=#{Regexp.escape(download[:public_path])}/i, set_cookie)
+ assert_match(/HttpOnly/i, set_cookie)
+ assert_match(/SameSite=Strict/i, set_cookie)
+
+ encrypted_cookie = issued_cookie_value(download[:cookie_name])
+ clear_auth_header
+
+ # Resumed downloads re-request the same URL, so reuse must work.
+ 2.times do |attempt|
+ replay_cookie(download[:cookie_name], encrypted_cookie)
+ request_internal_download(download[:internal_path], download[:public_path])
+
+ assert_equal 200, last_response.status,
+ "use #{attempt + 1} of #{download[:cookie_name]} was rejected"
+ assert_safe_relative_file_header(download[:file_path])
+ end
+
+ downloads.reject { |other| other[:cookie_name] == download[:cookie_name] }.each do |other|
+ replay_cookie(download[:cookie_name], encrypted_cookie)
+ request_internal_download(other[:internal_path], other[:public_path])
+
+ assert_equal 401, last_response.status,
+ "#{download[:cookie_name]} authorised #{other[:internal_path]}"
+ assert_nil last_response.headers['X-OnTrack-File']
+ end
+ end
+ ensure
+ unit&.destroy
+ end
+
+ def test_native_download_cookie_does_not_authorise_another_task_definition
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 2, stream_count: 0)
+ user = unit.main_convenor_user
+ issued_for, other = unit.task_definitions.order(:id).first(2)
+ download = native_downloads(unit, issued_for, user).second
+ other_download = native_downloads(unit, other, user).second
+ write_file(download[:file_path], 'archive')
+ write_file(other_download[:file_path], 'archive')
+
+ add_auth_header_for(user: user)
+ post download[:access_path]
+ encrypted_cookie = issued_cookie_value(download[:cookie_name])
+ clear_auth_header
+
+ replay_cookie(download[:cookie_name], encrypted_cookie)
+ request_internal_download(other_download[:internal_path], other_download[:public_path])
+
+ assert_equal 401, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ ensure
+ unit&.destroy
+ end
+
+ def test_direct_rails_downloads_name_the_file_in_content_disposition
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0)
+ user = unit.main_convenor_user
+ task_definition = unit.task_definitions.first
+
+ native_downloads(unit, task_definition, user).each do |download|
+ write_file(download[:file_path], 'archive')
+ clear_cookies
+ clear_request_headers
+ add_auth_header_for(user: user)
+ post download[:access_path]
+
+ encrypted_cookie = issued_cookie_value(download[:cookie_name])
+ clear_auth_header
+ replay_cookie(download[:cookie_name], encrypted_cookie)
+ get download[:public_path]
+
+ assert_equal 200, last_response.status, "#{download[:public_path]} was not served"
+
+ # Without this the browser falls back to naming the file after the last
+ # URL segment, e.g. "portfolio" with no extension.
+ disposition = last_response.headers['Content-Disposition']
+ assert_match(/\Aattachment;/, disposition)
+ assert_match(/filename="[^"]+\.zip"/, disposition)
+ assert_equal 'application/zip', last_response.headers['Content-Type']
+ assert_equal 'rails', last_response.headers['X-OnTrack-Served-By']
+ end
+ ensure
+ unit&.destroy
+ end
+
+ def test_expired_native_download_cookie_is_rejected_without_serving_a_file
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0)
+ user = unit.main_convenor_user
+ task_definition = unit.task_definitions.first
+ download = native_downloads(unit, task_definition, user).first
+ write_file(download[:file_path], 'archive')
+
+ add_auth_header_for(user: user)
+ post download[:access_path]
+ encrypted_cookie = issued_cookie_value(download[:cookie_name])
+ clear_auth_header
+
+ travel 31.seconds do
+ replay_cookie(download[:cookie_name], encrypted_cookie)
+ request_internal_download(download[:internal_path], download[:public_path])
+
+ assert_equal 401, last_response.status
+ assert_nil last_response.headers['X-OnTrack-File']
+ end
+ ensure
+ unit&.destroy
+ end
+
+ private
+
+ def request_internal_download(endpoint, original_uri, secret: CADDY_SECRET)
+ header 'X-OnTrack-Download-Auth', secret
+ header 'X-Forwarded-Uri', original_uri
+ get endpoint
+ end
+
+ def clear_request_headers
+ clear_auth_header
+ header 'Cookie', nil
+ header 'X-OnTrack-Download-Auth', nil
+ header 'X-Forwarded-Uri', nil
+ end
+
+ def create_student_task
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0)
+ student = FactoryBot.create(:user, :student)
+ project = unit.enrol_student(student, nil)
+ task_definition = unit.task_definitions.first
+ task = project.task_for_task_definition(task_definition)
+ [unit, project, task_definition, task]
+ end
+
+ def create_content_site
+ unit = FactoryBot.create(:unit, with_students: false, task_count: 0, stream_count: 0)
+ archive = Tempfile.new(['download-authorization-content', '.zip'])
+ Zip::File.open(archive.path, Zip::File::CREATE) do |zip|
+ zip.get_output_stream('index.html') { |stream| stream.write('Content
') }
+ end
+ site = UnitContentSite.store_upload!(
+ unit,
+ { filename: 'content.zip', tempfile: archive }
+ )
+ archive.close
+ [unit, site]
+ end
+
+ def set_content_credentials(user, token)
+ set_cookie "username=#{Rack::Utils.escape(user.username)}"
+ set_cookie "#{AuthenticationHelpers::CONTENT_TOKEN_COOKIE}=#{Rack::Utils.escape(token.authentication_token)}"
+ end
+
+ def native_downloads(unit, task_definition, user)
+ [
+ {
+ access_path: "/api/submission/unit/#{unit.id}/portfolio/access",
+ public_path: "/api/submission/unit/#{unit.id}/portfolio",
+ internal_path: '/api/internal/downloads/portfolio',
+ cookie_name: NativeDownloadCookie.cookie_name(:portfolio),
+ file_path: unit.get_portfolio_zip_filename(user)
+ },
+ {
+ access_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/download_submissions/access",
+ public_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/download_submissions",
+ internal_path: '/api/internal/downloads/task-submission-files',
+ cookie_name: NativeDownloadCookie.cookie_name(:task_submission_files),
+ file_path: unit.task_submissions_zip_path(user, task_definition)
+ },
+ {
+ access_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/student_pdfs/access",
+ public_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/student_pdfs",
+ internal_path: '/api/internal/downloads/task-submission-pdfs',
+ cookie_name: NativeDownloadCookie.cookie_name(:task_submission_pdfs),
+ file_path: unit.task_submissions_pdf_zip_path(user, task_definition)
+ }
+ ]
+ end
+
+ def replay_cookie(name, value)
+ # `value` is already in Set-Cookie wire form, so replay it verbatim.
+ header 'Cookie', "#{name}=#{value}"
+ end
+
+ def set_cookie_line(name)
+ Array(last_response.headers['Set-Cookie']).join("\n").lines.map(&:strip)
+ .find { |line| line.start_with?("#{name}=") }
+ end
+
+ # CGI::Cookie#value is array-like and holds the percent-encoded wire value,
+ # which is what the browser sends back. Rack::Test's own accessors drop the
+ # cookie's flags, so attribute assertions read the raw header instead.
+ def issued_cookie_value(name)
+ last_response.cookies.fetch(name).value.first.to_s
+ end
+
+ def write_file(path, contents)
+ FileUtils.mkdir_p(File.dirname(path))
+ File.binwrite(path, contents)
+ end
+
+ def assert_safe_relative_file_header(expected_path)
+ relative_path = last_response.headers['X-OnTrack-File']
+ expected = Pathname.new(expected_path).realpath.relative_path_from(Pathname.new(@student_work_dir).realpath).to_s
+ assert_equal expected.split('/').map { |segment| ERB::Util.url_encode(segment) }.join('/'), relative_path
+ assert_not Pathname.new(relative_path).absolute?
+ assert_not_includes Pathname.new(relative_path).each_filename.to_a, '..'
+ end
+end
diff --git a/test/factories/campuses_factory.rb b/test/factories/campuses_factory.rb
index c0fa5b55bb..1bd881497a 100644
--- a/test/factories/campuses_factory.rb
+++ b/test/factories/campuses_factory.rb
@@ -2,7 +2,7 @@
factory :campus do
name { Faker::Educator.unique.campus }
abbreviation { name[0...9] }
- mode { ['timetable', 'automatic', 'manual'].sample }
+ mode { %w[timetable automatic manual].sample }
active { true }
end
end
diff --git a/test/factories/discussion_comments.rb b/test/factories/discussion_comments.rb
index a32a5e89c8..554c46da08 100644
--- a/test/factories/discussion_comments.rb
+++ b/test/factories/discussion_comments.rb
@@ -2,7 +2,7 @@
factory :discussion_comment do
time_started { "2019-04-23 18:27:36" }
time_completed { "2019-04-23 18:27:36" }
- started { "" }
+ started { "" }
completed { "" }
end
end
diff --git a/test/factories/engagements_factory.rb b/test/factories/engagements_factory.rb
new file mode 100644
index 0000000000..0f21d6fd17
--- /dev/null
+++ b/test/factories/engagements_factory.rb
@@ -0,0 +1,15 @@
+FactoryBot.define do
+ factory :engagement do
+ project
+ user
+ engagement_type { 'attendance' }
+ note { 'Attended the weekly discussion.' }
+ occurred_at { Time.zone.now }
+ end
+
+ factory :engagement_comment do
+ engagement
+ user
+ comment { 'Thanks for recording this.' }
+ end
+end
diff --git a/test/factories/groups_factory.rb b/test/factories/groups_factory.rb
index 684dcf8e09..19a28dce24 100644
--- a/test/factories/groups_factory.rb
+++ b/test/factories/groups_factory.rb
@@ -4,9 +4,9 @@
factory :group do
group_set
- sequence(:name) { |n| "Group-#{n}" }
+ sequence(:name) { |n| "Group-#{n}" }
- after(:build) do |group, eval|
+ after(:build) do |group, _eval|
if group.tutorial.nil?
group.tutorial = group.group_set.unit.tutorials.first
end
diff --git a/test/factories/moderated_tasks.rb b/test/factories/moderated_tasks.rb
index df1075db35..d936d62b93 100644
--- a/test/factories/moderated_tasks.rb
+++ b/test/factories/moderated_tasks.rb
@@ -1,5 +1,4 @@
FactoryBot.define do
factory :moderated_task do
-
end
end
diff --git a/test/factories/overflow_task_claim_logs.rb b/test/factories/overflow_task_claim_logs.rb
new file mode 100644
index 0000000000..a853348015
--- /dev/null
+++ b/test/factories/overflow_task_claim_logs.rb
@@ -0,0 +1,14 @@
+FactoryBot.define do
+ factory :overflow_task_claim_log do
+ task { create(:task) }
+ unit { task.project.unit }
+ claimed_by_unit_role do
+ create(:unit_role, unit: unit, user: create(:user, :tutor), role: Role.tutor)
+ end
+ claimed_by_user { claimed_by_unit_role.user }
+ original_tutor_user_id { task.tutor&.id }
+ student_user_id { task.project.student.id }
+ days_awaiting_feedback { task.days_awaiting_feedback }
+ claimed_at { Time.zone.now }
+ end
+end
diff --git a/test/factories/overflow_task_claims.rb b/test/factories/overflow_task_claims.rb
index b4def1a12c..1bb3abb700 100644
--- a/test/factories/overflow_task_claims.rb
+++ b/test/factories/overflow_task_claims.rb
@@ -1,5 +1,6 @@
FactoryBot.define do
factory :overflow_task_claim do
-
+ task
+ claimed_by_unit_role { create(:unit_role, unit: task.project.unit, user: create(:user, :tutor), role: Role.tutor) }
end
end
diff --git a/test/factories/overseer_assessments.rb b/test/factories/overseer_assessments.rb
index cd1020a653..d858906fb6 100644
--- a/test/factories/overseer_assessments.rb
+++ b/test/factories/overseer_assessments.rb
@@ -1,7 +1,8 @@
FactoryBot.define do
factory :overseer_assessment do
- task nil
- submission_timestamp { "MyString" }
+ submission_history
+ task { submission_history.task }
+ submission_timestamp { submission_history.submission_timestamp }
result_task_status { "MyString" }
end
end
diff --git a/test/factories/overseer_steps.rb b/test/factories/overseer_steps.rb
index 284b2e6b1a..5bce08ba25 100644
--- a/test/factories/overseer_steps.rb
+++ b/test/factories/overseer_steps.rb
@@ -1,5 +1,4 @@
FactoryBot.define do
factory :overseer_step do
-
end
end
diff --git a/test/factories/projects_factory.rb b/test/factories/projects_factory.rb
index a5823e0714..8e975e3ff1 100644
--- a/test/factories/projects_factory.rb
+++ b/test/factories/projects_factory.rb
@@ -1,6 +1,6 @@
FactoryBot.define do
factory :project do
- unit { create(:unit, with_students:false) }
+ unit { create(:unit, with_students: false) }
campus { Campus.offset(rand(Campus.count)).first || create(:campus) }
task_stats { Project::DEFAULT_TASK_STATS }
user
diff --git a/test/factories/submission_histories.rb b/test/factories/submission_histories.rb
new file mode 100644
index 0000000000..974aa49390
--- /dev/null
+++ b/test/factories/submission_histories.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :submission_history do
+ task
+ sequence(:submission_timestamp) { |n| "#{Time.current.to_i}-#{n}" }
+ end
+end
diff --git a/test/factories/task_completion_snapshot_factory.rb b/test/factories/task_completion_snapshot_factory.rb
new file mode 100644
index 0000000000..871bd0611a
--- /dev/null
+++ b/test/factories/task_completion_snapshot_factory.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :task_completion_snapshot do
+ unit
+ snapshot_timestamp { Time.current.to_i.to_s }
+ end
+end
diff --git a/test/factories/task_definition_grade_due_dates.rb b/test/factories/task_definition_grade_due_dates.rb
index 027a44fee7..237d4758dd 100644
--- a/test/factories/task_definition_grade_due_dates.rb
+++ b/test/factories/task_definition_grade_due_dates.rb
@@ -1,5 +1,4 @@
FactoryBot.define do
factory :task_definition_grade_due_date do
-
end
end
diff --git a/test/factories/teaching_period_factory.rb b/test/factories/teaching_period_factory.rb
index 9af6fdca54..1c4c652c8b 100644
--- a/test/factories/teaching_period_factory.rb
+++ b/test/factories/teaching_period_factory.rb
@@ -2,14 +2,13 @@
FactoryBot.define do
factory :teaching_period do
sequence(:period, (1..3).cycle) { |n| "T#{n}" }
- sequence(:start_date, (1..3).cycle) { |n| Time.zone.now + n * 15.weeks }
+ sequence(:start_date, (1..3).cycle) { |n| Time.zone.now + (n * 15.weeks) }
year { start_date.year }
end_date { start_date + 14.weeks }
active_until { end_date + rand(1..2).weeks }
-
end
factory :breaks do
- start_date {start_date.Time.zone.now}
- number_of_weeks {number_of_weeks.rand(1..3)}
- end
+ start_date { start_date.Time.zone.now }
+ number_of_days { number_of_days.rand(1..3) * 7 }
+ end
end
diff --git a/test/factories/tutor_feedback_scores.rb b/test/factories/tutor_feedback_scores.rb
index 9ac2a7acd5..ec9502143c 100644
--- a/test/factories/tutor_feedback_scores.rb
+++ b/test/factories/tutor_feedback_scores.rb
@@ -1,5 +1,4 @@
FactoryBot.define do
factory :tutor_feedback_score do
-
end
end
diff --git a/test/factories/units_factory.rb b/test/factories/units_factory.rb
index 7cc6de1090..4c36d42e9e 100644
--- a/test/factories/units_factory.rb
+++ b/test/factories/units_factory.rb
@@ -2,13 +2,12 @@
require './lib/helpers/database_populator'
FactoryBot.define do
-
factory :task_definition do
unit
name { Faker::Lorem.unique.word }
description { Faker::Lorem.sentence }
target_grade { rand(GradeHelper::RANGE) }
- upload_requirements { [{'key' => 'file0','name' => 'Imported Code','type' => 'code'}] }
+ upload_requirements { [{ 'key' => 'file0', 'name' => 'Imported Code', 'type' => 'code' }] }
start_date { unit.start_date + rand(1..12).weeks }
sequence(:abbreviation) { |n| "#{GradeHelper.short_grade_for target_grade}#{((unit.start_date - start_date) / 1.week).floor + 1}.#{n}" }
weighting { rand(1..5) }
@@ -44,15 +43,15 @@
part_enrolled_student_count { 2 }
inactive_student_count { 1 }
task_count { 2 }
- tutorials { 1 } #per campus
- tutorial_config { [] } #[ {stream: 0, campus: 0} ]
+ tutorials { 1 } # per campus
+ tutorial_config { [] } # [ {stream: 0, campus: 0} ]
group_sets { 0 }
- groups { [] } #[ { gs: 0, students:0 } ]
- group_tasks { [] } #[ {idx: 0, gs: 0 }] - index of task, and index of group set
+ groups { [] } # [ { gs: 0, students:0 } ]
+ group_tasks { [] } # [ {idx: 0, gs: 0 }] - index of task, and index of group set
outcome_count { 2 }
stream_count { 0 }
campus_count { 1 }
- set_one_of_each_task { false } # In addition to the standard tasks, also add one of each different think of task - group, quality, graded, etc.
+ set_one_of_each_task { false } # In addition to the standard tasks, also add one of each different think of task - group, quality, graded, etc.
perform_submissions { false }
staff_count { 1 }
end
@@ -68,7 +67,7 @@
send_notifications { true }
allow_flexible_dates { false }
- after(:create) do | unit, eval |
+ after(:create) do |unit, eval|
group_sets = eval.group_sets
task_count = eval.task_count
group_tasks = eval.group_tasks.clone
@@ -79,10 +78,10 @@
if eval.set_one_of_each_task
group_sets = 1 unless group_sets > 0
task_count = 3 if task_count < 3
- group_tasks << {idx: 0, gs: group_sets - 1}
+ group_tasks << { idx: 0, gs: group_sets - 1 }
group_sets.times do |gs|
(eval.student_count / 4).times do
- groups << { gs: gs, students: 4}
+ groups << { gs: gs, students: 4 }
end
end
end
@@ -109,15 +108,15 @@
break if (c == campuses.last) && tutorial_streams.count > 0
if tutorial_streams.count > 0
- tutorial_streams.each { |ts| create_list(:tutorial, eval.tutorials, unit: unit, campus: c, tutorial_stream: ts ) }
+ tutorial_streams.each { |ts| create_list(:tutorial, eval.tutorials, unit: unit, campus: c, tutorial_stream: ts) }
else
- create_list(:tutorial, eval.tutorials, unit: unit, campus: c )
+ create_list(:tutorial, eval.tutorials, unit: unit, campus: c)
end
end
# Now update last campus - give it tutorials with no tutorial streams to mimic "cloud"
if tutorial_streams.count > 0
- create_list(:tutorial, eval.tutorials, unit: unit, campus: campuses.last )
+ create_list(:tutorial, eval.tutorials, unit: unit, campus: campuses.last)
end
unit.employ_staff(FactoryBot.create(:user, :convenor), Role.convenor)
@@ -136,15 +135,17 @@
campuses.each do |c|
campus_tutorials = unit.tutorials.where(campus: c)
(eval.unenrolled_student_count + eval.student_count + eval.part_enrolled_student_count + eval.inactive_student_count).times do |i|
- p = unit.enrol_student( FactoryBot.create(:user, :student), c )
+ p = unit.enrol_student(FactoryBot.create(:user, :student), c)
next if i < eval.unenrolled_student_count
if i < eval.unenrolled_student_count + eval.inactive_student_count
p.update(enrolled: false)
next
end
+ next if campus_tutorials.empty?
+
if campus_tutorials.first.tutorial_stream.present?
- tutorial_streams.each_with_index do |ts, i|
+ tutorial_streams.each_with_index do |ts, _i|
p.enrol_in ts.tutorials.where(campus_id: c.id).sample
end
else
@@ -153,7 +154,7 @@
end
eval.part_enrolled_student_count.times do
- unit.tutorial_enrolments.joins(:project).where('projects.campus_id = :campus_id', campus_id: c.id).sample.destroy
+ unit.tutorial_enrolments.joins(:project).where('projects.campus_id = :campus_id', campus_id: c.id).sample&.destroy
end
end
@@ -182,10 +183,10 @@
task = p.task_for_task_definition td
tutor = p.tutor_for(td)
- unless [TaskStatus.ready_for_feedback, TaskStatus.need_help, TaskStatus.working_on_it].include? ts
- DatabasePopulator.assess_task(p, task, tutor, ts, td.start_date + 1.week)
- else
+ if [TaskStatus.ready_for_feedback, TaskStatus.need_help, TaskStatus.working_on_it].include? ts
DatabasePopulator.assess_task(p, task, p.student, ts, td.start_date + 1.week)
+ else
+ DatabasePopulator.assess_task(p, task, tutor, ts, td.start_date + 1.week)
end
end
end
diff --git a/test/factories/users_factory.rb b/test/factories/users_factory.rb
index 6df2c2c6ca..b7e9fe62ea 100644
--- a/test/factories/users_factory.rb
+++ b/test/factories/users_factory.rb
@@ -10,19 +10,19 @@
role { Role.student }
student_id { Faker::Number.number(digits: 7) }
- before(:create) do |user, eval|
- while User.where(username: user.username).count > 0
- user.username = "#{user.username}-#{rand(1000)}"
- end
+ before(:create) do |user, _eval|
+ user.username = "#{user.username}-#{rand(1000)}" while User.where(username: user.username).count > 0
- while User.where(student_id: user.student_id).count > 0
- user.student_id = user.student_id.to_i + 1
- end
+ user.student_id = user.student_id.to_i + 1 while User.where(student_id: user.student_id).count > 0
+
+ # Faker only dedupes within a run, so a seeded database can already hold
+ # the address it hands out.
+ user.email = "#{rand(1000)}-#{user.email}" while User.where(email: user.email).count > 0
end
trait :student do
transient do
- enrol_in { 0 } # Number of units to enrol into
+ enrol_in { 0 } # Number of units to enrol into
end
after(:create) do |user, eval|
diff --git a/test/helpers/account_helper.rb b/test/helpers/account_helper.rb
index b512e6872c..43278040c5 100644
--- a/test/helpers/account_helper.rb
+++ b/test/helpers/account_helper.rb
@@ -1,7 +1,5 @@
module TestHelpers
-
module AccountHelper
-
##################################################
# Student user
##################################################
@@ -65,7 +63,5 @@ def first_admin_user
def last_admin_user
admin_users.last
end
-
end
-
-end
\ No newline at end of file
+end
diff --git a/test/helpers/auth_helper.rb b/test/helpers/auth_helper.rb
index c60348949b..205e84c4bc 100644
--- a/test/helpers/auth_helper.rb
+++ b/test/helpers/auth_helper.rb
@@ -17,7 +17,7 @@ def auth_token(user = User.first, token_type = :general)
token = user.valid_auth_tokens.where(token_type: token_type).first
return token.authentication_token unless token.nil?
- return user.generate_authentication_token!(token_type: token_type).authentication_token
+ user.generate_authentication_token!(token_type: token_type).authentication_token
end
#
diff --git a/test/helpers/json_helper.rb b/test/helpers/json_helper.rb
index d1f52d490d..6e9fe248a0 100644
--- a/test/helpers/json_helper.rb
+++ b/test/helpers/json_helper.rb
@@ -7,6 +7,8 @@ module TestHelpers
module JsonHelper
module_function
+ # These helpers use Rack::Test, which takes the request body as its second positional argument.
+ # rubocop:disable Rails/HttpPositionalArguments
#
# POSTs a hash data as JSON with content-type "application/json"
#
@@ -20,8 +22,11 @@ def post_json(endpoint, data)
# PUTs a hash data as JSON with content-type "application/json"
#
def put_json(endpoint, data)
- put URI::Parser.new.escape(endpoint), data.to_json, 'CONTENT_TYPE' => 'application/json'
+ header 'CONTENT_TYPE', 'application/json'
+
+ put URI::Parser.new.escape(endpoint), data.to_json
end
+ # rubocop:enable Rails/HttpPositionalArguments
#
# PUTs a hash data as JSON with content-type "application/json"
diff --git a/test/helpers/lti_helper_test.rb b/test/helpers/lti_helper_test.rb
new file mode 100644
index 0000000000..29369d02a2
--- /dev/null
+++ b/test/helpers/lti_helper_test.rb
@@ -0,0 +1,10 @@
+require 'test_helper'
+
+class LtiHelperTest < ActiveSupport::TestCase
+ test 'only one of many concurrent uses of a token id succeeds' do
+ jti = SecureRandom.uuid
+ results = Array.new(8) { Thread.new { LtiHelper.first_use?(jti, 30) } }.map(&:value)
+
+ assert_equal 1, results.count(true), results.inspect
+ end
+end
diff --git a/test/helpers/test_file_helper.rb b/test/helpers/test_file_helper.rb
index 0f4c543180..7001289fb5 100644
--- a/test/helpers/test_file_helper.rb
+++ b/test/helpers/test_file_helper.rb
@@ -11,15 +11,15 @@ def test_file_path(filename)
Rails.root.join('test_files', filename)
end
- def with_files( files, hash )
- files.each_with_index do | file, idx |
- hash[ "file#{idx}".to_sym ] = Rack::Test::UploadedFile.new(Rails.root.join(file[:path]), file[:type])
+ def with_files(files, hash)
+ files.each_with_index do |file, idx|
+ hash[:"file#{idx}"] = Rack::Test::UploadedFile.new(Rails.root.join(file[:path]), file[:type])
end
hash
end
def with_file(path, type, hash)
- with_files( [ {path: path, type: type} ], hash)
+ with_files([{ path: path, type: type }], hash)
end
def upload_file_csv(path)
@@ -29,6 +29,5 @@ def upload_file_csv(path)
def upload_file(path, type)
Rack::Test::UploadedFile.new(Rails.root.join(path), type)
end
-
end
end
diff --git a/test/helpers/tii_test_helper.rb b/test/helpers/tii_test_helper.rb
index 0a76ab35c5..1e52499659 100644
--- a/test/helpers/tii_test_helper.rb
+++ b/test/helpers/tii_test_helper.rb
@@ -8,12 +8,13 @@ module TiiTestHelper
module_function
def tii_headers(base = {})
+ api_key = Doubtfire::Application.fetch_credential_or_env(:tii, :api_key, env_key: 'TCA_API_KEY')
base["headers"] = {
- 'Accept'=>'application/json',
- 'Authorization'=>"Bearer #{ENV['TCA_API_KEY']}",
- 'Content-Type'=>'application/json',
- 'X-Turnitin-Integration-Name'=>'formatif-tii',
- 'X-Turnitin-Integration-Version'=>'1.0'
+ 'Accept' => 'application/json',
+ 'Authorization' => "Bearer #{api_key}",
+ 'Content-Type' => 'application/json',
+ 'X-Turnitin-Integration-Name' => 'formatif-tii',
+ 'X-Turnitin-Integration-Version' => '1.0'
}
base
@@ -36,46 +37,46 @@ def setup_tii_features_enabled
TCAClient::FeaturesEnabled.new(
{
similarity: TCAClient::FeaturesSimilarity.new(
- viewer_modes: {
- match_overview: true,
- all_sources: true
- },
- generation_settings: TCAClient::FeaturesGenerationSettings.new(
- search_repositories: [
- 'INTERNET',
- 'SUBMITTED_WORK',
- 'PUBLICATION',
- 'CROSSREF',
- 'CROSSREF_POSTED_CONTENT',
- ],
- submission_auto_excludes: true
- ),
- view_settings: {
- exclude_bibliography: true,
- exclude_quotes: true,
- exclude_abstract: true,
- exclude_methods: true,
- exclude_small_matches: true,
- exclude_internet: true,
- exclude_publications: true,
- exclude_crossref: true,
- exclude_crossref_posted_content: true,
- exclude_submitted_works: true,
- exclude_citations: true,
- exclude_preprints: true
- }
+ viewer_modes: {
+ match_overview: true,
+ all_sources: true
+ },
+ generation_settings: TCAClient::FeaturesGenerationSettings.new(
+ search_repositories: %w[
+ INTERNET
+ SUBMITTED_WORK
+ PUBLICATION
+ CROSSREF
+ CROSSREF_POSTED_CONTENT
+ ],
+ submission_auto_excludes: true
),
+ view_settings: {
+ exclude_bibliography: true,
+ exclude_quotes: true,
+ exclude_abstract: true,
+ exclude_methods: true,
+ exclude_small_matches: true,
+ exclude_internet: true,
+ exclude_publications: true,
+ exclude_crossref: true,
+ exclude_crossref_posted_content: true,
+ exclude_submitted_works: true,
+ exclude_citations: true,
+ exclude_preprints: true
+ }
+ ),
tenant: TCAClient::FeaturesTenant.new({
- require_eula: true
- }),
+ require_eula: true
+ }),
product_name: 'Turnitin Originality',
- access_options: [
- 'NATIVE',
- 'CORE_API',
- 'DRAFT_COACH'
+ access_options: %w[
+ NATIVE
+ CORE_API
+ DRAFT_COACH
]
- }
- )
+ }
+ )
end
end
@@ -99,31 +100,31 @@ def setup_tii_eula
valid_from: "2018-04-30T17:00:00Z",
valid_until: nil,
url: "https://static.turnitin.com/eula/v1beta/fr-fr/eula.html",
- available_languages: [
- "sv-SE",
- "zh-CN",
- "ja-JP",
- "ko-KR",
- "es-MX",
- "nl-NL",
- "ru-RU",
- "zh-TW",
- "ar-SA",
- "pt-BR",
- "de-DE",
- "el-GR",
- "nb-NO",
- "cs-CZ",
- "da-DK",
- "tr-TR",
- "pl-PL",
- "fi-FI",
- "it-IT",
- "bl-AH",
- "vi-VN",
- "fr-FR",
- "en-US",
- "ro-RO"
+ available_languages: %w[
+ sv-SE
+ zh-CN
+ ja-JP
+ ko-KR
+ es-MX
+ nl-NL
+ ru-RU
+ zh-TW
+ ar-SA
+ pt-BR
+ de-DE
+ el-GR
+ nb-NO
+ cs-CZ
+ da-DK
+ tr-TR
+ pl-PL
+ fi-FI
+ it-IT
+ bl-AH
+ vi-VN
+ fr-FR
+ en-US
+ ro-RO
]
)
end
diff --git a/test/mailers/communications_mailer_test.rb b/test/mailers/communications_mailer_test.rb
new file mode 100644
index 0000000000..692af180e2
--- /dev/null
+++ b/test/mailers/communications_mailer_test.rb
@@ -0,0 +1,34 @@
+require 'test_helper'
+
+class CommunicationsMailerTest < ActionMailer::TestCase
+ def test_plain_text_email_contains_only_the_entered_body
+ body = "Hello Ada,\n\nPlease review your work."
+ message = communication_email(body: body, plain_text: true)
+
+ assert_match %r{\Atext/plain}, message.content_type
+ assert_nil message.html_part
+ assert_equal body, message.body.decoded.gsub("\r\n", "\n")
+ end
+
+ def test_default_email_keeps_the_notification_layout
+ message = communication_email(body: 'Please review your work.')
+
+ assert message.html_part
+ assert message.text_part
+ assert_includes message.html_part.body.decoded, 'Notification'
+ end
+
+ private
+
+ def communication_email(body:, plain_text: false)
+ CommunicationsMailer.communication_email(
+ to: 'student@example.com',
+ from: 'convenor@example.com',
+ subject: 'Reminder',
+ body: body,
+ recipient: nil,
+ sender: Struct.new(:name).new('Convenor'),
+ plain_text: plain_text
+ )
+ end
+end
diff --git a/test/mailers/error_log_mailer_test.rb b/test/mailers/error_log_mailer_test.rb
index ed990597f9..8f98e5f36c 100644
--- a/test/mailers/error_log_mailer_test.rb
+++ b/test/mailers/error_log_mailer_test.rb
@@ -30,4 +30,31 @@ def test_latex_error_logs_are_attached
assert mail.attachments['log.txt'].present?
assert mail.attachments['log.txt'].body.include? 'this is the content of the log'
end
+
+ def test_portfolio_error_mail_keeps_student_details_and_latex_log
+ Doubtfire::Application.config.email_errors_to = 'test '
+ details = '5928 - Test Example (example123) COURSE123'
+ exception = Project::LatexError.new('full portfolio latex log')
+ mail = ErrorLogMailer.error_message(
+ "Failed portfolio job: #{details}",
+ "Failed to create portfolio for project #{details}",
+ exception
+ )
+
+ assert_includes mail.subject, details
+ assert_includes mail.text_part.body.decoded, details
+ assert_equal 'full portfolio latex log', mail.attachments['log.txt'].body.decoded
+ end
+
+ def test_can_send_error_log_mail_without_backtrace
+ Doubtfire::Application.config.email_errors_to = 'test '
+ exception = StandardError.new('test')
+
+ mail = ErrorLogMailer.error_message('test', 'test message', exception)
+
+ assert mail.present?
+ assert mail.to.include? 'test@test.com'
+ assert mail.body.include? exception.message
+ assert mail.body.include? 'No backtrace available'
+ end
end
diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb
index 1acf5aa90a..9dda5cc7cb 100644
--- a/test/mailers/unit_mail_test.rb
+++ b/test/mailers/unit_mail_test.rb
@@ -30,8 +30,8 @@ def test_send_portfolio_ready_from_main_convenor
mail = PortfolioEvidenceMailer.portfolio_ready(project)
- assert_equal 1, mail.from().count
- assert_equal convenor.email, mail.from().first
+ assert_equal 1, mail.from.count
+ assert_equal convenor.email, mail.from.first
assert mail.html_part.body.include? "projects/#{project.id}/portfolio"
unit.destroy!
end
@@ -48,10 +48,143 @@ def test_send_portfolio_fail_from_main_convenor
mail = PortfolioEvidenceMailer.portfolio_failed(project)
- assert_equal 1, mail.from().count
- assert_equal convenor.email, mail.from().first
+ assert_equal 1, mail.from.count
+ assert_equal convenor.email, mail.from.first
assert mail.html_part.body.include? "projects/#{project.id}/portfolio"
unit.destroy!
end
+ def test_send_overseer_assessment_failed_email
+ unit = FactoryBot.create :unit
+ convenor = FactoryBot.create :user, :convenor
+
+ ur = unit.employ_staff convenor, Role.convenor
+
+ unit.update main_convenor: ur
+
+ project = unit.active_projects.first
+ task = project.task_for_task_definition(unit.task_definitions.first)
+
+ mail = PortfolioEvidenceMailer.overseer_assessment_failed(project, [task])
+
+ assert_equal 1, mail.from.count
+ assert_equal convenor.email, mail.from.first
+ assert_equal project.student.email, mail.to.first
+ assert mail.html_part.body.include? "projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}"
+ end
+
+ def test_send_discussion_deadline_emails
+ unit = FactoryBot.create(:unit)
+ project = unit.active_projects.first
+ task = project.task_for_task_definition(unit.task_definitions.first)
+ sender = unit.main_convenor_user
+ deadline = 7.days.from_now.to_date
+
+ approaching = NotificationsMailer.discussion_deadline_approaching(task, sender, deadline)
+ missed = NotificationsMailer.discussion_deadline_missed(task, sender)
+
+ assert_equal project.student.email, approaching.to.first
+ assert_includes approaching.subject, 'Discussion deadline approaching'
+ assert_includes approaching.text_part.body.to_s, 'on or before'
+ assert_includes approaching.html_part.body.to_s, 'on or before'
+ assert_includes approaching.text_part.body.to_s, unit.formatted_discuss_timeout_date(deadline)
+ assert_includes approaching.text_part.body.to_s, "projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}"
+ assert_equal 1, approaching.html_part.body.to_s.scan('