canvas-lms/app/models/discussion_entry_participan...

208 lines
8.2 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 DiscussionEntryParticipant < ActiveRecord::Base
include Workflow
belongs_to :discussion_entry
belongs_to :user
before_create :set_root_account_id
validates :discussion_entry_id, :user_id, :workflow_state, presence: true
validate :prevent_creates
validates :report_type, inclusion: { in: %w[inappropriate offensive other],
message: -> { t("%{value} is not valid") } }
def prevent_creates
if new_record?
# e.g. DiscussionEntryParticipant.upsert_for_entries(entry, user, new_state: 'read')
errors.add(:base, "Regular creation is disabled on DiscussionEntryParticipant - use upsert_for_entries")
end
end
def self.read_entry_ids(entry_ids, user)
where(user_id: user, discussion_entry_id: entry_ids, workflow_state: "read")
.pluck(:discussion_entry_id)
end
def self.forced_read_state_entry_ids(entry_ids, user)
where(user_id: user, discussion_entry_id: entry_ids, forced_read_state: true)
.pluck(:discussion_entry_id)
end
def self.entry_ratings(entry_ids, user)
ratings = where(user_id: user, discussion_entry_id: entry_ids).where.not(rating: nil)
ratings.to_h { |x| [x.discussion_entry_id, x.rating] }
end
def self.not_null_column_object(column: nil, entry: nil, user: nil)
entry_participant = new(discussion_entry: entry, user:)
error_message = "Null value in column '#{column}' violates not-null constraint"
entry_participant.errors.add(column, error_message)
entry_participant
end
# creates or updates entry_participants returning ids if changed.
# small amount of validation to ensure we have the required attributes.
# build an insert statement.
# build an update statement.
# execute.
# takes an entry, user, and accepts batch, new_state, forced, and rating.
# runs for the entry when a batch is not provided.
# when run for a batch, still uses an entry, mainly to pull root_account_id.
# returns the ids of records changed or inserted, or a
# DiscussionEntryParticipant object with errors for backwards compatability to
# the previous method.
def self.upsert_for_entries(entry_or_topic, user, batch: nil, new_state: nil, forced: nil, rating: nil, report_type: nil)
return nil if entry_or_topic.nil? || user.nil?
batch ||= [entry_or_topic]
entry_or_topic.shard.activate do
raise(ArgumentError) if batch.count > 1_000
return not_null_column_object(column: :entry, entry: entry_or_topic, user:) unless entry_or_topic
return not_null_column_object(column: :user, entry: entry_or_topic, user:) unless user
insert_columns = %w[discussion_entry_id user_id root_account_id workflow_state read_at]
update_columns = []
update_values = []
# need to still set to false when passed as false, so check for not nil.
unless forced.nil?
update_columns << "forced_read_state"
update_values << connection.quote(forced)
end
# need to still set to 0 when passed as 0, so check for not nil.
unless rating.nil?
update_columns << "rating"
update_values << connection.quote(rating)
end
unless report_type.nil?
unless %w[inappropriate offensive other].include? report_type
raise(ArgumentError)
end
update_columns << "report_type"
update_values << connection.quote(report_type)
end
insert_columns += update_columns
# workflow_state is a non null column and is required for the insert side.
# The update side does not require workflow_state. Already exists in the
# non-null column
default_state = new_state || "unread"
insert_values = batch.map do |batch_entry|
row_values(batch_entry, user.id, entry_or_topic.root_account_id, default_state, update_values)
end
# takes ruby arrays of values into sql arrays ready for insert.
# [[entry_id, user_id, root_account_id, "'read'"],[...]] =>
# ["(entry_id,user_id,root_account_id,'read'),(...)"]
insert_rows = insert_values.map { |row| "(#{row.join(",")})" }
# new_state needs to be handled after the insert_values because we always
# have workflow_state in the insert, but should only be on the update side
# if the value is provided. We don't want to accidentally mark an entry as
# 'unread', but also don't want to include 'workflow_state' two times in the
# insert.
if new_state
update_columns << "workflow_state"
update_values << connection.quote(new_state)
read_at_datetime = (new_state&.to_s == "read") ? Time.now : nil
update_columns << "read_at"
update_values << connection.quote(read_at_datetime)
end
# if there are no values in the update_columns, there is no point to
# creating the record. A non-existent record is treated as an unread record.
return not_null_column_object(column: :workflow_state, entry: entry_or_topic, user:) if update_columns.empty?
# takes two ruby arrays and makes into a sql update statement.
# ['workflow_state', 'forced_read_state'], ["'read'", true] =>
# "workflow_state='read',forced_read_state=true"
update_statement = update_columns.zip(update_values).map { |a| a.join("=") }.join(",")
# takes the update_arrays and also creates a where clause so we don't update
# records that wouldn't end up changing.
# ['workflow_state', 'forced_read_state'], ["'read'", true] =>
# "(discussion_entry_participants.workflow_state,discussion_entry_participants.forced_read_state)
# IS DISTINCT FROM ('read',true)
where_clause = "(#{quoted_table_name}.#{update_columns.join(",#{quoted_table_name}.")})
IS DISTINCT FROM (#{update_values.join(",")})"
# actual sql query combined into a statement.
upsert_sql = <<~SQL.squish
INSERT INTO #{quoted_table_name}
(#{insert_columns.join(",")})
VALUES #{insert_rows.join(",")}
ON CONFLICT (discussion_entry_id,user_id)
DO UPDATE SET #{update_statement}
WHERE #{where_clause}
SQL
# run the query.
connection.exec_insert(upsert_sql)
end
end
def self.row_values(batch_entry, user_id, root_account_id, default_state, update_values)
read_at_datetime = (default_state&.to_s == "read") ? Time.now : nil
[
connection.quote(batch_entry.is_a?(ActiveRecord::Base) ? batch_entry.id_for_database : batch_entry),
connection.quote(user_id),
connection.quote(root_account_id),
connection.quote(default_state),
connection.quote(read_at_datetime),
] + update_values
end
def self.upsert_for_root_entry_and_descendants(root_entry, user, new_state: nil, forced: nil, rating: nil)
DiscussionEntry.where(root_entry:)
.or(DiscussionEntry.where(id: root_entry))
.active.find_ids_in_batches do |batch|
upsert_for_entries(root_entry, user, batch:, new_state:, forced:, rating:)
end
end
def self.upsert_for_topic(topic, user, new_state: nil, forced: nil, rating: nil)
topic.discussion_entries.active.find_ids_in_batches do |batch|
upsert_for_entries(topic, user, batch:, new_state:, forced:, rating:)
end
end
workflow do
state :unread
state :read
end
scope :read, -> { where(workflow_state: "read") }
scope :existing_participants, lambda { |user, entry_id|
select([:id, :discussion_entry_id])
.where(user_id: user, discussion_entry_id: entry_id)
}
def set_root_account_id
self.root_account_id = discussion_entry.root_account_id
end
end