344 lines
13 KiB
Ruby
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
|