canvas-lms/app/models/asset_user_access.rb

328 lines
12 KiB
Ruby
Raw Normal View History

2011-02-01 09:57:29 +08:00
#
# Copyright (C) 2011 - present Instructure, Inc.
2011-02-01 09:57:29 +08:00
#
# 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: [:account, :course, :group, :user], polymorphic_prefix: true
2011-02-01 09:57:29 +08:00
belongs_to :user
has_many :page_views
# if you add any more callbacks, be sure to update #log
2011-02-01 09:57:29 +08:00
before_save :infer_defaults
resolves_root_account through: ->(instance){ instance.infer_root_account_id }
scope :for_context, lambda { |context| where(:context_id => context, :context_type => context.class.to_s) }
scope :for_user, lambda { |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)
if context_type != 'User'
context&.resolved_root_account_id
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)
# 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
2011-02-01 09:57:29 +08:00
def category
self.asset_category
end
2011-02-01 09:57:29 +08:00
def infer_defaults
self.display_name = asset_display_name
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
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
2011-02-01 09:57:29 +08:00
def asset_display_name
return nil unless asset
2011-02-01 09:57:29 +08:00
if self.asset.respond_to?(:title) && !self.asset.title.nil?
asset.title
elsif self.asset.is_a? Enrollment
asset.user.name
elsif self.asset.respond_to?(:name) && !self.asset.name.nil?
asset.name
2011-02-01 09:57:29 +08:00
else
self.asset_code
end
end
2011-02-01 09:57:29 +08:00
def context_code
"#{self.context_type.underscore}_#{self.context_id}" rescue nil
end
def readable_name(include_group_name: true)
2011-02-01 09:57:29 +08:00
if self.asset_code && self.asset_code.match(/\:/)
split = self.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
self.display_name
end
2011-02-01 09:57:29 +08:00
else
self.display_name
end
else
re = Regexp.new("#{self.asset_code} - ")
self.display_name.nil? ? "" : self.display_name.gsub(re, "")
end
end
2011-02-01 09:57:29 +08:00
def asset
unless @asset
return nil unless asset_code
asset_code, general = 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
2011-02-01 09:57:29 +08:00
end
def asset_class_name
name = self.asset.class.name.underscore if self.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]
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 = self.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: user, asset_code: accessed_asset[:code]).
polymorphic_where(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
self.root_account_id ||= infer_root_account_id(accessed[:asset_for_root_account_id])
if self.class.use_log_compaction_for_views? && self.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 = self.changes_to_save
updated_key_set = self.changes_to_save.keys.to_set
return false unless updated_key_set.include?('view_score')
return false unless (updated_key_set - Set.new(['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.size < 1
return view_delta[0] == 1.0 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 self.action_level != 'participate'
self.action_level = (level == 'submit') ? 'participate' : level
end
end
def self.use_log_compaction_for_views?
self.view_counting_method.to_s == "log"
end
def self.view_counting_method
Canvas::Plugin.find(:asset_user_access_logs).settings[:write_path]
end
2011-02-01 09:57:29 +08:00
def self.infer_asset(code)
asset_code, general = code.split(":").reverse
asset = Context.find_asset_by_asset_string(asset_code)
asset
end
More accurate Access Report scores for Quizzes This patch makes it that when viewing the Access Report for a course student, their "Times Viewed" column will reflect the number of times the student has browsed the quiz or any of its related resources (like History, or attempt views), but not taken it. While the "Times Participated" column will reflect the number of times the student really took the quiz (1:1 mapping with the number of submissions.) TEST PLAN ---- ---- In both test cases, you'll need: - a course with a student enrolled - one browser session with a teacher logged in viewing the Access Report of the student - one browser session with the student logged CASE: Normal quizzes - Create a quiz with a few questions and unlimited attempts. - Refresh the teacher tab, keep an eye on Times "Viewed" and "Participated" columns - As the student: - Go to the quizzes page - Go to the quiz page - Refresh the teacher tab, and: - ONLY the "Times Viewed" score should be incremented by 1 - As the student: - Push the Take the Quiz - Refresh the teacher tab, and: - ONLY the "Times Participated" score should be incremented by 1 - As the student: - Refresh the quiz page (while taking it) - Refresh the teacher tab, and: - NEITHER score should be incremented CASE: OQAAT quizzes The expected behaviour for OQAAT quizzes is that the entire attempt counts as 1 participation, just like the normal quizzes. Follow the same steps as above, but: - While taking the quiz, and for every question page: - Refresh the teacher tab and make sure that neither score is incremented OBLIGATORY REFERENCES ---------- ---------- - Acceptance criteria @ http://docs.kodoware.com/canvas/cnvs-5294 refs CNVS-5294 Change-Id: I55883b8edbf417edb42b9fd103e08369e0e9e63c Reviewed-on: https://gerrit.instructure.com/26543 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Derek DeVries <ddevries@instructure.com> QA-Review: Jeremy Putnam <jeremyp@instructure.com> Product-Review: Derek DeVries <ddevries@instructure.com>
2013-11-22 01:33:04 +08:00
# 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 'quizzes' == self.asset_group_code
More accurate Access Report scores for Quizzes This patch makes it that when viewing the Access Report for a course student, their "Times Viewed" column will reflect the number of times the student has browsed the quiz or any of its related resources (like History, or attempt views), but not taken it. While the "Times Participated" column will reflect the number of times the student really took the quiz (1:1 mapping with the number of submissions.) TEST PLAN ---- ---- In both test cases, you'll need: - a course with a student enrolled - one browser session with a teacher logged in viewing the Access Report of the student - one browser session with the student logged CASE: Normal quizzes - Create a quiz with a few questions and unlimited attempts. - Refresh the teacher tab, keep an eye on Times "Viewed" and "Participated" columns - As the student: - Go to the quizzes page - Go to the quiz page - Refresh the teacher tab, and: - ONLY the "Times Viewed" score should be incremented by 1 - As the student: - Push the Take the Quiz - Refresh the teacher tab, and: - ONLY the "Times Participated" score should be incremented by 1 - As the student: - Refresh the quiz page (while taking it) - Refresh the teacher tab, and: - NEITHER score should be incremented CASE: OQAAT quizzes The expected behaviour for OQAAT quizzes is that the entire attempt counts as 1 participation, just like the normal quizzes. Follow the same steps as above, but: - While taking the quiz, and for every question page: - Refresh the teacher tab and make sure that neither score is incremented OBLIGATORY REFERENCES ---------- ---------- - Acceptance criteria @ http://docs.kodoware.com/canvas/cnvs-5294 refs CNVS-5294 Change-Id: I55883b8edbf417edb42b9fd103e08369e0e9e63c Reviewed-on: https://gerrit.instructure.com/26543 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Derek DeVries <ddevries@instructure.com> QA-Review: Jeremy Putnam <jeremyp@instructure.com> Product-Review: Derek DeVries <ddevries@instructure.com>
2013-11-22 01:33:04 +08:00
deductible_points = self.participate_score || 0
end
self.view_score ||= 0
More accurate Access Report scores for Quizzes This patch makes it that when viewing the Access Report for a course student, their "Times Viewed" column will reflect the number of times the student has browsed the quiz or any of its related resources (like History, or attempt views), but not taken it. While the "Times Participated" column will reflect the number of times the student really took the quiz (1:1 mapping with the number of submissions.) TEST PLAN ---- ---- In both test cases, you'll need: - a course with a student enrolled - one browser session with a teacher logged in viewing the Access Report of the student - one browser session with the student logged CASE: Normal quizzes - Create a quiz with a few questions and unlimited attempts. - Refresh the teacher tab, keep an eye on Times "Viewed" and "Participated" columns - As the student: - Go to the quizzes page - Go to the quiz page - Refresh the teacher tab, and: - ONLY the "Times Viewed" score should be incremented by 1 - As the student: - Push the Take the Quiz - Refresh the teacher tab, and: - ONLY the "Times Participated" score should be incremented by 1 - As the student: - Refresh the quiz page (while taking it) - Refresh the teacher tab, and: - NEITHER score should be incremented CASE: OQAAT quizzes The expected behaviour for OQAAT quizzes is that the entire attempt counts as 1 participation, just like the normal quizzes. Follow the same steps as above, but: - While taking the quiz, and for every question page: - Refresh the teacher tab and make sure that neither score is incremented OBLIGATORY REFERENCES ---------- ---------- - Acceptance criteria @ http://docs.kodoware.com/canvas/cnvs-5294 refs CNVS-5294 Change-Id: I55883b8edbf417edb42b9fd103e08369e0e9e63c Reviewed-on: https://gerrit.instructure.com/26543 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Derek DeVries <ddevries@instructure.com> QA-Review: Jeremy Putnam <jeremyp@instructure.com> Product-Review: Derek DeVries <ddevries@instructure.com>
2013-11-22 01:33:04 +08:00
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
private
def increment(attribute)
incremented_value = (self.send(attribute) || 0) + 1
self.send("#{attribute}=", incremented_value)
end
2011-02-01 09:57:29 +08:00
end