canvas-lms/app/models/asset_user_access.rb

358 lines
12 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/>.
#
# asset_code is used to specify the 'asset' or idea being accessed
# asset_group_code is for the group
# so, for example, the asset could be an assignment, the group would be the assignment_group
class AssetUserAccess < ActiveRecord::Base
extend RootAccountResolver
belongs_to :context, polymorphic: %i[account course group user], polymorphic_prefix: true
belongs_to :user
has_many :page_views
# if you add any more callbacks, be sure to update #log
before_save :infer_defaults
before_save :infer_root_account_id
resolves_root_account through: ->(instance) { instance.infer_root_account_id }
scope :for_context, ->(context) { where(context_id: context, context_type: context.class.to_s) }
scope :for_user, ->(user) { where(user_id: user) }
scope :participations, -> { where(action_level: "participate") }
scope :most_recent, -> { order("updated_at DESC") }
def infer_root_account_id(asset_for_root_account_id = nil)
self.root_account_id ||= if context_type != "User"
context&.resolved_root_account_id || 0
elsif asset_for_root_account_id.is_a?(User)
# Unfillable. Point to the dummy root account with id=0.
0
else
asset_for_root_account_id.try(:resolved_root_account_id) ||
asset_for_root_account_id.try(:root_account_id) || 0
# We could default `asset_for_root_account_id ||= asset`, but AUAs shouldn't
# ever be created outside of .log(), and calling `asset` would add a DB hit
end
end
def category
asset_category
end
def infer_defaults
self.display_name = asset_display_name
end
def category=(val)
self.asset_category = val
end
def display_name
# repair existing AssetUserAccesses that have bad display_names
if read_attribute(:display_name) == asset_code
better_display_name = asset_display_name
if better_display_name != asset_code
update_attribute(:display_name, better_display_name)
end
end
read_attribute(:display_name)
end
def asset_display_name
return nil unless asset
if asset.respond_to?(:title) && !asset.title.nil?
asset.title
elsif asset.is_a? Enrollment
asset.user.name
elsif asset.respond_to?(:name) && !asset.name.nil?
asset.name
else
asset_code
end
end
def context_code
"#{context_type.underscore}_#{context_id}" rescue nil
end
def readable_name(include_group_name: true)
if asset_code&.include?(":")
split = asset_code.split(":")
if split[1].match?(/course_\d+/)
case split[0]
when "announcements"
t("Course Announcements")
when "assignments"
t("Course Assignments")
when "calendar_feed"
t("Course Calendar")
when "collaborations"
t("Course Collaborations")
when "conferences"
t("Course Conferences")
when "files"
t("Course Files")
when "grades"
t("Course Grades")
when "home"
t("Course Home")
when "modules"
t("Course Modules")
when "outcomes"
t("Course Outcomes")
when "pages"
t("Course Pages")
when "quizzes"
t("Course Quizzes")
when "roster"
t("Course People")
when "speed_grader"
t("SpeedGrader")
when "syllabus"
t("Course Syllabus")
when "topics"
t("Course Discussions")
else
"Course #{split[0].titleize}"
end
elsif (match = split[1].match(/group_(\d+)/)) && (group = Group.where(id: match[1]).first)
case split[0]
when "announcements"
include_group_name ? t("%{group_name} - Group Announcements", group_name: group.name) : t("Group Announcements")
when "calendar_feed"
include_group_name ? t("%{group_name} - Group Calendar", group_name: group.name) : t("Group Calendar")
when "collaborations"
include_group_name ? t("%{group_name} - Group Collaborations", group_name: group.name) : t("Group Collaborations")
when "conferences"
include_group_name ? t("%{group_name} - Group Conferences", group_name: group.name) : t("Group Conferences")
when "files"
include_group_name ? t("%{group_name} - Group Files", group_name: group.name) : t("Group Files")
when "home"
include_group_name ? t("%{group_name} - Group Home", group_name: group.name) : t("Group Home")
when "pages"
include_group_name ? t("%{group_name} - Group Pages", group_name: group.name) : t("Group Pages")
when "roster"
include_group_name ? t("%{group_name} - Group People", group_name: group.name) : t("Group People")
when "topics"
include_group_name ? t("%{group_name} - Group Discussions", group_name: group.name) : t("Group Discussions")
else
"#{include_group_name ? "#{group.name} - " : ""}Group #{split[0].titleize}"
end
elsif split[1].match?(/user_\d+/)
case split[0]
when "files"
t("User Files")
else
display_name
end
else
display_name
end
else
re = Regexp.new("#{asset_code} - ")
display_name.nil? ? "" : display_name.gsub(re, "")
end
end
def asset
unless @asset
return nil unless asset_code
asset_code, = self.asset_code.split(":").reverse
@asset = Context.find_asset_by_asset_string(asset_code, context)
@asset ||= (match = asset_code.match(/enrollment_(\d+)/)) && Enrollment.where(id: match[1]).first
end
@asset
end
def asset_class_name
name = asset.class.name.underscore if asset
name = "Quiz" if name == "Quizzes::Quiz"
name
end
def self.get_correct_context(context, accessed_asset)
if accessed_asset[:category] == "files" && accessed_asset[:code]&.starts_with?("attachment")
attachment_id = accessed_asset[:code].match(/\A\w+_(\d+)\z/)[1]
asset = accessed_asset[:asset_for_root_account_id]
return asset.context if asset.is_a?(Attachment) && asset.id == attachment_id
Attachment.find_by(id: attachment_id)&.context
elsif context.is_a?(UserProfile)
context.user
elsif context.is_a?(AssessmentQuestion)
context.context
else
context
end
end
def self.log(user, context, accessed_asset)
return unless user && accessed_asset[:code]
correct_context = get_correct_context(context, accessed_asset)
return unless correct_context && Context::CONTEXT_TYPES.include?(correct_context.class_name.to_sym)
GuardRail.activate(:secondary) do
@access = AssetUserAccess.where(user:,
asset_code: accessed_asset[:code],
context: correct_context).first_or_initialize
end
accessed_asset[:level] ||= "view"
@access.log correct_context, accessed_asset
end
def log(kontext, accessed)
self.asset_category ||= accessed[:category]
self.asset_group_code ||= accessed[:group_code]
self.membership_type ||= accessed[:membership_type]
self.context = kontext
self.updated_at = self.last_access = Time.now.utc
log_action(accessed[:level])
# manually call callbacks to avoid transactions. this saves a BEGIN/COMMIT per request
infer_defaults
infer_root_account_id(accessed[:asset_for_root_account_id])
if self.class.use_log_compaction_for_views? && eligible_for_log_path?
# Since this is JUST a view bump, we'll write it to the
# view log and let periodic jobs compact them later
# (this is intentionally trading off more latency for less I/O pressure)
AssetUserAccessLog.put_view(self)
else
save_without_transaction
end
self
end
def eligible_for_log_path?
# in general we want writes to go to the table right now.
# view count updates happen a LOT though, so if the setting is
# configured such that we're allowed to use the log path, check
# if this set of changes is "just" a view update.
change_hash = changes_to_save
updated_key_set = changes_to_save.keys.to_set
return false unless updated_key_set.include?("view_score")
return false unless (updated_key_set - Set.new(%w[updated_at last_access view_score])).empty?
# ASSUMPTION: All view_score updates are a single increment.
# If this is violated, rather than failing to capture, we should accept the
# write through the row update for now (by returning false from here).
view_delta = change_hash["view_score"].compact
# ^array with old and new value, which CAN be null, hence compact
return false if view_delta.empty?
return (view_delta[0] - 1.0).abs < Float::EPSILON if view_delta.size == 1
(view_delta[1] - view_delta[0]).abs == 1 # this is an increment, if true
end
def log_action(level)
increment(:view_score) if %w[view participate].include?(level)
increment(:participate_score) if %w[participate submit].include?(level)
if action_level != "participate"
self.action_level = (level == "submit") ? "participate" : level
end
end
def self.use_log_compaction_for_views?
view_counting_method.to_s == "log"
end
def self.view_counting_method
Canvas::Plugin.find(:asset_user_access_logs).settings[:write_path]
end
def self.infer_asset(code)
asset_code, = code.split(":").reverse
Context.find_asset_by_asset_string(asset_code)
end
# For Quizzes, we want the view score not to include the participation score
# so it reflects the number of times a student really just browsed the quiz.
def corrected_view_score
deductible_points = 0
if self.asset_group_code == "quizzes"
deductible_points = participate_score || 0
end
self.view_score ||= 0
self.view_score -= deductible_points
end
# Includes both the icon name and the associated screenreader label for the icon
ICON_MAP = {
announcements: ["icon-announcement", t("Announcement")].freeze,
assignments: ["icon-assignment", t("Assignment")].freeze,
calendar: ["icon-calendar-month", t("Calendar")].freeze,
collaborations: ["icon-document", t("Collaboration")].freeze,
conferences: ["icon-group", t("Conference")].freeze,
external_tools: ["icon-link", t("App")].freeze,
files: ["icon-download", t("File")].freeze,
grades: ["icon-gradebook", t("Grades")].freeze,
home: ["icon-home", t("Home")].freeze,
inbox: ["icon-message", t("Inbox")].freeze,
modules: ["icon-module", t("Module")].freeze,
outcomes: ["icon-outcomes", t("Outcome")].freeze,
pages: ["icon-document", t("Page")].freeze,
quizzes: ["icon-quiz", t("Quiz")].freeze,
roster: ["icon-user", t("People")].freeze,
syllabus: ["icon-syllabus", t("Syllabus")].freeze,
topics: ["icon-discussion", t("Discussion")].freeze,
wiki: ["icon-document", t("Page")].freeze
}.freeze
def icon
ICON_MAP[asset_category.to_sym]&.[](0) || "icon-question"
end
def readable_category
ICON_MAP[asset_category.to_sym]&.[](1) || ""
end
def self.expiration_date
2.years.ago
end
DELETE_BATCH_SIZE = 10_000
DELETE_BATCH_SLEEP = 5
def self.delete_old_records
loop do
count = AssetUserAccess.connection.with_max_update_limit(DELETE_BATCH_SIZE) do
where(last_access: ..expiration_date).limit(DELETE_BATCH_SIZE).delete_all
end
break if count.zero?
sleep(DELETE_BATCH_SLEEP) # rubocop:disable Lint/NoSleep
end
end
private
def increment(attribute)
incremented_value = (send(attribute) || 0) + 1
send(:"#{attribute}=", incremented_value)
end
end