canvas-lms/app/models/context_module.rb

1007 lines
36 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class ContextModule < ActiveRecord::Base
include Workflow
include SearchTermHelper
include DuplicatingObjects
include LockedFor
include DifferentiableAssignment
include MasterCourses::Restrictor
restrict_columns :state, [:workflow_state]
restrict_columns :settings, %i[prerequisites completion_requirements requirement_count require_sequential_progress]
belongs_to :context, polymorphic: [:course]
belongs_to :root_account, class_name: "Account"
has_many :context_module_progressions, dependent: :destroy
has_many :content_tags, -> { order("content_tags.position, content_tags.title") }, dependent: :destroy
has_many :assignment_overrides, dependent: :destroy, inverse_of: :context_module
has_many :assignment_override_students, dependent: :destroy
has_many :module_student_visibilities
has_one :master_content_tag, class_name: "MasterCourses::MasterContentTag", inverse_of: :context_module
acts_as_list scope: { context: self, workflow_state: ["active", "unpublished"] }
serialize :prerequisites
serialize :completion_requirements
before_save :infer_position
before_save :validate_prerequisites
before_save :confirm_valid_requirements
before_save :set_root_account_id
after_save :touch_context
after_save :invalidate_progressions
after_save :relock_warning_check
after_save :clear_discussion_stream_items
after_save :send_items_to_stream
validates :workflow_state, :context_id, :context_type, presence: true
validates :name, presence: { if: :require_presence_of_name }
attr_accessor :require_presence_of_name
def relock_warning_check
# if the course is already active and we're adding more stringent requirements
# then we're going to give the user an option to re-lock students out of the modules
# otherwise they will be able to continue as before
@relock_warning = false
return if new_record?
if context.available? && active?
if saved_change_to_workflow_state? && workflow_state_before_last_save == "unpublished"
# should trigger when publishing a prerequisite for an already active module
@relock_warning = true if context.context_modules.active.any? { |mod| is_prerequisite_for?(mod) }
# if any of these changed while we were unpublished, then we also need to trigger
@relock_warning = true if prerequisites.any? || completion_requirements.any? || unlock_at.present?
end
if saved_change_to_completion_requirements? && (completion_requirements.to_a - completion_requirements_before_last_save.to_a).present?
# removing a requirement shouldn't trigger
@relock_warning = true
end
if saved_change_to_prerequisites? && (prerequisites.to_a - prerequisites_before_last_save.to_a).present?
# ditto with removing a prerequisite
@relock_warning = true
end
if saved_change_to_unlock_at? && unlock_at.present? && unlock_at_before_last_save.blank?
# adding a unlock_at date should trigger
@relock_warning = true
end
end
end
def relock_warning?
@relock_warning
end
def relock_progressions(relocked_modules = [], student_ids = nil)
return if relocked_modules.include?(self)
self.class.connection.after_transaction_commit do
relocked_modules << self
progression_scope = context_module_progressions.where.not(workflow_state: "locked")
progression_scope = progression_scope.where(user_id: student_ids) if student_ids
if progression_scope.in_batches(of: 10_000).update_all(["workflow_state = 'locked', lock_version = lock_version + 1, current = ?", false]) > 0
delay_if_production(n_strand: ["evaluate_module_progressions", global_context_id],
singleton: "evaluate_module_progressions:#{global_id}")
.evaluate_all_progressions
end
context.context_modules.each do |mod|
mod.relock_progressions(relocked_modules, student_ids) if is_prerequisite_for?(mod)
end
end
end
def invalidate_progressions
self.class.connection.after_transaction_commit do
if context_module_progressions.where(current: true).in_batches(of: 10_000).update_all(current: false) > 0
# don't queue a job unless necessary
delay_if_production(n_strand: ["evaluate_module_progressions", global_context_id],
singleton: "evaluate_module_progressions:#{global_id}")
.evaluate_all_progressions
end
@discussion_topics_to_recalculate&.each do |dt|
dt.delay_if_production(n_strand: ["evaluate_discussion_topic_progressions", global_context_id],
singleton: "evaluate_discussion_topic_progressions:#{dt.global_id}")
.recalculate_context_module_actions!
end
end
end
def evaluate_all_progressions
current_column = "context_module_progressions.current"
current_scope = context_module_progressions.where("#{current_column} IS NULL OR #{current_column} = ?", false).preload(:user)
current_scope.find_in_batches(batch_size: 100) do |progressions|
context.cache_item_visibilities_for_user_ids(progressions.map(&:user_id))
progressions.each do |progression|
progression.context_module = self
progression.evaluate!
end
context.clear_cached_item_visibilities
end
end
def check_for_stale_cache_after_unlocking!
GuardRail.activate(:primary) { touch } if unlock_at && unlock_at < Time.now && updated_at < unlock_at
end
def is_prerequisite_for?(mod)
(mod.prerequisites || []).any? { |prereq| prereq[:type] == "context_module" && prereq[:id] == id }
end
def self.module_positions(context)
# Keep a cached hash of all modules for a given context and their
# respective positions -- used when enforcing valid prerequisites
# and when generating the list of downstream modules
Rails.cache.fetch(["module_positions", context].cache_key) do
hash = {}
context.context_modules.not_deleted.each { |m| hash[m.id] = m.position || 0 }
hash
end
end
def remove_completion_requirement(id)
if completion_requirements.present?
new_requirements = completion_requirements.delete_if do |requirement|
requirement[:id] == id
end
update_attribute :completion_requirements, new_requirements
end
end
def infer_position
unless position
positions = ContextModule.module_positions(context)
self.position = if (max = positions.values.max)
max + 1
else
1
end
end
position
end
def get_potentially_conflicting_titles(title_base)
ContextModule.not_deleted.where(context_id:)
.starting_with_name(title_base).pluck("name").to_set
end
def duplicate_base_model(copy_title)
ContextModule.new({
context_id:,
context_type:,
name: copy_title,
position: ContextModule.not_deleted.where(context_id:).maximum(:position) + 1,
completion_requirements:,
workflow_state: "unpublished",
require_sequential_progress:,
completion_events:,
requirement_count:
})
end
def can_be_duplicated?
content_tags.none? do |content_tag|
!content_tag.deleted? && content_tag.content_type_class == "quiz"
end
end
def send_items_to_stream
if saved_change_to_workflow_state? && workflow_state == "active"
content_tags.where(content_type: "DiscussionTopic", workflow_state: "active").preload(:content).each do |ct|
ct.content.send_items_to_stream
end
end
end
def clear_discussion_stream_items
if saved_change_to_workflow_state? &&
["active", nil].include?(workflow_state_before_last_save) &&
workflow_state == "unpublished"
content_tags.where(content_type: "DiscussionTopic", workflow_state: "active").preload(:content).each do |ct|
ct.content.clear_stream_items
end
end
end
# This is intended for duplicating a content tag when we are duplicating a module
# Not intended for duplicating a content tag to keep in the original module
def duplicate_content_tag_base_model(original_content_tag)
ContentTag.new(
content_id: original_content_tag.content_id,
content_type: original_content_tag.content_type,
context_id: original_content_tag.context_id,
context_type: original_content_tag.context_type,
url: original_content_tag.url,
new_tab: original_content_tag.new_tab,
title: original_content_tag.title,
tag_type: original_content_tag.tag_type,
position: original_content_tag.position,
indent: original_content_tag.indent,
learning_outcome_id: original_content_tag.learning_outcome_id,
context_code: original_content_tag.context_code,
mastery_score: original_content_tag.mastery_score,
workflow_state: "unpublished"
)
end
private :duplicate_content_tag_base_model
# Intended for taking a content_tag in this module and duplicating it
# into a new module. Not intended for duplicating a content tag to be
# kept in the same module.
def duplicate_content_tag(original_content_tag)
new_tag = duplicate_content_tag_base_model(original_content_tag)
if original_content_tag.content.respond_to?(:duplicate)
new_tag.content = original_content_tag.content.duplicate
# If we have multiple assignments (e.g.) make sure they each get unused titles.
# A title isn't marked used if the assignment hasn't been saved yet.
new_tag.content.save!
new_tag.title = nil
end
new_tag
end
private :duplicate_content_tag
def set_root_account_id
self.root_account_id ||= context&.root_account_id
end
def only_visible_to_overrides
assignment_overrides.active.exists?
end
def duplicate
copy_title = get_copy_title(self, t("Copy"), name)
new_module = duplicate_base_model(copy_title)
living_tags = content_tags.reject(&:deleted?)
new_module.content_tags = living_tags.map do |content_tag|
duplicate_content_tag(content_tag)
end
new_module
end
def validate_prerequisites
positions = ContextModule.module_positions(context)
@already_confirmed_valid_requirements = false
prereqs = []
(prerequisites || []).each do |pre|
if pre[:type] == "context_module"
position = positions[pre[:id].to_i] || 0
prereqs << pre if position && position < (self.position || 0)
else
prereqs << pre
end
end
self.prerequisites = prereqs
self.position
end
alias_method :destroy_permanently!, :destroy
def destroy
self.workflow_state = "deleted"
self.deleted_at = Time.now.utc
module_assignments_quizzes = current_assignments_and_quizzes
ContentTag.where(context_module_id: self).where.not(workflow_state: "deleted").update(workflow_state: "deleted", updated_at: deleted_at)
delay_if_production(n_strand: "context_module_update_downstreams", priority: Delayed::LOW_PRIORITY).update_downstreams
save!
update_assignment_submissions(module_assignments_quizzes) if assignment_overrides.active.exists?
true
end
def restore
if workflow_state == "deleted" && deleted_at
# only restore tags deleted (approximately) when the module was deleted
# (tags are currently set to exactly deleted_at but older deleted modules used the current time on each tag)
tags_to_restore = content_tags.where(workflow_state: "deleted")
.where("updated_at BETWEEN ? AND ?", deleted_at - 5.seconds, deleted_at + 5.seconds)
.preload(:content)
tags_to_restore.each do |tag|
# don't restore the item if the asset has been deleted too
next if tag.asset_workflow_state == "deleted"
# although the module will be restored unpublished, the items should match the asset's published state
tag.workflow_state = if tag.content && tag.sync_workflow_state_to_asset?
tag.asset_workflow_state
else
"unpublished"
end
# deal with the possibility that the asset has been renamed after the module was deleted
tag.title = Context.asset_name(tag.content) if tag.content && tag.sync_title_to_asset_title?
tag.save
end
end
self.workflow_state = "unpublished"
save
end
def update_downstreams(_original_position = nil)
# TODO: remove the unused argument; it's not sent anymore, but it was sent through a delayed job
# so compatibility was maintained when sender was updated to not send it
positions = ContextModule.module_positions(context).to_a.sort_by { |a| a[1] }
downstream_ids = positions.select { |a| a[1] > (position || 0) }.pluck(0)
downstreams = downstream_ids.empty? ? [] : context.context_modules.not_deleted.where(id: downstream_ids)
downstreams.each(&:save_without_touching_context)
end
workflow do
state :active do
event :unpublish, transitions_to: :unpublished
end
state :unpublished do
event :publish, transitions_to: :active
end
state :deleted
end
scope :active, -> { where(workflow_state: "active") }
scope :unpublished, -> { where(workflow_state: "unpublished") }
scope :not_deleted, -> { where("context_modules.workflow_state<>'deleted'") }
scope :starting_with_name, lambda { |name|
where("name ILIKE ?", "#{name}%")
}
scope :visible_to_students_in_course_with_da, lambda { |user_id, course_id|
joins(:module_student_visibilities)
.where(module_student_visibilities: { user_id:, course_id: })
}
alias_method :published?, :active?
def publish_items!(progress: nil)
content_tags.each do |content_tag|
break if progress&.reload&.failed?
content_tag.trigger_publish!
end
end
def unpublish_items!(progress: nil)
content_tags.each do |content_tag|
break if progress&.reload&.failed?
content_tag.trigger_unpublish!
end
end
set_policy do
#################### Begin legacy permission block #########################
given do |user, session|
user && !context.root_account.feature_enabled?(:granular_permissions_manage_course_content) &&
context.grants_right?(user, session, :manage_content)
end
can :read and can :create and can :update and can :delete and can :read_as_admin
##################### End legacy permission block ##########################
given do |user, session|
user && context.root_account.feature_enabled?(:granular_permissions_manage_course_content) &&
context.grants_right?(user, session, :manage_course_content_add)
end
can :read and can :read_as_admin and can :create
given do |user, session|
user && context.root_account.feature_enabled?(:granular_permissions_manage_course_content) &&
context.grants_right?(user, session, :manage_course_content_edit)
end
can :read and can :read_as_admin and can :update
given do |user, session|
user && context.root_account.feature_enabled?(:granular_permissions_manage_course_content) &&
context.grants_right?(user, session, :manage_course_content_delete)
end
can :read and can :read_as_admin and can :delete
given { |user, session| context.grants_right?(user, session, :read_as_admin) }
can :read and can :read_as_admin
given { |user, session| context.grants_right?(user, session, :view_unpublished_items) }
can :view_unpublished_items
given { |user, session| context.grants_right?(user, session, :read) && active? }
can :read
end
def low_level_locked_for?(user, opts = {})
return false if grants_right?(user, :read_as_admin)
available = available_for?(user, opts)
return { object: self, module: self } unless available
return { object: self, module: self, unlock_at: } if to_be_unlocked
false
end
def available_for?(user, opts = {})
return true if active? && !to_be_unlocked && prerequisites.blank? &&
(completion_requirements.empty? || !require_sequential_progress)
if grants_right?(user, :read_as_admin)
return true
elsif !active?
return false
elsif context.user_has_been_observer?(user) # rubocop:disable Lint/DuplicateBranch
return true
end
progression = if opts[:user_context_module_progressions]
opts[:user_context_module_progressions][id]
end
progression ||= find_or_create_progression(user)
# if the progression is locked, then position in the progression doesn't
# matter. we're not available.
tag = opts[:tag]
avail = progression && !progression.locked? && !locked_for_tag?(tag, progression)
if !avail && opts[:deep_check_if_needed]
progression = evaluate_for(progression)
avail = progression && !progression.locked? && !locked_for_tag?(tag, progression)
end
avail
end
def locked_for_tag?(tag, progression)
locked = tag&.context_module_id == id && require_sequential_progress
locked && (progression.current_position&.< tag.position)
end
def self.module_names(context)
Rails.cache.fetch(["module_names", context].cache_key) do
gather_module_names(context.context_modules.not_deleted)
end
end
def self.active_module_names(context)
Rails.cache.fetch(["active_module_names", context].cache_key) do
gather_module_names(context.context_modules.active)
end
end
def self.gather_module_names(scope)
scope.pluck(:id, :name).each_with_object({}) do |(id, name), names|
names[id] = name
end
end
def prerequisites
@prerequisites ||= gather_prerequisites(ContextModule.module_names(context))
end
def active_prerequisites
@active_prerequisites ||= gather_prerequisites(ContextModule.active_module_names(context))
end
def gather_prerequisites(module_names)
all_prereqs = read_attribute(:prerequisites)
return [] unless all_prereqs&.any?
all_prereqs.select { |pre| module_names.key?(pre[:id]) }.map { |pre| pre.merge(name: module_names[pre[:id]]) }
end
def prerequisites=(prereqs)
Rails.cache.delete(["module_names", context].cache_key) # ensure the module list is up to date
case prereqs
when Array
# validate format, skipping invalid ones
prereqs = prereqs.select do |pre|
pre.key?(:id) && pre.key?(:name) && pre[:type] == "context_module"
end
when String
res = []
module_names = ContextModule.module_names(context)
pres = prereqs.split(",")
pre_regex = /module_(\d+)/
pres.each do |pre|
next unless (match = pre_regex.match(pre))
id = match[1].to_i
if module_names.key?(id)
res << { id:, type: "context_module", name: module_names[id] }
end
end
prereqs = res
else
prereqs = nil
end
@prerequisites = nil
@active_prerequisites = nil
write_attribute(:prerequisites, prereqs)
end
def completion_requirements=(val)
if val.is_a?(Array)
hash = {}
val.each { |i| hash[i[:id]] = i }
val = hash
end
if val.is_a?(Hash)
# requirements hash can contain invalid data (e.g. {"none"=>"none"}) from the ui,
# filter & manipulate the data to something more reasonable
val = val.map do |id, req|
if req.is_a?(Hash)
req[:id] = id unless req[:id]
req
end
end
val = validate_completion_requirements(val.compact)
else
val = nil
end
write_attribute(:completion_requirements, val)
end
def validate_completion_requirements(requirements)
requirements = requirements.map do |req|
new_req = {
id: req[:id].to_i,
type: req[:type],
}
new_req[:min_score] = req[:min_score].to_f if req[:type] == "min_score" && req[:min_score]
new_req
end
tags = content_tags.not_deleted.index_by(&:id)
validated_reqs = requirements.select do |req|
if req[:id] && (tag = tags[req[:id]])
if %w[must_view must_mark_done must_contribute].include?(req[:type])
true
elsif %w[must_submit min_score].include?(req[:type])
true if tag.scoreable?
end
end
end
unless new_record?
old_requirements = completion_requirements || []
validated_reqs.each do |req|
next unless req[:type] == "must_contribute" && !old_requirements.detect { |r| r[:id] == req[:id] && r[:type] == req[:type] } # new requirement
tag = tags[req[:id]]
if tag.content_type == "DiscussionTopic"
@discussion_topics_to_recalculate ||= []
@discussion_topics_to_recalculate << tag.content
end
end
end
validated_reqs
end
def completion_requirements_visible_to(user, opts = {})
valid_ids = content_tags_visible_to(user, opts).map(&:id)
completion_requirements.select { |cr| valid_ids.include? cr[:id] }
end
def content_tags_visible_to(user, opts = {})
@content_tags_visible_to ||= {}
@content_tags_visible_to[user.try(:id)] ||= begin
is_teacher = opts[:is_teacher] != false && grants_right?(user, :read_as_admin)
tags = is_teacher ? cached_not_deleted_tags : cached_active_tags
if !is_teacher && user
opts[:is_teacher] = false
tags = filter_tags_for_da(tags, user, opts)
end
# always return an array now because filter_tags_for_da *might* return one
tags.to_a
end
end
def visibility_for_user(user, session = nil)
opts = {}
opts[:can_read] = context.grants_right?(user, session, :read)
if opts[:can_read]
opts[:can_read_as_admin] = context.grants_right?(user, session, :read_as_admin)
end
opts
end
def filter_tags_for_da(tags, user, opts = {})
filter = proc do |inner_tags, user_ids|
visible_item_ids = {}
inner_tags.select do |tag|
item_type =
case tag.content_type
when "Assignment"
:assignment
when "DiscussionTopic"
:discussion
when "WikiPage"
:page
when *Quizzes::Quiz.class_names
:quiz
end
if item_type
visible_item_ids[item_type] ||= context.visible_item_ids_for_users(item_type, user_ids) # don't load the visibilities if there are no items of that type
visible_item_ids[item_type].include?(tag.content_id)
else
true
end
end
end
shard.activate do
DifferentiableAssignment.filter(tags, user, context, opts) do |ts, user_ids|
filter.call(ts, user_ids, context_id, opts)
end
end
end
def reload
@prerequisites = nil
@active_prerequisites = nil
clear_cached_lookups
super
end
def clear_cached_lookups
@cached_active_tags = nil
@cached_not_deleted_tags = nil
@content_tags_visible_to = nil
end
def cached_active_tags
@cached_active_tags ||= if content_tags.loaded?
# don't reload the preloaded content
content_tags.select(&:active?)
else
content_tags.active.to_a
end
end
def cached_not_deleted_tags
@cached_not_deleted_tags ||= if content_tags.loaded?
# don't reload the preloaded content
content_tags.reject(&:deleted?)
else
content_tags.not_deleted.to_a
end
end
def add_item(params, added_item = nil, opts = {})
params[:type] = params[:type].underscore if params[:type]
top_position = (content_tags.not_deleted.maximum(:position) || 0) + 1
position = opts[:position] || top_position
position = [position, params[:position].to_i].max if params[:position]
if content_tags.not_deleted.where(position:).count != 0
position = top_position
end
case params[:type]
when "wiki_page", "page"
item = opts[:wiki_page] || context.wiki_pages.where(id: params[:id]).first
when "attachment", "file"
item = opts[:attachment] || context.attachments.not_deleted.find_by(id: params[:id])
when "assignment"
item = opts[:assignment] || context.assignments.active.where(id: params[:id]).first
item = item.submittable_object if item.respond_to?(:submittable_object) && item.submittable_object
when "discussion_topic", "discussion"
item = opts[:discussion_topic] || context.discussion_topics.active.where(id: params[:id]).first
when "quiz"
item = opts[:quiz] || context.quizzes.active.where(id: params[:id]).first
end
workflow_state = ContentTag.asset_workflow_state(item) if item
workflow_state ||= "active"
case params[:type]
when "external_url"
title = params[:title]
added_item ||= content_tags.build(context:)
added_item.attributes = {
url: params[:url],
new_tab: params[:new_tab],
tag_type: "context_module",
title:,
indent: params[:indent],
position:
}
added_item.content_id = 0
added_item.content_type = "ExternalUrl"
added_item.context_module_id = id
added_item.indent = params[:indent] || 0
added_item.workflow_state = "unpublished" if added_item.new_record?
when "context_external_tool", "external_tool", "lti/message_handler"
title = params[:title]
added_item ||= content_tags.build(context:)
content = if params[:type] == "lti/message_handler"
Lti::MessageHandler.for_context(context).where(id: params[:id]).first
else
ContextExternalTool.find_external_tool(params[:url], context, params[:id].to_i) || ContextExternalTool.new.tap { |tool| tool.id = 0 }
end
added_item.attributes = {
content:,
url: params[:url],
new_tab: params[:new_tab],
tag_type: "context_module",
title:,
indent: params[:indent],
position:
}
added_item.context_module_id = id
added_item.indent = params[:indent] || 0
added_item.workflow_state = "unpublished" if added_item.new_record?
added_item.link_settings = params[:link_settings]
if content.is_a?(ContextExternalTool) && content.use_1_3? && content.id != 0
# This method is called both to create a module item and to update one
# (e.g. in a blueprint course sync.)
#
# For new module items (or old module items that don't have a resource
# link), we create a new ResourceLink if one cannot be found for the
# lookup_uuid, or if lookup_uuid is not given.
added_item.associated_asset ||=
Lti::ResourceLink.find_or_initialize_for_context_and_lookup_uuid(
context:,
lookup_uuid: params[:lti_resource_link_lookup_uuid].presence,
custom: Lti::DeepLinkingUtil.validate_custom_params(params[:custom_params]),
context_external_tool: content,
url: params[:url]
)
end
when "context_module_sub_header", "sub_header"
title = params[:title]
added_item ||= content_tags.build(context:)
added_item.attributes = {
tag_type: "context_module",
title:,
indent: params[:indent],
position:
}
added_item.content_id = 0
added_item.content_type = "ContextModuleSubHeader"
added_item.context_module_id = id
added_item.indent = params[:indent] || 0
added_item.workflow_state = "unpublished" if added_item.new_record?
else
return nil unless item
title = params[:title] || (item.title rescue item.name)
added_item ||= content_tags.build(context:)
added_item.attributes = {
content: item,
tag_type: "context_module",
title:,
indent: params[:indent],
position:
}
added_item.context_module_id = id
added_item.indent = params[:indent] || 0
added_item.workflow_state = workflow_state if added_item.new_record?
end
added_item.save
added_item
end
# specify a 1-based position to insert the items at; leave nil to append to the end of the module
# ignores current module item positions in favor of an objective position
def insert_items(items, start_pos = nil)
tags = content_tags.not_deleted.select(:id, :position, :content_type, :content_id).to_a
if start_pos
start_pos = 1 if start_pos < 1
next_pos = start_pos
else
next_pos = (content_tags.maximum(:position) || 0) + 1
end
new_tags = []
items.each do |item|
next unless item.is_a?(ActiveRecord::Base)
next unless %w[Attachment Assignment WikiPage Quizzes::Quiz DiscussionTopic ContextExternalTool].include?(item.class_name)
item = item.submittable_object if item.is_a?(Assignment) && item.submittable_object
next if tags.any? { |tag| tag.content_type == item.class_name && tag.content_id == item.id }
state = (item.respond_to?(:published?) && !item.published?) ? "unpublished" : "active"
new_tags << content_tags.create!(context:,
title: Context.asset_name(item),
content: item,
tag_type: "context_module",
indent: 0,
position: next_pos,
workflow_state: state)
next_pos += 1
end
return unless start_pos
tag_ids_to_move = {}
tags_before = (start_pos < 2) ? [] : tags[0..start_pos - 2]
tags_after = (start_pos > tags.length) ? [] : tags[start_pos - 1..]
(tags_before + new_tags + tags_after).each_with_index do |item, index|
index_change = index + 1 - item.position
if index_change != 0
tag_ids_to_move[index_change] ||= []
tag_ids_to_move[index_change] << item.id
end
end
tag_ids_to_move.each do |position_change, ids|
content_tags.where(id: ids).update_all(sanitize_sql(["position = position + ?", position_change]))
end
end
def update_for(user, action, tag, points = nil)
return nil unless context.grants_right?(user, :participate_as_student)
return nil unless (progression = evaluate_for(user))
return nil if progression.locked?
progression.update_requirement_met!(action, tag, points)
progression
end
def completion_requirement_for(action, tag)
completion_requirements.to_a.find do |requirement|
next false unless requirement[:id] == tag.local_id
case requirement[:type]
when "must_view"
action == :read || action == :contributed
when "must_mark_done"
action == :done
when "must_contribute"
action == :contributed
when "must_submit", "min_score"
action == :scored || # rubocop:disable Style/MultipleComparison
action == :submitted # to mark progress in the incomplete_requirements (moves from 'unlocked' to 'started')
else
false
end
end
end
def self.requirement_description(req)
case req[:type]
when "must_view"
t("requirements.must_view", "must view the page")
when "must_mark_done"
t("must mark as done")
when "must_contribute"
t("requirements.must_contribute", "must contribute to the page")
when "must_submit"
t("requirements.must_submit", "must submit the assignment")
when "min_score"
t("requirements.min_score", "must score at least a %{score}", score: req[:min_score])
else
nil
end
end
def confirm_valid_requirements(do_save = false)
return if @already_confirmed_valid_requirements
@already_confirmed_valid_requirements = true
# the write accessor validates for us
self.completion_requirements = completion_requirements || []
save if do_save && completion_requirements_changed?
completion_requirements
end
def find_or_create_progressions(users)
users = Array(users)
users_hash = {}
users.each { |u| users_hash[u.id] = u }
progressions = context_module_progressions.where(user_id: users)
progressions_hash = {}
progressions.each { |p| progressions_hash[p.user_id] = p }
newbies = users.reject { |u| progressions_hash[u.id] }
progressions += newbies.map { |u| find_or_create_progression(u) }
progressions.each { |p| p.user = users_hash[p.user_id] }
progressions.uniq
end
def find_or_create_progression(user)
return nil unless user
shard.activate do
GuardRail.activate(:primary) do
if context.enrollments.except(:preload).where(user_id: user).exists?
ContextModuleProgression.create_and_ignore_on_duplicate(user:, context_module: self)
end
end
end
end
def evaluate_for(user_or_progression)
if user_or_progression.is_a?(ContextModuleProgression)
progression, user = [user_or_progression, user_or_progression.user]
elsif user_or_progression
progression, user = [find_or_create_progression(user_or_progression), user_or_progression]
end
return nil unless progression && user
progression.context_module = self if progression.context_module_id == id
progression.user = user if progression.user_id == user.id
progression.evaluate!
end
def to_be_unlocked
unlock_at && unlock_at > Time.now
end
def migration_position
@migration_position_counter ||= 0
@migration_position_counter += 1
end
attr_accessor :item_migration_position
VALID_COMPLETION_EVENTS = [:publish_final_grade].freeze
def completion_events
(read_attribute(:completion_events) || "").split(",").map(&:to_sym)
end
def completion_events=(value)
unless value
write_attribute(:completion_events, nil)
return
end
write_attribute(:completion_events, (value.map(&:to_sym) & VALID_COMPLETION_EVENTS).join(","))
end
VALID_COMPLETION_EVENTS.each do |event|
class_eval <<~RUBY, __FILE__, __LINE__ + 1
def #{event}=(value)
if Canvas::Plugin.value_to_boolean(value)
self.completion_events |= [:#{event}]
else
self.completion_events -= [:#{event}]
end
end
def #{event}?
completion_events.include?(:#{event})
end
RUBY
end
def completion_event_callbacks
callbacks = []
if publish_final_grade? && (plugin = Canvas::Plugin.find("grade_export")) && plugin.enabled?
callbacks << ->(user) { context.publish_final_grades(user, user.id) }
end
callbacks
end
def requirement_type
(completion_requirements.present? && requirement_count == 1) ? "one" : "all"
end
def all_assignment_overrides
assignment_overrides
end
def update_assignment_submissions(module_assignments_quizzes = current_assignments_and_quizzes)
if Account.site_admin.feature_enabled?(:differentiated_modules)
SubmissionLifecycleManager.recompute_course(context, assignments: module_assignments_quizzes, update_grades: true)
end
end
def current_assignments_and_quizzes
return unless Account.site_admin.feature_enabled?(:differentiated_modules)
module_assignments = Assignment.active.where(id: content_tags.active.where(content_type: "Assignment").select(:content_id)).pluck(:id)
# TODO: Include quizzes in the slm call.
# Need to account for quiz context module overrides in EDD first
# module_quizzes = Assignment.active.where(id: Quizzes::Quiz.active.where(id: content_tags.active.where(content_type: "Quizzes::Quiz").select(:content_id)).select(:assignment_id)).pluck(:id)
assignments_quizzes = module_assignments # + module_quizzes
Assignment.where(id: assignments_quizzes)
end
end