1525 lines
53 KiB
Ruby
1525 lines
53 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/>.
|
|
#
|
|
|
|
require 'atom'
|
|
|
|
class Enrollment < ActiveRecord::Base
|
|
|
|
SIS_TYPES = {
|
|
'TeacherEnrollment' => 'teacher',
|
|
'TaEnrollment' => 'ta',
|
|
'DesignerEnrollment' => 'designer',
|
|
'StudentEnrollment' => 'student',
|
|
'ObserverEnrollment' => 'observer'
|
|
}
|
|
|
|
include Workflow
|
|
|
|
belongs_to :course, inverse_of: :enrollments
|
|
belongs_to :course_section, inverse_of: :enrollments
|
|
belongs_to :root_account, class_name: 'Account', inverse_of: :enrollments
|
|
belongs_to :user, inverse_of: :enrollments
|
|
belongs_to :sis_pseudonym, class_name: 'Pseudonym', inverse_of: :sis_enrollments
|
|
belongs_to :associated_user, :class_name => 'User'
|
|
|
|
belongs_to :role
|
|
include Role::AssociationHelper
|
|
|
|
has_one :enrollment_state, :dependent => :destroy, inverse_of: :enrollment
|
|
|
|
has_many :role_overrides, :as => :context, :inverse_of => :context
|
|
has_many :pseudonyms, :primary_key => :user_id, :foreign_key => :user_id
|
|
has_many :course_account_associations, :foreign_key => 'course_id', :primary_key => 'course_id'
|
|
has_many :scores, -> { active }
|
|
|
|
validates_presence_of :user_id, :course_id, :type, :root_account_id, :course_section_id, :workflow_state, :role_id
|
|
validates_inclusion_of :limit_privileges_to_course_section, :in => [true, false]
|
|
validates_inclusion_of :associated_user_id, :in => [nil],
|
|
:unless => lambda { |enrollment| enrollment.type == 'ObserverEnrollment' },
|
|
:message => "only ObserverEnrollments may have an associated_user_id"
|
|
validate :cant_observe_self, :if => lambda { |enrollment| enrollment.type == 'ObserverEnrollment' }
|
|
|
|
validate :valid_role?
|
|
validate :valid_course?
|
|
validate :valid_section?
|
|
validate :not_student_view
|
|
|
|
# update bulk destroy if changing or adding an after save
|
|
before_save :assign_uuid
|
|
before_validation :assert_section
|
|
after_save :recalculate_enrollment_state
|
|
after_save :update_user_account_associations_if_necessary
|
|
before_save :audit_groups_for_deleted_enrollments
|
|
before_validation :ensure_role_id
|
|
after_create :create_linked_enrollments
|
|
after_create :create_enrollment_state
|
|
after_save :copy_scores_from_existing_enrollment, if: :need_to_copy_scores?
|
|
after_save :clear_email_caches
|
|
after_save :cancel_future_appointments
|
|
after_save :update_linked_enrollments
|
|
after_save :set_update_cached_due_dates
|
|
after_save :touch_graders_if_needed
|
|
after_save :reset_notifications_cache
|
|
after_save :dispatch_invitations_later
|
|
after_save :add_to_favorites_later
|
|
after_commit :update_cached_due_dates
|
|
after_save :update_assignment_overrides_if_needed
|
|
after_create :needs_grading_count_updated, if: :active_student?
|
|
after_update :needs_grading_count_updated, if: :active_student_changed?
|
|
after_commit :sync_microsoft_group
|
|
|
|
attr_accessor :already_enrolled, :need_touch_user, :skip_touch_user
|
|
scope :current, -> { joins(:course).where(QueryBuilder.new(:active).conditions).readonly(false) }
|
|
scope :current_and_invited, -> { joins(:course).where(QueryBuilder.new(:current_and_invited).conditions).readonly(false) }
|
|
scope :current_and_future, -> { joins(:course).where(QueryBuilder.new(:current_and_future).conditions).readonly(false) }
|
|
scope :concluded, -> { joins(:course).where(QueryBuilder.new(:completed).conditions).readonly(false) }
|
|
scope :current_and_concluded, -> { joins(:course).where(QueryBuilder.new(:current_and_concluded).conditions).readonly(false) }
|
|
|
|
def ensure_role_id
|
|
self.role_id ||= self.role.id
|
|
end
|
|
|
|
def cant_observe_self
|
|
self.errors.add(:associated_user_id, "Cannot observe yourself") if self.user_id == self.associated_user_id
|
|
end
|
|
|
|
def valid_course?
|
|
if !deleted? && course.deleted?
|
|
self.errors.add(:course_id, "is not a valid course")
|
|
end
|
|
end
|
|
|
|
def valid_section?
|
|
unless deleted? || course_section.active?
|
|
self.errors.add(:course_section_id, "is not a valid section")
|
|
end
|
|
end
|
|
|
|
def not_student_view
|
|
if type != 'StudentViewEnrollment' && (new_record? || association(:user).loaded?) &&
|
|
user.fake_student?
|
|
self.errors.add(:user_id, "cannot add a student view student in a regular role")
|
|
end
|
|
end
|
|
|
|
def valid_role?
|
|
return true if self.deleted? || role.built_in?
|
|
|
|
unless self.role.base_role_type == self.type
|
|
self.errors.add(:role_id, "is not valid for the enrollment type")
|
|
end
|
|
|
|
unless self.course.account.valid_role?(role)
|
|
self.errors.add(:role_id, "is not an available role for this course's account")
|
|
end
|
|
end
|
|
|
|
def self.get_built_in_role_for_type(enrollment_type, root_account_id:)
|
|
role = Role.get_built_in_role("StudentEnrollment", root_account_id: root_account_id) if enrollment_type == "StudentViewEnrollment"
|
|
role ||= Role.get_built_in_role(enrollment_type, root_account_id: root_account_id)
|
|
role
|
|
end
|
|
|
|
def default_role
|
|
Enrollment.get_built_in_role_for_type(self.type, root_account_id: self.course.root_account_id)
|
|
end
|
|
|
|
# see #active_student?
|
|
def self.active_student_conditions
|
|
"(enrollments.type IN ('StudentEnrollment', 'StudentViewEnrollment') AND enrollments.workflow_state = 'active')"
|
|
end
|
|
|
|
# see .active_student_conditions
|
|
def active_student?(was = false)
|
|
suffix = was ? "_before_last_save" : ""
|
|
|
|
%w[StudentEnrollment StudentViewEnrollment].include?(send("type#{suffix}")) &&
|
|
send("workflow_state#{suffix}") == "active"
|
|
end
|
|
|
|
def active_student_changed?
|
|
active_student? != active_student?(:was)
|
|
end
|
|
|
|
def clear_needs_grading_count_cache
|
|
Assignment.
|
|
where(context_id: course_id, context_type: 'Course').
|
|
where("EXISTS (?) AND NOT EXISTS (?)",
|
|
Submission.where(user_id: user_id).
|
|
where("assignment_id=assignments.id").
|
|
where("#{Submission.needs_grading_conditions} OR
|
|
(workflow_state = 'deleted' AND submission_type IS NOT NULL AND
|
|
(score IS NULL OR NOT grade_matches_current_submission OR
|
|
(submission_type = 'online_quiz' AND quiz_submission_id IS NOT NULL)))"),
|
|
Enrollment.where(Enrollment.active_student_conditions).
|
|
where(user_id: user_id, course_id: course_id).
|
|
where("id<>?", self)).
|
|
clear_cache_keys(:needs_grading)
|
|
end
|
|
|
|
def needs_grading_count_updated
|
|
self.class.connection.after_transaction_commit do
|
|
clear_needs_grading_count_cache
|
|
end
|
|
end
|
|
|
|
include StickySisFields
|
|
are_sis_sticky :start_at, :end_at
|
|
|
|
has_a_broadcast_policy
|
|
|
|
set_broadcast_policy do |p|
|
|
p.dispatch :enrollment_invitation
|
|
p.to { self.user }
|
|
p.whenever { |record|
|
|
!record.self_enrolled &&
|
|
record.course &&
|
|
record.user.registered? &&
|
|
!record.observer? &&
|
|
((record.invited? && (record.just_created || record.saved_change_to_workflow_state?)) || @re_send_confirmation)
|
|
}
|
|
|
|
p.dispatch :enrollment_registration
|
|
p.to { self.user.communication_channel }
|
|
p.whenever { |record|
|
|
!record.self_enrolled &&
|
|
record.course &&
|
|
!record.user.registered? &&
|
|
((record.invited? && (record.just_created || record.saved_change_to_workflow_state?)) || @re_send_confirmation)
|
|
}
|
|
|
|
p.dispatch :enrollment_notification
|
|
p.to { self.user }
|
|
p.whenever { |record|
|
|
!record.self_enrolled &&
|
|
record.course &&
|
|
!record.course.created? &&
|
|
!record.observer? &&
|
|
record.just_created && record.active?
|
|
}
|
|
|
|
p.dispatch :enrollment_accepted
|
|
p.to {self.course.participating_admins.restrict_to_sections([self.course_section_id]) - [self.user] }
|
|
p.whenever { |record|
|
|
record.course &&
|
|
!record.observer? &&
|
|
!record.just_created && (record.changed_state(:active, :invited) || record.changed_state(:active, :creation_pending))
|
|
}
|
|
end
|
|
|
|
def dispatch_invitations_later
|
|
# if in an invited state but not frd "invited?" because of future date restrictions, send it later
|
|
if (self.just_created || self.saved_change_to_workflow_state? || @re_send_confirmation) && self.workflow_state == 'invited' && self.inactive? && self.available_at &&
|
|
!self.self_enrolled && !(self.observer? && self.user.registered?)
|
|
# this won't work if they invite them and then change the course/term/section dates _afterwards_ so hopefully people don't do that
|
|
delay(run_at: self.available_at, singleton: "send_enrollment_invitations_#{global_id}").re_send_confirmation_if_invited!
|
|
end
|
|
end
|
|
|
|
scope :active, -> { where("enrollments.workflow_state<>'deleted'") }
|
|
|
|
scope :admin, -> {
|
|
select(:course_id).
|
|
joins(:course).
|
|
where("enrollments.type IN ('TeacherEnrollment','TaEnrollment', 'DesignerEnrollment') AND (courses.workflow_state IN ('created', 'claimed') OR (enrollments.workflow_state='active' AND courses.workflow_state='available'))") }
|
|
|
|
scope :instructor, -> {
|
|
select(:course_id).
|
|
joins(:course).
|
|
where("enrollments.type IN ('TeacherEnrollment','TaEnrollment') AND (courses.workflow_state IN ('created', 'claimed') OR (enrollments.workflow_state='active' AND courses.workflow_state='available'))") }
|
|
|
|
scope :of_student_type, -> { where(:type => "StudentEnrollment") }
|
|
|
|
scope :of_admin_type, -> { where(:type => ['TeacherEnrollment','TaEnrollment', 'DesignerEnrollment']) }
|
|
|
|
scope :of_instructor_type, -> { where(:type => ['TeacherEnrollment', 'TaEnrollment']) }
|
|
|
|
scope :of_content_admins, -> { where(:type => ['TeacherEnrollment', 'DesignerEnrollment']) }
|
|
|
|
scope :student, -> {
|
|
select(:course_id).
|
|
joins(:course).
|
|
where(:type => 'StudentEnrollment', :workflow_state => 'active', :courses => { :workflow_state => 'available' }) }
|
|
|
|
scope :student_in_claimed_or_available, -> {
|
|
select(:course_id).
|
|
joins(:course).
|
|
where(:type => 'StudentEnrollment', :workflow_state => 'active', :courses => { :workflow_state => ['available', 'claimed', 'created'] }) }
|
|
|
|
scope :all_student, -> {
|
|
eager_load(:course).
|
|
where("(enrollments.type = 'StudentEnrollment'
|
|
AND enrollments.workflow_state IN ('invited', 'active', 'completed')
|
|
AND courses.workflow_state IN ('available', 'completed')) OR
|
|
(enrollments.type = 'StudentViewEnrollment'
|
|
AND enrollments.workflow_state = 'active'
|
|
AND courses.workflow_state != 'deleted')") }
|
|
|
|
scope :not_deleted, -> {
|
|
joins(:course).
|
|
where("(courses.workflow_state<>'deleted') AND (enrollments.workflow_state<>'deleted')")
|
|
}
|
|
|
|
scope :not_fake, -> { where("enrollments.type<>'StudentViewEnrollment'") }
|
|
|
|
|
|
def self.readable_types
|
|
# with enough use, even translations can add up
|
|
RequestCache.cache('enrollment_readable_types') do
|
|
{
|
|
'TeacherEnrollment' => t('#enrollment.roles.teacher', "Teacher"),
|
|
'TaEnrollment' => t('#enrollment.roles.ta', "TA"),
|
|
'DesignerEnrollment' => t('#enrollment.roles.designer', "Designer"),
|
|
'StudentEnrollment' => t('#enrollment.roles.student', "Student"),
|
|
'StudentViewEnrollment' => t('#enrollment.roles.student', "Student"),
|
|
'ObserverEnrollment' => t('#enrollment.roles.observer', "Observer")
|
|
}
|
|
end
|
|
end
|
|
|
|
def self.readable_type(type)
|
|
readable_types[type] || readable_types['StudentEnrollment']
|
|
end
|
|
|
|
def self.sis_type(type)
|
|
SIS_TYPES[type] || SIS_TYPES['StudentEnrollment']
|
|
end
|
|
|
|
def sis_type
|
|
Enrollment.sis_type(self.type)
|
|
end
|
|
|
|
def sis_role
|
|
(!self.role.built_in? && self.role.name) || Enrollment.sis_type(self.type)
|
|
end
|
|
|
|
def self.valid_types
|
|
SIS_TYPES.keys
|
|
end
|
|
|
|
def self.valid_type?(type)
|
|
SIS_TYPES.has_key?(type)
|
|
end
|
|
|
|
def reload(options = nil)
|
|
@enrollment_dates = nil
|
|
super
|
|
end
|
|
|
|
def should_update_user_account_association?
|
|
self.id_before_last_save.nil? || self.saved_change_to_course_id? || self.saved_change_to_course_section_id? ||
|
|
self.saved_change_to_root_account_id? || being_restored?
|
|
end
|
|
|
|
def update_user_account_associations_if_necessary
|
|
return if self.fake_student?
|
|
if id_before_last_save.nil? || being_restored?
|
|
return if %w{creation_pending deleted}.include?(self.user.workflow_state)
|
|
associations = User.calculate_account_associations_from_accounts([self.course.account_id, self.course_section.course.account_id, self.course_section.nonxlist_course.try(:account_id)].compact.uniq)
|
|
self.user.update_account_associations(:incremental => true, :precalculated_associations => associations)
|
|
elsif should_update_user_account_association?
|
|
self.user.update_account_associations_later
|
|
end
|
|
end
|
|
protected :update_user_account_associations_if_necessary
|
|
|
|
def other_section_enrollment_exists?
|
|
# If other active sessions that the user is enrolled in exist.
|
|
self.course.student_enrollments.where.not(:workflow_state => ['deleted', 'rejected']).for_user(self.user).where.not(id: self.id).exists?
|
|
end
|
|
|
|
def audit_groups_for_deleted_enrollments
|
|
# did the student cease to be enrolled in a non-deleted state in a section?
|
|
had_section = self.course_section_id_was.present?
|
|
deleted_states = ['deleted', 'rejected']
|
|
was_active = !deleted_states.include?(self.workflow_state_was)
|
|
is_deleted = deleted_states.include?(self.workflow_state)
|
|
return unless had_section && was_active &&
|
|
(self.course_section_id_changed? || is_deleted)
|
|
|
|
# what section the user is abandoning, and the section they're moving to
|
|
# (if it's in the same course and the enrollment's not deleted)
|
|
section = CourseSection.find(self.course_section_id_was)
|
|
|
|
# ok, consider groups the user is in from the abandoned section's course
|
|
self.user.groups.preload(:group_category).where(
|
|
:context_type => 'Course', :context_id => section.course_id).each do |group|
|
|
|
|
# check group deletion criteria if either enrollment is not a deletion
|
|
# or it may be a deletion/unenrollment from a section but not from the course as a whole (still enrolled in another section)
|
|
if !is_deleted || other_section_enrollment_exists?
|
|
# don't bother unless the group's category has section restrictions
|
|
next unless group.group_category && group.group_category.restricted_self_signup?
|
|
|
|
# skip if the user is the only user in the group. there's no one to have
|
|
# a conflicting section.
|
|
next unless group.users.where.not(id: self.user_id).exists?
|
|
|
|
# check if the group has the section the user is abandoning as a common
|
|
# section (from CourseSection#common_to_users? view, the enrollment is
|
|
# still there since it queries the db directly and we haven't saved yet);
|
|
# if not, dropping the section is not necessary
|
|
next unless section.common_to_users?(group.users)
|
|
end
|
|
|
|
# at this point, the group is restricted, there's more than one user and
|
|
# it appears that the group is common to the section being left by the user so
|
|
# remove the user from the group. Or the student was only enrolled in one section and
|
|
# by leaving the section he/she is completely leaving the course so remove the
|
|
# user from any group related to the course.
|
|
membership = group.group_memberships.where(user_id: self.user_id).first
|
|
membership.destroy if membership
|
|
end
|
|
end
|
|
protected :audit_groups_for_deleted_enrollments
|
|
|
|
def observers
|
|
student? ? user.linked_observers.active.linked_through_root_account(self.root_account) : []
|
|
end
|
|
|
|
def create_linked_enrollments
|
|
observers.each do |observer|
|
|
create_linked_enrollment_for(observer)
|
|
end
|
|
end
|
|
|
|
def update_linked_enrollments(restore: false)
|
|
observers.each do |observer|
|
|
enrollment = restore ? linked_enrollment_for(observer) : active_linked_enrollment_for(observer)
|
|
if enrollment
|
|
enrollment.update_from(self)
|
|
elsif restore || (self.saved_change_to_workflow_state? && ['inactive', 'deleted'].include?(self.workflow_state_before_last_save))
|
|
create_linked_enrollment_for(observer)
|
|
end
|
|
end
|
|
end
|
|
|
|
def create_linked_enrollment_for(observer)
|
|
# we don't want to create a new observer enrollment if one exists
|
|
self.class.unique_constraint_retry do
|
|
enrollment = linked_enrollment_for(observer)
|
|
return true if enrollment && !enrollment.deleted?
|
|
return false unless observer.can_be_enrolled_in_course?(course)
|
|
enrollment ||= observer.observer_enrollments.build
|
|
enrollment.associated_user_id = user_id
|
|
enrollment.shard = shard if enrollment.new_record?
|
|
enrollment.update_from(self, !!@skip_broadcasts)
|
|
end
|
|
end
|
|
|
|
def linked_enrollment_for(observer)
|
|
observer.observer_enrollments.where(
|
|
:associated_user_id => user_id,
|
|
:course_section_id => course_section_id_before_last_save || course_section_id).
|
|
shard(Shard.shard_for(course_id)).first
|
|
end
|
|
|
|
def active_linked_enrollment_for(observer)
|
|
enrollment = linked_enrollment_for(observer)
|
|
# we don't want to "undelete" observer enrollments that have been
|
|
# explicitly deleted
|
|
return nil if enrollment && enrollment.deleted? && workflow_state_before_last_save != 'deleted'
|
|
enrollment
|
|
end
|
|
|
|
# This is Part 1 of the update_cached_due_dates callback. It sets @update_cached_due_dates which determines
|
|
# whether or not the update_cached_due_dates after_commit callback runs after this record has been committed.
|
|
# This split allows us to suspend this callback and affect the update_cached_due_dates callback since after_commit
|
|
# callbacks aren't being suspended properly. We suspend this callback during some bulk operations.
|
|
def set_update_cached_due_dates
|
|
@update_cached_due_dates = saved_change_to_workflow_state? && (student? || fake_student?) && course
|
|
end
|
|
|
|
def update_cached_due_dates
|
|
if @update_cached_due_dates
|
|
update_grades = being_restored?(to_state: 'active') ||
|
|
being_restored?(to_state: 'inactive') ||
|
|
saved_change_to_id?
|
|
DueDateCacher.recompute_users_for_course(user_id, course, nil, update_grades: update_grades)
|
|
end
|
|
end
|
|
|
|
def update_from(other, skip_broadcasts=false)
|
|
self.course_id = other.course_id
|
|
if self.type == 'ObserverEnrollment' && other.workflow_state == 'invited'
|
|
self.workflow_state = 'active'
|
|
else
|
|
self.workflow_state = other.workflow_state
|
|
end
|
|
self.start_at = other.start_at
|
|
self.end_at = other.end_at
|
|
self.course_section_id = other.course_section_id
|
|
self.root_account_id = other.root_account_id
|
|
self.skip_touch_user = other.skip_touch_user
|
|
if skip_broadcasts
|
|
save_without_broadcasting!
|
|
else
|
|
save!
|
|
end
|
|
end
|
|
|
|
def clear_email_caches
|
|
if self.saved_change_to_workflow_state? && (self.workflow_state_before_last_save == 'invited' || self.workflow_state == 'invited')
|
|
if Enrollment.cross_shard_invitations?
|
|
Shard.birth.activate do
|
|
self.user.communication_channels.email.unretired.each { |cc| Rails.cache.delete([cc.path, 'all_invited_enrollments2'].cache_key)}
|
|
end
|
|
else
|
|
self.user.communication_channels.email.unretired.each { |cc| Rails.cache.delete([cc.path, 'invited_enrollments2'].cache_key)}
|
|
end
|
|
end
|
|
end
|
|
|
|
def cancel_future_appointments
|
|
if saved_change_to_workflow_state? && %w{completed deleted}.include?(workflow_state)
|
|
unless self.course.current_enrollments.where(:user_id => self.user_id).exists? # ignore if they have another still valid enrollment
|
|
course.appointment_participants.active.current.for_context_codes(user.asset_string).update_all(:workflow_state => 'deleted')
|
|
end
|
|
end
|
|
end
|
|
|
|
def conclude
|
|
self.workflow_state = "completed"
|
|
self.completed_at = Time.now
|
|
self.save
|
|
end
|
|
|
|
def unconclude
|
|
self.workflow_state = 'active'
|
|
self.completed_at = nil
|
|
self.save
|
|
end
|
|
|
|
def deactivate
|
|
self.workflow_state = "inactive"
|
|
self.save
|
|
end
|
|
|
|
def reactivate
|
|
self.workflow_state = "active"
|
|
self.save
|
|
end
|
|
|
|
def defined_by_sis?
|
|
!!self.sis_batch_id
|
|
end
|
|
|
|
def assigned_observer?
|
|
self.observer? && self.associated_user_id
|
|
end
|
|
|
|
def participating?
|
|
self.state_based_on_date == :active
|
|
end
|
|
|
|
def participating_student?
|
|
self.student? && self.participating?
|
|
end
|
|
|
|
def participating_observer?
|
|
self.observer? && self.participating?
|
|
end
|
|
|
|
def participating_teacher?
|
|
self.teacher? && self.participating?
|
|
end
|
|
|
|
def participating_ta?
|
|
self.ta? && self.participating?
|
|
end
|
|
|
|
def participating_instructor?
|
|
self.instructor? && self.participating?
|
|
end
|
|
|
|
def participating_designer?
|
|
self.designer? && self.participating?
|
|
end
|
|
|
|
def participating_admin?
|
|
self.admin? && self.participating?
|
|
end
|
|
|
|
def participating_content_admin?
|
|
self.content_admin? && self.participating?
|
|
end
|
|
|
|
def associated_user_name
|
|
self.associated_user && self.associated_user.short_name
|
|
end
|
|
|
|
def assert_section
|
|
self.course_section = self.course.default_section if !self.course_section_id && self.course
|
|
self.root_account_id ||= self.course.root_account_id rescue nil
|
|
end
|
|
|
|
def course_name(display_user = nil)
|
|
self.course.nickname_for(display_user) || t('#enrollment.default_course_name', "Course")
|
|
end
|
|
|
|
def short_name(length = nil, display_user = nil)
|
|
return @short_name if @short_name
|
|
@short_name = self.course_section.display_name if self.course_section && self.root_account && self.root_account.show_section_name_as_course_name
|
|
@short_name ||= self.course_name(display_user)
|
|
@short_name = @short_name[0..length] if length
|
|
@short_name
|
|
end
|
|
|
|
def long_name(display_user = nil)
|
|
return @long_name if @long_name
|
|
@long_name = self.course_name(display_user)
|
|
@long_name = t('#enrollment.with_section', "%{course_name}, %{section_name}", :course_name => @long_name, :section_name => self.course_section.display_name) if self.course_section && self.course_section.display_name && self.course_section.display_name != self.course_name(display_user)
|
|
@long_name
|
|
end
|
|
|
|
TYPE_RANKS = {
|
|
:default => ['TeacherEnrollment','TaEnrollment','DesignerEnrollment','StudentEnrollment','StudentViewEnrollment','ObserverEnrollment'],
|
|
:student => ['StudentEnrollment','TeacherEnrollment','TaEnrollment','DesignerEnrollment','StudentViewEnrollment','ObserverEnrollment']
|
|
}
|
|
TYPE_RANK_HASHES = Hash[TYPE_RANKS.map{ |k, v| [k, rank_hash(v)] }]
|
|
def self.type_rank_sql(order = :default)
|
|
# don't call rank_sql during class load
|
|
rank_sql(TYPE_RANKS[order], 'enrollments.type')
|
|
end
|
|
|
|
def rank_sortable(order = :default)
|
|
TYPE_RANK_HASHES[order][self.class.to_s]
|
|
end
|
|
|
|
STATE_RANK = ['active', ['invited', 'creation_pending'], 'completed', 'inactive', 'rejected', 'deleted']
|
|
STATE_RANK_HASH = rank_hash(STATE_RANK)
|
|
def self.state_rank_sql
|
|
# don't call rank_sql during class load
|
|
@state_rank_sql ||= rank_sql(STATE_RANK, 'enrollments.workflow_state')
|
|
end
|
|
|
|
def state_sortable
|
|
STATE_RANK_HASH[state.to_s]
|
|
end
|
|
|
|
STATE_BY_DATE_RANK = ['active', ['invited', 'creation_pending', 'pending_active', 'pending_invited'], 'completed', 'inactive', 'rejected', 'deleted']
|
|
STATE_BY_DATE_RANK_HASH = rank_hash(STATE_BY_DATE_RANK)
|
|
def self.state_by_date_rank_sql
|
|
@state_by_date_rank_sql ||= rank_sql(STATE_BY_DATE_RANK, 'enrollment_states.state').
|
|
sub(/^CASE/, "CASE WHEN enrollment_states.restricted_access THEN #{STATE_BY_DATE_RANK.index('inactive')}") # pretend restricted access is the same as inactive
|
|
end
|
|
|
|
def state_with_date_sortable
|
|
STATE_RANK_HASH[state_based_on_date.to_s]
|
|
end
|
|
|
|
def accept!
|
|
res = accept
|
|
raise "can't accept" unless res
|
|
res
|
|
end
|
|
|
|
def accept(force = false)
|
|
GuardRail.activate(:primary) do
|
|
return false unless force || invited?
|
|
if update_attribute(:workflow_state, 'active')
|
|
if self.type == 'StudentEnrollment'
|
|
Enrollment.recompute_final_score_in_singleton(self.user_id, self.course_id)
|
|
end
|
|
true
|
|
end
|
|
end
|
|
end
|
|
|
|
def reset_notifications_cache
|
|
if self.saved_change_to_workflow_state?
|
|
StreamItemCache.invalidate_recent_stream_items(self.user_id, "Course", self.course_id)
|
|
end
|
|
end
|
|
|
|
def add_to_favorites_later
|
|
if self.saved_change_to_workflow_state? && self.workflow_state == 'active'
|
|
self.class.connection.after_transaction_commit do
|
|
delay_if_production(priority: Delayed::LOW_PRIORITY).add_to_favorites
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.batch_add_to_favorites(enrollment_ids)
|
|
Enrollment.where(:id => enrollment_ids).each(&:add_to_favorites)
|
|
end
|
|
|
|
def add_to_favorites
|
|
# this method was written by Alan Smithee
|
|
self.user.shard.activate do
|
|
if user.favorites.where(:context_type => 'Course').exists? # only add a favorite if they've ever favorited anything even if it's no longer in effect
|
|
Favorite.unique_constraint_retry do
|
|
user.favorites.where(:context_type => 'Course', :context_id => course).first_or_create!
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
workflow do
|
|
state :invited do
|
|
event :reject, :transitions_to => :rejected
|
|
event :complete, :transitions_to => :completed
|
|
end
|
|
|
|
state :creation_pending do
|
|
event :invite, :transitions_to => :invited
|
|
end
|
|
|
|
state :active do
|
|
event :reject, :transitions_to => :rejected
|
|
event :complete, :transitions_to => :completed
|
|
end
|
|
|
|
state :deleted
|
|
state :rejected do
|
|
event :unreject, :transitions_to => :invited
|
|
end
|
|
state :completed
|
|
|
|
# Inactive is a "hard" state, i.e. tuition not paid
|
|
state :inactive
|
|
end
|
|
|
|
def enrollment_dates
|
|
Canvas::Builders::EnrollmentDateBuilder.preload([self]) unless @enrollment_dates
|
|
@enrollment_dates
|
|
end
|
|
|
|
def enrollment_state
|
|
raise "cannot call enrollment_state on a new record" if new_record?
|
|
result = super
|
|
unless result
|
|
association(:enrollment_state).reload
|
|
result = super
|
|
end
|
|
result.enrollment = self # ensure reverse association
|
|
result
|
|
end
|
|
|
|
def create_enrollment_state
|
|
self.enrollment_state =
|
|
self.shard.activate do
|
|
GuardRail.activate(:primary) do
|
|
EnrollmentState.unique_constraint_retry do
|
|
EnrollmentState.where(:enrollment_id => self).first_or_create
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def recalculate_enrollment_state
|
|
if (self.saved_changes.keys & %w{workflow_state start_at end_at}).any?
|
|
@enrollment_dates = nil
|
|
self.enrollment_state.state_is_current = false
|
|
self.enrollment_state.is_direct_recalculation = true
|
|
end
|
|
self.enrollment_state.skip_touch_user ||= self.skip_touch_user
|
|
self.enrollment_state.ensure_current_state
|
|
end
|
|
|
|
def state_based_on_date
|
|
RequestCache.cache('enrollment_state_based_on_date', self, self.workflow_state, self.saved_changes?) do
|
|
if %w{invited active completed}.include?(self.workflow_state)
|
|
self.enrollment_state.get_effective_state
|
|
else
|
|
self.workflow_state.to_sym
|
|
end
|
|
end
|
|
end
|
|
|
|
def readable_state_based_on_date
|
|
# when view restrictions are in place, the effective state_based_on_date is :inactive, but
|
|
# to admins we should show that they are :completed or :pending
|
|
self.enrollment_state.get_display_state
|
|
end
|
|
|
|
def available_at
|
|
if self.enrollment_state.pending?
|
|
self.enrollment_state.state_valid_until
|
|
end
|
|
end
|
|
|
|
def view_restrictable?
|
|
(self.student? && !self.fake_student?) || self.observer?
|
|
end
|
|
|
|
def restrict_past_view?
|
|
self.view_restrictable? && RequestCache.cache('restrict_student_past_view', self.global_course_id) do
|
|
self.course.restrict_student_past_view?
|
|
end
|
|
end
|
|
|
|
def restrict_future_view?
|
|
self.view_restrictable? && RequestCache.cache('restrict_student_future_view', self.global_course_id) do
|
|
self.course.restrict_student_future_view?
|
|
end
|
|
end
|
|
|
|
def restrict_future_listing?
|
|
self.enrollment_state.pending? &&
|
|
(self.enrollment_state.restricted_access? || (!self.admin? && self.course.unpublished?)) &&
|
|
self.course.account.restrict_student_future_listing[:value]
|
|
end
|
|
|
|
def active?
|
|
state_based_on_date == :active
|
|
end
|
|
|
|
def inactive?
|
|
state_based_on_date == :inactive
|
|
end
|
|
|
|
def hard_inactive?
|
|
workflow_state == 'inactive'
|
|
end
|
|
|
|
def invited?
|
|
state_based_on_date == :invited
|
|
end
|
|
|
|
def accepted?
|
|
state_based_on_date == :accepted
|
|
end
|
|
|
|
def completed?
|
|
self.enrollment_state.get_display_state == :completed
|
|
end
|
|
|
|
def explicitly_completed?
|
|
state == :completed
|
|
end
|
|
|
|
def completed_at
|
|
if date = self.read_attribute(:completed_at)
|
|
date
|
|
elsif !new_record? && completed?
|
|
self.enrollment_state.state_started_at
|
|
end
|
|
end
|
|
|
|
alias_method :destroy_permanently!, :destroy
|
|
def destroy
|
|
self.workflow_state = 'deleted'
|
|
result = self.save
|
|
if result
|
|
self.user.try(:update_account_associations)
|
|
scores.update_all(updated_at: Time.zone.now, workflow_state: :deleted)
|
|
|
|
Assignment.remove_user_as_final_grader(user_id, course_id) if remove_user_as_final_grader?
|
|
end
|
|
result
|
|
end
|
|
|
|
def restore
|
|
self.workflow_state = 'active'
|
|
self.completed_at = nil
|
|
self.save
|
|
true
|
|
end
|
|
|
|
def re_send_confirmation!
|
|
@re_send_confirmation = true
|
|
self.save
|
|
@re_send_confirmation = false
|
|
true
|
|
end
|
|
|
|
def re_send_confirmation_if_invited!
|
|
self.re_send_confirmation! if self.invited?
|
|
end
|
|
|
|
def has_permission_to?(action)
|
|
@permission_lookup ||= {}
|
|
unless @permission_lookup.has_key? action
|
|
@permission_lookup[action] = RoleOverride.enabled_for?(course, action, self.role_id, nil)
|
|
end
|
|
@permission_lookup[action].include?(:self)
|
|
end
|
|
|
|
def base_role_name
|
|
self.class.to_s
|
|
end
|
|
|
|
# Determine if a user has permissions to conclude this enrollment.
|
|
#
|
|
# user - The user requesting permission to conclude/delete enrollment.
|
|
# context - The current context, e.g. course or section.
|
|
# session - The current user's session (pass nil if not available).
|
|
#
|
|
# return Boolean
|
|
def can_be_concluded_by(user, context, session)
|
|
can_remove = [StudentEnrollment].include?(self.class) &&
|
|
context.grants_right?(user, session, :manage_students) &&
|
|
context.id == ((context.is_a? Course) ? self.course_id : self.course_section_id)
|
|
can_remove || context.grants_right?(user, session, manage_admin_users_perm)
|
|
end
|
|
|
|
# Determine if a user has permissions to delete this enrollment.
|
|
#
|
|
# user - The user requesting permission to conclude/delete enrollment.
|
|
# context - The current context, e.g. course or section.
|
|
# session - The current user's session (pass nil if not available).
|
|
#
|
|
# return Boolean
|
|
def can_be_deleted_by(user, context, session)
|
|
return context.grants_right?(user, session, :use_student_view) if fake_student?
|
|
|
|
can_remove = [StudentEnrollment, ObserverEnrollment].include?(self.class) && context.grants_right?(user, session, :manage_students)
|
|
|
|
if self.root_account.feature_enabled? :granular_permissions_manage_users
|
|
can_remove ||= can_delete_via_granular(user, session, context)
|
|
can_remove &&= self.user_id != user.id || context.account.grants_right?(user, session, :allow_course_admin_actions)
|
|
else
|
|
can_remove ||= context.grants_right?(user, session, :manage_admin_users) unless student?
|
|
can_remove &&= self.user_id != user.id || context.account.grants_right?(user, session, :manage_admin_users)
|
|
end
|
|
can_remove && context.id == (context.is_a?(Course) ? self.course_id : self.course_section_id)
|
|
end
|
|
|
|
def pending?
|
|
self.invited? || self.creation_pending?
|
|
end
|
|
|
|
def email
|
|
self.user.email rescue t('#enrollment.default_email', "No Email")
|
|
end
|
|
|
|
def user_name
|
|
read_attribute(:user_name) || self.user.name rescue t('#enrollment.default_user_name', "Unknown User")
|
|
end
|
|
|
|
def context
|
|
@context ||= course
|
|
end
|
|
|
|
def context_id
|
|
@context_id ||= course_id
|
|
end
|
|
|
|
def can_switch_to?(type)
|
|
case type
|
|
when 'ObserverEnrollment'
|
|
['TeacherEnrollment', 'TaEnrollment', 'DesignerEnrollment'].include?(self.type)
|
|
when 'StudentEnrollment'
|
|
['TeacherEnrollment', 'TaEnrollment', 'DesignerEnrollment'].include?(self.type)
|
|
when 'TaEnrollment'
|
|
['TeacherEnrollment'].include?(self.type)
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
def self.workflow_readable_type(state)
|
|
case state.to_s
|
|
when 'active'
|
|
t('#enrollment.workflow.active', "Active")
|
|
when 'completed'
|
|
t('#enrollment.workflow.completed', "Completed")
|
|
when 'deleted'
|
|
t('#enrollment.workflow.deleted', "Deleted")
|
|
when 'invited'
|
|
t('#enrollment.workflow.invited', "Invited")
|
|
when 'pending', 'creation_pending'
|
|
t('#enrollment.workflow.pending', "Pending")
|
|
when 'rejected'
|
|
t('#enrollment.workflow.rejected', "Rejected")
|
|
when 'inactive'
|
|
t('#enrollment.workflow.inactive', "Inactive")
|
|
end
|
|
end
|
|
|
|
def readable_role_name
|
|
self.role.built_in? ? self.readable_type : self.role.name
|
|
end
|
|
|
|
def readable_type
|
|
Enrollment.readable_type(self.class.to_s)
|
|
end
|
|
|
|
# This is called to recompute the users' cached scores for a given course
|
|
# when:
|
|
#
|
|
# * The user is merged with another user; the scores are recomputed for the
|
|
# new user in each of his/her courses.
|
|
#
|
|
# * An assignment's default grade is changed; all users in the assignment's
|
|
# course have their scores for that course recomputed.
|
|
#
|
|
# * A course is merged into another, a section is crosslisted/uncrosslisted,
|
|
# or a section is otherwise moved between courses; scores are recomputed
|
|
# for all users in the target course.
|
|
#
|
|
# * A course's group_weighting_scheme is changed; scores are recomputed for
|
|
# all users in the course.
|
|
#
|
|
# * Assignments are reordered (since an assignment may change groups, which
|
|
# may have weights); scores are recomputed for all users in the associated
|
|
# course.
|
|
#
|
|
# * An assignment's points_possible is changed; scores are recomputed for all
|
|
# users in the associated course.
|
|
#
|
|
# * An assignment group's rules or group_weight are changed; scores are
|
|
# recomputed for all users in the associated course.
|
|
#
|
|
# * A submission's score is changed; scores for the submission owner in the
|
|
# associated course are recomputed.
|
|
#
|
|
# * An assignment is deleted/undeleted
|
|
#
|
|
# * An enrollment is accepted (to address the scenario where a student
|
|
# is transferred from one section to another, and final grades need
|
|
# to be transferred)
|
|
#
|
|
# If some new feature comes up that affects calculation of a user's score,
|
|
# please add appropriate calls to this so that the cached values don't get
|
|
# stale! And once you've added the call, add the condition to the comment
|
|
# here for future enlightenment.
|
|
|
|
def self.recompute_final_score(*args, **kwargs)
|
|
GradeCalculator.recompute_final_score(*args, **kwargs)
|
|
end
|
|
|
|
# This method is intended to not duplicate work for a single user.
|
|
def self.recompute_final_score_in_singleton(user_id, course_id, **opts)
|
|
# Guard against getting more than one user_id
|
|
raise ArgumentError, "Cannot call with more than one user" if Array(user_id).size > 1
|
|
|
|
delay_if_production(singleton: "Enrollment.recompute_final_score:#{user_id}:#{course_id}:#{opts[:grading_period_id]}",
|
|
max_attempts: 10).
|
|
recompute_final_score(user_id, course_id, **opts)
|
|
end
|
|
|
|
def self.recompute_due_dates_and_scores(user_id)
|
|
Course.where(:id => StudentEnrollment.where(user_id: user_id).distinct.pluck(:course_id)).each do |course|
|
|
DueDateCacher.recompute_users_for_course([user_id], course, nil, update_grades: true)
|
|
end
|
|
end
|
|
|
|
def self.recompute_final_scores(user_id)
|
|
StudentEnrollment.where(user_id: user_id).distinct.pluck(:course_id).each do |course_id|
|
|
recompute_final_score_in_singleton(user_id, course_id)
|
|
end
|
|
end
|
|
|
|
def computed_current_grade(id_opts=nil)
|
|
cached_score_or_grade(:current, :grade, :posted, id_opts)
|
|
end
|
|
|
|
def computed_final_grade(id_opts=nil)
|
|
cached_score_or_grade(:final, :grade, :posted, id_opts)
|
|
end
|
|
|
|
def computed_current_score(id_opts=nil)
|
|
cached_score_or_grade(:current, :score, :posted, id_opts)
|
|
end
|
|
|
|
def computed_final_score(id_opts=nil)
|
|
cached_score_or_grade(:final, :score, :posted, id_opts)
|
|
end
|
|
|
|
def effective_current_grade(id_opts=nil)
|
|
score = find_score(id_opts)
|
|
|
|
if score&.overridden? && course.allow_final_grade_override?
|
|
score.effective_final_grade
|
|
else
|
|
computed_current_grade(id_opts)
|
|
end
|
|
end
|
|
|
|
def effective_current_score(id_opts=nil)
|
|
score = find_score(id_opts)
|
|
|
|
if score&.overridden? && course.allow_final_grade_override?
|
|
score.effective_final_score
|
|
else
|
|
computed_current_score(id_opts)
|
|
end
|
|
end
|
|
|
|
def effective_final_grade(id_opts=nil)
|
|
score = find_score(id_opts)
|
|
|
|
if score&.overridden? && course.allow_final_grade_override?
|
|
score.effective_final_grade
|
|
else
|
|
computed_final_grade(id_opts)
|
|
end
|
|
end
|
|
|
|
def effective_final_score(id_opts=nil)
|
|
score = find_score(id_opts)
|
|
|
|
if score&.overridden? && course.allow_final_grade_override?
|
|
score.effective_final_score
|
|
else
|
|
computed_final_score(id_opts)
|
|
end
|
|
end
|
|
|
|
def override_grade(id_opts=nil)
|
|
return nil unless course.allow_final_grade_override? && course.grading_standard_enabled?
|
|
score = find_score(id_opts)
|
|
score.effective_final_grade if score&.override_score
|
|
end
|
|
|
|
def override_score(id_opts=nil)
|
|
return nil unless course.allow_final_grade_override?
|
|
score = find_score(id_opts)
|
|
score&.override_score
|
|
end
|
|
|
|
def computed_current_points(id_opts=nil)
|
|
find_score(id_opts)&.current_points
|
|
end
|
|
|
|
def unposted_current_points(id_opts=nil)
|
|
find_score(id_opts)&.unposted_current_points
|
|
end
|
|
|
|
def unposted_current_grade(id_opts=nil)
|
|
cached_score_or_grade(:current, :grade, :unposted, id_opts)
|
|
end
|
|
|
|
def unposted_final_grade(id_opts=nil)
|
|
cached_score_or_grade(:final, :grade, :unposted, id_opts)
|
|
end
|
|
|
|
def unposted_current_score(id_opts=nil)
|
|
cached_score_or_grade(:current, :score, :unposted, id_opts)
|
|
end
|
|
|
|
def unposted_final_score(id_opts=nil)
|
|
cached_score_or_grade(:final, :score, :unposted, id_opts)
|
|
end
|
|
|
|
def cached_score_or_grade(current_or_final, score_or_grade, posted_or_unposted, id_opts=nil)
|
|
score = find_score(id_opts)
|
|
method = +"#{current_or_final}_#{score_or_grade}"
|
|
method.prepend("unposted_") if posted_or_unposted == :unposted
|
|
score&.send(method)
|
|
end
|
|
private :cached_score_or_grade
|
|
|
|
def find_score(id_opts=nil)
|
|
id_opts ||= Score.params_for_course
|
|
valid_keys = %i(course_score grading_period grading_period_id assignment_group assignment_group_id)
|
|
return nil if id_opts.except(*valid_keys).any?
|
|
result = if scores.loaded?
|
|
scores.detect { |score| score.attributes >= id_opts.with_indifferent_access }
|
|
else
|
|
scores.where(id_opts).first
|
|
end
|
|
if result
|
|
result.enrollment = self
|
|
# have to go through gymnastics to force-preload a has_one :through without causing a db transaction
|
|
if association(:course).loaded?
|
|
assn = result.association(:course)
|
|
assn.target = course
|
|
end
|
|
end
|
|
result
|
|
end
|
|
|
|
def graded_at
|
|
score = find_score
|
|
if score.present?
|
|
score.updated_at
|
|
else
|
|
# TODO: drop the graded_at column after the data fixup to populate
|
|
# the scores table completes
|
|
read_attribute(:graded_at)
|
|
end
|
|
end
|
|
|
|
def self.typed_enrollment(type)
|
|
return nil unless ['StudentEnrollment', 'StudentViewEnrollment', 'TeacherEnrollment', 'TaEnrollment', 'ObserverEnrollment', 'DesignerEnrollment'].include?(type)
|
|
type.constantize
|
|
end
|
|
|
|
# overridden to return true in appropriate subclasses
|
|
def student?
|
|
false
|
|
end
|
|
|
|
def fake_student?
|
|
false
|
|
end
|
|
|
|
def student_with_conditions?(include_future:, include_fake_student:)
|
|
return false unless student? || fake_student?
|
|
if include_fake_student
|
|
include_future || participating?
|
|
else
|
|
include_future ? student? : participating_student?
|
|
end
|
|
end
|
|
|
|
def observer?
|
|
false
|
|
end
|
|
|
|
def teacher?
|
|
false
|
|
end
|
|
|
|
def ta?
|
|
false
|
|
end
|
|
|
|
def designer?
|
|
false
|
|
end
|
|
|
|
def instructor?
|
|
teacher? || ta?
|
|
end
|
|
|
|
def admin?
|
|
instructor? || designer?
|
|
end
|
|
|
|
def content_admin?
|
|
teacher? || designer?
|
|
end
|
|
|
|
def to_atom
|
|
Atom::Entry.new do |entry|
|
|
entry.title = t('#enrollment.title', "%{user_name} in %{course_name}", :user_name => self.user_name, :course_name => self.course_name)
|
|
entry.updated = self.updated_at
|
|
entry.published = self.created_at
|
|
entry.links << Atom::Link.new(:rel => 'alternate',
|
|
:href => "/courses/#{self.course.id}/enrollments/#{self.id}")
|
|
end
|
|
end
|
|
|
|
set_policy do
|
|
given { |user, session| self.course.grants_any_right?(user, session, :manage_students, manage_admin_users_perm, :read_roster) }
|
|
can :read
|
|
|
|
given { |user| self.user == user }
|
|
can :read and can :read_grades
|
|
|
|
given { |user, session| self.course.students_visible_to(user, include: :priors).where(:id => self.user_id).exists? &&
|
|
self.course.grants_any_right?(user, session, :manage_grades, :view_all_grades) }
|
|
can :read and can :read_grades
|
|
|
|
given { |user| course.observer_enrollments.where(user_id: user, associated_user_id: self.user_id).exists? }
|
|
can :read and can :read_grades
|
|
|
|
given {|user, session| self.course.grants_right?(user, session, :participate_as_student) && self.user.show_user_services }
|
|
can :read_services
|
|
|
|
# read_services says this person has permission to see what web services this enrollment has linked to their account
|
|
given {|user, session| self.grants_right?(user, session, :read) && self.user.show_user_services }
|
|
can :read_services
|
|
end
|
|
|
|
scope :before, lambda { |date|
|
|
where("enrollments.created_at<?", date)
|
|
}
|
|
|
|
scope :for_user, lambda { |user| where(:user_id => user) }
|
|
|
|
scope :for_courses_with_user_name, lambda { |courses|
|
|
where(:course_id => courses).
|
|
joins(:user).
|
|
select("user_id, course_id, users.name AS user_name")
|
|
}
|
|
scope :invited, -> { where(:workflow_state => 'invited') }
|
|
scope :accepted, -> { where("enrollments.workflow_state<>'invited'") }
|
|
scope :active_or_pending, -> { where("enrollments.workflow_state NOT IN ('rejected', 'completed', 'deleted', 'inactive')") }
|
|
scope :all_active_or_pending, -> { where("enrollments.workflow_state NOT IN ('rejected', 'completed', 'deleted')") } # includes inactive
|
|
|
|
scope :active_by_date, -> { joins(:enrollment_state).where("enrollment_states.state = 'active'") }
|
|
scope :invited_by_date, -> { joins(:enrollment_state).where("enrollment_states.restricted_access = ?", false).
|
|
where("enrollment_states.state IN ('invited', 'pending_invited')") }
|
|
scope :active_or_pending_by_date, -> { joins(:enrollment_state).where("enrollment_states.restricted_access = ?", false).
|
|
where("enrollment_states.state IN ('active', 'invited', 'pending_invited', 'pending_active')") }
|
|
scope :invited_or_pending_by_date, -> { joins(:enrollment_state).where("enrollment_states.restricted_access = ?", false).
|
|
where("enrollment_states.state IN ('invited', 'pending_invited', 'pending_active')") }
|
|
scope :completed_by_date, -> { joins(:enrollment_state).where("enrollment_states.restricted_access = ?", false).
|
|
where("enrollment_states.state = ?", "completed") }
|
|
scope :not_inactive_by_date, -> { joins(:enrollment_state).where("enrollment_states.restricted_access = ?", false).
|
|
where("enrollment_states.state IN ('active', 'invited', 'completed', 'pending_invited', 'pending_active')") }
|
|
|
|
scope :active_or_pending_by_date_ignoring_access, -> { joins(:enrollment_state).
|
|
where("enrollment_states.state IN ('active', 'invited', 'pending_invited', 'pending_active')") }
|
|
scope :not_inactive_by_date_ignoring_access, -> { joins(:enrollment_state).
|
|
where("enrollment_states.state IN ('active', 'invited', 'completed', 'pending_invited', 'pending_active')") }
|
|
scope :new_or_active_by_date, -> { joins(:enrollment_state).
|
|
where("enrollment_states.state IN ('active', 'invited', 'pending_invited', 'pending_active', 'creation_pending')") }
|
|
|
|
scope :currently_online, -> { joins(:pseudonyms).where("pseudonyms.last_request_at>?", 5.minutes.ago) }
|
|
# this returns enrollments for creation_pending users; should always be used in conjunction with the invited scope
|
|
scope :for_email, lambda { |email|
|
|
joins(:user => :communication_channels).
|
|
where("users.workflow_state='creation_pending' AND communication_channels.workflow_state='unconfirmed' AND path_type='email' AND LOWER(path)=LOWER(?)", email).
|
|
select("enrollments.*").
|
|
readonly(false)
|
|
}
|
|
def self.cached_temporary_invitations(email)
|
|
if Enrollment.cross_shard_invitations?
|
|
Shard.birth.activate do
|
|
invitations = Rails.cache.fetch([email, 'all_invited_enrollments2'].cache_key) do
|
|
Shard.with_each_shard(CommunicationChannel.associated_shards(email)) do
|
|
Enrollment.invited.for_email(email).to_a
|
|
end
|
|
end
|
|
end
|
|
else
|
|
Rails.cache.fetch([email, 'invited_enrollments2'].cache_key) do
|
|
Enrollment.invited.for_email(email).to_a
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.order_by_sortable_name
|
|
clause = User.sortable_name_order_by_clause('users')
|
|
scope = self.order(clause)
|
|
if scope.select_values.present?
|
|
scope = scope.select(clause)
|
|
else
|
|
scope = scope.select(self.arel_table[Arel.star])
|
|
end
|
|
scope
|
|
end
|
|
|
|
def self.top_enrollment_by(key, rank_order = :default)
|
|
raise "top_enrollment_by_user must be scoped" unless all.where_clause.present?
|
|
|
|
key = key.to_s
|
|
order(Arel.sql("#{key}, #{type_rank_sql(rank_order)}")).distinct_on(key)
|
|
end
|
|
|
|
def assign_uuid
|
|
# DON'T use ||=, because that will cause an immediate save to the db if it
|
|
# doesn't already exist
|
|
self.uuid = CanvasSlug.generate_securish_uuid if !read_attribute(:uuid)
|
|
end
|
|
protected :assign_uuid
|
|
|
|
def uuid
|
|
if !read_attribute(:uuid)
|
|
self.update_attribute(:uuid, CanvasSlug.generate_securish_uuid)
|
|
end
|
|
read_attribute(:uuid)
|
|
end
|
|
|
|
def self.limit_privileges_to_course_section!(course, user, limit)
|
|
course.shard.activate do
|
|
Enrollment.where(:course_id => course, :user_id => user).each do |enrollment|
|
|
enrollment.limit_privileges_to_course_section = !!limit
|
|
enrollment.save!
|
|
end
|
|
end
|
|
user.clear_cache_key(:enrollments)
|
|
end
|
|
|
|
def self.course_user_state(course, uuid)
|
|
Rails.cache.fetch(['user_state', course, uuid].cache_key) do
|
|
enrollment = course.enrollments.where(uuid: uuid).first
|
|
if enrollment
|
|
{
|
|
:enrollment_state => enrollment.workflow_state,
|
|
:user_state => enrollment.user.state,
|
|
:is_admin => enrollment.admin?
|
|
}
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.serialization_excludes; [:uuid,:computed_final_score, :computed_current_score]; end
|
|
|
|
# enrollment term per-section is deprecated; a section's term is inherited from the
|
|
# course it is currently tied to
|
|
def enrollment_term
|
|
self.course.enrollment_term
|
|
end
|
|
|
|
def effective_start_at
|
|
# try and use the enrollment dates logic first, since it knows about
|
|
# overrides, etc. but if it doesn't find anything, start guessing by
|
|
# looking at the enrollment, section, course, then term. if we still didn't
|
|
# find it, fall back to the section or course creation date.
|
|
enrollment_dates.map(&:first).compact.min ||
|
|
start_at ||
|
|
course_section && course_section.start_at ||
|
|
course.start_at ||
|
|
course.enrollment_term && course.enrollment_term.start_at ||
|
|
course_section && course_section.created_at ||
|
|
course.created_at
|
|
end
|
|
|
|
def effective_end_at
|
|
# try and use the enrollment dates logic first, since it knows about
|
|
# overrides, etc. but if it doesn't find anything, start guessing by
|
|
# looking at the enrollment, section, course, then term.
|
|
enrollment_dates.map(&:last).compact.max ||
|
|
end_at ||
|
|
course_section && course_section.end_at ||
|
|
course.conclude_at ||
|
|
course.enrollment_term && course.enrollment_term.end_at
|
|
end
|
|
|
|
def self.cross_shard_invitations?
|
|
false
|
|
end
|
|
|
|
def total_activity_time
|
|
self.read_attribute(:total_activity_time).to_i
|
|
end
|
|
|
|
def touch_graders_if_needed
|
|
if !active_student? && active_student?(:was) && self.course.submissions.where(:user_id => self.user_id).exists?
|
|
self.class.connection.after_transaction_commit do
|
|
self.course.admins.clear_cache_keys(:todo_list)
|
|
end
|
|
end
|
|
end
|
|
|
|
def update_assignment_overrides_if_needed
|
|
assignment_scope = Assignment.where(context_id: self.course_id, context_type: 'Course')
|
|
override_scope = AssignmentOverrideStudent.where(user_id: self.user_id)
|
|
|
|
if being_deleted? && !enrollments_exist_for_user_in_course?
|
|
return unless (assignment_ids = assignment_scope.pluck(:id)).any?
|
|
|
|
# this is handled in after_commit :update_cached_due_dates
|
|
AssignmentOverrideStudent.suspend_callbacks(:update_cached_due_dates) do
|
|
override_scope.where(assignment_id: assignment_ids).find_each(&:destroy)
|
|
end
|
|
end
|
|
|
|
if being_accepted?
|
|
return unless ConditionalRelease::Service.enabled_in_context?(self.course)
|
|
# Deleted student overrides associated with assignments with a Mastery Path override
|
|
releases = override_scope.where(workflow_state: 'deleted').
|
|
where(assignment: assignment_scope).
|
|
joins(assignment: :assignment_overrides).
|
|
where(assignment_overrides: {
|
|
set_type: AssignmentOverride::SET_TYPE_NOOP,
|
|
set_id: AssignmentOverride::NOOP_MASTERY_PATHS,
|
|
workflow_state: 'active'
|
|
}).distinct
|
|
return unless releases.exists?
|
|
# Add parent join to reduce duplication, which are used in both cases below
|
|
releases = releases.
|
|
joins("INNER JOIN #{AssignmentOverride.quoted_table_name} parent ON assignment_override_students.assignment_override_id = parent.id")
|
|
# Restore student overrides associated with an active assignment override
|
|
releases.where('parent.workflow_state = \'active\'').update(workflow_state: 'active')
|
|
# Restore student overrides and assignment overrides if assignment override is deleted
|
|
releases.preload(:assignment_override).where('parent.workflow_state = \'deleted\'').find_each do |release|
|
|
release.update(workflow_state: 'active')
|
|
release.assignment_override.update(workflow_state: 'active')
|
|
end
|
|
end
|
|
end
|
|
|
|
def section_or_course_date_in_past?
|
|
if self.course_section&.end_at
|
|
self.course_section.end_at < Time.zone.now
|
|
elsif self.course.conclude_at
|
|
self.course.conclude_at < Time.zone.now
|
|
end
|
|
end
|
|
|
|
def student_or_fake_student?
|
|
['StudentEnrollment', 'StudentViewEnrollment'].include?(type)
|
|
end
|
|
|
|
private
|
|
|
|
def enrollments_exist_for_user_in_course?
|
|
Enrollment.active.where(user_id: self.user_id, course_id: self.course_id).exists?
|
|
end
|
|
|
|
def copy_scores_from_existing_enrollment
|
|
Score.where(enrollment_id: self).each(&:destroy_permanently!)
|
|
other_enrollment_of_same_type.scores.each { |score| score.dup.update!(enrollment: self) }
|
|
end
|
|
|
|
def need_to_copy_scores?
|
|
return false unless saved_change_to_id? || being_restored?
|
|
student_or_fake_student? && other_enrollment_of_same_type.present?
|
|
end
|
|
|
|
def other_enrollment_of_same_type
|
|
return @other_enrollment_of_same_type if defined?(@other_enrollment_of_same_type)
|
|
|
|
@other_enrollment_of_same_type = other_enrollments_of_type(type).first
|
|
end
|
|
|
|
def other_enrollments_of_type(types)
|
|
Enrollment.where(
|
|
course_id: course,
|
|
user_id: user,
|
|
type: Array.wrap(types)
|
|
).where.not(id: id).where.not(workflow_state: :deleted)
|
|
end
|
|
|
|
def manage_admin_users_perm
|
|
self.root_account.feature_enabled?(:granular_permissions_manage_users) ? :allow_course_admin_actions : :manage_admin_users
|
|
end
|
|
|
|
def can_delete_via_granular(user, session, context)
|
|
self.teacher? && context.grants_right?(user, session, :remove_teacher_from_course) ||
|
|
self.ta? && context.grants_right?(user, session, :remove_ta_from_course) ||
|
|
self.designer? && context.grants_right?(user, session, :remove_designer_from_course) ||
|
|
self.observer? && context.grants_right?(user, session, :remove_observer_from_course)
|
|
end
|
|
|
|
def remove_user_as_final_grader?
|
|
instructor? &&
|
|
!other_enrollments_of_type(['TaEnrollment', 'TeacherEnrollment']).exists?
|
|
end
|
|
|
|
def being_accepted?
|
|
saved_change_to_workflow_state? && workflow_state == 'active' && workflow_state_before_last_save == 'invited'
|
|
end
|
|
|
|
def being_restored?(to_state: workflow_state)
|
|
saved_change_to_workflow_state? && workflow_state_before_last_save == 'deleted' && workflow_state == to_state
|
|
end
|
|
|
|
def being_reactivated?
|
|
saved_change_to_workflow_state? && workflow_state != 'deleted' && workflow_state_before_last_save == 'inactive'
|
|
end
|
|
|
|
def being_uncompleted?
|
|
saved_change_to_workflow_state? && workflow_state != 'deleted' && workflow_state_before_last_save == 'completed'
|
|
end
|
|
|
|
def being_deleted?
|
|
workflow_state == 'deleted' && workflow_state_before_last_save != 'deleted'
|
|
end
|
|
|
|
def sync_microsoft_group
|
|
return if self.type == 'StudentViewEnrollment'
|
|
return unless self.root_account.feature_enabled?(:microsoft_group_enrollments_syncing)
|
|
return unless self.root_account.settings[:microsoft_sync_enabled]
|
|
|
|
MicrosoftSync::Group.not_deleted.find_by(course_id: course_id)&.enqueue_future_partial_sync self
|
|
end
|
|
end
|