canvas-lms/app/models/discussion_topic.rb

1852 lines
69 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/>.
#
require "atom"
class DiscussionTopic < ActiveRecord::Base
include Workflow
include SendToStream
include HasContentTags
include CopyAuthorizedLinks
include TextHelper
include HtmlTextHelper
include ContextModuleItem
include SearchTermHelper
include Submittable
include Plannable
include MasterCourses::Restrictor
include DuplicatingObjects
include LockedFor
restrict_columns :content, [:title, :message]
restrict_columns :settings, %i[require_initial_post
discussion_type
assignment_id
pinned
locked
allow_rating
only_graders_can_rate
sort_by_rating
group_category_id]
restrict_columns :state, [:workflow_state]
restrict_columns :availability_dates, [:delayed_post_at, :lock_at]
restrict_assignment_columns
attr_writer :can_unpublish, :preloaded_subentry_count, :sections_changed
attr_accessor :user_has_posted, :saved_by, :total_root_discussion_entries
module DiscussionTypes
SIDE_COMMENT = "side_comment"
THREADED = "threaded"
FLAT = "flat"
TYPES = DiscussionTypes.constants.map { |c| DiscussionTypes.const_get(c) }
end
module Errors
class LockBeforeDueDate < StandardError; end
end
attr_readonly :context_id, :context_type, :user_id, :anonymous_state, :is_anonymous_author
has_many :discussion_entries, -> { order(:created_at) }, dependent: :destroy, inverse_of: :discussion_topic
has_many :discussion_entry_drafts, dependent: :destroy, inverse_of: :discussion_topic
has_many :rated_discussion_entries,
lambda {
order(
Arel.sql("COALESCE(parent_id, 0)"), Arel.sql("COALESCE(rating_sum, 0) DESC"), :created_at
)
},
class_name: "DiscussionEntry"
has_many :root_discussion_entries, -> { preload(:user).where("discussion_entries.parent_id IS NULL AND discussion_entries.workflow_state<>'deleted'") }, class_name: "DiscussionEntry"
has_one :external_feed_entry, as: :asset
belongs_to :root_account, class_name: "Account"
belongs_to :external_feed
belongs_to :context, polymorphic: [:course, :group]
belongs_to :attachment
belongs_to :editor, class_name: "User"
belongs_to :root_topic, class_name: "DiscussionTopic"
belongs_to :group_category
has_many :sub_assignments, through: :assignment
has_many :child_topics, class_name: "DiscussionTopic", foreign_key: :root_topic_id, dependent: :destroy
has_many :discussion_topic_participants, dependent: :destroy
has_many :discussion_entry_participants, through: :discussion_entries
has_many :discussion_topic_section_visibilities,
lambda {
where("discussion_topic_section_visibilities.workflow_state<>'deleted'")
},
inverse_of: :discussion_topic,
dependent: :destroy
has_many :course_sections, through: :discussion_topic_section_visibilities, dependent: :destroy
belongs_to :user
has_one :master_content_tag, class_name: "MasterCourses::MasterContentTag", inverse_of: :discussion_topic
has_many :assignment_overrides, dependent: :destroy, inverse_of: :discussion_topic
has_many :assignment_override_students, dependent: :destroy
validates_associated :discussion_topic_section_visibilities
validates :context_id, :context_type, presence: true
validates :discussion_type, inclusion: { in: DiscussionTypes::TYPES }
validates :message, length: { maximum: maximum_long_text_length, allow_blank: true }
validates :title, length: { maximum: maximum_string_length, allow_nil: true }
# For our users, when setting checkpoints, the value must be between 1 and 10.
# But we also allow 0 when there are no checkpoints.
validates :reply_to_entry_required_count, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }
validates :reply_to_entry_required_count, numericality: { greater_than: 0 }, if: -> { reply_to_entry_checkpoint.present? }
validate :validate_draft_state_change, if: :workflow_state_changed?
validate :section_specific_topics_must_have_sections
validate :only_course_topics_can_be_section_specific
validate :assignments_cannot_be_section_specific
validate :course_group_discussion_cannot_be_section_specific
sanitize_field :message, CanvasSanitize::SANITIZE
copy_authorized_links(:message) { [context, nil] }
acts_as_list scope: { context: self, pinned: true }
before_create :initialize_last_reply_at
before_create :set_root_account_id
before_save :default_values
before_save :set_schedule_delayed_transitions
after_save :update_assignment
after_save :update_subtopics
after_save :touch_context
after_save :schedule_delayed_transitions
after_save :update_materialized_view_if_changed
after_save :recalculate_progressions_if_sections_changed
after_save :sync_attachment_with_publish_state
after_update :clear_non_applicable_stream_items
after_create :create_participant
after_create :create_materialized_view
def section_specific_topics_must_have_sections
if !deleted? && is_section_specific && discussion_topic_section_visibilities.none?(&:active?)
errors.add(:is_section_specific, t("Section specific topics must have sections"))
else
true
end
end
def only_course_topics_can_be_section_specific
if is_section_specific && !(context.is_a? Course)
errors.add(:is_section_specific, t("Only course announcements and discussions can be section-specific"))
else
true
end
end
def assignments_cannot_be_section_specific
if is_section_specific && assignment
errors.add(:is_section_specific, t("Discussion assignments cannot be section-specific"))
else
true
end
end
def course_group_discussion_cannot_be_section_specific
if is_section_specific && has_group_category?
errors.add(:is_section_specific, t("Discussions with groups cannot be section-specific"))
else
true
end
end
def sections_for(user)
return unless is_section_specific?
unlocked_teacher = context.enrollments.active.instructor
.where(limit_privileges_to_course_section: false, user:)
if unlocked_teacher.count > 0
CourseSection.where(id: DiscussionTopicSectionVisibility.active
.where(discussion_topic_id: id)
.select("discussion_topic_section_visibilities.course_section_id"))
else
CourseSection.where(id: DiscussionTopicSectionVisibility.active.where(discussion_topic_id: id)
.where(Enrollment.active_or_pending
.where(user_id: user)
.where("enrollments.course_section_id = discussion_topic_section_visibilities.course_section_id")
.arel.exists)
.select("discussion_topic_section_visibilities.course_section_id"))
end
end
def address_book_context_for(user)
if is_section_specific?
sections_for(user)
else
context
end
end
def threaded=(v)
self.discussion_type = Canvas::Plugin.value_to_boolean(v) ? DiscussionTypes::THREADED : DiscussionTypes::SIDE_COMMENT
end
def threaded?
discussion_type == DiscussionTypes::THREADED || context.feature_enabled?("react_discussions_post")
end
alias_method :threaded, :threaded?
def discussion_type
read_attribute(:discussion_type) || DiscussionTypes::SIDE_COMMENT
end
def validate_draft_state_change
old_draft_state, new_draft_state = changes["workflow_state"]
return if old_draft_state == new_draft_state
if new_draft_state == "unpublished" && !can_unpublish?
errors.add :workflow_state, I18n.t("#discussion_topics.error_draft_state_with_posts",
"This topic cannot be set to draft state because it contains posts.")
end
end
def default_values
self.context_code = "#{context_type.underscore}_#{context_id}"
if title.blank?
self.title = t("#discussion_topic.default_title", "No Title")
end
d_type = read_attribute(:discussion_type)
d_type ||= context.feature_enabled?("react_discussions_post") ? DiscussionTypes::THREADED : DiscussionTypes::SIDE_COMMENT
self.discussion_type = d_type
@content_changed = message_changed? || title_changed?
default_submission_values
if has_group_category?
self.subtopics_refreshed_at ||= Time.zone.parse("Jan 1 2000")
end
self.lock_at = CanvasTime.fancy_midnight(lock_at&.in_time_zone(context.time_zone))
%i[
could_be_locked
podcast_enabled
podcast_has_student_posts
require_initial_post
pinned
locked
allow_rating
only_graders_can_rate
sort_by_rating
].each { |attr| self[attr] = false if self[attr].nil? }
end
protected :default_values
def has_group_category?
!!group_category_id
end
def set_schedule_delayed_transitions
@delayed_post_at_changed = delayed_post_at_changed?
if delayed_post_at? && @delayed_post_at_changed
@should_schedule_delayed_post = true
self.workflow_state = "post_delayed" if [:migration, :after_migration].include?(saved_by) && delayed_post_at > Time.now
end
if lock_at && lock_at_changed?
@should_schedule_lock_at = true
self.locked = false if [:migration, :after_migration].include?(saved_by) && lock_at > Time.now
end
true
end
def update_materialized_view_if_changed
if saved_change_to_sort_by_rating?
update_materialized_view
end
end
def recalculate_progressions_if_sections_changed
# either changed sections or undid section specificness
return unless is_section_specific? ? @sections_changed : is_section_specific_before_last_save
self.class.connection.after_transaction_commit do
if context_module_tags.preload(:context_module).exists?
context_module_tags.map(&:context_module).uniq.each do |cm|
cm.invalidate_progressions
cm.touch
end
end
end
end
def schedule_delayed_transitions
return if saved_by == :migration
bp = true if @importing_migration&.migration_type == "master_course_import"
delay(run_at: delayed_post_at).update_based_on_date(for_blueprint: bp) if @should_schedule_delayed_post
delay(run_at: lock_at).update_based_on_date(for_blueprint: bp) if @should_schedule_lock_at
# need to clear these in case we do a save whilst saving (e.g.
# Announcement#respect_context_lock_rules), so as to avoid the dreaded
# double delayed job ಠ_ಠ
@should_schedule_delayed_post = nil
@should_schedule_lock_at = nil
end
def sync_attachment_with_publish_state
if (saved_change_to_workflow_state? || saved_change_to_locked? || saved_change_to_attachment_id?) &&
attachment && !attachment.hidden? # if it's already hidden leave alone
locked = !!(unpublished? || not_available_yet? || not_available_anymore?)
attachment.update_attribute(:locked, locked)
end
end
def update_subtopics
if !deleted? && (has_group_category? || !!group_category_id_before_last_save)
delay_if_production(singleton: "refresh_subtopics_#{global_id}").refresh_subtopics
end
end
def refresh_subtopics
sub_topics = []
category = group_category
if category && root_topic_id.blank? && !deleted?
category.groups.active.order(:id).each do |group|
sub_topics << ensure_child_topic_for(group)
end
end
shard.activate do
# delete any lingering child topics
DiscussionTopic.where(root_topic_id: self).where.not(id: sub_topics).update_all(workflow_state: "deleted")
end
end
def ensure_child_topic_for(group)
group.shard.activate do
DiscussionTopic.unique_constraint_retry do
topic = DiscussionTopic.where(context_id: group, context_type: "Group", root_topic_id: self).first
topic ||= group.discussion_topics.build { |dt| dt.root_topic = self }
topic.message = message
topic.title = CanvasTextHelper.truncate_text("#{title} - #{group.name}", { max_length: 250 }) # because of course people do this
topic.assignment_id = assignment_id
topic.attachment_id = attachment_id
topic.group_category_id = group_category_id
topic.user_id = user_id
topic.discussion_type = discussion_type
topic.workflow_state = workflow_state
topic.allow_rating = allow_rating
topic.only_graders_can_rate = only_graders_can_rate
topic.sort_by_rating = sort_by_rating
topic.save if topic.changed?
topic
end
end
end
def update_assignment
return if deleted?
if !assignment_id && @old_assignment_id
context_module_tags.find_each do |cmt|
cmt.confirm_valid_module_requirements
cmt.update_course_pace_module_items
end
end
if @old_assignment_id
Assignment.where(id: @old_assignment_id, context_id:, context_type:, submission_types: "discussion_topic").update_all(workflow_state: "deleted", updated_at: Time.now.utc)
old_assignment = Assignment.find(@old_assignment_id)
ContentTag.delete_for(old_assignment)
# prevent future syncs from recreating the deleted assignment
if is_child_content?
old_assignment.submission_types = "none"
own_tag = MasterCourses::ChildContentTag.where(content: self).take
own_tag&.child_subscription&.create_content_tag_for!(old_assignment, downstream_changes: ["workflow_state"])
end
elsif assignment && @saved_by != :assignment && !root_topic_id
deleted_assignment = assignment.deleted?
sync_assignment
assignment.workflow_state = "published" if is_announcement && deleted_assignment
assignment.description = message
if saved_change_to_group_category_id?
assignment.validate_assignment_overrides(force_override_destroy: true)
end
assignment.save
end
# make sure that if the topic has a new assignment (either by going from
# ungraded to graded, or from one assignment to another; we ignore the
# transition from graded to ungraded) we acknowledge that the users that
# have posted have contributed to the topic and that course paces are up
# to date
if assignment_id && saved_change_to_assignment_id?
recalculate_context_module_actions!
context_module_tags.find_each(&:update_course_pace_module_items)
end
end
protected :update_assignment
def recalculate_context_module_actions!
posters.each { |user| context_module_action(user, :contributed) }
end
def is_announcement
false
end
def homeroom_announcement?(_context)
false
end
def root_topic?
!root_topic_id && has_group_category?
end
# only the root level entries
def discussion_subentries
root_discussion_entries
end
# count of all active discussion_entries
def discussion_subentry_count
@preloaded_subentry_count || discussion_entries.active.count
end
def for_group_discussion?
has_group_category? && root_topic?
end
def plaintext_message=(val)
self.message = format_message(strip_tags(val)).first
end
def plaintext_message
truncate_html(message, max_length: 250)
end
def create_participant
discussion_topic_participants.create(user:, workflow_state: "read", unread_entry_count: 0, subscribed: !subscription_hold(user, nil)) if user
end
def update_materialized_view
# kick off building of the view
self.class.connection.after_transaction_commit do
DiscussionTopic::MaterializedView.for(self).update_materialized_view(xlog_location: self.class.current_xlog_location)
end
end
def group_category_deleted_with_entries?
group_category.try(:deleted_at?) && !can_group?
end
def get_potentially_conflicting_titles(title_base)
DiscussionTopic.active.where(context_type:, context_id:)
.starting_with_title(title_base).pluck("title").to_set
end
# This is a guess of what to copy over.
def duplicate_base_model(title, opts)
DiscussionTopic.new({
title:,
message:,
context_id:,
context_type:,
user_id: opts[:user] ? opts[:user].id : user_id,
type:,
workflow_state: "unpublished",
could_be_locked:,
context_code:,
podcast_enabled:,
require_initial_post:,
podcast_has_student_posts:,
discussion_type:,
delayed_post_at:,
lock_at:,
pinned:,
locked:,
group_category_id:,
allow_rating:,
only_graders_can_rate:,
sort_by_rating:,
todo_date:,
is_section_specific:,
anonymous_state:
})
end
# Presumes that self has no parents
# Does not duplicate the child topics; the hooks take care of that for us.
def duplicate(opts = {})
# Don't clone a new record
return self if new_record?
default_opts = {
duplicate_assignment: true,
copy_title: nil,
user: nil
}
opts_with_default = default_opts.merge(opts)
copy_title =
opts_with_default[:copy_title] || get_copy_title(self, t("Copy"), title)
result = duplicate_base_model(copy_title, opts_with_default)
# Start with a position guaranteed to not conflict with existing ones.
# Clients are encouraged to set the correct position later on and do
# an insert_at upon save.
if pinned
result.position = context.discussion_topics.active.where(pinned: true).maximum(:position) + 1
end
if assignment && opts_with_default[:duplicate_assignment]
result.assignment = assignment.duplicate({
duplicate_discussion_topic: false,
copy_title: result.title
})
end
result.discussion_topic_section_visibilities = []
if is_section_specific
original_visibilities = discussion_topic_section_visibilities.active
original_visibilities.each do |visibility|
new_visibility = DiscussionTopicSectionVisibility.new(
discussion_topic: result,
course_section: visibility.course_section
)
result.discussion_topic_section_visibilities << new_visibility
end
end
# For some reason, the relation doesn't take care of this for us. Don't understand why.
# Without this line, *two* discussion topic duplicates appear when a save is performed.
result.assignment&.discussion_topic = result
result
end
# If no join record exists, assume all discussion enrties are unread, and
# that a join record will be created the first time one is marked as read.
attr_accessor :current_user
def read_state(current_user = nil)
current_user ||= self.current_user
return "read" unless current_user # default for logged out user
uid = current_user.is_a?(User) ? current_user.id : current_user
ws = if discussion_topic_participants.loaded?
discussion_topic_participants.detect { |dtp| dtp.user_id == uid }&.workflow_state
else
discussion_topic_participants.where(user_id: uid).pick(:workflow_state)
end
ws || "unread"
end
def read?(current_user = nil)
read_state(current_user) == "read"
end
def unread?(current_user = nil)
!read?(current_user)
end
def change_read_state(new_state, current_user = nil)
current_user ||= self.current_user
return nil unless current_user
context_module_action(current_user, :read) if new_state == "read"
return true if new_state == read_state(current_user)
StreamItem.update_read_state_for_asset(self, new_state, current_user.id)
update_or_create_participant(current_user:, new_state:)
end
def change_all_read_state(new_state, current_user = nil, opts = {})
current_user ||= self.current_user
return unless current_user
update_fields = { workflow_state: new_state }
update_fields[:forced_read_state] = opts[:forced] if opts.key?(:forced)
transaction do
update_stream_item_state(current_user, new_state)
update_participants_read_state(current_user, new_state, update_fields)
end
end
def update_stream_item_state(current_user, new_state)
context_module_action(current_user, :read) if new_state == "read"
StreamItem.update_read_state_for_asset(self, new_state, current_user.id)
end
protected :update_stream_item_state
def update_participants_read_state(current_user, new_state, update_fields)
# if workflow_state is unread, and force_read_state is not provided then
# mark everything as unread but use the defaults, or allow other entries to
# be implicitly unread, but still update any existing records.
if new_state == "unread" && !update_fields.key?(:forced_read_state)
DiscussionEntryParticipant.where(discussion_entry_id: discussion_entries.select(:id), user: current_user)
.where.not(workflow_state: new_state)
.in_batches.update_all(update_fields)
else
DiscussionEntryParticipant.upsert_for_topic(self,
current_user,
new_state:,
forced: update_fields[:forced_read_state])
end
update_or_create_participant(current_user:,
new_state:,
new_count: (new_state == "unread") ? default_unread_count : 0)
end
protected :update_participants_read_state
def default_unread_count
discussion_entries.active.count
end
# Do not use the lock options unless you truly need
# the lock, for instance to update the count.
# Careless use has caused database transaction deadlocks
def unread_count(current_user = nil, lock: false, opts: {})
current_user ||= self.current_user
return 0 unless current_user # default for logged out users
environment = lock ? :primary : :secondary
GuardRail.activate(environment) do
topic_participant = if opts[:use_preload] && association(:discussion_topic_participants).loaded?
discussion_topic_participants.find { |dtp| dtp.user_id == current_user.id }
else
discussion_topic_participants.where(user_id: current_user).select(:unread_entry_count).lock(lock).take
end
topic_participant&.unread_entry_count || default_unread_count
end
end
# Cases where you CAN'T subscribe:
# - initial post is required and you haven't made one
# - it's an announcement
# - this is a root level graded group discussion and you aren't in any of the groups
# - this is group level discussion and you aren't in the group
def subscription_hold(user, session)
return nil unless user
if initial_post_required?(user, session)
:initial_post_required
elsif root_topic? && !child_topic_for(user)
:not_in_group_set
elsif context.is_a?(Group) && !context.has_member?(user)
:not_in_group
end
end
def subscribed?(current_user = nil, opts: {})
current_user ||= self.current_user
return false unless current_user # default for logged out user
if root_topic?
participant = DiscussionTopicParticipant.where(user_id: current_user.id,
discussion_topic_id: child_topics.pluck(:id)).take
end
participant ||= if opts[:use_preload] && association(:discussion_topic_participants).loaded?
discussion_topic_participants.find { |dtp| dtp.user_id == current_user.id }
else
discussion_topic_participants.where(user_id: current_user).take
end
if participant
if participant.subscribed.nil?
# if there is no explicit subscription, assume the author and posters
# are subscribed, everyone else is not subscribed
(current_user == user || participant.discussion_topic.posters.include?(current_user)) && !participant.discussion_topic.subscription_hold(current_user, nil)
else
participant.subscribed
end
else
current_user == user && !subscription_hold(current_user, nil)
end
end
def subscribe(current_user = nil)
change_subscribed_state(true, current_user)
end
def unsubscribe(current_user = nil)
change_subscribed_state(false, current_user)
end
def change_subscribed_state(new_state, current_user = nil)
current_user ||= self.current_user
return unless current_user
return true if subscribed?(current_user) == new_state
if root_topic?
return if change_child_topic_subscribed_state(new_state, current_user)
ctss = DiscussionTopicParticipant.new
ctss.errors.add(:discussion_topic_id, I18n.t("no child topic found"))
ctss
else
update_or_create_participant(current_user:, subscribed: new_state)
end
end
def child_topic_for(user)
return unless context.is_a?(Course)
group_ids = user.group_memberships.active.pluck(:group_id) &
context.groups.active.pluck(:id)
child_topics.active.where(context_id: group_ids, context_type: "Group").first
end
def change_child_topic_subscribed_state(new_state, current_user)
topic = child_topic_for(current_user)
topic&.update_or_create_participant(current_user:, subscribed: new_state)
end
protected :change_child_topic_subscribed_state
def update_or_create_participant(opts = {})
current_user = opts[:current_user] || self.current_user
return nil unless current_user
topic_participant = nil
GuardRail.activate(:primary) do
DiscussionTopic.uncached do
DiscussionTopic.unique_constraint_retry do
topic_participant = discussion_topic_participants.where(user_id: current_user).lock.first
topic_participant ||= discussion_topic_participants.build(user: current_user,
unread_entry_count: unread_count(current_user, lock: true),
workflow_state: "unread",
subscribed: current_user == user && !subscription_hold(current_user, nil))
topic_participant.workflow_state = opts[:new_state] if opts[:new_state]
topic_participant.unread_entry_count += opts[:offset] if opts[:offset] && opts[:offset] != 0
topic_participant.unread_entry_count = opts[:new_count] if opts[:new_count]
topic_participant.subscribed = opts[:subscribed] if opts.key?(:subscribed)
topic_participant.save
end
end
end
topic_participant
end
scope :not_ignored_by, lambda { |user, purpose|
where.not(Ignore.where(asset_type: "DiscussionTopic", user_id: user, purpose:)
.where("asset_id=discussion_topics.id").arel.exists)
}
scope :todo_date_between, lambda { |starting, ending|
where("(discussion_topics.type = 'Announcement' AND posted_at BETWEEN :start_at and :end_at)
OR todo_date BETWEEN :start_at and :end_at",
{ start_at: starting, end_at: ending })
}
scope :for_courses_and_groups, lambda { |course_ids, group_ids|
where("(discussion_topics.context_type = 'Course'
AND discussion_topics.context_id IN (?))
OR (discussion_topics.context_type = 'Group'
AND discussion_topics.context_id IN (?))",
course_ids,
group_ids)
}
class QueryError < StandardError
attr_accessor :status_code
def initialize(message = nil, status_code = nil)
super(message)
self.status_code = status_code
end
end
# Retrieves all the *course* (as oppposed to group) discussion topics that apply
# to the given sections. Group topics will not be returned. TODO: figure out
# a good way to deal with group topics here.
#
# Takes in an array of section objects, and it is required that they all belong
# to the same course. At least one section must be provided.
scope :in_sections, lambda { |course_sections|
course_ids = course_sections.pluck(:course_id).uniq
if course_ids.length != 1
raise QueryError, I18n.t("Searching for announcements in sections must span exactly one course")
end
course_id = course_ids.first
joins("LEFT OUTER JOIN #{DiscussionTopicSectionVisibility.quoted_table_name}
AS discussion_section_visibilities ON discussion_topics.is_section_specific = true AND
discussion_section_visibilities.discussion_topic_id = discussion_topics.id")
.where("discussion_topics.context_type = 'Course' AND
discussion_topics.context_id = :course_id",
{ course_id: })
.where("discussion_section_visibilities.id IS null OR
(discussion_section_visibilities.workflow_state = 'active' AND
discussion_section_visibilities.course_section_id IN (:course_sections))",
{ course_sections: course_sections.pluck(:id) }).distinct
}
scope :visible_to_student_sections, lambda { |student|
visibility_scope = DiscussionTopicSectionVisibility
.active
.where("discussion_topic_section_visibilities.discussion_topic_id = discussion_topics.id")
.where(
Enrollment.active_or_pending.where(user_id: student)
.where("enrollments.course_section_id = discussion_topic_section_visibilities.course_section_id")
.arel.exists
)
merge(
DiscussionTopic.where.not(discussion_topics: { context_type: "Course" })
.or(DiscussionTopic.where(discussion_topics: { is_section_specific: false }))
.or(DiscussionTopic.where(visibility_scope.arel.exists))
)
}
scope :recent, -> { where("discussion_topics.last_reply_at>?", 2.weeks.ago).order("discussion_topics.last_reply_at DESC") }
scope :only_discussion_topics, -> { where(type: nil) }
scope :for_subtopic_refreshing, -> { where("discussion_topics.subtopics_refreshed_at IS NOT NULL AND discussion_topics.subtopics_refreshed_at<discussion_topics.updated_at").order("discussion_topics.subtopics_refreshed_at") }
scope :active, -> { where("discussion_topics.workflow_state<>'deleted'") }
scope :for_context_codes, ->(codes) { where(context_code: codes) }
scope :before, ->(date) { where("discussion_topics.created_at<?", date) }
scope :by_position, -> { order("discussion_topics.position ASC, discussion_topics.created_at DESC, discussion_topics.id DESC") }
scope :by_position_legacy, -> { order("discussion_topics.position DESC, discussion_topics.created_at DESC, discussion_topics.id DESC") }
scope :by_last_reply_at, -> { order("discussion_topics.last_reply_at DESC, discussion_topics.created_at DESC, discussion_topics.id DESC") }
scope :by_posted_at, lambda {
order(Arel.sql(<<~SQL.squish))
COALESCE(discussion_topics.delayed_post_at, discussion_topics.posted_at, discussion_topics.created_at) DESC,
discussion_topics.created_at DESC,
discussion_topics.id DESC
SQL
}
scope :read_for, lambda { |user|
eager_load(:discussion_topic_participants)
.where("discussion_topic_participants.id IS NOT NULL
AND (discussion_topic_participants.user_id = :user
AND discussion_topic_participants.workflow_state = 'read')",
user:)
}
scope :unread_for, lambda { |user|
joins(sanitize_sql(["LEFT OUTER JOIN #{DiscussionTopicParticipant.quoted_table_name} ON
discussion_topic_participants.discussion_topic_id=discussion_topics.id AND
discussion_topic_participants.user_id=?",
user.id]))
.where("discussion_topic_participants IS NULL
OR discussion_topic_participants.workflow_state <> 'read'
OR discussion_topic_participants.unread_entry_count > 0")
}
scope :published, -> { where("discussion_topics.workflow_state = 'active'") }
# TODO: this scope is appearing in a few models now with identical code.
# Can this be extracted somewhere?
scope :starting_with_title, lambda { |title|
where("title ILIKE ?", "#{title}%")
}
alias_attribute :available_from, :delayed_post_at
alias_attribute :available_until, :lock_at
def unlock_at
Account.site_admin.feature_enabled?(:differentiated_modules) ? super : delayed_post_at
end
def unlock_at=(value)
Account.site_admin.feature_enabled?(:differentiated_modules) ? super : self.delayed_post_at = value
end
def should_lock_yet
# not assignment or vdd aware! only use this to check the topic's own field!
# you should be checking other lock statuses in addition to this one
lock_at && lock_at < Time.now.utc
end
alias_method :not_available_anymore?, :should_lock_yet
def should_not_post_yet
# not assignment or vdd aware! only use this to check the topic's own field!
# you should be checking other lock statuses in addition to this one
delayed_post_at && delayed_post_at > Time.now.utc
end
alias_method :not_available_yet?, :should_not_post_yet
# There may be delayed jobs that expect to call this to update the topic, so be sure to alias
# the old method name if you change it
# Also: if this method is scheduled by a blueprint sync, ensure it isn't counted as a manual downstream change
def update_based_on_date(for_blueprint: false)
skip_downstream_changes! if for_blueprint
transaction do
reload lock: true # would call lock!, except, oops, workflow overwrote it :P
lock if should_lock_yet
delayed_post unless should_not_post_yet
end
end
alias_method :try_posting_delayed, :update_based_on_date
alias_method :auto_update_workflow, :update_based_on_date
workflow do
state :active
state :unpublished
state :post_delayed do
event :delayed_post, transitions_to: :active do
self.last_reply_at = Time.now
self.posted_at = Time.now
end
# with draft state, this means published. without, unpublished. so we really do support both events
end
state :deleted
end
def active?
# using state instead of workflow_state so this works with new records
state == :active || (!is_announcement && state == :post_delayed)
end
def publish
# follows the logic of setting post_delayed in other places of this file
self.workflow_state = (delayed_post_at && delayed_post_at > Time.now) ? "post_delayed" : "active"
self.last_reply_at = Time.now
self.posted_at = Time.now
end
def publish!
publish
save!
end
def unpublish
self.workflow_state = "unpublished"
end
def unpublish!
unpublish
save!
end
def can_lock?
!(assignment.try(:due_at) && assignment.due_at > Time.now)
end
def comments_disabled?
!!(is_a?(Announcement) &&
context.is_a?(Course) &&
context.lock_all_announcements)
end
def lock(opts = {})
raise Errors::LockBeforeDueDate unless can_lock?
self.locked = true
save! unless opts[:without_save]
end
alias_method :lock!, :lock
def unlock(opts = {})
self.locked = false
self.workflow_state = "active" if workflow_state == "locked"
save! unless opts[:without_save]
end
alias_method :unlock!, :unlock
def published?
return false if workflow_state == "unpublished"
return false if workflow_state == "post_delayed" && is_announcement
true
end
def can_unpublish?(opts = {})
return @can_unpublish unless @can_unpublish.nil?
@can_unpublish = if assignment
!assignment.has_student_submissions?
else
student_ids = opts[:student_ids] || context.all_real_student_enrollments.select(:user_id)
if for_group_discussion?
!DiscussionEntry.active.joins(:discussion_topic).merge(child_topics).where(user_id: student_ids).exists?
else
!discussion_entries.active.where(user_id: student_ids).exists?
end
end
end
def self.create_graded_topic!(course:, title:, user: nil)
raise ActiveRecord::RecordInvalid if course.nil?
assignment = course.assignments.create!(submission_types: "discussion_topic", updating_user: user, title:)
assignment.discussion_topic
end
def self.preload_can_unpublish(context, topics, assmnt_ids_with_subs = nil)
return unless topics.any?
assmnt_ids_with_subs ||= Assignment.assignment_ids_with_submissions(topics.filter_map(&:assignment_id))
student_ids = context.all_real_student_enrollments.select(:user_id)
topic_ids_with_entries = DiscussionEntry.active.where(discussion_topic_id: topics)
.where(user_id: student_ids).distinct.pluck(:discussion_topic_id)
topic_ids_with_entries += DiscussionTopic.where.not(root_topic_id: nil)
.where(id: topic_ids_with_entries).distinct.pluck(:root_topic_id)
topics.each do |topic|
topic.can_unpublish = if topic.assignment_id
!assmnt_ids_with_subs.include?(topic.assignment_id)
else
!topic_ids_with_entries.include?(topic.id)
end
end
end
def self.preload_subentry_counts(topics)
counts_by_topic_id = DiscussionEntry
.active
.where(discussion_topic_id: topics.pluck(:id))
.group(:discussion_topic_id)
.count
topics.each { |topic| topic.preloaded_subentry_count = counts_by_topic_id.fetch(topic.id, 0) }
end
def can_group?(opts = {})
can_unpublish?(opts)
end
def should_send_to_stream
published? &&
!not_available_yet? &&
!cloned_item_id &&
!(root_topic_id && has_group_category?) &&
!in_unpublished_module? &&
!locked_by_module?
end
on_create_send_to_streams do
if should_send_to_stream
active_participants_with_visibility
end
end
on_update_send_to_streams do
check_state = is_announcement ? "post_delayed" : "unpublished"
became_active = workflow_state_before_last_save == check_state && workflow_state == "active"
if should_send_to_stream && (@content_changed || became_active)
active_participants_with_visibility
end
end
# This is manually called for module publishing
def send_items_to_stream
if should_send_to_stream
queue_create_stream_items
end
end
def in_unpublished_module?
return true if ContentTag.where(content_type: "DiscussionTopic", content_id: self, workflow_state: "unpublished").exists?
ContextModule.joins(:content_tags).where(content_tags: { content_type: "DiscussionTopic", content_id: self }, workflow_state: "unpublished").exists?
end
def locked_by_module?
return false unless context_module_tags.any?
ContentTag.where(content_type: "DiscussionTopic", content_id: self, workflow_state: "active").all? { |tag| tag.context_module.unlock_at&.future? }
end
def should_clear_all_stream_items?
(!published? && saved_change_to_attribute?(:workflow_state)) ||
(is_announcement && not_available_yet? && saved_change_to_attribute?(:delayed_post_at))
end
def clear_non_applicable_stream_items
return clear_stream_items if should_clear_all_stream_items?
section = is_section_specific? ? @sections_changed : is_section_specific_before_last_save
lock = locked_by_module?
if lock || section
delay_if_production.partially_clear_stream_items(locked_by_module: lock, section_specific: section)
end
end
def partially_clear_stream_items(locked_by_module: false, section_specific: false)
remaining_participants = participants if section_specific
user_ids = []
stream_item&.stream_item_instances&.shard(stream_item)&.find_each do |item|
if (locked_by_module && locked_by_module_item?(item.user)) ||
(section_specific && remaining_participants.none? { |p| p.id == item.user_id })
destroy_item_and_track(item, user_ids)
end
end
clear_stream_item_cache_for(user_ids)
end
def destroy_item_and_track(item, user_ids)
user_ids.push(item.user_id)
item.destroy
end
def clear_stream_item_cache_for(user_ids)
if stream_item && user_ids.any?
StreamItemCache.delay_if_production(priority: Delayed::LOW_PRIORITY)
.invalidate_all_recent_stream_items(
user_ids,
stream_item.context_type,
stream_item.context_id
)
end
end
def require_initial_post?
require_initial_post || root_topic&.require_initial_post
end
def user_ids_who_have_posted_and_admins
ids = discussion_entries.active.select(:user_id).pluck(:user_id)
ids = ids.uniq
ids += course.admin_enrollments.active.pluck(:user_id) if course.is_a?(Course)
ids
end
def user_can_see_posts?(user, session = nil, associated_user_ids = [])
return false unless user
!require_initial_post? || grants_right?(user, session, :read_as_admin) ||
([user.id] + associated_user_ids).intersect?(user_ids_who_have_posted_and_admins)
end
def locked_announcement?
is_a?(Announcement) && locked?
end
def reply_from(opts)
raise IncomingMail::Errors::ReplyToDeletedDiscussion if deleted?
raise IncomingMail::Errors::UnknownAddress if context.root_account.deleted?
user = opts[:user]
if opts[:html]
message = opts[:html].strip
else
message = opts[:text].strip
message = format_message(message).first
end
user = nil unless user && context.users.include?(user)
if !user
raise IncomingMail::Errors::InvalidParticipant
elsif !grants_right?(user, :read)
nil
else
shard.activate do
entry = discussion_entries.new(message:, user:)
if entry.grants_right?(user, :create) && !comments_disabled? && !locked_announcement?
entry.save!
entry
else
raise IncomingMail::Errors::ReplyToLockedTopic
end
end
end
end
alias_method :destroy_permanently!, :destroy
def destroy
ContentTag.delete_for(self)
self.workflow_state = "deleted"
self.deleted_at = Time.now.utc
discussion_topic_section_visibilities&.update_all(workflow_state: "deleted")
save
if for_assignment? && root_topic_id.blank? && !assignment.deleted?
assignment.skip_downstream_changes! if @skip_downstream_changes
assignment.destroy
end
child_topics.each(&:destroy)
end
def restore(from = nil)
unless restorable?
errors.add(:deleted_at, I18n.t("Cannot undelete a child topic when the root course topic is also deleted. Please undelete the root course topic instead."))
return false
end
if is_section_specific?
DiscussionTopicSectionVisibility.where(discussion_topic_id: id).to_a.uniq(&:course_section_id).each do |dtsv|
dtsv.workflow_state = "active"
dtsv.save
end
end
discussion_topic_section_visibilities.reload
self.workflow_state = can_unpublish? ? "unpublished" : "active"
save
if from != :assignment && for_assignment? && root_topic_id.blank?
assignment.restore(:discussion_topic)
end
child_topics.each(&:restore)
end
def restorable?
# Not restorable if the root topic context is a course and
# root topic is deleted.
!(root_topic&.context_type == "Course" && root_topic&.deleted?)
end
def unlink!(type)
@saved_by = type
self.assignment = nil
if discussion_entries.empty?
destroy
else
save
end
child_topics.each { |t| t.unlink!(:assignment) }
end
def self.per_page
10
end
def initialize_last_reply_at
unless [:migration, :after_migration].include?(saved_by)
self.posted_at ||= Time.now.utc
self.last_reply_at ||= Time.now.utc
end
end
set_policy do
# Users may have can :read, but should not have access to all the data
# because the topic is locked_for?(user)
given { |user| visible_for?(user) }
can :read
given { |user| grants_right?(user, :read) }
can :read_replies
given { |user| self.user && self.user == user && visible_for?(user) && !locked_for?(user, check_policies: true) && can_participate_in_course?(user) }
can :reply
given { |user| self.user && self.user == user && available_for?(user) && context.user_can_manage_own_discussion_posts?(user) && context.grants_right?(user, :participate_as_student) }
can :update
given { |user| self.user && self.user == user and discussion_entries.active.empty? && available_for?(user) && !root_topic_id && context.user_can_manage_own_discussion_posts?(user) && context.grants_right?(user, :participate_as_student) }
can :delete
given do |user, session|
!locked_for?(user, check_policies: true) &&
context.grants_right?(user, session, :post_to_forum) && visible_for?(user) && can_participate_in_course?(user)
end
can :reply
given { |user, session| user_can_create(user, session) }
can :create
given { |user, session| user_can_create(user, session) && user_can_duplicate(user, session) }
can :duplicate
given { |user, session| context.respond_to?(:allow_student_forum_attachments) && context.allow_student_forum_attachments && context.grants_any_right?(user, session, :create_forum, :post_to_forum) }
can :attach
given { course.student_reporting? }
can :student_reporting
given { |user, session| !root_topic_id && context.grants_all_rights?(user, session, :read_forum, :moderate_forum) && available_for?(user) }
can :update and can :read_as_admin and can :delete and can :create and can :read and can :attach
# Moderators can still modify content even in unavailable topics (*especially* unlocking them), but can't create new content
given { |user, session| !root_topic_id && context.grants_all_rights?(user, session, :read_forum, :moderate_forum) }
can :update and can :read_as_admin and can :delete and can :read and can :attach
given { |user, session| root_topic&.grants_right?(user, session, :read_as_admin) }
can :read_as_admin
given { |user, session| root_topic&.grants_right?(user, session, :delete) }
can :delete
given { |user, session| root_topic&.grants_right?(user, session, :read) }
can :read
given { |user, session| context.grants_all_rights?(user, session, :moderate_forum, :read_forum) }
can :moderate_forum
given do |user, session|
allow_rating && (!only_graders_can_rate ||
course.grants_right?(user, session, :manage_grades))
end
can :rate
end
def self.context_allows_user_to_create?(context, user, session)
new(context:).grants_right?(user, session, :create)
end
def context_allows_user_to_create?(user)
return true unless context.respond_to?(:allow_student_discussion_topics)
return true if context.grants_right?(user, :read_as_admin)
context.allow_student_discussion_topics
end
def user_can_create(user, session)
!is_announcement &&
context.grants_right?(user, session, :create_forum) &&
context_allows_user_to_create?(user)
end
def user_can_duplicate(user, session)
context.is_a?(Group) ||
course.user_is_instructor?(user) ||
context.grants_right?(user, session, :read_as_admin)
end
def discussion_topic_id
id
end
def discussion_topic
self
end
def to_atom(opts = {})
author_name = user.present? ? user.name : t("#discussion_topic.atom_no_author", "No Author")
prefix = [is_announcement ? t("#titles.announcement", "Announcement") : t("#titles.discussion", "Discussion")]
prefix << context.name if opts[:include_context]
Atom::Entry.new do |entry|
entry.title = [before_label(prefix.to_sentence), title].join(" ")
entry.authors << Atom::Person.new(name: author_name)
entry.updated = updated_at
entry.published = created_at
entry.id = "tag:#{HostUrl.default_host},#{created_at.strftime("%Y-%m-%d")}:/discussion_topics/#{feed_code}"
entry.links << Atom::Link.new(rel: "alternate",
href: "http://#{HostUrl.context_host(context)}/#{context_url_prefix}/discussion_topics/#{id}")
entry.content = Atom::Content::Html.new(message || "")
end
end
def context_prefix
context_url_prefix
end
def context_module_action(user, action, points = nil)
return root_topic.context_module_action(user, action, points) if root_topic
tags_to_update = context_module_tags.to_a
if for_assignment?
tags_to_update += assignment.context_module_tags
if context.grants_right?(user, :participate_as_student) && assignment.visible_to_user?(user) && [:contributed, :deleted].include?(action)
only_update = (action == :deleted) # if we're deleting an entry, don't make a submission if it wasn't there already
ensure_submission(user, only_update)
end
end
unless action == :deleted
tags_to_update.each { |tag| tag.context_module_action(user, action, points) }
end
end
def ensure_submission(user, only_update = false)
topic = (root_topic? && child_topic_for(user)) || self
submissions = []
all_entries_for_user = topic.discussion_entries.all_for_user(user)
if topic.root_account&.feature_enabled?(:discussion_checkpoints) && checkpoints?
reply_to_topic_submitted_at = topic.discussion_entries.top_level_for_user(user).minimum(:created_at)
if reply_to_topic_submitted_at.present?
reply_to_topic_submission = ensure_particular_submission(reply_to_topic_checkpoint, user, reply_to_topic_submitted_at, only_update:)
submissions << reply_to_topic_submission if reply_to_topic_submission.present?
end
reply_to_entries = topic.discussion_entries.non_top_level_for_user(user)
if reply_to_entries.any? && enough_replies_for_checkpoint?(reply_to_entries)
reply_to_entry_submitted_at = reply_to_entries.minimum(:created_at)
reply_to_entry_submission = ensure_particular_submission(reply_to_entry_checkpoint, user, reply_to_entry_submitted_at, only_update:)
submissions << reply_to_entry_submission if reply_to_entry_submission.present?
end
else
submitted_at = all_entries_for_user.minimum(:created_at)
submission = ensure_particular_submission(assignment, user, submitted_at, only_update:)
submissions << submission if submission.present?
end
return unless submissions.any?
attachment_ids = all_entries_for_user.where.not(attachment_id: nil).pluck(:attachment_id).sort.map(&:to_s).join(",")
submissions.each do |s|
s.attachment_ids = attachment_ids
s.save! if s.changed?
end
end
def ensure_particular_submission(assignment, user, submitted_at, only_update: false)
submission = Submission.active.where(assignment_id: assignment.id, user_id: user).first
unless only_update || (submission && submission.submission_type == "discussion_topic" && submission.workflow_state != "unsubmitted")
submission = assignment.submit_homework(user,
submission_type: "discussion_topic",
submitted_at:)
end
submission
end
def send_notification_for_context?
notification_context =
if context.is_a?(Group) && context.context.is_a?(Course)
context.context # we need to go deeper
else
context
end
notification_context.available?
end
def course_broadcast_data
context&.broadcast_data
end
has_a_broadcast_policy
set_broadcast_policy do |p|
p.dispatch :new_discussion_topic
p.to { users_with_permissions(active_participants_with_visibility) }
p.whenever do |record|
record.send_notification_for_context? and
((record.just_created && record.active?) || record.changed_state(:active, record.is_announcement ? :post_delayed : :unpublished))
end
p.data { course_broadcast_data }
end
def delay_posting=(val); end
def set_assignment=(val); end
# From the given list of users, return those that are permitted to see the section
# of the topic. If the topic is not section specific this just returns the
# original list.
def users_with_section_visibility(users)
return users unless is_section_specific? && context.is_a?(Course)
non_nil_users = users.compact
section_ids = DiscussionTopicSectionVisibility.active.where(discussion_topic_id: id)
.pluck(:course_section_id)
user_ids = non_nil_users.pluck(:id)
# Context is known to be a course here
users_in_sections = context.enrollments.active_or_pending
.where(user_id: user_ids, course_section_id: section_ids).pluck(:user_id).to_set
unlocked_teachers = context.enrollments.active_or_pending.instructor
.where(limit_privileges_to_course_section: false, user_id: user_ids)
.pluck(:user_id).to_set
permitted_user_ids = users_in_sections.union(unlocked_teachers)
non_nil_users.select { |u| permitted_user_ids.include?(u.id) }
end
def participants(include_observers = false)
participants = context.participants(include_observers:, by_date: true)
participants_in_section = users_with_section_visibility(participants.compact)
if user && !participants_in_section.to_set(&:id).include?(user.id)
participants_in_section += [user]
end
participants_in_section
end
def visible_to_admins_only?
(context.respond_to?(:available?) && !context.available?) ||
unpublished? || not_available_yet? || not_available_anymore?
end
def active_participants(include_observers = false)
if visible_to_admins_only? && context.respond_to?(:participating_admins)
context.participating_admins
else
participants(include_observers)
end
end
def active_participants_include_tas_and_teachers(include_observers = false)
participants = active_participants(include_observers)
if context.is_a?(Group) && !context.course.nil?
participants += context.course.participating_instructors_by_date
participants = participants.compact.uniq
end
participants
end
def users_with_permissions(users)
permission = is_announcement ? :read_announcements : :read_forum
course = self.course
unless course.is_a?(Course)
return users.select do |u|
is_announcement ? context.grants_right?(u, :read_announcements) : context.grants_right?(u, :read_forum)
end
end
readers = self.course.filter_users_by_permission(users, permission)
users_with_section_visibility(readers)
end
def course
@course ||= context.is_a?(Group) ? context.context : context
end
def group
@group ||= context.is_a?(Group) ? context : nil
end
def active_participants_with_visibility
return active_participants_include_tas_and_teachers unless for_assignment?
users_with_visibility = assignment.students_with_visibility.pluck(:id)
admin_ids = course.participating_admins.pluck(:id)
users_with_visibility.concat(admin_ids)
# observers will not be returned, which is okay for the functions current use cases (but potentially not others)
active_participants_include_tas_and_teachers.select { |p| users_with_visibility.include?(p.id) }
end
def participating_users(user_ids)
context.respond_to?(:participating_users) ? context.participating_users(user_ids) : User.find(user_ids)
end
def subscribers
# this duplicates some logic from #subscribed? so we don't have to call
# #posters for each legacy subscriber.
sub_ids = discussion_topic_participants.where(subscribed: true).pluck(:user_id)
legacy_sub_ids = discussion_topic_participants.where(subscribed: nil).pluck(:user_id)
poster_ids = posters.map(&:id)
legacy_sub_ids &= poster_ids
sub_ids += legacy_sub_ids
subscribed_users = participating_users(sub_ids).to_a
filter_message_users(subscribed_users)
end
def filter_message_users(users)
if for_assignment?
students_with_visibility = assignment.students_with_visibility.pluck(:id)
admin_ids = course.participating_admins.pluck(:id)
observer_ids = course.participating_observers.pluck(:id)
observed_students = ObserverEnrollment.observed_student_ids_by_observer_id(course, observer_ids)
users.select! do |user|
students_with_visibility.include?(user.id) || admin_ids.include?(user.id) ||
# an observer with no students or one with students who have visibility
(observed_students[user.id] && (observed_students[user.id] == [] || observed_students[user.id].intersect?(students_with_visibility)))
end
end
users
end
def posters
user_ids = discussion_entries.map(&:user_id).push(user_id).uniq
participating_users(user_ids)
end
def user_name
user&.name
end
def available_from_for(user)
if assignment
assignment.overridden_for(user).unlock_at
else
available_from
end
end
def available_for?(user, opts = {})
return false unless published?
return false if is_announcement && locked?
!locked_for?(user, opts)
end
# Public: Determine if the given user can view this discussion topic.
#
# user - The user attempting to view the topic (default: nil).
#
# Returns a boolean.
def visible_for?(user = nil)
RequestCache.cache("discussion_visible_for", self, is_announcement, user) do
# user is the topic's author
next true if user && user.id == user_id
next false unless context
next false unless is_announcement ? context.grants_right?(user, :read_announcements) : context.grants_right?(user, :read_forum)
# Don't have visibilites for any of the specific sections in a section specific topic
if context.is_a?(Course) && try(:is_section_specific)
section_visibilities = context.course_section_visibility(user)
next false if section_visibilities == :none
if section_visibilities != :all
course_specific_sections = course_sections.pluck(:id)
next false unless section_visibilities.intersect?(course_specific_sections)
end
end
# user is an admin in the context (teacher/ta/designer) OR
# user is an account admin with appropriate permission
next true if context.grants_any_right?(user, :manage, :read_course_content)
# assignment exists and isn't assigned to user (differentiated assignments)
if for_assignment? && !assignment.visible_to_user?(user)
next false
end
# topic is not published
if !published?
next false
elsif is_announcement && (unlock_at = available_from_for(user))
# unlock date exists and has passed
next unlock_at < Time.now.utc
# everything else
else
next true
end
end
end
def can_participate_in_course?(user)
if group&.deleted?
false
elsif course.is_a?(Course)
# this probably isn't a perfect way to determine this but I can't think of a better one
course.enrollments.for_user(user).active_by_date.exists? || course.grants_right?(user, :read_as_admin)
else
true
end
end
# Determine if the discussion topic is locked for a user. The topic is locked
# if the delayed_post_at is in the future or the assignment is locked.
# This does not determine the visibility of the topic to the user,
# only that they are unable to reply and unable to see the message.
# Generally you want to call :locked_for?(user, check_policies: true), which
# will call this method.
def low_level_locked_for?(user, opts = {})
return false if opts[:check_policies] && grants_right?(user, :read_as_admin)
RequestCache.cache(locked_request_cache_key(user)) do
locked = false
if delayed_post_at && delayed_post_at > Time.now
locked = { object: self, unlock_at: delayed_post_at }
elsif lock_at && lock_at < Time.now
locked = { object: self, lock_at:, can_view: true }
elsif !opts[:skip_assignment] && (l = assignment&.low_level_locked_for?(user, opts))
locked = l
elsif could_be_locked && (item = locked_by_module_item?(user, opts))
locked = { object: self, module: item.context_module }
elsif locked? # nothing more specific, it's just locked
locked = { object: self, can_view: true }
elsif (l = root_topic&.low_level_locked_for?(user, opts)) # rubocop:disable Lint/DuplicateBranch
locked = l
end
locked
end
end
def self.reject_context_module_locked_topics(topics, user)
progressions = ContextModuleProgression
.joins(context_module: :content_tags)
.where({
:user => user,
"content_tags.content_type" => "DiscussionTopic",
"content_tags.content_id" => topics,
})
.select("context_module_progressions.*")
.distinct_on("context_module_progressions.id")
.preload(:user)
progressions = progressions.index_by(&:context_module_id)
topics.reject do |topic|
topic.locked_by_module_item?(user, {
deep_check_if_needed: true,
user_context_module_progressions: progressions,
})
end
end
def entries_for_feed(user, podcast_feed = false)
return [] unless user_can_see_posts?(user)
return [] if locked_for?(user, check_policies: true)
entries = discussion_entries.active
if podcast_feed && !podcast_has_student_posts && context.is_a?(Course)
entries = entries.where(user_id: context.admins)
end
entries
end
def self.podcast_elements(messages, context)
attachment_ids = []
media_object_ids = []
messages_hash = {}
messages.each do |message|
txt = message.message || ""
attachment_matches = txt.scan(%r{/#{context.class.to_s.pluralize.underscore}/#{context.id}/files/(\d+)/download})
attachment_ids += (attachment_matches || []).pluck(0)
media_object_matches = txt.scan(/media_comment_([\w-]+)/) + txt.scan(/data-media-id="([\w-]+)"/)
media_object_ids += (media_object_matches || []).pluck(0).uniq
(attachment_ids + media_object_ids).each do |id|
messages_hash[id] ||= message
end
end
media_object_ids = media_object_ids.uniq.compact
attachment_ids = attachment_ids.uniq.compact
attachments = attachment_ids.empty? ? [] : context.attachments.active.find_all_by_id(attachment_ids)
attachments = attachments.select { |a| a.content_type&.match(/(video|audio)/) }
attachments.each do |attachment|
attachment.podcast_associated_asset = messages_hash[attachment.id.to_s]
end
media_object_ids -= attachments.filter_map(&:media_entry_id) # don't include media objects if the file is already included
media_objects = media_object_ids.empty? ? [] : MediaObject.where(media_id: media_object_ids).to_a
media_objects = media_objects.uniq(&:media_id)
media_objects = media_objects.map do |media_object|
if media_object.media_id == "maybe" || media_object.deleted? || (media_object.context_type != "User" && media_object.context != context)
media_object = nil
end
if media_object&.podcast_format_details
media_object.podcast_associated_asset = messages_hash[media_object.media_id]
end
media_object
end
to_podcast(attachments + media_objects.compact)
end
def self.to_podcast(elements)
require "rss/2.0"
elements.filter_map do |elem|
asset = elem.podcast_associated_asset
next unless asset
item = RSS::Rss::Channel::Item.new
item.title = before_label((asset.title rescue "")) + elem.name
link = nil
case asset
when DiscussionTopic
link = "http://#{HostUrl.context_host(asset.context)}/#{asset.context_url_prefix}/discussion_topics/#{asset.id}"
when DiscussionEntry
link = "http://#{HostUrl.context_host(asset.context)}/#{asset.context_url_prefix}/discussion_topics/#{asset.discussion_topic_id}#entry-#{asset.id}"
end
item.link = link
item.guid = RSS::Rss::Channel::Item::Guid.new
item.pubDate = elem.updated_at.utc
item.description = asset ? asset.message : elem.name
item.enclosure
case elem
when Attachment
item.guid.content = link + "/#{elem.uuid}"
url = "http://#{HostUrl.context_host(elem.context)}/#{elem.context_url_prefix}" \
"/files/#{elem.id}/download#{elem.extension}?verifier=#{elem.uuid}"
item.enclosure = RSS::Rss::Channel::Item::Enclosure.new(url, elem.size, elem.content_type)
when MediaObject
item.guid.content = link + "/#{elem.media_id}"
details = elem.podcast_format_details
content_type = "video/mpeg"
content_type = "audio/mpeg" if elem.media_type == "audio"
size = details[:size].to_i.kilobytes
ext = details[:extension] || details[:fileExt]
url = "http://#{HostUrl.context_host(elem.context)}/#{elem.context_url_prefix}" \
"/media_download.#{ext}?type=#{ext}&entryId=#{elem.media_id}&redirect=1"
item.enclosure = RSS::Rss::Channel::Item::Enclosure.new(url, size, content_type)
end
item
end
end
def initial_post_required?(user, session = nil)
if require_initial_post?
associated_user_ids = user.observer_enrollments.active.where(course_id: course).pluck(:associated_user_id).compact
return !user_can_see_posts?(user, session, associated_user_ids)
end
false
end
# returns the materialized view of the discussion as structure, participant_ids, and entry_ids
# the view is already converted to a json string, the other two arrays of ids are ruby arrays
# see the description of the format in the discussion topics api documentation.
#
# returns nil if the view is not currently available, and kicks off a
# background job to build the view. this typically only takes a couple seconds.
#
# if a new message is posted, it won't appear in this view until the job to
# update it completes. so this view is eventually consistent.
#
# if the topic itself is not yet created, it will return blank data. this is for situations
# where we're creating topics on the first write - until that first write, we need to return
# blank data on reads.
def materialized_view(opts = {})
if new_record?
["[]", [], [], []]
else
DiscussionTopic::MaterializedView.materialized_view_for(self, opts)
end
end
# synchronously create/update the materialized view
def create_materialized_view
DiscussionTopic::MaterializedView.for(self).update_materialized_view(synchronous: true, use_master: true)
end
def grading_standard_or_default
grading_standard_context = assignment || context
if grading_standard_context.present?
grading_standard_context.grading_standard_or_default
else
GradingStandard.default_instance
end
end
def set_root_account_id
self.root_account_id ||= context&.root_account_id
end
def anonymous?
!anonymous_state.nil?
end
def checkpoints?
sub_assignments.any?
end
def reply_to_topic_checkpoint
sub_assignments.find_by(sub_assignment_tag: CheckpointLabels::REPLY_TO_TOPIC)
end
def reply_to_entry_checkpoint
sub_assignments.find_by(sub_assignment_tag: CheckpointLabels::REPLY_TO_ENTRY)
end
def create_checkpoints(reply_to_topic_points:, reply_to_entry_points:, reply_to_entry_required_count: 1)
return false if checkpoints?
return false unless context.is_a?(Course)
return false unless assignment.present?
assignment.update!(has_sub_assignments: true)
assignment.sub_assignments.create!(context:, sub_assignment_tag: CheckpointLabels::REPLY_TO_TOPIC, points_possible: reply_to_topic_points)
assignment.sub_assignments.create!(context:, sub_assignment_tag: CheckpointLabels::REPLY_TO_ENTRY, points_possible: reply_to_entry_points)
self.reply_to_entry_required_count = reply_to_entry_required_count
save
end
private
def enough_replies_for_checkpoint?(reply_to_entries)
reply_to_entries.count >= reply_to_entry_required_count
end
end