297 lines
10 KiB
Ruby
297 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2017 - 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/>.
|
|
#
|
|
|
|
module Plannable
|
|
ACTIVE_WORKFLOW_STATES = ["active", "published"].freeze
|
|
|
|
def self.included(base)
|
|
base.class_eval do
|
|
has_many :planner_overrides, as: :plannable
|
|
after_save :update_associated_planner_overrides
|
|
before_save :check_if_associated_planner_overrides_need_updating
|
|
scope :available_to_planner, -> { where(workflow_state: ACTIVE_WORKFLOW_STATES) }
|
|
end
|
|
end
|
|
|
|
def update_associated_planner_overrides_later
|
|
delay.update_associated_planner_overrides if @associated_planner_items_need_updating != false
|
|
end
|
|
|
|
def update_associated_planner_overrides
|
|
PlannerOverride.update_for(self) if @associated_planner_items_need_updating
|
|
end
|
|
|
|
def check_if_associated_planner_overrides_need_updating
|
|
@associated_planner_items_need_updating = false
|
|
return if new_record?
|
|
return if respond_to?(:context_type) && !PlannerOverride::CONTENT_TYPES.include?(context_type)
|
|
|
|
@associated_planner_items_need_updating = true if try(:workflow_state_changed?) || workflow_state == "deleted"
|
|
end
|
|
|
|
def planner_override_for(user)
|
|
if respond_to? :submittable_object
|
|
submittable_override = submittable_object&.planner_override_for(user)
|
|
return submittable_override if submittable_override
|
|
end
|
|
|
|
if association(:planner_overrides).loaded?
|
|
planner_overrides.find { |po| po.user_id == user.id && po.workflow_state != "deleted" }
|
|
else
|
|
planner_overrides.where(user_id: user).where.not(workflow_state: "deleted").take
|
|
end
|
|
end
|
|
|
|
class Bookmarker
|
|
class Bookmark < Array
|
|
attr_writer :descending
|
|
|
|
def <=>(other)
|
|
val = super
|
|
val *= -1 if @descending
|
|
val
|
|
end
|
|
end
|
|
# mostly copy-pasted version of SimpleBookmarker
|
|
# ***
|
|
# Now you can add some hackyness to your life by passing in an array for some sweet coalescing action
|
|
# as well as the ability to reverse order
|
|
# e.g. Plannable::Bookmarker.new(Assignment, true, [:due_at, :created_at], :id)
|
|
# You can also pass in a hash with the association name as the key and column name as the value
|
|
# to order by the joined values:
|
|
# e.g. Plannable::Bookmarker.new(AssessmentRequest, true, {submission: {assignment: :due_at}}, :id)
|
|
|
|
def initialize(model, descending, *columns)
|
|
@model = model
|
|
@descending = !!descending
|
|
@columns = format_columns(columns)
|
|
end
|
|
|
|
def format_columns(columns)
|
|
columns.map do |col|
|
|
col.is_a?(Array) ? col.map { |c| format_column(c) } : format_column(col)
|
|
end
|
|
end
|
|
|
|
def format_column(col)
|
|
return col if col.is_a?(Hash)
|
|
|
|
col.to_s
|
|
end
|
|
|
|
# Gets the association from a hash or single nested hash to use for preloading
|
|
# e.g. {submission: :cached_due_date} => :submission
|
|
# or {submission: {assignment: :due_at}} => {submission: :assignment}
|
|
# or {submission: {assignment: {course: id}}} => {submission: {assignment: :course}}
|
|
def association_to_preload(col)
|
|
result = {}
|
|
col.each_pair do |key, value|
|
|
return key if value.is_a?(Symbol)
|
|
|
|
result[key] = value.is_a?(Hash) ? association_to_preload(value) : value
|
|
end
|
|
result
|
|
end
|
|
|
|
def association_value(object, col)
|
|
rel_array = []
|
|
rel_hash = col.clone
|
|
while rel_hash
|
|
rel_array << rel_hash.keys.first
|
|
curr_val = rel_hash[rel_array.last]
|
|
if curr_val.is_a?(Hash)
|
|
rel_hash = curr_val
|
|
else
|
|
rel_array << curr_val
|
|
rel_hash = nil
|
|
end
|
|
end
|
|
rel_array.reduce(object) { |val, key| val.send(key) }
|
|
end
|
|
|
|
# Grabs the value to use for the bookmark & comparison
|
|
def column_value(object, col)
|
|
case col
|
|
when Array
|
|
object.attributes.values_at(*col).compact.first # coalesce nulls
|
|
when Hash
|
|
association_value(object, col)
|
|
else
|
|
object.attributes[col]
|
|
end
|
|
end
|
|
|
|
def bookmark_for(object)
|
|
bookmark = Bookmark.new
|
|
bookmark.descending = @descending
|
|
# coming from users_controller, @columns looks like ["due_at", "id"]
|
|
# coming from planner_controller, like [[:due_at, :created_at], :id]
|
|
# or [[{:submission=>{:assignment=>:peer_reviews_due_at}}, {:assessor_asset=>:cached_due_date}, "created_at"], "id"]
|
|
@columns.flatten.each do |col|
|
|
val = column_value(object, col)
|
|
val = val.utc.strftime("%Y-%m-%d %H:%M:%S.%6N") if val.respond_to?(:strftime)
|
|
unless val.nil?
|
|
bookmark << val
|
|
break
|
|
end
|
|
end
|
|
bookmark << object.id
|
|
end
|
|
|
|
TYPE_MAP = {
|
|
string: ->(val) { val.is_a?(String) },
|
|
integer: ->(val) { val.is_a?(Integer) },
|
|
datetime: ->(val) { val.is_a?(String) && !!(DateTime.parse(val) rescue false) }
|
|
}.freeze
|
|
|
|
def validate(bookmark)
|
|
bookmark.is_a?(Array) &&
|
|
bookmark.size == @columns.size &&
|
|
@columns.each.with_index.all? do |columns, i|
|
|
columns = [columns] unless columns.is_a?(Array)
|
|
columns.all? do |col|
|
|
col = @model.columns_hash[col]
|
|
if col
|
|
type = TYPE_MAP[col.type]
|
|
nullable = col.null
|
|
type && ((nullable && bookmark[i].nil?) || type.call(bookmark[i]))
|
|
else
|
|
true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def restrict_scope(scope, pager)
|
|
if (bookmark = pager.current_bookmark)
|
|
scope = scope.where(*comparison(bookmark))
|
|
end
|
|
scope.except(:order).order(order_by)
|
|
end
|
|
|
|
def order_by
|
|
@order_by ||= Arel.sql(@columns.map { |col| column_order(col) }.join(", "))
|
|
end
|
|
|
|
# Gets the object or object's associated column name to be used in the SQL query
|
|
def column_name(col)
|
|
return associated_table_column_name(col) if col.is_a?(Hash)
|
|
|
|
"#{@model.table_name}.#{col}"
|
|
end
|
|
|
|
# Joins the associated table & column together as a string to be used in a SQL query
|
|
def associated_table_column_name(col)
|
|
table, column = associated_table_column(col)
|
|
table_name = Object.const_defined?(table.to_s.classify) ? table.to_s.classify.constantize.quoted_table_name : table.to_s
|
|
[table_name, column].join(".")
|
|
end
|
|
|
|
# Finds the relevant table & column name when a hash is passed by checking if
|
|
# the hash specifies a direct or nested (i.e. the hash's value is also a hash) association
|
|
# returns an array of [table, column]
|
|
def associated_table_column(col)
|
|
return col.to_s unless col.is_a?(Hash)
|
|
|
|
col.values.first.is_a?(Hash) ? col.values.first.first : col.first
|
|
end
|
|
|
|
def column_order(col_name)
|
|
if col_name.is_a?(Array)
|
|
order = "COALESCE(#{col_name.map { |c| column_name(c) }.join(", ")})"
|
|
else
|
|
order = column_comparand(col_name)
|
|
if @model.columns_hash[col_name].null
|
|
order = "#{column_comparand(col_name, "=")} IS NULL, #{order}"
|
|
end
|
|
end
|
|
order += " DESC" if @descending
|
|
order
|
|
end
|
|
|
|
def column_comparand(column, comparator = ">", placeholder = nil)
|
|
col_name = placeholder ||
|
|
(column.is_a?(Array) ? "COALESCE(#{column.map { |c| column_name(c) }.join(", ")})" : column_name(column))
|
|
if comparator != "=" && type_for_column(column) == :string
|
|
col_name = BookmarkedCollection.best_unicode_collation_key(col_name)
|
|
end
|
|
col_name
|
|
end
|
|
|
|
def column_comparison(column, comparator, value)
|
|
if value.nil? && comparator == ">"
|
|
# sorting by a nullable column puts nulls last, so for our sort order
|
|
# 'column > NULL' is universally false
|
|
["0=1"]
|
|
elsif value.nil?
|
|
# likewise only NULL values in column satisfy 'column = NULL' and
|
|
# 'column >= NULL'
|
|
["#{column_comparand(column, "=")} IS NULL"]
|
|
else
|
|
sql = "#{column_comparand(column, comparator)} #{comparator} #{column_comparand(column, comparator, "?")}"
|
|
if !column.is_a?(Array) && @model.columns_hash[column].null && comparator != "="
|
|
# our sort order wants "NULL > ?" to be universally true for non-NULL
|
|
# values (we already handle NULL values above). but it is false in
|
|
# SQL, so we need to include "column IS NULL" with > or >=
|
|
sql = "(#{sql} OR #{column_comparand(column, "=")} IS NULL)"
|
|
end
|
|
[sql, value]
|
|
end
|
|
end
|
|
|
|
def type_for_column(col)
|
|
col = col.first if col.is_a?(Array)
|
|
@model.columns_hash[col]&.type
|
|
end
|
|
|
|
# Generate a sql comparison like so:
|
|
#
|
|
# a > ?
|
|
# OR a = ? AND b > ?
|
|
# OR a = ? AND b = ? AND c > ?
|
|
#
|
|
# Technically there's an extra check in the actual result (for index
|
|
# happiness), but it's logically equivalent to the example above
|
|
def comparison(bookmark)
|
|
top_clauses = []
|
|
args = []
|
|
visited = []
|
|
pairs = @columns.zip(bookmark)
|
|
comparator = @descending ? "<" : ">"
|
|
while pairs.present?
|
|
col, val = pairs.shift
|
|
clauses = []
|
|
visited.each do |c, v|
|
|
clause, *clause_args = column_comparison(c, "=", v)
|
|
clauses << clause
|
|
args.concat(clause_args)
|
|
end
|
|
clause, *clause_args = column_comparison(col, comparator, val)
|
|
clauses << clause
|
|
top_clauses << clauses.join(" AND ")
|
|
args.concat(clause_args)
|
|
visited << [col, val]
|
|
end
|
|
sql = "(" + top_clauses.join(" OR ") + ")"
|
|
[sql, *args]
|
|
end
|
|
end
|
|
end
|