canvas-lms/app/models/enrollment_state.rb

344 lines
13 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2016 - 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 EnrollmentState < ActiveRecord::Base
# a 1-1 table with enrollments
# that was really only a separate table because enrollments had a billion columns already
# and the data here was going to have a lot of churn too
# anyways, determining whether an enrollment actually grants any useful permissions
# depends on a complicated chain of dates and states stored on other models
# (and how those dates compare to _now_)
# e.g. an enrollment can have an "active" workflow_state but if the term was set to end last week
# the user won't be considered active in the course anymore (i.e. "soft"-concluded)
# TL;DR: this table acts as a fairly-reliable cache
# (date triggers are resolved within ~5 minutes typically)
# so we can quickly determine frd permissions
# and build simple queries (e.g. use course.enrollments.active_by_date
# instead of pulling all potentially active enrollments and filtering in-app)
extend RootAccountResolver
belongs_to :enrollment, inverse_of: :enrollment_state
attr_accessor :skip_touch_user, :user_needs_touch, :is_direct_recalculation
validates_presence_of :enrollment_id
resolves_root_account through: :enrollment
self.primary_key = 'enrollment_id'
def hash
global_enrollment_id.hash
end
# check if we've manually marked the enrollment state as potentially out of date (or if the stored date trigger has past)
def state_needs_recalculation?
!self.state_is_current? || self.state_valid_until && self.state_valid_until < Time.now
end
def ensure_current_state
GuardRail.activate(:primary) do
retry_count = 0
begin
self.recalculate_state if self.state_needs_recalculation? || retry_count > 0 # force double-checking on lock conflict
self.recalculate_access if !self.access_is_current? || retry_count > 0
self.save! if self.changed?
rescue ActiveRecord::StaleObjectError
# retry up to five times, otherwise return current (stale) data
self.enrollment.association(:enrollment_state).target = nil # don't cache an old enrollment state, just in case
self.reload
retry_count += 1
retry if retry_count < 5
logger.error { "Failed to evaluate stale enrollment state: #{self.inspect}" }
end
end
end
# tweak the new state (in a handful of cases) into a symbol compatible with older enrollment state checks
# - a locked down enrollment is basically the same as a inactive one
# - an invitation in a course yet to start is functionally identical to an invitation in a started course
# - :accepted is kind of silly, but it's how the old code signified an active enrollment in a course that hadn't started
def get_effective_state
self.ensure_current_state
if restricted_access?
:inactive
elsif self.state == 'pending_invited'
:invited
elsif self.state == 'pending_active'
:accepted
else
self.state.to_sym
end
end
def get_display_state
self.ensure_current_state
if pending?
:pending
else
self.state.to_sym
end
end
def pending?
%w{pending_active pending_invited creation_pending}.include?(self.state)
end
def recalculate_state
self.state_valid_until = nil
self.state_started_at = nil
wf_state = self.enrollment.workflow_state
invited_or_active = %w{invited active}.include?(wf_state)
if invited_or_active
if self.enrollment.course.completed?
self.state = 'completed'
else
self.calculate_state_based_on_dates
end
else
self.state = wf_state
end
self.state_is_current = true
if self.state_changed? && self.enrollment.view_restrictable?
self.access_is_current = false
end
if self.state_changed?
self.user_needs_touch = true
unless self.skip_touch_user
self.class.connection.after_transaction_commit do
self.enrollment.user.touch unless User.skip_touch_for_type?(:enrollments)
self.enrollment.user.clear_cache_key(:enrollments)
self.enrollment.user.clear_cache_key(:k5_user)
end
end
end
end
# TL;DR an enrollment can have its start and end dates determined in a variety of places
# (see Canvas::Builders::EnrollmentDateBuilder for more details)
# so this translates the current enrollment's workflow_state depending
# whether we're currently before the start, after the end, or between the two
def calculate_state_based_on_dates
wf_state = self.enrollment.workflow_state
ranges = self.enrollment.enrollment_dates
now = Time.now
# start_at <= now <= end_at, allowing for open ranges on either end
if range = ranges.detect{|start_at, end_at| (start_at || now) <= now && now <= (end_at || now) }
# we're in the middle of the start-end so the state is just the same as the workflow state
self.state = wf_state
start_at, end_at = range
self.state_started_at = start_at
self.state_valid_until = end_at # stores the next date trigger
else
global_start_at = ranges.map(&:compact).map(&:min).compact.min
if !global_start_at
# Not strictly within any range so no translation needed
self.state = wf_state
elsif global_start_at < now
# we've past the end date so no matter what the state was, we're "completed" now
self.state_started_at = ranges.map(&:last).compact.min
self.state = 'completed'
elsif self.enrollment.fake_student? # Allow student view students to use the course before the term starts
self.state = wf_state
else
# the course has yet to begin for the enrollment
self.state_valid_until = global_start_at # store the date when that will change
if self.enrollment.view_restrictable?
# these enrollment states mean they still can't participate yet even if they've accepted it,
# but should be able to view just like an invited enrollment
if wf_state == 'active'
self.state = 'pending_active'
else
self.state = 'pending_invited'
end
else
# admin user restricted by term dates
self.state = 'inactive'
end
end
end
end
# normally if you're part of a course that hasn't started yet or has already finished
# you can still access the course in a "view-only" mode
# but courses/accounts can disable this
def recalculate_access
if self.enrollment.view_restrictable?
self.restricted_access =
if self.pending?
self.enrollment.restrict_future_view?
elsif self.state == 'completed'
self.enrollment.restrict_past_view?
else
false
end
else
self.restricted_access = false
end
self.access_is_current = true
end
# ********************
# The rest of these class-level methods keep the database state up to date when dates and access settings are changed elsewhere
def self.enrollments_needing_calculation(scope=Enrollment.all)
scope.joins(:enrollment_state).
where("enrollment_states.state_is_current = ? OR enrollment_states.access_is_current = ?", false, false)
end
def self.process_states_in_ranges(start_at, end_at, enrollment_scope=Enrollment.all)
Enrollment.find_ids_in_ranges(:start_at => start_at, :end_at => end_at, :batch_size => 250) do |min_id, max_id|
process_states_for(enrollments_needing_calculation(enrollment_scope).where(:id => min_id..max_id))
end
end
def self.process_term_states_in_ranges(start_at, end_at, term, enrollment_type=nil)
scope = term.enrollments
scope = scope.where(:type => enrollment_type) if enrollment_type
process_states_in_ranges(start_at, end_at, scope)
end
def self.process_account_states_in_ranges(start_at, end_at, account_ids)
process_states_in_ranges(start_at, end_at, enrollments_for_account_ids(account_ids))
end
def self.process_states_for_ids(enrollment_ids)
process_states_for(Enrollment.where(:id => enrollment_ids).to_a)
end
def self.process_states_for(enrollments)
enrollments = Array(enrollments)
Canvas::Builders::EnrollmentDateBuilder.preload(enrollments, false)
enrollments.each do |enrollment|
enrollment.enrollment_state.skip_touch_user = true
update_enrollment(enrollment)
end
user_ids_to_touch = enrollments.select{|e| e.enrollment_state.user_needs_touch}.map(&:user_id)
if user_ids_to_touch.any?
User.touch_and_clear_cache_keys(user_ids_to_touch, :enrollments)
end
end
def self.update_enrollment(enrollment)
enrollment.enrollment_state.ensure_current_state
end
INVALIDATEABLE_STATES = %w{pending_invited pending_active invited active completed inactive}.freeze # don't worry about creation_pending or rejected, etc
def self.invalidate_states(enrollment_scope)
EnrollmentState.where(:enrollment_id => enrollment_scope, :state => INVALIDATEABLE_STATES).
update_all(["lock_version = COALESCE(lock_version, 0) + 1, state_is_current = ?", false])
end
def self.invalidate_states_and_access(enrollment_scope)
EnrollmentState.where(:enrollment_id => enrollment_scope, :state => INVALIDATEABLE_STATES).
update_all(["lock_version = COALESCE(lock_version, 0) + 1, state_is_current = ?, access_is_current = ?", false, false])
end
def self.force_recalculation(enrollment_ids, strand: nil)
if enrollment_ids.any?
EnrollmentState.where(:enrollment_id => enrollment_ids).
update_all(["lock_version = COALESCE(lock_version, 0) + 1, state_is_current = ?", false])
args = strand ? {n_strand: strand} : {}
EnrollmentState.delay_if_production(**args).process_states_for_ids(enrollment_ids)
end
end
def self.invalidate_access(enrollment_scope, states_to_update)
EnrollmentState.where(:enrollment_id => enrollment_scope, :state => states_to_update).
update_all(["lock_version = COALESCE(lock_version, 0) + 1, access_is_current = ?", false])
end
def self.enrollments_for_account_ids(account_ids)
Enrollment.joins(:course).where(:courses => {:account_id => account_ids}).where(:type => %w{StudentEnrollment ObserverEnrollment})
end
ENROLLMENT_BATCH_SIZE = 1_000
def self.invalidate_states_for_term(term, enrollment_type=nil)
# invalidate and re-queue individual jobs for reprocessing because it might be too big to do all at once
scope = term.enrollments
scope = scope.where(:type => enrollment_type) if enrollment_type
scope.find_ids_in_ranges(:batch_size => ENROLLMENT_BATCH_SIZE) do |min_id, max_id|
if invalidate_states(scope.where(:id => min_id..max_id)) > 0
EnrollmentState.delay_if_production(priority: Delayed::LOW_PRIORITY,
n_strand: ['invalidate_states_for_term', term.global_root_account_id]).
process_term_states_in_ranges(min_id, max_id, term, enrollment_type)
end
end
end
def self.invalidate_states_for_course_or_section(course_or_section, invalidate_access: false)
scope = course_or_section.enrollments
if (invalidate_access ? invalidate_states_and_access(scope) : invalidate_states(scope)) > 0
process_states_for(enrollments_needing_calculation(scope))
end
end
def self.access_states_to_update(changed_keys)
states_to_update = []
# only need to invalidate access for future students if future access changed, etc
states_to_update += ['pending_invited', 'pending_active'] if changed_keys.include?(:restrict_student_future_view)
states_to_update += ['completed'] if changed_keys.include?(:restrict_student_past_view)
states_to_update
end
def self.invalidate_access_for_accounts(account_ids, changed_keys)
states_to_update = access_states_to_update(changed_keys)
enrollments_for_account_ids(account_ids).find_ids_in_ranges(:batch_size => ENROLLMENT_BATCH_SIZE) do |min_id, max_id|
scope = enrollments_for_account_ids(account_ids).where(:id => min_id..max_id)
if invalidate_access(scope, states_to_update) > 0
EnrollmentState.delay_if_production(priority: Delayed::LOW_PRIORITY,
n_strand: ['invalidate_access_for_accounts', Shard.current.id]).
process_account_states_in_ranges(min_id, max_id, account_ids)
end
end
end
def self.invalidate_access_for_course(course, changed_keys)
states_to_update = access_states_to_update(changed_keys)
scope = course.enrollments.where(:type => %w{StudentEnrollment ObserverEnrollment})
if invalidate_access(scope, states_to_update) > 0
process_states_for(enrollments_needing_calculation(scope))
end
end
# called every ~5 minutes by a periodic delayed job
def self.recalculate_expired_states
while (enrollments = Enrollment.joins(:enrollment_state).where("enrollment_states.state_valid_until IS NOT NULL AND
enrollment_states.state_valid_until < ?", Time.now.utc).limit(250).to_a) && enrollments.any?
process_states_for(enrollments)
end
end
end