canvas-lms/app/models/user.rb

2776 lines
111 KiB
Ruby
Raw Normal View History

2011-02-01 09:57:29 +08:00
#
# Copyright (C) 2011 - 2012 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/>.
#
class User < ActiveRecord::Base
# this has to be before include Context to prevent a circular dependency in Course
def self.sortable_name_order_by_clause(table = nil)
col = table ? "#{table}.sortable_name" : 'sortable_name'
best_unicode_collation_key(col)
end
2011-02-01 09:57:29 +08:00
include Context
include UserFollow::FollowedItem
2011-02-01 09:57:29 +08:00
attr_accessible :name, :short_name, :sortable_name, :time_zone, :show_user_services, :gender, :visible_inbox_types, :avatar_image, :subscribe_to_emails, :locale, :bio, :birthdate, :terms_of_use, :self_enrollment_code, :initial_enrollment_type
attr_accessor :original_id, :menu_data
2011-02-01 09:57:29 +08:00
before_save :infer_defaults
serialize :preferences
include Workflow
# Internal: SQL fragments used to return enrollments in their respective workflow
# states. Where needed, these consider the state of the course to ensure that
# students do not see their enrollments on unpublished courses.
#
# strict_course_state can be used to bypass the course state checks. This is
# useful in places like the course settings UI, where we use these conditions
# to search users in the course (rather than as an association on a
# particular user)
def self.enrollment_conditions(state, strict_course_state = true)
#strict_course_state = true
case state
when :active
if strict_course_state
"( enrollments.workflow_state = 'active' and ((courses.workflow_state = 'claimed' and enrollments.type IN ('TeacherEnrollment', 'TaEnrollment', 'DesignerEnrollment', 'StudentViewEnrollment')) or (courses.workflow_state = 'available')) )"
else
"( enrollments.workflow_state = 'active' and courses.workflow_state != 'deleted' )"
end
when :invited
if strict_course_state
"( enrollments.workflow_state = 'invited' and ((courses.workflow_state = 'available' and (enrollments.type = 'StudentEnrollment' or enrollments.type = 'ObserverEnrollment')) or (courses.workflow_state != 'deleted' and (enrollments.type IN ('TeacherEnrollment', 'TaEnrollment', 'DesignerEnrollment', 'StudentViewEnrollment')))))"
else
"( enrollments.workflow_state IN ('invited', 'creation_pending') and courses.workflow_state != 'deleted' )"
end
when :deleted; "enrollments.workflow_state = 'deleted'"
when :rejected; "enrollments.workflow_state = 'rejected'"
when :completed; "enrollments.workflow_state = 'completed'"
when :creation_pending; "enrollments.workflow_state = 'creation_pending'"
when :inactive; "enrollments.workflow_state = 'inactive'"
when :current_and_invited
enrollment_conditions(:active, strict_course_state) +
" OR " +
enrollment_conditions(:invited, strict_course_state)
end
end
has_many :communication_channels, :order => 'communication_channels.position ASC', :dependent => :destroy
2011-02-01 09:57:29 +08:00
has_one :communication_channel, :order => 'position'
has_many :enrollments, :dependent => :destroy
has_many :current_enrollments, :class_name => 'Enrollment', :include => [:course, :course_section], :conditions => enrollment_conditions(:active), :order => 'enrollments.created_at'
has_many :invited_enrollments, :class_name => 'Enrollment', :include => [:course, :course_section], :conditions => enrollment_conditions(:invited), :order => 'enrollments.created_at'
has_many :current_and_invited_enrollments, :class_name => 'Enrollment', :include => [:course], :order => 'enrollments.created_at',
:conditions => enrollment_conditions(:current_and_invited)
has_many :not_ended_enrollments, :class_name => 'Enrollment', :conditions => "enrollments.workflow_state NOT IN ('rejected', 'completed', 'deleted')", :order => 'enrollments.created_at'
has_many :concluded_enrollments, :class_name => 'Enrollment', :include => [:course, :course_section], :conditions => enrollment_conditions(:completed), :order => 'enrollments.created_at'
has_many :observer_enrollments
has_many :observee_enrollments, :foreign_key => :associated_user_id, :class_name => 'ObserverEnrollment'
has_many :user_observers, :dependent => :delete_all
has_many :observers, :through => :user_observers, :class_name => 'User'
has_many :user_observees, :class_name => 'UserObserver', :foreign_key => :observer_id, :dependent => :delete_all
has_many :observed_users, :through => :user_observees, :source => :user
has_many :courses, :through => :current_enrollments, :uniq => true
has_many :current_and_invited_courses, :source => :course, :through => :current_and_invited_enrollments
has_many :concluded_courses, :source => :course, :through => :concluded_enrollments, :uniq => true
has_many :all_courses, :source => :course, :through => :enrollments
has_many :current_and_concluded_enrollments, :class_name => 'Enrollment', :include => [:course, :course_section],
:conditions => [enrollment_conditions(:active), enrollment_conditions(:completed)].join(' OR '), :order => 'enrollments.created_at'
has_many :current_and_concluded_courses, :source => :course, :through => :current_and_concluded_enrollments, :uniq => true
2011-02-01 09:57:29 +08:00
has_many :group_memberships, :include => :group, :dependent => :destroy
has_many :groups, :through => :group_memberships
has_many :current_group_memberships, :include => :group, :class_name => 'GroupMembership', :conditions => "group_memberships.workflow_state = 'accepted' AND groups.workflow_state <> 'deleted'"
has_many :current_groups, :through => :current_group_memberships, :source => :group
2011-02-01 09:57:29 +08:00
has_many :user_account_associations
has_many :associated_accounts, :source => :account, :through => :user_account_associations, :order => 'user_account_associations.depth'
has_many :associated_root_accounts, :source => :account, :through => :user_account_associations, :order => 'user_account_associations.depth', :conditions => 'accounts.parent_account_id IS NULL'
has_many :developer_keys
has_many :access_tokens, :include => :developer_key
2011-02-01 09:57:29 +08:00
has_many :student_enrollments
has_many :ta_enrollments
has_many :teacher_enrollments, :class_name => 'TeacherEnrollment', :conditions => ["enrollments.type = 'TeacherEnrollment'"]
2011-02-01 09:57:29 +08:00
has_many :submissions, :include => [:assignment, :submission_comments], :order => 'submissions.updated_at DESC', :dependent => :destroy
has_many :pseudonyms_with_channels, :class_name => 'Pseudonym', :order => 'position', :include => :communication_channels
has_many :pseudonyms, :order => 'position', :dependent => :destroy
has_many :active_pseudonyms, :class_name => 'Pseudonym', :conditions => ['pseudonyms.workflow_state != ?', 'deleted']
2011-02-01 09:57:29 +08:00
has_many :pseudonym_accounts, :source => :account, :through => :pseudonyms
has_one :pseudonym, :conditions => ['pseudonyms.workflow_state != ?', 'deleted'], :order => 'position'
2011-02-01 09:57:29 +08:00
has_many :attachments, :as => 'context', :dependent => :destroy
has_many :active_images, :as => :context, :class_name => 'Attachment', :conditions => ["attachments.file_state != ? AND attachments.content_type LIKE 'image%'", 'deleted'], :order => 'attachments.display_name', :include => :thumbnail
2011-02-01 09:57:29 +08:00
has_many :active_assignments, :as => :context, :class_name => 'Assignment', :conditions => ['assignments.workflow_state != ?', 'deleted']
has_many :all_attachments, :as => 'context', :class_name => 'Attachment'
has_many :folders, :as => 'context', :order => 'folders.name'
has_many :active_folders, :class_name => 'Folder', :as => :context, :conditions => ['folders.workflow_state != ?', 'deleted'], :order => 'folders.name'
has_many :active_folders_with_sub_folders, :class_name => 'Folder', :as => :context, :include => [:active_sub_folders], :conditions => ['folders.workflow_state != ?', 'deleted'], :order => 'folders.name'
2011-02-01 09:57:29 +08:00
has_many :active_folders_detailed, :class_name => 'Folder', :as => :context, :include => [:active_sub_folders, :active_file_attachments], :conditions => ['folders.workflow_state != ?', 'deleted'], :order => 'folders.name'
has_many :calendar_events, :as => 'context', :dependent => :destroy, :include => [:parent_event]
2011-02-01 09:57:29 +08:00
has_many :eportfolios, :dependent => :destroy
has_many :quiz_submissions, :dependent => :destroy
has_many :dashboard_messages, :class_name => 'Message', :conditions => {:to => "dashboard", :workflow_state => 'dashboard'}, :order => 'created_at DESC', :dependent => :destroy
has_many :collaborations, :order => 'created_at DESC'
has_many :user_services, :order => 'created_at', :dependent => :destroy
has_one :scribd_account, :as => :scribdable
has_many :rubric_associations, :as => :context, :include => :rubric, :order => 'rubric_associations.created_at DESC'
has_many :rubrics
has_many :context_rubrics, :as => :context, :class_name => 'Rubric'
has_many :grading_standards
has_many :context_module_progressions
has_many :assessment_question_bank_users
has_many :assessment_question_banks, :through => :assessment_question_bank_users
has_many :learning_outcome_results
2011-02-01 09:57:29 +08:00
has_many :inbox_items, :order => 'created_at DESC'
has_many :submission_comment_participants
has_many :submission_comments, :through => :submission_comment_participants, :include => {:submission => {:assignment => {}, :user => {}} }
has_many :collaborators
has_many :collaborations, :through => :collaborators, :include => [:user, :collaborators]
has_many :assigned_submission_assessments, :class_name => 'AssessmentRequest', :foreign_key => 'assessor_id', :include => {:user => {}, :submission => :assignment}
has_many :assigned_assessments, :class_name => 'AssessmentRequest', :foreign_key => 'assessor_id'
has_many :web_conference_participants
has_many :web_conferences, :through => :web_conference_participants
has_many :account_users
has_many :media_objects, :as => :context
has_many :user_generated_media_objects, :class_name => 'MediaObject'
has_many :user_notes
has_many :account_reports
has_many :stream_item_instances, :dependent => :delete_all
has_many :all_conversations, :class_name => 'ConversationParticipant', :include => :conversation
has_many :conversation_batches, :include => :root_conversation_message
has_many :favorites
has_many :favorite_courses, :source => :course, :through => :current_and_invited_enrollments, :conditions => "EXISTS (SELECT 1 FROM favorites WHERE context_type = 'Course' AND context_id = enrollments.course_id AND user_id = enrollments.user_id)"
has_many :zip_file_imports, :as => :context
has_many :messages
has_many :following_user_follows, :class_name => 'UserFollow', :as => :followed_item
has_many :user_follows, :foreign_key => 'following_user_id'
has_many :collections, :as => :context
has_many :collection_items, :through => :collections
has_many :collection_item_upvotes
has_one :profile, :class_name => 'UserProfile'
alias :orig_profile :profile
multi-factor authentication closes #9532 test plan: * enable optional MFA, and check the following: * normal log in should not be affected * you can enroll in MFA from your profile page * you can re-enroll in MFA from your profile page * you can disable MFA from your profile page * MFA can be reset by an admin on your user page * when enrolled, you are asked for verification code after username/password when logging in * you can't access any other part of the site directly until until entering your verification code * enable required MFA, and check the following * when not enrolled in MFA, and you log in, you are forced to enroll * you cannot disable MFA from your profile page * you can re-enroll in MFA from your profile page * an admin (other than himself) can reset MFA from the user page * for enrolling in MFA * use Google Authenticator and scan the QR code; you should have 30-seconds or so of extra leeway to enter your code * having no SMS communication channels on your profile, the enrollment page should just have a form to add a new phone * having one or more SMS communication channels on your profile, the enrollment page should list them, or allow you to create a new one (and switch back) * having more than one SMS communication channel on your profile, the enrollment page should remember which one you have selected after you click "send" * an unconfirmed SMS channel should go to confirmed when it's used to enroll in MFA * you should not be able to go directly to /login/otp to enroll if you used "Remember me" token to log in * MFA login flow * if configured with SMS, it should send you an SMS after you put in your username/password; you should have about 5 minutes of leeway to put it in * if you don't check "remember computer" checkbox, you should have to enter a verification code each time you log in * if you do check it, you shouldn't have to enter your code anymore (for three days). it also shouldn't SMS you a verification code each time you log in * setting MFA to required for admins should make it required for admins, optional for other users * with MFA enabled, directly go to /login/otp after entering username/password but before entering a verification code; it should send you back to the main login page * if you enrolled via SMS, you should not be able to remove that SMS from your profile * there should not be a reset MFA link on a user page if they haven't enrolled * test a login or required enrollment sequence with CAS and/or SAML Change-Id: I692de7405bf7ca023183e717930ee940ccf0d5e6 Reviewed-on: https://gerrit.instructure.com/12700 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Brian Palmer <brianp@instructure.com>
2012-08-03 05:17:50 +08:00
belongs_to :otp_communication_channel, :class_name => 'CommunicationChannel'
include StickySisFields
are_sis_sticky :name, :sortable_name, :short_name
def conversations
# i.e. exclude any where the user has deleted all the messages
all_conversations.visible.scoped(:order => "last_message_at DESC, conversation_id DESC")
end
2011-02-01 09:57:29 +08:00
def page_views
PageView.for_user(self)
end
2011-02-01 09:57:29 +08:00
named_scope :of_account, lambda { |account|
{
:joins => :user_account_associations,
:conditions => ['user_account_associations.account_id = ?', account.id]
}
}
named_scope :recently_logged_in, lambda{
{
:include => :pseudonyms,
:conditions => ['pseudonyms.current_login_at > ?', 1.month.ago],
:order => 'pseudonyms.current_login_at DESC',
:limit => 25
}
}
named_scope :include_pseudonym, lambda{
{:include => :pseudonym }
}
named_scope :restrict_to_sections, lambda{|sections|
2011-02-01 09:57:29 +08:00
section_ids = Array(sections).map{|s| s.is_a?(Fixnum) ? s : s.id }
if section_ids.empty?
{:conditions => {}}
else
{:conditions => ["enrollments.limit_privileges_to_course_section IS NULL OR enrollments.limit_privileges_to_course_section != ? OR enrollments.course_section_id IN (?)", true, section_ids]}
end
2011-02-01 09:57:29 +08:00
}
named_scope :name_like, lambda { |name|
{ :conditions => ["(", wildcard('users.name', 'users.short_name', name), " OR exists (select 1 from pseudonyms where ", wildcard('pseudonyms.sis_user_id', 'pseudonyms.unique_id', name), " and pseudonyms.user_id = users.id and (", User.send(:sanitize_sql_array, Pseudonym.active.proxy_options[:conditions]), ")))"].join }
2011-02-01 09:57:29 +08:00
}
named_scope :active, lambda {
{ :conditions => ["users.workflow_state != ?", 'deleted'] }
}
2011-02-01 09:57:29 +08:00
named_scope :has_current_student_enrollments, :conditions => "EXISTS (SELECT * FROM enrollments JOIN courses ON courses.id = enrollments.course_id AND courses.workflow_state = 'available' WHERE enrollments.user_id = users.id AND enrollments.workflow_state IN ('active','invited') AND enrollments.type = 'StudentEnrollment')"
# NOTE: if :order is passed in, sortable name will be tacked onto the end
# rather than prepending or replacing it
def self.order_by_sortable_name(options = {})
direction = options.delete(:direction) || :ascending
sort_clause = "#{sortable_name_order_by_clause} #{direction == :descending ? "DESC" : "ASC"}"
add_sort_key!(options, sort_clause)
uber_scope(options)
end
def self.by_top_enrollment(options = {})
options[:select] ||= "users.*"
options[:select] << ", MIN(#{Enrollment.type_rank_sql(:student)}) AS enrollment_rank"
options[:group] = User.connection.group_by(User)
options[:order] = "enrollment_rank"
order_by_sortable_name(options)
end
named_scope :enrolled_in_course_between, lambda{|course_ids, start_at, end_at|
2011-02-01 09:57:29 +08:00
ids_string = course_ids.join(",")
{
:joins => :enrollments,
:conditions => ["enrollments.course_id in (#{ids_string}) AND enrollments.created_at > ? AND enrollments.created_at < ?", start_at, end_at]
}
}
named_scope :for_course_with_last_login, lambda {|course, root_account_id, enrollment_type|
course_id = course.is_a?(Course) ? course.id : course
enrollment_conditions = sanitize_sql(['enrollments.course_id = ? AND enrollments.workflow_state != ?', course_id, 'deleted'])
enrollment_conditions += sanitize_sql(['AND enrollments.type = ?', enrollment_type]) if enrollment_type
{
# add a field to each user that is the aggregated max from current_login_at and last_login_at from their pseudonyms
:select => 'users.*, MAX(current_login_at) as last_login, MAX(current_login_at) IS NULL as login_info_exists',
# left outer join ensures we get the user even if they don't have a pseudonym
:joins => sanitize_sql([<<-SQL, root_account_id]),
LEFT OUTER JOIN pseudonyms ON pseudonyms.user_id = users.id AND pseudonyms.account_id = ?
INNER JOIN enrollments ON enrollments.user_id = users.id
SQL
:conditions => enrollment_conditions,
# the trick to get unique users
:group => 'users.id'
}
}
2011-02-01 09:57:29 +08:00
has_a_broadcast_policy
attr_accessor :require_acceptance_of_terms, :require_presence_of_name,
:require_self_enrollment_code, :require_birthdate, :self_enrollment_code,
:self_enrollment_course, :validation_root_account
# users younger than this age can't sign up without a course join code
def self.self_enrollment_min_age
13
end
validates_length_of :name, :maximum => maximum_string_length, :allow_nil => true
validates_length_of :short_name, :maximum => maximum_string_length, :allow_nil => true
validates_length_of :sortable_name, :maximum => maximum_string_length, :allow_nil => true
validates_presence_of :name, :if => :require_presence_of_name
validates_locale :locale, :browser_locale, :allow_nil => true
validates_acceptance_of :terms_of_use, :if => :require_acceptance_of_terms, :allow_nil => false
validates_each :birthdate do |record, attr, value|
next unless record.require_birthdate
if value
record.errors.add(attr, "too_young") if !record.require_self_enrollment_code && value > self_enrollment_min_age.years.ago
else
record.errors.add(attr, "blank")
end
end
validates_each :self_enrollment_code do |record, attr, value|
next unless record.require_self_enrollment_code
if value.blank?
record.errors.add(attr, "blank")
elsif record.validation_root_account
record.self_enrollment_course = record.validation_root_account.all_courses.find_by_self_enrollment_code(value)
record.errors.add(attr, "invalid") unless record.self_enrollment_course
else
record.errors.add(attr, "account_required")
end
end
2011-02-01 09:57:29 +08:00
before_save :assign_uuid
before_save :update_avatar_image
after_save :update_account_associations_if_necessary
after_save :self_enroll_if_necessary
def self.skip_updating_account_associations(&block)
@skip_updating_account_associations = true
block.call
ensure
@skip_updating_account_associations = false
end
def self.skip_updating_account_associations?
!!@skip_updating_account_associations
end
def update_account_associations_later
self.send_later_if_production(:update_account_associations) unless self.class.skip_updating_account_associations?
end
def update_account_associations_if_necessary
update_account_associations if !self.class.skip_updating_account_associations? && self.workflow_state_changed?
end
def update_account_associations(opts = {})
User.update_account_associations([self], opts)
end
def self.add_to_account_chain_cache(account_id, account_chain_cache)
if account_id.is_a? Account
account = account_id
account_id = account.id
2011-02-01 09:57:29 +08:00
end
return account_chain_cache[account_id] if account_chain_cache.has_key?(account_id)
account ||= Account.find(account_id)
return account_chain_cache[account.id] = [account.id] if account.root_account?
account_chain_cache[account.id] = [account.id] + add_to_account_chain_cache(account.parent_account_id, account_chain_cache)
2011-02-01 09:57:29 +08:00
end
def self.calculate_account_associations_from_accounts(starting_account_ids, account_chain_cache = {})
results = {}
remaining_ids = []
starting_account_ids.each do |account_id|
unless account_chain_cache.has_key? account_id
remaining_ids << account_id
next
end
account_chain = account_chain_cache[account_id]
account_chain.each_with_index do |account_id, idx|
results[account_id] ||= idx
results[account_id] = idx if idx < results[account_id]
2011-02-01 09:57:29 +08:00
end
end
unless remaining_ids.empty?
accounts = Account.find_all_by_id(remaining_ids)
accounts.each do |account|
account_chain = add_to_account_chain_cache(account, account_chain_cache)
account_chain.each_with_index do |account_id, idx|
results[account_id] ||= idx
results[account_id] = idx if idx < results[account_id]
end
end
2011-02-01 09:57:29 +08:00
end
results
end
# Users are tied to accounts a couple ways:
# Through enrollments:
# User -> Enrollment -> Section -> Course -> Account
# User -> Enrollment -> Section -> Non-Xlisted Course -> Account
# Through pseudonyms:
# User -> Pseudonym -> Account
# Through account_users
# User -> AccountUser -> Account
def calculate_account_associations(account_chain_cache = {})
return [] if %w{creation_pending deleted}.include?(self.workflow_state)
# Hopefully these have all been pre-loaded
starting_account_ids = self.enrollments.map { |e| e.workflow_state != 'deleted' ? [e.course_section.course.account_id, e.course_section.nonxlist_course.try(:account_id)] : nil }.flatten.compact
starting_account_ids += self.pseudonyms.map { |p| p.active? ? p.account_id : nil }.compact
starting_account_ids += self.account_users.map(&:account_id)
starting_account_ids.uniq!
result = User.calculate_account_associations_from_accounts(starting_account_ids, account_chain_cache)
result
end
def self.update_account_associations(users_or_user_ids, opts = {})
return if users_or_user_ids.empty?
opts.reverse_merge! :account_chain_cache => {}
account_chain_cache = opts[:account_chain_cache]
# Split it up into manageable chunks
if users_or_user_ids.length > 500
users_or_user_ids.uniq.compact.each_slice(500) do |users_or_user_ids_slice|
update_account_associations(users_or_user_ids_slice, opts)
end
return
end
incremental = opts[:incremental]
precalculated_associations = opts[:precalculated_associations]
user_ids = users_or_user_ids
user_ids = user_ids.map(&:id) if user_ids.first.is_a?(User)
users_or_user_ids = User.find(:all, :conditions => {:id => user_ids}, :include => [:pseudonyms, :account_users, { :enrollments => { :course_section => [ :course, :nonxlist_course ] }}]) if !user_ids.first.is_a?(User) && !precalculated_associations
UserAccountAssociation.transaction do
current_associations = {}
to_delete = []
UserAccountAssociation.find(:all, :conditions => { :user_id => user_ids }).each do |aa|
key = [aa.user_id, aa.account_id]
# duplicates
if current_associations.has_key?(key)
to_delete << aa.id
next
end
current_associations[key] = [aa.id, aa.depth]
2011-02-01 09:57:29 +08:00
end
users_or_user_ids.each do |user_id|
if user_id.is_a? User
user = user_id
user_id = user_id.id
end
account_ids_with_depth = precalculated_associations
if account_ids_with_depth.nil?
user ||= User.find(user_id)
account_ids_with_depth = user.calculate_account_associations(account_chain_cache)
end
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
# we don't want student view students to have account associations.
next if user && user.fake_student?
account_ids_with_depth.each do |account_id, depth|
key = [user_id, account_id]
association = current_associations[key]
if association.nil?
# new association, create it
UserAccountAssociation.create! do |aa|
aa.user_id = user_id
aa.account_id = account_id
aa.depth = depth
end
else
# for incremental, only update the old association if it is deeper than the new one
# for non-incremental, update it if it changed
if incremental && association[1] > depth || !incremental && association[1] != depth
UserAccountAssociation.update_all("depth=#{depth}", :id => association[0])
end
# remove from list of existing for non-incremental
current_associations.delete(key) unless incremental
end
2011-02-01 09:57:29 +08:00
end
end
to_delete += current_associations.map { |k, v| v[0] }
UserAccountAssociation.delete_all(:id => to_delete) unless incremental || to_delete.empty?
2011-02-01 09:57:29 +08:00
end
end
2011-02-01 09:57:29 +08:00
# These two methods can be overridden by a plugin if you want to have an approval process for new teachers
def registration_approval_required?; false; end
def new_teacher_registration(form_params = {}); end
2011-02-01 09:57:29 +08:00
set_broadcast_policy do |p|
p.dispatch :new_teacher_registration
p.to { Account.site_admin.users }
p.whenever { |record|
record.just_created && record.school_name && record.school_position
2011-02-01 09:57:29 +08:00
}
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 = AutoHandle.generate_securish_uuid if !read_attribute(:uuid)
2011-02-01 09:57:29 +08:00
end
protected :assign_uuid
2011-02-01 09:57:29 +08:00
named_scope :with_service, lambda { |service|
if service.is_a?(UserService)
{:include => :user_services, :conditions => ['user_services.service = ?', service.service]}
else
{:include => :user_services, :conditions => ['user_services.service = ?', service.to_s]}
end
}
named_scope :enrolled_before, lambda{|date|
{:conditions => ['enrollments.created_at < ?', date]}
}
2011-02-01 09:57:29 +08:00
def group_memberships_for(context)
return [] unless context
self.group_memberships.select do |m|
m.group &&
m.group.context_id == context.id &&
m.group.context_type == context.class.to_s &&
!m.group.deleted? &&
m.accepted?
2011-02-01 09:57:29 +08:00
end.map(&:group)
end
2011-02-01 09:57:29 +08:00
def <=>(other)
self.name <=> other.name
end
2011-02-01 09:57:29 +08:00
def default_pseudonym_id
self.pseudonyms.active.first.id
end
2011-02-01 09:57:29 +08:00
def available?
true
end
2011-02-01 09:57:29 +08:00
def participants
[]
end
# compatibility only - this isn't really last_name_first
2011-02-01 09:57:29 +08:00
def last_name_first
self.sortable_name
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def last_name_first_or_unnamed
res = last_name_first
res = "No Name" if res.strip.empty?
res
end
2011-02-01 09:57:29 +08:00
def first_name
User.name_parts(self.sortable_name)[0] || ''
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def last_name
User.name_parts(self.sortable_name)[1] || ''
2011-02-01 09:57:29 +08:00
end
# Feel free to add, but the "authoritative" list (http://en.wikipedia.org/wiki/Title_(name)) is quite large
SUFFIXES = /^(Sn?r\.?|Senior|Jn?r\.?|Junior|II|III|IV|V|VI|Esq\.?|Esquire)$/i
# see also user_sortable_name.js
def self.name_parts(name, prior_surname = nil)
return [nil, nil, nil] unless name
surname, given, suffix = name.strip.split(/\s*,\s*/, 3)
# Doe, John, Sr.
# Otherwise change Ho, Chi, Min to Ho, Chi Min
if suffix && !(suffix =~ SUFFIXES)
given = "#{given} #{suffix}"
suffix = nil
end
if given
# John Doe, Sr.
if !suffix && given =~ SUFFIXES
suffix = given
given = surname
surname = nil
2011-02-01 09:57:29 +08:00
end
else
# John Doe
given = name.strip
surname = nil
end
given_parts = given.split
# John Doe Sr.
if !suffix && given_parts.length > 1 && given_parts.last =~ SUFFIXES
suffix = given_parts.pop
2011-02-01 09:57:29 +08:00
end
# Use prior information on the last name to try and reconstruct it
prior_surname_parts = nil
surname = given_parts.pop(prior_surname_parts.length).join(' ') if !surname && prior_surname.present? && (prior_surname_parts = prior_surname.split) && !prior_surname_parts.empty? && given_parts.length >= prior_surname_parts.length && given_parts[-prior_surname_parts.length..-1] == prior_surname_parts
# Last resort; last name is just the last word given
surname = given_parts.pop if !surname && given_parts.length > 1
[ given_parts.empty? ? nil : given_parts.join(' '), surname, suffix ]
2011-02-01 09:57:29 +08:00
end
def self.last_name_first(name, name_was = nil)
given, surname, suffix = name_parts(name, name_parts(name_was)[1])
given = [given, suffix].compact.join(' ')
surname ? "#{surname}, #{given}".strip : given
end
2011-02-01 09:57:29 +08:00
def self.user_lookup_cache_key(id)
['_user_lookup2', id].cache_key
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def self.invalidate_cache(id)
Rails.cache.delete(user_lookup_cache_key(id)) if id
rescue
2011-02-01 09:57:29 +08:00
nil
end
2011-02-01 09:57:29 +08:00
def infer_defaults
self.name = nil if self.name == "User"
self.name ||= self.email || t(:default_user_name, "User")
self.short_name = nil if self.short_name == ""
2011-02-01 09:57:29 +08:00
self.short_name ||= self.name
self.sortable_name = nil if self.sortable_name == ""
# recalculate the sortable name if the name changed, but the sortable name didn't, and the sortable_name matches the old name
self.sortable_name = nil if !self.sortable_name_changed? && self.name_changed? && User.name_parts(self.sortable_name).compact.join(' ') == self.name_was
self.sortable_name = User.last_name_first(self.name, self.sortable_name_was) unless read_attribute(:sortable_name)
2011-02-01 09:57:29 +08:00
self.reminder_time_for_due_dates ||= 48.hours.to_i
self.reminder_time_for_grading ||= 0
self.initial_enrollment_type = nil unless ['student', 'teacher', 'ta', 'observer'].include?(initial_enrollment_type)
2011-02-01 09:57:29 +08:00
User.invalidate_cache(self.id) if self.id
true
end
2011-02-01 09:57:29 +08:00
def sortable_name
self.sortable_name = read_attribute(:sortable_name) || User.last_name_first(self.name)
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def primary_pseudonym
self.pseudonyms.active.first
end
2011-02-01 09:57:29 +08:00
def primary_pseudonym=(p)
p = Pseudonym.find(p)
p.move_to_top
self.reload
p
end
2011-02-01 09:57:29 +08:00
def email_channel
# It's already ordered, so find the first one, if there's one.
communication_channels.to_a.find{|cc| cc.path_type == 'email' && cc.workflow_state != 'retired' }
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def email
# if you change this cache_key, change it in email_cached? as well
value = Rails.cache.fetch(['user_email', self].cache_key) do
email_channel.try(:path) || :none
2011-02-01 09:57:29 +08:00
end
# this sillyness is because rails equates falsey as not in the cache
value == :none ? nil : value
end
def email_cached?
Rails.cache.exist?(['user_email', self].cache_key)
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def self.cached_name(id)
key = user_lookup_cache_key(id)
user = Rails.cache.fetch(key) do
2011-02-01 09:57:29 +08:00
User.find_by_id(id)
end
user && user.name
end
2011-02-01 09:57:29 +08:00
def gmail_channel
google_services = self.user_services.find_all_by_service_domain("google.com")
addr = google_services.find{|s| s.service_user_id}.service_user_id rescue nil
self.communication_channels.email.by_path(addr).find(:first)
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def gmail
res = gmail_channel.path rescue nil
res ||= self.user_services.find_all_by_service_domain("google.com").map(&:service_user_id).compact.first
res ||= email
end
2011-02-01 09:57:29 +08:00
def google_docs_address
service = self.user_services.find_by_service('google_docs')
service && service.service_user_id
end
2011-02-01 09:57:29 +08:00
def email=(e)
if e.is_a?(CommunicationChannel) and e.user_id == self.id
cc = e
else
cc = self.communication_channels.find_or_create_by_path_and_path_type(e, 'email')
cc.user = self
2011-02-01 09:57:29 +08:00
end
cc.move_to_top
cc.save!
self.reload
cc.path
end
def sms_channel
# It's already ordered, so find the first one, if there's one.
communication_channels.find(:first, :conditions => {:path_type => 'sms'})
end
2011-02-01 09:57:29 +08:00
def sms
sms_channel.path if sms_channel
end
2011-02-01 09:57:29 +08:00
def sms=(s)
if s.is_a?(CommunicationChannel) and s.user_id == self.id
cc = s
else
cc = CommunicationChannel.find_or_create_by_path_and_user_id(s, self.id)
end
cc.move_to_top
cc.save!
self.reload
cc.path
end
2011-02-01 09:57:29 +08:00
def short_name
read_attribute(:short_name) || name
end
def unread_inbox_items_count
count = read_attribute(:unread_inbox_items_count)
if count.nil?
self.unread_inbox_items_count = count = self.inbox_items.unread.count rescue 0
self.save
end
count
end
workflow do
state :pre_registered do
event :register, :transitions_to => :registered
end
2011-02-01 09:57:29 +08:00
# Not listing this first so it is not the default.
state :pending_approval do
event :approve, :transitions_to => :pre_registered
event :reject, :transitions_to => :deleted
end
2011-02-01 09:57:29 +08:00
state :creation_pending do
event :create_user, :transitions_to => :pre_registered
event :register, :transitions_to => :registered
end
state :registered
2011-02-01 09:57:29 +08:00
state :deleted
end
2011-02-01 09:57:29 +08:00
def unavailable?
deleted?
end
2011-02-01 09:57:29 +08:00
alias_method :destroy!, :destroy
def destroy(even_if_managed_passwords=false)
ActiveRecord::Base.transaction do
self.workflow_state = 'deleted'
self.save
self.pseudonyms.each{|p| p.destroy(even_if_managed_passwords) }
self.communication_channels.each{|cc| cc.destroy }
self.enrollments.each{|e| e.destroy }
end
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def remove_from_root_account(account)
self.enrollments.find_all_by_root_account_id(account.id).each(&:destroy)
self.pseudonyms.active.find_all_by_account_id(account.id).each { |p| p.destroy(true) }
self.account_users.find_all_by_account_id(account.id).each(&:destroy)
self.save
self.update_account_associations
2011-02-01 09:57:29 +08:00
end
def associate_with_shard(shard)
end
def self.clone_communication_channel(cc, new_user, max_position)
new_cc = cc.clone
new_cc.shard = new_user.shard
new_cc.position += max_position
new_cc.user = new_user
new_cc.save!
cc.notification_policies.each do |np|
new_np = np.clone
new_np.shard = new_user.shard
new_np.communication_channel = new_cc
new_np.save!
end
end
2011-02-01 09:57:29 +08:00
def move_to_user(new_user)
return unless new_user
return if new_user == self
new_user.save if new_user.changed?
new_user.associate_with_shard(self.shard)
max_position = new_user.communication_channels.last.try(:position) || 0
to_retire_ids = []
2011-02-01 09:57:29 +08:00
self.communication_channels.each do |cc|
source_cc = cc
# have to find conflicting CCs, and make sure we don't have conflicts
# To avoid the case where a user has duplicate CCs and one of them is retired, don't look for retired ccs
# it's okay to do that even if the only matching CC is a retired CC, because it would end up on the no-op
# case below anyway.
# Behavior is undefined if a user has both an active and an unconfirmed CC; it's not allowed with current
# validations, but could be there due to older code that didn't enforce the uniqueness. The results would
# simply be that they'll continue to have duplicate unretired CCs
target_cc = new_user.communication_channels.detect { |cc| cc.path.downcase == source_cc.path.downcase && cc.path_type == source_cc.path_type && !cc.retired? }
if !target_cc && self.shard != new_user.shard
User.clone_communication_channel(source_cc, new_user, max_position)
end
next unless target_cc
# we prefer keeping the "most" active one, preferring the target user if they're equal
# the comments inline show all the different cases, with the source cc on the left,
# target cc on the right. The * indicates the CC that will be retired in order
# to resolve the conflict
if target_cc.active?
# retired, active
# unconfirmed*, active
# active*, active
to_retire = source_cc
elsif source_cc.active?
# active, unconfirmed*
# active, retired
to_retire = target_cc
if self.shard != new_user.shard
target_cc.retire unless target_cc.retired?
User.clone_communication_channel(source_cc, new_user, max_position)
end
elsif target_cc.unconfirmed?
# unconfirmed*, unconfirmed
# retired, unconfirmed
to_retire = source_cc
elsif source_cc.unconfirmed? && self.shard != new_user.shard
# unconfirmed, retired
User.clone_communication_channel(source_cc, new_user, max_position)
end
#elsif
# retired, retired
#end
if to_retire && !to_retire.retired?
to_retire_ids << to_retire.id
end
2011-02-01 09:57:29 +08:00
end
if self.shard != new_user.shard
self.communication_channels.update_all(:workflow_state => 'retired') unless self.communication_channels.empty?
self.user_services.each do |us|
new_us = us.clone
new_us.shard = new_user.shard
new_us.user = new_user
new_us.save!
end
self.user_services.delete_all
else
self.shard.activate do
CommunicationChannel.update_all({:workflow_state => 'retired'}, :id => to_retire_ids) unless to_retire_ids.empty?
2011-02-01 09:57:29 +08:00
end
self.communication_channels.update_all("user_id=#{new_user.id}, position=position+#{max_position}") unless self.communication_channels.empty?
2011-02-01 09:57:29 +08:00
end
Shard.with_each_shard(self.associated_shards) do
max_position = Pseudonym.find(:last, :conditions => { :user_id => new_user.id }, :order => 'position').try(:position) || 0
Pseudonym.update_all("position=position+#{max_position}, user_id=#{new_user.id}", :user_id => self.id)
to_delete_ids = []
new_user_enrollments = Enrollment.find(:all, :conditions => { :user_id => new_user.id })
Enrollment.scoped(:conditions => { :user_id => self.id }).each do |enrollment|
source_enrollment = enrollment
# non-deleted enrollments should be unique per [course_section, type]
target_enrollment = new_user_enrollments.detect { |enrollment| enrollment.course_section_id == source_enrollment.course_section_id && enrollment.type == source_enrollment.type && !['deleted', 'inactive', 'rejected'].include?(enrollment.workflow_state) }
next unless target_enrollment
# we prefer keeping the "most" active one, preferring the target user if they're equal
# the comments inline show all the different cases, with the source enrollment on the left,
# target enrollment on the right. The * indicates the enrollment that will be deleted in order
# to resolve the conflict.
if target_enrollment.active?
# deleted, active
# inactive, active
# rejected, active
# invited*, active
# creation_pending*, active
# active*, active
# completed*, active
to_delete = source_enrollment
elsif source_enrollment.active?
# active, deleted
# active, inactive
# active, rejected
# active, invited*
# active, creation_pending*
# active, completed*
to_delete = target_enrollment
elsif target_enrollment.completed?
# deleted, completed
# inactive, completed
# rejected, completed
# invited*, completed
# creation_pending*, completed
# completed*, completed
to_delete = source_enrollment
elsif source_enrollment.completed?
# completed, deleted
# completed, inactive
# completed, rejected
# completed, invited*
# completed, creation_pending*
to_delete = target_enrollment
elsif target_enrollment.invited?
# deleted, invited
# inactive, invited
# rejected, invited
# creation_pending*, invited
# invited*, invited
to_delete = source_enrollment
elsif source_enrollment.invited?
# invited, deleted
# invited, inactive
# invited, rejected
# invited, creation_pending*
to_delete = target_enrollment
elsif target_enrollment.creation_pending?
# deleted, creation_pending
# inactive, creation_pending
# rejected, creation_pending
# creation_pending*, creation_pending
to_delete = source_enrollment
end
#elsif
# creation_pending, deleted
# creation_pending, inactive
# creation_pending, rejected
# deleted, rejected
# inactive, rejected
# rejected, rejected
# rejected, deleted
# rejected, inactive
# deleted, inactive
# inactive, inactive
# inactive, deleted
# deleted, deleted
#end
to_delete_ids << to_delete.id if to_delete && !['deleted', 'inactive', 'rejected'].include?(to_delete.workflow_state)
end
Enrollment.update_all({:workflow_state => 'deleted'}, :id => to_delete_ids) unless to_delete_ids.empty?
[
[:quiz_id, :quiz_submissions],
[:assignment_id, :submissions]
].each do |unique_id, table|
begin
# Submissions are a special case since there's a unique index
# on the table, and if both the old user and the new user
# have a submission for the same assignment there will be
# a conflict.
already_there_ids = table.to_s.classify.constantize.find_all_by_user_id(new_user.id).map(&unique_id)
already_there_ids = [0] if already_there_ids.empty?
table.to_s.classify.constantize.update_all({:user_id => new_user.id}, "user_id=#{self.id} AND #{unique_id} NOT IN (#{already_there_ids.join(',')})")
rescue => e
logger.error "migrating #{table} column user_id failed: #{e.to_s}"
2011-02-01 09:57:29 +08:00
end
end
all_conversations.find_each{ |c| c.move_to_user(new_user) } unless Shard.current != new_user.shard
updates = {}
['account_users','asset_user_accesses',
'attachments',
'calendar_events','collaborations',
'context_module_progressions','discussion_entries','discussion_topics',
'enrollments','group_memberships','page_comments',
'rubric_assessments',
'submission_comment_participants','user_services','web_conferences',
'web_conference_participants','wiki_pages'].each do |key|
updates[key] = "user_id"
end
updates['submission_comments'] = 'author_id'
updates['conversation_messages'] = 'author_id'
updates = updates.to_a
updates << ['enrollments', 'associated_user_id']
updates.each do |table, column|
begin
klass = table.classify.constantize
if klass.new.respond_to?("#{column}=".to_sym)
klass.connection.execute("UPDATE #{table} SET #{column}=#{new_user.id} WHERE #{column}=#{self.id}")
end
rescue => e
logger.error "migrating #{table} column #{column} failed: #{e.to_s}"
end
end
unless Shard.current != new_user.shard
# delete duplicate enrollments where this user is the observee
new_user.observee_enrollments.remove_duplicates!
# delete duplicate observers/observees, move the rest
user_observees.where(:user_id => new_user.user_observees.map(&:user_id)).delete_all
user_observees.update_all(:observer_id => new_user.id)
xor_observer_ids = (Set.new(user_observers.map(&:observer_id)) ^ new_user.user_observers.map(&:observer_id)).to_a
user_observers.where(:observer_id => new_user.user_observers.map(&:observer_id)).delete_all
user_observers.update_all(:user_id => new_user.id)
# for any observers not already watching both users, make sure they have
# any missing observer enrollments added
new_user.user_observers.where(:observer_id => xor_observer_ids).each(&:create_linked_enrollments)
end
Enrollment.send_later(:recompute_final_scores, new_user.id)
new_user.update_account_associations
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
self.reload
new_user.touch
2011-02-01 09:57:29 +08:00
self.destroy
end
2011-02-01 09:57:29 +08:00
# Overwrites the old user name, if there was one. Fills in the new one otherwise.
def assert_name(name=nil)
if name && (self.pre_registered? || self.creation_pending?) && name != email
self.name = name
save!
end
self
end
2011-02-01 09:57:29 +08:00
def to_atom
Atom::Entry.new do |entry|
entry.title = self.name
entry.updated = self.updated_at
entry.published = self.created_at
entry.links << Atom::Link.new(:rel => 'alternate',
2011-02-01 09:57:29 +08:00
:href => "/users/#{self.id}")
end
end
2011-02-01 09:57:29 +08:00
def admins
[self]
end
2011-02-01 09:57:29 +08:00
def students
[self]
end
2011-02-01 09:57:29 +08:00
def latest_pseudonym
Pseudonym.scoped(:order => 'created_at DESC', :conditions => {:user_id => id}).active.first
end
2011-02-01 09:57:29 +08:00
def used_feature(feature)
self.update_attribute(:features_used, ((self.features_used || "").split(/,/).map(&:to_s) + [feature.to_s]).uniq.join(','))
end
2011-02-01 09:57:29 +08:00
def used_feature?(feature)
self.features_used && self.features_used.split(/,/).include?(feature.to_s)
end
2011-02-01 09:57:29 +08:00
def available_courses
# this list should be longer if the person has admin privileges...
2011-02-01 09:57:29 +08:00
self.courses
end
2011-02-01 09:57:29 +08:00
def courses_with_grades
self.available_courses.select{|c| c.grants_right?(self, nil, :participate_as_student)}
end
memoize :courses_with_grades
def sis_pseudonym_for(context)
root_account = context.root_account
raise "could not resolve root account" unless root_account.is_a?(Account)
if self.pseudonyms.loaded? && self.shard == root_account.shard
self.pseudonyms.detect { |p| p.active? && p.sis_user_id && p.account_id == root_account.id }
else
root_account.shard.activate do
root_account.pseudonyms.active.find_by_user_id(self.id, :conditions => "sis_user_id IS NOT NULL")
end
end
end
2011-02-01 09:57:29 +08:00
set_policy do
given { |user| user == self }
can :read and can :manage and can :manage_content and can :manage_files and can :manage_calendar and can :send_messages and can :update_avatar
given { |user| user.present? && self.public? }
can :follow
given { |user| user == self && user.user_can_edit_name? }
can :rename
2011-02-01 09:57:29 +08:00
given {|user| self.courses.any?{|c| c.user_is_instructor?(user)}}
can :rename and can :create_user_notes and can :read_user_notes
2011-02-01 09:57:29 +08:00
given do |user|
user && (
# this means that the user we are given is an administrator of an account of one of the courses that this user is enrolled in
self.all_courses.any? { |c| c.grants_right?(user, nil, :read_reports) }
)
end
can :rename and can :remove_avatar and can :view_statistics
given do |user|
user && self.all_courses.any? { |c| c.grants_right?(user, nil, :manage_user_notes) }
end
can :create_user_notes and can :read_user_notes
given { |user| user && self.all_courses.any? { |c| c.grants_right?(user, nil, :read_user_notes) } }
can :read_user_notes
given do |user|
user && (
self.associated_accounts.any?{|a| a.grants_right?(user, nil, :manage_user_notes)}
)
end
can :create_user_notes and can :read_user_notes and can :delete_user_notes
2011-02-01 09:57:29 +08:00
given do |user|
user && (
# or, if the user we are given is an admin in one of this user's accounts
Account.site_admin.grants_right?(user, :manage_students) ||
self.associated_accounts.any? {|a| a.grants_right?(user, nil, :manage_students) }
2011-02-01 09:57:29 +08:00
)
end
can :manage_user_details and can :update_avatar and can :remove_avatar and can :rename and can :view_statistics and can :read
2011-02-01 09:57:29 +08:00
given do |user|
user && (
# or, if the user we are given is an admin in one of this user's accounts
Account.site_admin.grants_right?(user, :manage_user_logins) ||
self.associated_accounts.any?{|a| a.grants_right?(user, nil, :manage_user_logins) }
2011-02-01 09:57:29 +08:00
)
end
can :manage_user_details and can :manage_logins and can :rename and can :view_statistics and can :read
end
def can_masquerade?(masquerader, account)
return true if self == masquerader
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
# student view should only ever have enrollments in a single course
return true if self.fake_student? && self.courses.any?{ |c| c.grants_right?(masquerader, nil, :use_student_view) }
return false unless
account.grants_right?(masquerader, nil, :become_user) && self.find_pseudonym_for_account(account, true)
account_users = account.all_account_users_for(self)
return true if account_users.empty?
account_users.map(&:account).uniq.all? do |account|
needed_rights = account.check_policy(self)
account.grants_rights?(masquerader, nil, *needed_rights).values.all?
end
2011-02-01 09:57:29 +08:00
end
def self.infer_id(obj)
case obj
when User
obj.id
when Numeric
obj
when CommunicationChannel
obj.user_id
when Pseudonym
obj.user_id
when AccountUser
2011-02-01 09:57:29 +08:00
obj.user_id
when OpenObject
obj.id
when String
obj.to_i
else
2011-02-01 09:57:29 +08:00
raise ArgumentError, "Cannot infer a user_id from #{obj.inspect}"
end
end
2011-02-01 09:57:29 +08:00
def management_contexts
contexts = [self] + self.courses + self.groups.active + self.all_courses
contexts.uniq
end
def file_management_contexts
contexts = [self] + self.courses + self.groups.active + self.all_courses
contexts.uniq.select{|c| c.grants_right?(self, nil, :manage_files) }
end
def facebook
self.user_services.for_service('facebook').first rescue nil
end
2011-02-01 09:57:29 +08:00
def visible_inbox_types=(val)
types = (val || "").split(",")
write_attribute(:visible_inbox_types, types.map{|t| t.classify }.join(","))
end
2011-02-01 09:57:29 +08:00
def show_in_inbox?(type)
if self.respond_to?(:visible_inbox_types) && self.visible_inbox_types
types = self.visible_inbox_types.split(",")
types.include?(type)
else
true
end
end
2011-02-01 09:57:29 +08:00
def submitted_submission_for(assignment_id)
@submissions ||= self.submissions.having_submission.to_a
@submissions.detect{|s| s.assignment_id == assignment_id }
end
def attempted_quiz_submission_for(quiz_id)
@quiz_submissions ||= self.quiz_submissions.select{|s| !s.settings_only? }
2011-02-01 09:57:29 +08:00
@quiz_submissions.detect{|qs| qs.quiz_id == quiz_id }
end
2011-02-01 09:57:29 +08:00
def module_progression_for(module_id)
@module_progressions ||= self.context_module_progressions.to_a
@module_progressions.detect{|p| p.context_module_id == module_id }
end
2011-02-01 09:57:29 +08:00
def clear_cached_lookups
@module_progressions = nil
@quiz_submissions = nil
@submissions = nil
end
2011-02-01 09:57:29 +08:00
def update_avatar_image(force_reload=false)
if !self.avatar_image_url || force_reload
if self.avatar_image_source == 'facebook'
# TODO: support this
2011-02-01 09:57:29 +08:00
elsif self.avatar_image_source == 'twitter'
twitter = self.user_services.for_service('twitter').first rescue nil
if twitter
url = URI.parse("http://twitter.com/users/show.json?user_id=#{twitter.service_user_id}")
data = JSON.parse(Net::HTTP.get(url)) rescue nil
if data
self.avatar_image_url = data['profile_image_url_https'] || self.avatar_image_url
2011-02-01 09:57:29 +08:00
self.avatar_image_updated_at = Time.now
end
end
end
end
end
2011-02-01 09:57:29 +08:00
def self.max_messages_per_day
Setting.get('max_messages_per_day_per_user', 500).to_i
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def max_messages_per_day
User.max_messages_per_day
end
def gravatar_url(size=50, fallback=nil, request=nil)
fix avatar fallbacks in conversations (and generally), fixes #7539 fix regression from #5618 where we stopped passing along the fallback for conversation avatars. also fixed it so we always respect the fallback, host/scheme, and domain account avatar setting. previously we would cache the first one for a given user, to the detriment of any subsequent requests for that user (e.g. conversations uses the default fallback for the recipient finder, and a custom one for everything else. without this change, whichever one got requested first would win and get cached as the gravatar fallback). test plan: setup: * make sure you have avatars set up on various users (some submitted, some approved) stuff from #5618: * make sure the old style of avatar image urls still work * go to a page with user avatars (like discussions) * make sure avatars appear correctly for users with avatars * put in a bogus URL ("/images/users/a") * make sure it doesn't die * put in a bogus URL with a hyphen ("/images/users/1-1") * make sure it doesn't die then test conversations: * go to conversations with avatars enabled * ensure that you see the appropriate gray silhouette for users without a gravatar in the conversation/message panes * ensure you see the alternating gray/white silhouette (depending on focus) for those users in the recipient finder (it's actually the blank image, the silhouette is a background image behind it) then test hostname and account setting fu: * log in under a different domain (e.g. 127.0.0.1 instead of localhost) and go to discussions * ensure that the fallback image is served up from the current domain * change the avatar setting to enabled_pending * ensure that you only see approved avatars (see initial setup) then test cache invalidation: * approve a pending avatar * ensure the avatar now shows up under all domains Change-Id: I9cd007463e3cb4a302b1986f9d4bb61fe16799ac Reviewed-on: https://gerrit.instructure.com/9130 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-02 14:54:17 +08:00
fallback = self.class.avatar_fallback_url(fallback, request)
"https://secure.gravatar.com/avatar/#{Digest::MD5.hexdigest(self.email) rescue '000'}?s=#{size}&d=#{CGI::escape(fallback)}"
2011-02-01 09:57:29 +08:00
end
# Public: Set a user's avatar image. This is a convenience method that sets
# the avatar_image_source, avatar_image_url, avatar_updated_at, and
# avatar_state on the user model.
#
# val - A hash of options used to configure the avatar.
# :type - The type of avatar. Should be 'facebook,' 'gravatar,'
# 'external,' or 'attachment.'
# :url - The URL of the gravatar. Used for types 'external' and
# 'attachment.'
#
# Returns nothing if avatar is set; false if avatar is locked.
2011-02-01 09:57:29 +08:00
def avatar_image=(val)
return false if avatar_state == :locked
# Clear out the old avatar first, in case of failure to get new avatar.
# The order of these attributes is standard throughout the method.
self.avatar_image_source = 'no_pic'
self.avatar_image_url = nil
self.avatar_image_updated_at = Time.zone.now
self.avatar_state = 'approved'
# Return here if we're passed a nil val or any non-hash val (both of which
# will just nil the user's avatar).
return unless val.is_a?(Hash)
2011-02-01 09:57:29 +08:00
if val['type'] == 'facebook'
# TODO: support this
2011-02-01 09:57:29 +08:00
elsif val['type'] == 'gravatar'
self.avatar_image_source = 'gravatar'
self.avatar_image_url = nil
self.avatar_state = 'submitted'
elsif val['type'] == 'external'
self.avatar_image_source = 'external'
self.avatar_image_url = val['url']
self.avatar_state = 'submitted'
elsif val['type'] == 'attachment' && val['url']
2011-02-01 09:57:29 +08:00
self.avatar_image_source = 'attachment'
self.avatar_image_url = val['url']
2011-02-01 09:57:29 +08:00
self.avatar_state = 'submitted'
end
end
2011-02-01 09:57:29 +08:00
def report_avatar_image!(associated_context=nil)
if avatar_state == :approved || avatar_state == :locked
avatar_state = 're_reported'
else
avatar_state = 'reported'
end
save!
end
2011-02-01 09:57:29 +08:00
def avatar_state
if ['none', 'submitted', 'approved', 'locked', 'reported', 're_reported'].include?(read_attribute(:avatar_state))
read_attribute(:avatar_state).to_sym
else
:none
end
end
2011-02-01 09:57:29 +08:00
def avatar_state=(val)
if ['none', 'submitted', 'approved', 'locked', 'reported', 're_reported'].include?(val.to_s)
if val == 'none'
self.avatar_image_url = nil
self.avatar_image_source = 'no_pic'
self.avatar_image_updated_at = Time.now
end
write_attribute(:avatar_state, val.to_s)
2011-02-01 09:57:29 +08:00
end
end
2011-02-01 09:57:29 +08:00
def avatar_reportable?
[:submitted, :approved, :reported, :re_reported].include?(avatar_state)
end
2011-02-01 09:57:29 +08:00
def avatar_approvable?
[:submitted, :reported, :re_reported].include?(avatar_state)
end
2011-02-01 09:57:29 +08:00
def avatar_approved?
[:approved, :locked, :re_reported].include?(avatar_state)
end
def self.avatar_key(user_id)
user_id = user_id.to_s
if !user_id.blank? && user_id != '0'
"#{user_id}-#{Canvas::Security.hmac_sha1(user_id)[0, 10]}"
else
"0"
end
end
def self.user_id_from_avatar_key(key)
user_id, sig = key.to_s.split(/-/, 2)
(Canvas::Security.hmac_sha1(user_id.to_s)[0, 10] == sig) ? user_id : nil
end
# Returns the LTI membership based on the LTI specs here: http://www.imsglobal.org/LTI/v1p1pd/ltiIMGv1p1pd.html#_Toc309649701
def lti_role_types(context=nil)
memberships = []
if context.is_a?(Course)
memberships += current_enrollments.find_all_by_course_id(context.id).uniq
end
if context.respond_to?(:account_chain) && !context.account_chain_ids.empty?
memberships += account_users.find_all_by_membership_type_and_account_id('AccountAdmin', context.account_chain_ids).uniq
end
basic lti navigation links By properly configuring external tools (see /spec/models/course_spec/rb:898 for examples) they can be added as left-side navigation links to a course, an account, or to the user profile section of Canvas. testing notes: - you have to manually set options on the external tool: - for user navigation the tool needs to be created on the root account with the following settings: {:user_navigation => {:url => <url>, :text => <tab label>} } (there are also some optional language options you can set using the :labels attribute) - for account navigation it's the same - for course navigation it's the same, except with :course_navigation there's also some additional options: :visibility => <value> // public, members, admins :default => <value> // disabled, enabled test plan: - configure a user navigation tool at the root account level, make sure it shows up in the user's profile section - configure a course navigation tool at the account level, make sure it shows up in the course's navigation - configure a course navigation tool at the course level, make sure it shows up in the course's navigation - make sure :default => 'disabled' course navigation tools don't appear by default in the navigation, but can be enabled on the course settings page - make sure :visibility => 'members' only shows up for course members - make sure :visibility => 'admins' only shows up for course admins - configure an account navigation tool at the account level, make sure it shows up in the account's navigation, and any sub-account's navigation Change-Id: I977da3c6b89a9e32b4cff4c2b6b221f8162782ff Reviewed-on: https://gerrit.instructure.com/5427 Reviewed-by: Brian Whitmer <brian@instructure.com> Tested-by: Hudson <hudson@instructure.com>
2011-08-18 13:49:01 +08:00
return ["urn:lti:sysrole:ims/lis/None"] if memberships.empty?
memberships.map{|membership|
case membership
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
when StudentEnrollment, StudentViewEnrollment
'Learner'
when TeacherEnrollment
'Instructor'
when TaEnrollment
'Instructor'
when DesignerEnrollment
'ContentDeveloper'
when ObserverEnrollment
'urn:lti:instrole:ims/lis/Observer'
when AccountUser
'urn:lti:instrole:ims/lis/Administrator'
else
'urn:lti:instrole:ims/lis/Observer'
end
}.uniq
end
fix avatar fallbacks in conversations (and generally), fixes #7539 fix regression from #5618 where we stopped passing along the fallback for conversation avatars. also fixed it so we always respect the fallback, host/scheme, and domain account avatar setting. previously we would cache the first one for a given user, to the detriment of any subsequent requests for that user (e.g. conversations uses the default fallback for the recipient finder, and a custom one for everything else. without this change, whichever one got requested first would win and get cached as the gravatar fallback). test plan: setup: * make sure you have avatars set up on various users (some submitted, some approved) stuff from #5618: * make sure the old style of avatar image urls still work * go to a page with user avatars (like discussions) * make sure avatars appear correctly for users with avatars * put in a bogus URL ("/images/users/a") * make sure it doesn't die * put in a bogus URL with a hyphen ("/images/users/1-1") * make sure it doesn't die then test conversations: * go to conversations with avatars enabled * ensure that you see the appropriate gray silhouette for users without a gravatar in the conversation/message panes * ensure you see the alternating gray/white silhouette (depending on focus) for those users in the recipient finder (it's actually the blank image, the silhouette is a background image behind it) then test hostname and account setting fu: * log in under a different domain (e.g. 127.0.0.1 instead of localhost) and go to discussions * ensure that the fallback image is served up from the current domain * change the avatar setting to enabled_pending * ensure that you only see approved avatars (see initial setup) then test cache invalidation: * approve a pending avatar * ensure the avatar now shows up under all domains Change-Id: I9cd007463e3cb4a302b1986f9d4bb61fe16799ac Reviewed-on: https://gerrit.instructure.com/9130 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-02 14:54:17 +08:00
AVATAR_SETTINGS = ['enabled', 'enabled_pending', 'sis_only', 'disabled']
def avatar_url(size=nil, avatar_setting=nil, fallback=nil, request=nil)
return fallback if avatar_setting == 'disabled'
2011-02-01 09:57:29 +08:00
size ||= 50
avatar_setting ||= 'enabled'
fix avatar fallbacks in conversations (and generally), fixes #7539 fix regression from #5618 where we stopped passing along the fallback for conversation avatars. also fixed it so we always respect the fallback, host/scheme, and domain account avatar setting. previously we would cache the first one for a given user, to the detriment of any subsequent requests for that user (e.g. conversations uses the default fallback for the recipient finder, and a custom one for everything else. without this change, whichever one got requested first would win and get cached as the gravatar fallback). test plan: setup: * make sure you have avatars set up on various users (some submitted, some approved) stuff from #5618: * make sure the old style of avatar image urls still work * go to a page with user avatars (like discussions) * make sure avatars appear correctly for users with avatars * put in a bogus URL ("/images/users/a") * make sure it doesn't die * put in a bogus URL with a hyphen ("/images/users/1-1") * make sure it doesn't die then test conversations: * go to conversations with avatars enabled * ensure that you see the appropriate gray silhouette for users without a gravatar in the conversation/message panes * ensure you see the alternating gray/white silhouette (depending on focus) for those users in the recipient finder (it's actually the blank image, the silhouette is a background image behind it) then test hostname and account setting fu: * log in under a different domain (e.g. 127.0.0.1 instead of localhost) and go to discussions * ensure that the fallback image is served up from the current domain * change the avatar setting to enabled_pending * ensure that you only see approved avatars (see initial setup) then test cache invalidation: * approve a pending avatar * ensure the avatar now shows up under all domains Change-Id: I9cd007463e3cb4a302b1986f9d4bb61fe16799ac Reviewed-on: https://gerrit.instructure.com/9130 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-02 14:54:17 +08:00
fallback = self.class.avatar_fallback_url(fallback, request)
2011-02-01 09:57:29 +08:00
if avatar_setting == 'enabled' || (avatar_setting == 'enabled_pending' && avatar_approved?) || (avatar_setting == 'sis_only')
@avatar_url ||= self.avatar_image_url
2011-02-01 09:57:29 +08:00
end
@avatar_url ||= fallback if self.avatar_image_source == 'no_pic'
@avatar_url ||= gravatar_url(size, fallback, request) if avatar_setting == 'enabled'
@avatar_url ||= fallback
2011-02-01 09:57:29 +08:00
end
def avatar_path
"/images/users/#{User.avatar_key(self.id)}"
end
fix avatar fallbacks in conversations (and generally), fixes #7539 fix regression from #5618 where we stopped passing along the fallback for conversation avatars. also fixed it so we always respect the fallback, host/scheme, and domain account avatar setting. previously we would cache the first one for a given user, to the detriment of any subsequent requests for that user (e.g. conversations uses the default fallback for the recipient finder, and a custom one for everything else. without this change, whichever one got requested first would win and get cached as the gravatar fallback). test plan: setup: * make sure you have avatars set up on various users (some submitted, some approved) stuff from #5618: * make sure the old style of avatar image urls still work * go to a page with user avatars (like discussions) * make sure avatars appear correctly for users with avatars * put in a bogus URL ("/images/users/a") * make sure it doesn't die * put in a bogus URL with a hyphen ("/images/users/1-1") * make sure it doesn't die then test conversations: * go to conversations with avatars enabled * ensure that you see the appropriate gray silhouette for users without a gravatar in the conversation/message panes * ensure you see the alternating gray/white silhouette (depending on focus) for those users in the recipient finder (it's actually the blank image, the silhouette is a background image behind it) then test hostname and account setting fu: * log in under a different domain (e.g. 127.0.0.1 instead of localhost) and go to discussions * ensure that the fallback image is served up from the current domain * change the avatar setting to enabled_pending * ensure that you only see approved avatars (see initial setup) then test cache invalidation: * approve a pending avatar * ensure the avatar now shows up under all domains Change-Id: I9cd007463e3cb4a302b1986f9d4bb61fe16799ac Reviewed-on: https://gerrit.instructure.com/9130 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-02 14:54:17 +08:00
def self.default_avatar_fallback
"/images/messages/avatar-50.png"
fix avatar fallbacks in conversations (and generally), fixes #7539 fix regression from #5618 where we stopped passing along the fallback for conversation avatars. also fixed it so we always respect the fallback, host/scheme, and domain account avatar setting. previously we would cache the first one for a given user, to the detriment of any subsequent requests for that user (e.g. conversations uses the default fallback for the recipient finder, and a custom one for everything else. without this change, whichever one got requested first would win and get cached as the gravatar fallback). test plan: setup: * make sure you have avatars set up on various users (some submitted, some approved) stuff from #5618: * make sure the old style of avatar image urls still work * go to a page with user avatars (like discussions) * make sure avatars appear correctly for users with avatars * put in a bogus URL ("/images/users/a") * make sure it doesn't die * put in a bogus URL with a hyphen ("/images/users/1-1") * make sure it doesn't die then test conversations: * go to conversations with avatars enabled * ensure that you see the appropriate gray silhouette for users without a gravatar in the conversation/message panes * ensure you see the alternating gray/white silhouette (depending on focus) for those users in the recipient finder (it's actually the blank image, the silhouette is a background image behind it) then test hostname and account setting fu: * log in under a different domain (e.g. 127.0.0.1 instead of localhost) and go to discussions * ensure that the fallback image is served up from the current domain * change the avatar setting to enabled_pending * ensure that you only see approved avatars (see initial setup) then test cache invalidation: * approve a pending avatar * ensure the avatar now shows up under all domains Change-Id: I9cd007463e3cb4a302b1986f9d4bb61fe16799ac Reviewed-on: https://gerrit.instructure.com/9130 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-02 14:54:17 +08:00
end
def self.avatar_fallback_url(fallback=nil, request=nil)
return fallback if fallback == '%{fallback}'
if fallback and uri = URI.parse(fallback) rescue nil
uri.scheme ||= request ? request.protocol[0..-4] : "https" # -4 to chop off the ://
if HostUrl.cdn_host
uri.host = HostUrl.cdn_host
elsif request && !uri.host
uri.host = request.host
uri.port = request.port if ![80, 443].include?(request.port)
elsif !uri.host
uri.host, uri.port = HostUrl.default_host.split(/:/)
end
fix avatar fallbacks in conversations (and generally), fixes #7539 fix regression from #5618 where we stopped passing along the fallback for conversation avatars. also fixed it so we always respect the fallback, host/scheme, and domain account avatar setting. previously we would cache the first one for a given user, to the detriment of any subsequent requests for that user (e.g. conversations uses the default fallback for the recipient finder, and a custom one for everything else. without this change, whichever one got requested first would win and get cached as the gravatar fallback). test plan: setup: * make sure you have avatars set up on various users (some submitted, some approved) stuff from #5618: * make sure the old style of avatar image urls still work * go to a page with user avatars (like discussions) * make sure avatars appear correctly for users with avatars * put in a bogus URL ("/images/users/a") * make sure it doesn't die * put in a bogus URL with a hyphen ("/images/users/1-1") * make sure it doesn't die then test conversations: * go to conversations with avatars enabled * ensure that you see the appropriate gray silhouette for users without a gravatar in the conversation/message panes * ensure you see the alternating gray/white silhouette (depending on focus) for those users in the recipient finder (it's actually the blank image, the silhouette is a background image behind it) then test hostname and account setting fu: * log in under a different domain (e.g. 127.0.0.1 instead of localhost) and go to discussions * ensure that the fallback image is served up from the current domain * change the avatar setting to enabled_pending * ensure that you only see approved avatars (see initial setup) then test cache invalidation: * approve a pending avatar * ensure the avatar now shows up under all domains Change-Id: I9cd007463e3cb4a302b1986f9d4bb61fe16799ac Reviewed-on: https://gerrit.instructure.com/9130 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-02 14:54:17 +08:00
uri.to_s
else
avatar_fallback_url(default_avatar_fallback, request)
end
end
# Clear the avatar_image_url attribute and save it if the URL contains the given uuid.
#
# ==== Arguments
# * <tt>uuid</tt> - The Attachment#uuid value for the file. Used as part of the url identifier.
def clear_avatar_image_url_with_uuid(uuid)
raise ArgumentError, "'uuid' is required and cannot be blank" if uuid.blank?
if self.avatar_image_url.to_s.match(/#{uuid}/)
self.avatar_image_url = nil
self.save
end
end
2011-02-01 09:57:29 +08:00
named_scope :with_avatar_state, lambda{|state|
if state == 'any'
{
:conditions =>['avatar_image_url IS NOT NULL AND avatar_state IS NOT NULL AND avatar_state != ?', 'none'],
2011-02-01 09:57:29 +08:00
:order => 'avatar_image_updated_at DESC'
}
else
{
:conditions => ['avatar_image_url IS NOT NULL AND avatar_state = ?', state],
2011-02-01 09:57:29 +08:00
:order => 'avatar_image_updated_at DESC'
}
end
}
2011-02-01 09:57:29 +08:00
def sorted_rubrics
context_codes = ([self] + self.management_contexts).uniq.map(&:asset_string)
rubrics = self.context_rubrics.active
rubrics += Rubric.active.find_all_by_context_code(context_codes)
rubrics.uniq.sort_by{|r| [(r.association_count || 0) > 3 ? 'a' : 'b', (r.title.downcase rescue 'zzzzz')]}
end
def assignments_recently_graded(opts={})
opts = { :start_at => 1.week.ago, :limit => 10 }.merge(opts)
Submission.recently_graded_assignments(id, opts[:start_at], opts[:limit])
end
memoize :assignments_recently_graded
def assignments_recently_graded_total_count(opts={})
assignments_recently_graded(opts.merge({:limit => nil})).size
end
memoize :assignments_recently_graded_total_count
2011-02-01 09:57:29 +08:00
def preferences
read_attribute(:preferences) || write_attribute(:preferences, {})
end
def watched_conversations_intro?
preferences[:watched_conversations_intro] == true
end
def watched_conversations_intro(value=true)
preferences[:watched_conversations_intro] = value
end
def send_scores_in_emails?
preferences[:send_scores_in_emails] == true
end
def close_announcement(announcement)
preferences[:closed_notifications] ||= []
# serialize ids relative to the user
self.shard.activate do
preferences[:closed_notifications] << announcement.id
end
preferences[:closed_notifications].uniq!
save
end
2011-02-01 09:57:29 +08:00
def ignore_item!(asset_string, purpose, permanent=nil)
permanent ||= false
asset_string = asset_string.gsub(/![0-9a-z_]/, '')
preferences[:ignore] ||= {}
preferences[:ignore][purpose.to_sym] ||= {}
preferences[:ignore][purpose.to_sym].each do |key, item|
preferences[:ignore][purpose.to_sym].delete(key) if item && (!item[:set] || item[:set] < 6.months.ago.utc.iso8601)
end
preferences[:ignore][purpose.to_sym][asset_string] = {:permanent => permanent, :set => Time.now.utc.iso8601}
self.updated_at = Time.now
2011-02-01 09:57:29 +08:00
save!
end
2011-02-01 09:57:29 +08:00
def ignored_item_changed!(asset_string, purpose)
preferences[:ignore] ||= {}
preferences[:ignore][purpose.to_sym] ||= {}
if preferences[:ignore][purpose.to_sym][asset_string]
preferences[:ignore][purpose.to_sym].delete(asset_string) if !preferences[:ignore][purpose.to_sym][asset_string][:permanent]
end
self.updated_at = Time.now
2011-02-01 09:57:29 +08:00
save!
end
2011-02-01 09:57:29 +08:00
def ignored_items(purpose)
(preferences[:ignore] || {})[purpose.to_sym] || {}
end
def assignments_needing_submitting(opts={})
course_codes = opts[:contexts] ? (Array(opts[:contexts]).map(&:asset_string) & current_student_enrollment_course_codes) : current_student_enrollment_course_codes
ignored_ids = ignored_items(:submitting).select{|key, val| key.match(/\Aassignment_/) }.map{|key, val| key.sub(/\Aassignment_/, "") }
Assignment.for_context_codes(course_codes).active.due_before(1.week.from_now).
expecting_submission.due_after(opts[:due_after] || 4.weeks.ago).
need_submitting_info(id, opts[:limit] || 15, ignored_ids).
not_locked
2011-02-01 09:57:29 +08:00
end
memoize :assignments_needing_submitting
2011-02-01 09:57:29 +08:00
def assignments_needing_submitting_total_count(opts={})
course_codes = opts[:contexts] ? (Array(opts[:contexts]).map(&:asset_string) & current_student_enrollment_course_codes) : current_student_enrollment_course_codes
ignored_ids = ignored_items(:submitting).select{|key, val| key.match(/\Aassignment_/) }.map{|key, val| key.sub(/\Aassignment_/, "") }
Assignment.for_context_codes(course_codes).active.due_before(1.week.from_now).expecting_submission.due_after(4.weeks.ago).need_submitting_info(id, nil, ignored_ids).size
end
memoize :assignments_needing_submitting_total_count
2011-02-01 09:57:29 +08:00
def assignments_needing_grading(opts={})
course_codes = opts[:contexts] ? (Array(opts[:contexts]).map(&:asset_string) & current_admin_enrollment_course_codes) : current_admin_enrollment_course_codes
ignored_ids = ignored_items(:grading).select{|key, val| key.match(/\Aassignment_/) }.map{|key, val| key.sub(/\Aassignment_/, "") }
Assignment.for_context_codes(course_codes).active.expecting_submission.need_grading_info(opts[:limit] || 15, ignored_ids).reject{|a| a.needs_grading_count_for_user(self) == 0}
2011-02-01 09:57:29 +08:00
end
memoize :assignments_needing_grading
def assignments_needing_grading_total_count(opts={})
course_codes = opts[:contexts] ? (Array(opts[:contexts]).map(&:asset_string) & current_admin_enrollment_course_codes) : current_admin_enrollment_course_codes
ignored_ids = ignored_items(:grading).select{|key, val| key.match(/\Aassignment_/) }.map{|key, val| key.sub(/\Aassignment_/, "") }
Assignment.for_context_codes(course_codes).active.expecting_submission.need_grading_info(nil, ignored_ids).reject{|a| a.needs_grading_count_for_user(self) == 0}.size
end
memoize :assignments_needing_grading_total_count
2011-02-01 09:57:29 +08:00
def generate_access_verifier(ts)
require 'openssl'
digest = OpenSSL::Digest::MD5.new
OpenSSL::HMAC.hexdigest(digest, uuid, ts.to_s)
end
2011-02-01 09:57:29 +08:00
private :generate_access_verifier
def access_verifier
ts = Time.now.utc.to_i
[ts, generate_access_verifier(ts)]
end
2011-02-01 09:57:29 +08:00
def valid_access_verifier?(ts, sig)
ts.to_i > 5.minutes.ago.to_i && ts.to_i < 1.minute.from_now.to_i && sig == generate_access_verifier(ts.to_i)
end
2011-02-01 09:57:29 +08:00
def uuid
if !read_attribute(:uuid)
self.update_attribute(:uuid, AutoHandle.generate_securish_uuid)
2011-02-01 09:57:29 +08:00
end
read_attribute(:uuid)
end
def self.serialization_excludes; [:uuid,:phone,:features_used,:otp_communication_channel_id,:otp_secret_key_enc,:otp_secret_key_salt]; end
2011-02-01 09:57:29 +08:00
def migrate_content_links(html, from_course)
Course.migrate_content_links(html, from_course, self)
end
2011-02-01 09:57:29 +08:00
attr_accessor :merge_mappings
attr_accessor :merge_results
def merge_mapped_id(*args)
nil
end
2011-02-01 09:57:29 +08:00
def map_merge(*args)
end
def log_merge_result(text)
@merge_results ||= []
@merge_results << text
end
def warn_merge_result(text)
record_merge_result(text)
end
def file_structure_for(user)
User.file_structure_for(self, user)
end
2011-02-01 09:57:29 +08:00
def secondary_identifier
self.email || self.id
end
2011-02-01 09:57:29 +08:00
def self.file_structure_for(context, user)
results = {
2011-02-01 09:57:29 +08:00
:contexts => [context],
:collaborations => [],
:folders => [],
:folders_with_subcontent => [],
:files => []
}
context_codes = results[:contexts].map{|c| c.asset_string }
2011-02-01 09:57:29 +08:00
if !context.is_a?(User) && user
results[:collaborations] = user.collaborations.active.find(:all, :include => [:user, :users]).select{|c| c.context_id && c.context_type && context_codes.include?("#{c.context_type.underscore}_#{c.context_id}") }
results[:collaborations] = results[:collaborations].sort_by{|c| c.created_at}.reverse
2011-02-01 09:57:29 +08:00
end
results[:contexts].each do |context|
results[:folders] += context.active_folders_with_sub_folders
2011-02-01 09:57:29 +08:00
end
results[:folders] = results[:folders].sort_by{|f| [f.parent_folder_id || 0, f.position || 0, f.name || "", f.created_at]}
results
2011-02-01 09:57:29 +08:00
end
refactor user creation/invitations closes #5833 fixes #5573, #5572, #5753 * communication channels are now only unique within a single user * UserList changes * Always resolve pseudonym#unique_ids * Support looking up by SMS CCs * Option to either require e-mails match an existing CC, or e-mails that don't match a Pseudonym will always be returned unattached (relying on better merging behavior to not have a gazillion accounts created) * Method to return users, creating new ones (*without* a Pseudonym) if necessary. (can't create with a pseudonym, since Pseudonym#unique_id is still unique, I can't have multiple outstanding users with the same unique_id) * EnrollmentsFromUserList is mostly gutted, now using UserList's functionality directy. * Use UserList for adding account admins, removing the now unused Account#add_admin => User#find_by_email/User#assert_by_email codepath * Update UsersController#create to not worry about duplicate communication channels * Remove AccountsController#add_user, and just use UsersController#create * Change SIS::UserImporter to send out a merge opportunity e-mail if a conflicting CC is found (but still create the CC) * In /profile, don't worry about conflicting CCs (the CC confirmation process will now allow merging) * Remove CommunicationChannelsController#try_merge and #merge * For the non-simple case of CoursesController#enrollment_invitation redirect to /register (CommunicationsChannelController#confirm) * Remove CoursesController#transfer_enrollment * Move PseudonymsController#registration_confirmation to CommunicationChannelsController#confirm (have to be able to register an account without a Pseudonym yet) * Fold the old direct confirm functionality in, if there are no available merge opportunities * Allow merging the new account with the currently logged in user * Allow changing the Pseudonym#unique_id when registering a new account (since there might be conflicts) * Display a list of merge opportunities based on conflicting communication channels * Provide link(s) to log in as the other user, redirecting back to the registration page after login is complete (to complete the merge as the current user) * Remove several assert_* methods that are no longer needed * Update PseudonymSessionsController a bit to deal with the new way of dealing with conflicting CCs (especially CCs from LDAP), and to redirect back to the registration/confirmation page when attempting to do a merge * Expose the open_registration setting; use it to control if inviting users to a course is able to create new users Change-Id: If2f38818a71af656854d3bf8431ddbf5dcb84691 Reviewed-on: https://gerrit.instructure.com/6149 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Jacob Fugal <jacob@instructure.com>
2011-10-13 04:30:48 +08:00
def self_enroll_if_necessary
return unless @self_enrollment_course
@self_enrollment_course.self_enroll_student(self, :skip_pseudonym => @just_created, :skip_touch_user => true)
end
2011-02-01 09:57:29 +08:00
def time_difference_from_date(hash)
n = hash[:number].to_i
n = nil if n == 0
if hash[:metric] == "weeks"
(n || 1).weeks.to_i
elsif hash[:metric] == "days"
(n || 1).days.to_i
elsif hash[:metric] == "hours"
(n || 1).hours.to_i
elsif hash[:metric] == "never"
0
else
nil
end
end
2011-02-01 09:57:29 +08:00
def remind_for_due_dates=(hash)
self.reminder_time_for_due_dates = time_difference_from_date(hash)
end
2011-02-01 09:57:29 +08:00
def remind_for_grading=(hash)
self.reminder_time_for_grading = time_difference_from_date(hash)
end
2011-02-01 09:57:29 +08:00
def is_a_context?
true
end
2011-02-01 09:57:29 +08:00
def account
self.pseudonym.account rescue Account.default
end
memoize :account
sub-account branding; closes #9368 allow sub accounts to include their own global scripts and stylesheets. if global includes are enabled on the root account, root account administrators will have an option to enable them for immediate child accounts. those child accounts can then choose to enable them for their sub-accounts, and so on down the chain. these includes are added to the page in order from highest to lowest account, so sub-accounts are able to override styles added by their parents. the logic for which styles to display on which pages is as follows: - on account pages, include all styles in the chain from this account up to the root account. this ensures that you can always see styles for account X without any sub-account overrides on account X's page - on course/group pages, include all styles in the chain from the account which contains that course/group up to the root - on the dashboard, calendar, user pages, and other pages that don't fall into one of the above categories, we find the lowest account that contains all of the current user's active classes + groups, and include styles from that account up to the root test plan: - in a root account, create two sub-accounts, create courses in each of them, and create 3 users, one enrolled only in the first course, one only in the second course, and one enrolled in both courses. - enable global includes on the root account (no sub-accounts yet) add files, and make sure all three students see them. - now enable sub-account includes, and add include files to each sub-account - make sure both users in course 1 see include for sub-account 1 - make sure user 1 sees include for sub-account 1 on her dashboard, but user 3 does not. Change-Id: I3d07d4bced39593f3084d5eac6ea3137666e319b Reviewed-on: https://gerrit.instructure.com/12248 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Simon Williams <simon@instructure.com>
2012-07-10 05:30:16 +08:00
# this finds the reverse account chain starting at in_root_account and ending
# at the lowest account such that all of the accounts to which the user is
# associated which descend from in_root_account, descend from one of the
# accounts in the chain. In other words, if the users associated accounts
# made a tree, it would be the chain between the root and the first branching
# point.
def common_account_chain(in_root_account)
rid = in_root_account.id
accts = self.associated_accounts.scoped(:conditions => ["accounts.id = ? OR accounts.root_account_id = ?", rid, rid])
return [] if accts.blank?
children = accts.inject({}) do |hash,acct|
pid = acct.parent_account_id
if pid.present?
hash[pid] ||= []
hash[pid] << acct
sub-account branding; closes #9368 allow sub accounts to include their own global scripts and stylesheets. if global includes are enabled on the root account, root account administrators will have an option to enable them for immediate child accounts. those child accounts can then choose to enable them for their sub-accounts, and so on down the chain. these includes are added to the page in order from highest to lowest account, so sub-accounts are able to override styles added by their parents. the logic for which styles to display on which pages is as follows: - on account pages, include all styles in the chain from this account up to the root account. this ensures that you can always see styles for account X without any sub-account overrides on account X's page - on course/group pages, include all styles in the chain from the account which contains that course/group up to the root - on the dashboard, calendar, user pages, and other pages that don't fall into one of the above categories, we find the lowest account that contains all of the current user's active classes + groups, and include styles from that account up to the root test plan: - in a root account, create two sub-accounts, create courses in each of them, and create 3 users, one enrolled only in the first course, one only in the second course, and one enrolled in both courses. - enable global includes on the root account (no sub-accounts yet) add files, and make sure all three students see them. - now enable sub-account includes, and add include files to each sub-account - make sure both users in course 1 see include for sub-account 1 - make sure user 1 sees include for sub-account 1 on her dashboard, but user 3 does not. Change-Id: I3d07d4bced39593f3084d5eac6ea3137666e319b Reviewed-on: https://gerrit.instructure.com/12248 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Simon Williams <simon@instructure.com>
2012-07-10 05:30:16 +08:00
end
hash
end
sub-account branding; closes #9368 allow sub accounts to include their own global scripts and stylesheets. if global includes are enabled on the root account, root account administrators will have an option to enable them for immediate child accounts. those child accounts can then choose to enable them for their sub-accounts, and so on down the chain. these includes are added to the page in order from highest to lowest account, so sub-accounts are able to override styles added by their parents. the logic for which styles to display on which pages is as follows: - on account pages, include all styles in the chain from this account up to the root account. this ensures that you can always see styles for account X without any sub-account overrides on account X's page - on course/group pages, include all styles in the chain from the account which contains that course/group up to the root - on the dashboard, calendar, user pages, and other pages that don't fall into one of the above categories, we find the lowest account that contains all of the current user's active classes + groups, and include styles from that account up to the root test plan: - in a root account, create two sub-accounts, create courses in each of them, and create 3 users, one enrolled only in the first course, one only in the second course, and one enrolled in both courses. - enable global includes on the root account (no sub-accounts yet) add files, and make sure all three students see them. - now enable sub-account includes, and add include files to each sub-account - make sure both users in course 1 see include for sub-account 1 - make sure user 1 sees include for sub-account 1 on her dashboard, but user 3 does not. Change-Id: I3d07d4bced39593f3084d5eac6ea3137666e319b Reviewed-on: https://gerrit.instructure.com/12248 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Simon Williams <simon@instructure.com>
2012-07-10 05:30:16 +08:00
longest_chain = [in_root_account]
while true
next_children = children[longest_chain.last.id]
break unless next_children.present? && next_children.count == 1
longest_chain << next_children.first
sub-account branding; closes #9368 allow sub accounts to include their own global scripts and stylesheets. if global includes are enabled on the root account, root account administrators will have an option to enable them for immediate child accounts. those child accounts can then choose to enable them for their sub-accounts, and so on down the chain. these includes are added to the page in order from highest to lowest account, so sub-accounts are able to override styles added by their parents. the logic for which styles to display on which pages is as follows: - on account pages, include all styles in the chain from this account up to the root account. this ensures that you can always see styles for account X without any sub-account overrides on account X's page - on course/group pages, include all styles in the chain from the account which contains that course/group up to the root - on the dashboard, calendar, user pages, and other pages that don't fall into one of the above categories, we find the lowest account that contains all of the current user's active classes + groups, and include styles from that account up to the root test plan: - in a root account, create two sub-accounts, create courses in each of them, and create 3 users, one enrolled only in the first course, one only in the second course, and one enrolled in both courses. - enable global includes on the root account (no sub-accounts yet) add files, and make sure all three students see them. - now enable sub-account includes, and add include files to each sub-account - make sure both users in course 1 see include for sub-account 1 - make sure user 1 sees include for sub-account 1 on her dashboard, but user 3 does not. Change-Id: I3d07d4bced39593f3084d5eac6ea3137666e319b Reviewed-on: https://gerrit.instructure.com/12248 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Simon Williams <simon@instructure.com>
2012-07-10 05:30:16 +08:00
end
longest_chain
sub-account branding; closes #9368 allow sub accounts to include their own global scripts and stylesheets. if global includes are enabled on the root account, root account administrators will have an option to enable them for immediate child accounts. those child accounts can then choose to enable them for their sub-accounts, and so on down the chain. these includes are added to the page in order from highest to lowest account, so sub-accounts are able to override styles added by their parents. the logic for which styles to display on which pages is as follows: - on account pages, include all styles in the chain from this account up to the root account. this ensures that you can always see styles for account X without any sub-account overrides on account X's page - on course/group pages, include all styles in the chain from the account which contains that course/group up to the root - on the dashboard, calendar, user pages, and other pages that don't fall into one of the above categories, we find the lowest account that contains all of the current user's active classes + groups, and include styles from that account up to the root test plan: - in a root account, create two sub-accounts, create courses in each of them, and create 3 users, one enrolled only in the first course, one only in the second course, and one enrolled in both courses. - enable global includes on the root account (no sub-accounts yet) add files, and make sure all three students see them. - now enable sub-account includes, and add include files to each sub-account - make sure both users in course 1 see include for sub-account 1 - make sure user 1 sees include for sub-account 1 on her dashboard, but user 3 does not. Change-Id: I3d07d4bced39593f3084d5eac6ea3137666e319b Reviewed-on: https://gerrit.instructure.com/12248 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Simon Williams <simon@instructure.com>
2012-07-10 05:30:16 +08:00
end
def courses_with_primary_enrollment(association = :current_and_invited_courses, enrollment_uuid = nil, options = {})
res = self.shard.activate do
Rails.cache.fetch([self, 'courses_with_primary_enrollment', association, options].cache_key, :expires_in => 15.minutes) do
courses = send(association).with_each_shard do |scope|
scope.distinct_on(["courses.id"],
:select => "courses.*, enrollments.id AS primary_enrollment_id, enrollments.type AS primary_enrollment, #{Enrollment.type_rank_sql} AS primary_enrollment_rank, enrollments.workflow_state AS primary_enrollment_state",
:order => "courses.id, #{Enrollment.type_rank_sql}, #{Enrollment.state_rank_sql}")
end
unless options[:include_completed_courses]
date_restricted = Enrollment.find(:all, :conditions => { :id => courses.map(&:primary_enrollment_id) }).select{ |e| e.completed? || e.inactive? }.map(&:id)
courses.reject! { |course| date_restricted.include?(course.primary_enrollment_id.to_i) }
end
courses
end.dup
end
if association == :current_and_invited_courses
if enrollment_uuid && pending_course = Course.find(:first,
:select => "courses.*, enrollments.type AS primary_enrollment, #{Enrollment.type_rank_sql} AS primary_enrollment_rank, enrollments.workflow_state AS primary_enrollment_state",
:joins => :enrollments, :conditions => ["enrollments.uuid=? AND enrollments.workflow_state='invited'", enrollment_uuid])
res << pending_course
res.uniq!
end
pending_enrollments = temporary_invitations
unless pending_enrollments.empty?
Enrollment.send(:preload_associations, pending_enrollments, :course)
res.concat(pending_enrollments.map { |e| c = e.course; c.write_attribute(:primary_enrollment, e.type); c.write_attribute(:primary_enrollment_rank, e.rank_sortable.to_s); c.write_attribute(:primary_enrollment_state, e.workflow_state); c.write_attribute(:invitation, e.uuid); c })
res.uniq!
end
end
res.sort_by{ |c| [c.primary_enrollment_rank, c.name.downcase] }
end
memoize :courses_with_primary_enrollment
def cached_active_emails
self.shard.activate do
Rails.cache.fetch([self, 'active_emails'].cache_key) do
self.communication_channels.active.email.map(&:path)
end
end
end
def temporary_invitations
cached_active_emails.map { |email| Enrollment.cached_temporary_invitations(email).dup.reject { |e| e.user_id == self.id } }.flatten
end
2011-02-01 09:57:29 +08:00
# activesupport/lib/active_support/memoizable.rb from rails and
# http://github.com/seamusabshere/cacheable/blob/master/lib/cacheable.rb from the cacheable gem
# to get a head start
# this method takes an optional {:include_enrollment_uuid => uuid} so that you can pass it the session[:enrollment_uuid] and it will include it.
2011-02-01 09:57:29 +08:00
def cached_current_enrollments(opts={})
self.shard.activate do
res = Rails.cache.fetch([self, 'current_enrollments', opts[:include_enrollment_uuid] ].cache_key) do
res = self.current_and_invited_enrollments.with_each_shard
if opts[:include_enrollment_uuid] && pending_enrollment = Enrollment.find_by_uuid_and_workflow_state(opts[:include_enrollment_uuid], "invited")
res << pending_enrollment
res.uniq!
end
res
2011-02-01 09:57:29 +08:00
end
end + temporary_invitations
2011-02-01 09:57:29 +08:00
end
memoize :cached_current_enrollments
2011-02-01 09:57:29 +08:00
def cached_not_ended_enrollments
self.shard.activate do
@cached_all_enrollments = Rails.cache.fetch([self, 'not_ended_enrollments'].cache_key) do
self.not_ended_enrollments.with_each_shard
end
2011-02-01 09:57:29 +08:00
end
end
2011-02-01 09:57:29 +08:00
def cached_current_group_memberships
self.shard.activate do
@cached_current_group_memberships = Rails.cache.fetch([self, 'current_group_memberships'].cache_key) do
self.current_group_memberships.with_each_shard
end
end
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def current_student_enrollment_course_codes
@current_student_enrollment_course_codes ||= Rails.cache.fetch([self, 'current_student_enrollment_course_codes'].cache_key) do
self.enrollments.student.scoped(:select => "course_id").map{|e| "course_#{e.course_id}"}
end
end
2011-02-01 09:57:29 +08:00
def current_admin_enrollment_course_codes
@current_admin_enrollment_course_codes ||= Rails.cache.fetch([self, 'current_admin_enrollment_course_codes'].cache_key) do
self.enrollments.admin.scoped(:select => "course_id").map{|e| "course_#{e.course_id}"}
end
end
2011-02-01 09:57:29 +08:00
# TODO: this smells, I really don't get it (anymore... I wrote it :-( )
def self.module_progression_job_queued(user_id, time_string=nil)
time_string ||= Time.now.utc.iso8601
@@user_jobs ||= {}
@@user_jobs[user_id] ||= time_string
end
2011-02-01 09:57:29 +08:00
def self.module_progression_jobs_queued?(user_id)
recent = 1.minute.ago.utc.iso8601
@@user_jobs ||= {}
!!(@@user_jobs && @@user_jobs[user_id] && @@user_jobs[user_id] > recent)
end
2011-02-01 09:57:29 +08:00
def submissions_for_context_codes(context_codes, opts={})
return [] if (!context_codes || context_codes.empty?)
opts[:start_at] ||= 2.weeks.ago
opts[:limit] ||= 20
ActiveRecord::Base::ConnectionSpecification.with_environment(:slave) do
submissions = []
submissions += self.submissions.after(opts[:start_at]).for_context_codes(context_codes).find(
:all,
:conditions => ["submissions.score IS NOT NULL AND assignments.workflow_state != ? AND assignments.muted = ?", 'deleted', false],
:include => [:assignment, :user, :submission_comments],
:order => 'submissions.created_at DESC',
:limit => opts[:limit]
)
# THIS IS SLOW, it takes ~230ms for mike
submissions += Submission.for_context_codes(context_codes).find(
:all,
:select => "submissions.*, last_updated_at_from_db",
:joins => self.class.send(:sanitize_sql_array, [<<-SQL, opts[:start_at], self.id, self.id]),
INNER JOIN (
SELECT MAX(submission_comments.created_at) AS last_updated_at_from_db, submission_id
FROM submission_comments, submission_comment_participants
WHERE submission_comments.id = submission_comment_id
AND (submission_comments.created_at > ?)
AND (submission_comment_participants.user_id = ?)
AND (submission_comments.author_id <> ?)
GROUP BY submission_id
) AS relevant_submission_comments ON submissions.id = submission_id
INNER JOIN assignments ON assignments.id = submissions.assignment_id AND assignments.workflow_state <> 'deleted'
SQL
:order => 'last_updated_at_from_db DESC',
:limit => opts[:limit],
:conditions => { "assignments.muted" => false }
)
submissions = submissions.sort_by{|t| (t.last_updated_at_from_db.to_datetime.in_time_zone rescue nil) || t.created_at}.reverse
submissions = submissions.uniq
submissions.first(opts[:limit])
submissions
end
2011-02-01 09:57:29 +08:00
end
memoize :submissions_for_context_codes
2011-02-01 09:57:29 +08:00
# This is only feedback for student contexts (unless specific contexts are passed in)
def recent_feedback(opts={})
context_codes = opts[:context_codes] || (opts[:contexts] ? setup_context_lookups(opts[:contexts]) : self.current_student_enrollment_course_codes)
submissions_for_context_codes(context_codes, opts)
end
memoize :recent_feedback
def visible_stream_item_instances(opts={})
new dashboard design the new dashboard design categorizes recent activity into buckets that can be expanded/collapsed, and inidividual messages can be dismissed. the categories are announcements, conversations, discussions and assignments. this redesign applies to the homepage dashboard, the group home page, and the course homepage when "recent activity dashboard" is selected as the course home page type.o the motiviation is that the dashboard should capture and present in one place important information happening in all the user's courses or groups, and allow for jumping into this information to see more details: - announcements/discussions should show on the dashboard when they are created, or when there are root replies to recent announcements - conversations should show on the dashboard when there is new activity - assignments should show on the dashboard when they are created, or when changes are made at least a couple hours after being created the presence of a dashboard item means there is activity for that item that may be of interest to the user. additionally, the dashboard items will show read/unread state (excluding assignments) for items which the user has not yet viewed. additionally, global messages such as course inivitations, account level announcements, and new user messages have been restyled, but will keep their place above the recent activity widget on the dashboard. test plan: - visit many exising user's dashboards and make sure they are functional in the new style. - visit canvas as a brand new user (no enrollments), a new user enrolled in a new course and make sure the dashboard is restyled and the messaging makes sense. - make an account level announcement and make sure it shows up on user's dashboards. - create all different types of conversations: single, group, bulk private, from submission comment, add user to convo, etc. and make sure the appropriate dashboard items appear and make sense - create discussions and announcements, reply to them at the root level and at the sub entry level (sub entries will not make new dashboard items), test from both a read and unread user's perspective, making sure dashboard items are correct. (note that read/unread state will not be correct for existing items before this code is applied, but should be correct for future items moving forward) - dismiss dashboard items and account announcements, make sure they stay dismissed. - test creating assignments, waiting > 2 hours, and updating due dates or other assignment details. make sure items appear. note that unread state will not exist for assignment notifications. closes #10783 refs #11038 refs #11039 Change-Id: I276a8cb1fae4c8a46425d0a368455e15a0c470c5 Reviewed-on: https://gerrit.instructure.com/14540 Reviewed-by: Jon Jensen <jon@instructure.com> Tested-by: Jenkins <jenkins@instructure.com>
2012-10-05 05:49:54 +08:00
instances = stream_item_instances.scoped({
:conditions => { :hidden => false },
:order => 'stream_item_instances.id desc',
})
# dont make the query do an stream_item_instances.context_code IN
# ('course_20033','course_20237','course_20247' ...) if they dont pass any
# contexts, just assume it wants any context code.
2011-02-01 09:57:29 +08:00
if opts[:contexts]
# still need to optimize the query to use a root_context_code. that way a
# users course dashboard even if they have groups does a query with
# "context_code=..." instead of "context_code IN ..."
conditions = setup_context_association_lookups("stream_item_instances.context", opts[:contexts], :backcompat => true)
instances = instances.scoped(:conditions => conditions) unless conditions.first.empty?
elsif opts[:context]
# backcompat searching on context_code
instances = instances.scoped(:conditions =>
["(stream_item_instances.context_type=? AND stream_item_instances.context_id=?) OR (stream_item_instances.context_code=? AND stream_item_instances.context_type IS NULL)",
opts[:context].class.base_class.name,
opts[:context].id,
opts[:context].asset_string])
2011-02-01 09:57:29 +08:00
end
instances
end
2011-02-01 09:57:29 +08:00
def cached_recent_stream_items(opts={})
expires_in = 1.day
if opts[:contexts]
items = []
Array(opts[:contexts]).each do |context|
items.concat(
Rails.cache.fetch(StreamItemCache.recent_stream_items_key(self, context.class.base_class.name, context.id),
:expires_in => expires_in) {
recent_stream_items(:context => context)
})
end
items.sort { |a,b| b.id <=> a.id }
else
# no context in cache key
Rails.cache.fetch(StreamItemCache.recent_stream_items_key(self), :expires_in => expires_in) {
recent_stream_items
}
end
end
def recent_stream_items(opts={})
self.shard.activate do
ActiveRecord::Base::ConnectionSpecification.with_environment(:slave) do
visible_instances = visible_stream_item_instances(opts).scoped({
:include => :stream_item,
:limit => Setting.get('recent_stream_item_limit', 100),
})
visible_instances.map do |sii|
si = sii.stream_item
si.data.write_attribute(:unread, sii.unread?) if si.present?
si
end.compact
end
end
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def calendar_events_for_calendar(opts={})
opts = opts.dup
context_codes = opts[:context_codes] || (opts[:contexts] ? setup_context_lookups(opts[:contexts]) : self.cached_context_codes)
return [] if (!context_codes || context_codes.empty?)
opts[:start_at] ||= 2.weeks.ago
opts[:end_at] ||= 1.weeks.from_now
2011-02-01 09:57:29 +08:00
events = []
ev = CalendarEvent
ev = CalendarEvent.active if !opts[:include_deleted_events]
event_codes = context_codes + AppointmentGroup.manageable_by(self, context_codes).intersecting(opts[:start_at], opts[:end_at]).map(&:asset_string)
events += ev.for_user_and_context_codes(self, event_codes, []).between(opts[:start_at], opts[:end_at]).updated_after(opts[:updated_at])
events += Assignment.active.for_context_codes(context_codes).due_between(opts[:start_at], opts[:end_at]).updated_after(opts[:updated_at]).with_just_calendar_attributes
2011-02-01 09:57:29 +08:00
events.sort_by{|e| [e.start_at, e.title || ""] }.uniq
end
def upcoming_events(opts={})
context_codes = opts[:context_codes] || (opts[:contexts] ? setup_context_lookups(opts[:contexts]) : self.cached_context_codes)
return [] if (!context_codes || context_codes.empty?)
2011-02-01 09:57:29 +08:00
opts[:end_at] ||= 1.weeks.from_now
opts[:limit] ||= 20
events = CalendarEvent.active.for_user_and_context_codes(self, context_codes).between(Time.now.utc, opts[:end_at]).scoped(:limit => opts[:limit]).reject(&:hidden?)
events += Assignment.active.for_context_codes(context_codes).due_between(Time.now.utc, opts[:end_at]).scoped(:limit => opts[:limit]).include_submitted_count
appointment_groups = AppointmentGroup.manageable_by(self, context_codes).intersecting(Time.now.utc, opts[:end_at]).scoped(:limit => opts[:limit])
appointment_groups.each { |ag| ag.context = ag.contexts_for_user(self).first }
events += appointment_groups
2011-02-01 09:57:29 +08:00
events.sort_by{|e| [e.start_at, e.title] }.uniq.first(opts[:limit])
end
def undated_events(opts={})
opts = opts.dup
context_codes = opts[:context_codes] || (opts[:contexts] ? setup_context_lookups(opts[:contexts]) : self.cached_context_codes)
return [] if (!context_codes || context_codes.empty?)
undated_events = []
undated_events += CalendarEvent.active.for_user_and_context_codes(self, context_codes, []).undated.updated_after(opts[:updated_at])
2011-02-01 09:57:29 +08:00
undated_events += Assignment.active.for_context_codes(context_codes).undated.updated_after(opts[:updated_at]).with_just_calendar_attributes
undated_events.sort_by{|e| e.title }
end
2011-02-01 09:57:29 +08:00
def setup_context_lookups(contexts=nil)
# TODO: All the event methods use this and it's really slow.
Array(contexts || cached_contexts).map(&:asset_string)
end
memoize :setup_context_lookups
def setup_context_association_lookups(column, contexts=nil, opts = {})
contexts = Array(contexts || cached_contexts)
conditions = [[]]
backcompat = opts[:backcompat]
contexts.map do |context|
if backcompat
conditions.first << "((#{column}_type=? AND #{column}_id=?) OR (#{column}_code=? AND #{column}_type IS NULL))"
else
conditions.first << "(#{column}_type=? AND #{column}_id=?)"
end
conditions.concat [context.class.base_class.name, context.id]
conditions << context.asset_string if backcompat
end
conditions[0] = conditions[0].join(" OR ")
conditions
end
2011-02-01 09:57:29 +08:00
# TODO: doesn't actually cache, needs to be optimized
def cached_contexts
@cached_contexts ||= begin
context_groups = []
# according to the set_policy block in group.rb, user u can manage group
# g if either:
# (a) g.context.grants_right?(u, :manage_groups)
# (b) g.has_member?(u)
# this is a very performance sensitive method, so we're bypassing the
# normal policy checking and somewhat duplicating auth logic here. which
# is a shame. it'd be really nice to add support to our policy framework
# for understanding how to load associations based on policies.
self.courses.all(:include => :active_groups).select { |c| c.grants_right?(self, :manage_groups) }.each { |c| context_groups += c.active_groups }
self.courses + (self.groups.active + context_groups).uniq
2011-02-01 09:57:29 +08:00
end
end
2011-02-01 09:57:29 +08:00
# TODO: doesn't actually cache, needs to be optimized
def cached_context_codes
Array(self.cached_contexts).map(&:asset_string)
end
# context codes of things that might have a schedulable appointment for the
# given user, i.e. courses and sections
def appointment_context_codes
ret = {:primary => [], :secondary => []}
cached_current_enrollments.each do |e|
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
next unless e.student? && e.active?
ret[:primary] << "course_#{e.course_id}"
ret[:secondary] << "course_section_#{e.course_section_id}"
end
ret[:secondary].concat groups.map{ |g| "group_category_#{g.group_category_id}" }
ret
end
memoize :appointment_context_codes
def manageable_appointment_context_codes
ret = {:full => [], :limited => [], :secondary => []}
cached_current_enrollments.each do |e|
next unless e.course.grants_right?(self, nil, :manage_calendar)
if e.course.visibility_limited_to_course_sections?(self)
ret[:limited] << "course_#{e.course_id}"
ret[:secondary] << "course_section_#{e.course_section_id}"
else
ret[:full] << "course_#{e.course_id}"
end
end
ret
end
memoize :manageable_appointment_context_codes
conversation course filters, fixes #6827 implemented generic tags for conversations (and messages), but currently those only track course/group asset strings. conversations are automatically tagged with the most relevant courses/groups, i.e. 1. if a specific course/group is a recipient, it will be added as a tag 2. if a course can be inferred, it will be added as a tag. this can happen several ways: * if a user is added by browsing/searching under a course * if a synthetic context or section is a recipient (e.g. course_1_students) * if a group is a recipient 3. if there are no explicit or inferred tags, we do the old behavior (tag courses/groups that are shared by > 50% of the audience) some notes: 1. as was the previous behavior, you can only see tags (courses/groups) that you belong to. for example, a teacher could send a message to a student group, but the teacher would not see the group as a tag (though the students would) 2. tags can be added as recipients are added (though we still just show <= 2 in the UI). a private conversation may get new tags if a bare PM is sent and a different context can be inferred (see below) 3. private conversations may have tags removed, since we also track them at a message level. the scenario is if you have a bunch of messages from your Spanish 101 teacher. the course ends and you then take Spanish 102 from the same teacher, and the teacher sends the class a bunch of PMs. if you delete the old messages, your view of the conversation will lose the Spanish 101 tags. added a course filter dropdown in the ui. currently this is limited to active enrollments, and only returns courses (no groups) test plan: 1. test automatic tagging for new conversations * ensure explicit tags work (course as recipient) * ensure inferred tags work (e.g. user under a course) * ensure default tags work (e.g. send a PM to someone you have a class with) 2. test automatic tagging for adding recipients (same as above) 3. test automatic tagging for a new message on a private conversation, i.e. 1. find a user you share 2+ contexts with 2. start a private conversation w/ a single inferred tag, ensure that only that tag is on the conversation 3. start another private conversation w/ a different inferred tag (this will reuse the existing conversation), ensure it has both tags 4. test private conversation tag recalculation, i.e. perform test 3, remove a message, and ensure there is just one tag again 5. test conversation filtering in the UI migration: there is no traditional migration per se, rather we will run something like: Conversation.find_each{ |c| c.migrate_context_tags! } Change-Id: If467c8739ef39a655ef5a528b0da77213130e825 Reviewed-on: https://gerrit.instructure.com/8225 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Jacob Fugal <jacob@instructure.com>
2012-01-24 02:26:47 +08:00
def conversation_context_codes
Rails.cache.fetch([self, 'conversation_context_codes2'].cache_key, :expires_in => 1.day) do
( courses.map{ |c| "course_#{c.id}" } +
concluded_courses.map{ |c| "course_#{c.id}" } +
current_groups.map{ |g| "group_#{g.id}"}
).uniq
end
conversation course filters, fixes #6827 implemented generic tags for conversations (and messages), but currently those only track course/group asset strings. conversations are automatically tagged with the most relevant courses/groups, i.e. 1. if a specific course/group is a recipient, it will be added as a tag 2. if a course can be inferred, it will be added as a tag. this can happen several ways: * if a user is added by browsing/searching under a course * if a synthetic context or section is a recipient (e.g. course_1_students) * if a group is a recipient 3. if there are no explicit or inferred tags, we do the old behavior (tag courses/groups that are shared by > 50% of the audience) some notes: 1. as was the previous behavior, you can only see tags (courses/groups) that you belong to. for example, a teacher could send a message to a student group, but the teacher would not see the group as a tag (though the students would) 2. tags can be added as recipients are added (though we still just show <= 2 in the UI). a private conversation may get new tags if a bare PM is sent and a different context can be inferred (see below) 3. private conversations may have tags removed, since we also track them at a message level. the scenario is if you have a bunch of messages from your Spanish 101 teacher. the course ends and you then take Spanish 102 from the same teacher, and the teacher sends the class a bunch of PMs. if you delete the old messages, your view of the conversation will lose the Spanish 101 tags. added a course filter dropdown in the ui. currently this is limited to active enrollments, and only returns courses (no groups) test plan: 1. test automatic tagging for new conversations * ensure explicit tags work (course as recipient) * ensure inferred tags work (e.g. user under a course) * ensure default tags work (e.g. send a PM to someone you have a class with) 2. test automatic tagging for adding recipients (same as above) 3. test automatic tagging for a new message on a private conversation, i.e. 1. find a user you share 2+ contexts with 2. start a private conversation w/ a single inferred tag, ensure that only that tag is on the conversation 3. start another private conversation w/ a different inferred tag (this will reuse the existing conversation), ensure it has both tags 4. test private conversation tag recalculation, i.e. perform test 3, remove a message, and ensure there is just one tag again 5. test conversation filtering in the UI migration: there is no traditional migration per se, rather we will run something like: Conversation.find_each{ |c| c.migrate_context_tags! } Change-Id: If467c8739ef39a655ef5a528b0da77213130e825 Reviewed-on: https://gerrit.instructure.com/8225 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Jacob Fugal <jacob@instructure.com>
2012-01-24 02:26:47 +08:00
end
memoize :conversation_context_codes
def section_context_codes(context_codes)
course_ids = context_codes.grep(/\Acourse_\d+\z/).map{ |s| s.sub(/\Acourse_/, '').to_i }
return [] unless course_ids.present?
Course.find_all_by_id(course_ids).inject([]) do |ary, course|
ary.concat course.sections_visible_to(self).map(&:asset_string)
end
end
def manageable_courses(include_concluded = false)
Course.manageable_by_user(self.id, include_concluded).not_deleted
2011-02-01 09:57:29 +08:00
end
def manageable_courses_name_like(query = '', include_concluded = false)
self.manageable_courses(include_concluded).not_deleted.name_like(query).limit(50)
end
2011-02-01 09:57:29 +08:00
def last_completed_module
self.context_module_progressions.select{|p| p.completed? }.sort_by{|p| p.completed_at || p.created_at }.last.context_module rescue nil
end
2011-02-01 09:57:29 +08:00
def last_completed_course
self.enrollments.select{|e| e.completed? }.sort_by{|e| e.completed_at || e.created_at }.last.course rescue nil
end
2011-02-01 09:57:29 +08:00
def last_mastered_assignment
self.learning_outcome_results.sort_by{|r| r.assessed_at || r.created_at }.select{|r| r.mastery? }.map{|r| r.assignment }.last
end
def profile_pics_folder
save attachments before message creation, fixes #7229 rather than proxy attachments through the conversations controller and cause a long-running db transaction, we now just send them to the right place (files controller, s3, whatever), and then create the message. we now store the attachment in a special folder on the user so that they can more easily be tracked in the future for quota management. because we now just store one instance of each attachment, sending a bulk private message w/ attachments should be a bit less painful. known regression: if a user deletes a conversation attachment from their files area, it deletes if for all recipients. this is essentially the same problem as tickets #6732 and #7481 where we don't let a "deleted" attachment to still be viewed via associations with other objects. test plan: 1. send an attachment on a new conversation and confirm that was sent correctly and can be viewed by recipients 2. send an attachment on an existing conversation and confirm that was sent correctly and can be viewed by recipients 3. send an attachment on a bulk private conversation and 1. confirm that was sent correctly and can be viewed by recipients 2. confirm that only one attachment was actually created, but each message in each conversation is linked to it 4. send multiple attachments and confirm that they were sent correctly and can be viewed by recipients 5. perform steps 1-4 for both local and s3 uploads Change-Id: I7cb21c635f98e47163ef81f0c4050346c64faa91 Reviewed-on: https://gerrit.instructure.com/9046 Reviewed-by: Jon Jensen <jon@instructure.com> Tested-by: Hudson <hudson@instructure.com>
2012-02-28 07:54:00 +08:00
initialize_default_folder(Folder::PROFILE_PICS_FOLDER_NAME)
end
def conversation_attachments_folder
initialize_default_folder(Folder::CONVERSATION_ATTACHMENTS_FOLDER_NAME)
end
def initialize_default_folder(name)
folder = self.active_folders.find_by_name(name)
unless folder
save attachments before message creation, fixes #7229 rather than proxy attachments through the conversations controller and cause a long-running db transaction, we now just send them to the right place (files controller, s3, whatever), and then create the message. we now store the attachment in a special folder on the user so that they can more easily be tracked in the future for quota management. because we now just store one instance of each attachment, sending a bulk private message w/ attachments should be a bit less painful. known regression: if a user deletes a conversation attachment from their files area, it deletes if for all recipients. this is essentially the same problem as tickets #6732 and #7481 where we don't let a "deleted" attachment to still be viewed via associations with other objects. test plan: 1. send an attachment on a new conversation and confirm that was sent correctly and can be viewed by recipients 2. send an attachment on an existing conversation and confirm that was sent correctly and can be viewed by recipients 3. send an attachment on a bulk private conversation and 1. confirm that was sent correctly and can be viewed by recipients 2. confirm that only one attachment was actually created, but each message in each conversation is linked to it 4. send multiple attachments and confirm that they were sent correctly and can be viewed by recipients 5. perform steps 1-4 for both local and s3 uploads Change-Id: I7cb21c635f98e47163ef81f0c4050346c64faa91 Reviewed-on: https://gerrit.instructure.com/9046 Reviewed-by: Jon Jensen <jon@instructure.com> Tested-by: Hudson <hudson@instructure.com>
2012-02-28 07:54:00 +08:00
folder = self.folders.create!(:name => name,
:parent_folder => Folder.root_folders(self).find {|f| f.name == Folder::MY_FILES_FOLDER_NAME })
end
folder
end
2011-02-01 09:57:29 +08:00
def quota
return read_attribute(:storage_quota) if read_attribute(:storage_quota)
accounts = associated_root_accounts.reject(&:site_admin?)
accounts.empty? ?
self.class.default_storage_quota :
accounts.sum(&:default_user_storage_quota)
end
def self.default_storage_quota
Setting.get_cached('user_default_quota', 50.megabytes.to_s).to_i
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def update_last_user_note
note = user_notes.active.scoped(:order => 'user_notes.created_at DESC', :limit=>1).first
self.last_user_note = note ? note.created_at : nil
end
2011-02-01 09:57:29 +08:00
TAB_PROFILE = 0
TAB_COMMUNICATION_PREFERENCES = 1
TAB_FILES = 2
TAB_EPORTFOLIOS = 3
TAB_HOME = 4
def sis_user_id
pseudonym.try(:sis_user_id)
end
def highest_role
return 'admin' unless self.accounts.empty?
return 'teacher' if self.cached_current_enrollments.any?(&:admin?)
return 'student' if self.cached_current_enrollments.any?(&:student?)
return 'user'
end
memoize :highest_role
def roles
res = ['user']
res << 'student' if self.cached_current_enrollments.any?(&:student?)
res << 'teacher' if self.cached_current_enrollments.any?(&:admin?)
res << 'admin' unless self.accounts.empty?
res
end
memoize :roles
def eportfolios_enabled?
accounts = associated_root_accounts.reject(&:site_admin?)
accounts.size == 0 || accounts.any?{ |a| a.settings[:enable_eportfolios] != false }
end
def initiate_conversation(user_ids, private = nil)
user_ids = ([self.id] + user_ids).uniq
private = user_ids.size <= 2 if private.nil?
Conversation.initiate(user_ids, private).conversation_participants.find_by_user_id(self.id)
end
def messageable_user_clause
"users.workflow_state IN ('registered', 'pre_registered')"
end
def messageable_enrollment_user_clause
"EXISTS (SELECT 1 FROM users WHERE id = enrollments.user_id AND #{messageable_user_clause})"
end
def messageable_enrollment_clause(options={})
options = {:strict_course_state => true}.merge(options)
<<-SQL
(
#{self.class.enrollment_conditions(:current_and_invited, options[:strict_course_state])}
OR
#{self.class.enrollment_conditions(:completed, options[:strict_course_state])}
#{options[:include_concluded_students] ? "" : "AND enrollments.type IN ('TeacherEnrollment', 'TaEnrollment')"}
)
SQL
end
def enrollment_visibility
Rails.cache.fetch([self, 'enrollment_visibility_with_sections_2'].cache_key, :expires_in => 1.day) do
full_course_ids = []
section_id_hash = {}
restricted_course_hash = {}
user_counts = {}
section_user_counts = {}
student_in_course_ids = []
linked_observer_ids = observee_enrollments.collect {|e| e.user_id}.uniq
courses_with_primary_enrollment(:current_and_concluded_courses, nil, :include_completed_courses => true).each do |course|
section_visibilities = course.section_visibilities_for(self)
conditions = nil
case course.enrollment_visibility_level_for(self, section_visibilities)
when :full
full_course_ids << course.id
when :sections
section_id_hash[course.id] = section_visibilities.map{|s| s[:course_section_id]}
conditions = {:course_section_id => section_id_hash[course.id]}
when :restricted
section_visibilities.each do |s|
restricted_course_hash[course.id] ||= []
restricted_course_hash[course.id] << s[:associated_user_id] if s[:associated_user_id]
end
conditions = "enrollments.type = 'TeacherEnrollment' OR enrollments.type = 'TaEnrollment' OR enrollments.user_id IN (#{([self.id] + restricted_course_hash[course.id].uniq).join(',')})"
end
base_conditions = messageable_enrollment_clause
base_conditions << " AND " << messageable_enrollment_user_clause
if course.primary_enrollment == 'StudentEnrollment'
student_in_course_ids << course.id
base_conditions << " AND (enrollments.type != 'ObserverEnrollment'"
base_conditions << " OR enrollments.user_id IN (#{linked_observer_ids.join(',')})" if linked_observer_ids.any?
base_conditions << ")"
end
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
user_counts[course.id] = course.enrollments.scoped(:conditions => base_conditions).scoped(:conditions => conditions).scoped(:conditions => "enrollments.type != 'StudentViewEnrollment'").count("DISTINCT user_id")
sections = course.sections_visible_to(self)
if sections.size > 1
sections.each{ |section| section_user_counts[section.id] = 0 }
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
connection.select_all("SELECT course_section_id, COUNT(DISTINCT user_id) AS user_count FROM courses, enrollments WHERE (#{base_conditions}) AND course_section_id IN (#{sections.map(&:id).join(', ')}) AND courses.id = #{course.id} AND enrollments.type != 'StudentViewEnrollment' GROUP BY course_section_id").each do |row|
section_user_counts[row["course_section_id"].to_i] = row["user_count"].to_i
end
end
end
{:full_course_ids => full_course_ids,
:section_id_hash => section_id_hash,
:restricted_course_hash => restricted_course_hash,
:user_counts => user_counts,
:section_user_counts => section_user_counts,
:student_in_course_ids => student_in_course_ids,
:linked_observer_ids => linked_observer_ids
}
end
end
memoize :enrollment_visibility
def messageable_groups
group_visibility = group_membership_visibility
Group.scoped(:conditions => {:id => visible_group_ids.reject{ |id| group_visibility[:user_counts][id] == 0 } + [0]})
end
def visible_group_ids
Rails.cache.fetch([self, 'messageable_groups'].cache_key, :expires_in => 1.day) do
(courses + concluded_courses.recently_ended).inject(self.current_groups) { |groups, course|
groups | course.groups.active
}.map(&:id)
end
end
memoize :visible_group_ids
def group_membership_visibility
Rails.cache.fetch([self, 'group_membership_visibility'].cache_key, :expires_in => 1.day) do
course_visibility = enrollment_visibility
own_group_ids = current_groups.map(&:id)
full_group_ids = []
section_id_hash = {}
user_counts = {}
if visible_group_ids.present?
Group.find_all_by_id(visible_group_ids).each do |group|
if own_group_ids.include?(group.id) || group.context_type == 'Course' && course_visibility[:full_course_ids].include?(group.context_id)
full_group_ids << group.id
user_counts[group.id] = group.users.size
elsif group.context_type == 'Course' && sections = course_visibility[:section_id_hash][group.context_id]
section_id_hash[group.id] = sections
user_counts[group.id] = group.context.enrollments.scoped(:conditions => [
"user_id IN (?) AND course_section_id IN (?) AND #{messageable_enrollment_user_clause} AND #{messageable_enrollment_clause(:include_concluded_students => true)}",
group.group_memberships.map(&:user_id),
sections
]).size
end
end
end
{:full_group_ids => full_group_ids,
:section_id_hash => section_id_hash,
:user_counts => user_counts
}
end
end
memoize :group_membership_visibility
MESSAGEABLE_USER_COLUMNS = ['id', 'short_name', 'name', 'avatar_image_url', 'avatar_image_source'].map{|col|"users.#{col}"}
MESSAGEABLE_USER_COLUMN_SQL = MESSAGEABLE_USER_COLUMNS.join(", ")
MESSAGEABLE_USER_CONTEXT_REGEX = /\A(course|section|group)_(\d+)(_([a-z]+))?\z/
def messageable_users(options = {})
# if :ids is specified but empty (different than just not specified), don't
# bother doing a query that's guaranteed to return no results.
return [] if options[:ids] && options[:ids].empty?
# provides a mechanism for admins to search within a context, even if not
# enrolled in it
admin_context = options[:admin_context]
course_hash = enrollment_visibility
course_hash[:full_course_ids] << admin_context.id if admin_context.is_a?(Course)
course_hash[:full_course_ids] << admin_context.course_id if admin_context.is_a?(CourseSection)
full_course_ids = course_hash[:full_course_ids]
restricted_course_hash = course_hash[:restricted_course_hash]
group_hash = group_membership_visibility
group_hash[:full_group_ids] << admin_context.id if admin_context.is_a?(Group)
full_group_ids = group_hash[:full_group_ids]
group_section_ids = []
student_in_course_ids = course_hash[:student_in_course_ids]
linked_observer_ids = course_hash[:linked_observer_ids]
account_ids = []
limited_id = {}
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
enrollment_type_sql = " AND enrollments.type != 'StudentViewEnrollment'"
if student_in_course_ids.present?
enrollment_type_sql += " AND (enrollments.type != 'ObserverEnrollment' OR course_id NOT IN (#{student_in_course_ids.join(',')})"
enrollment_type_sql += " OR user_id IN (#{linked_observer_ids.join(',')})" if linked_observer_ids.present?
enrollment_type_sql += ")"
end
include_concluded_students = true
if options[:context]
if options[:context].sub(/_all\z/, '') =~ MESSAGEABLE_USER_CONTEXT_REGEX
type = $1
include_concluded_students = false unless type == 'group'
limited_id[type] = $2.to_i
enrollment_type = $4
if enrollment_type && type != 'group' # course and section only, since the only group "enrollment type" is member
if enrollment_type == 'admins'
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
enrollment_type_sql += " AND enrollments.type IN ('TeacherEnrollment','TaEnrollment')"
else
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
enrollment_type_sql += " AND enrollments.type = '#{enrollment_type.capitalize.singularize}Enrollment'"
end
end
end
full_course_ids &= [limited_id['course']]
full_group_ids &= [limited_id['group']]
restricted_course_hash.delete_if{ |course_id, ids| course_id != limited_id['course']}
if limited_id['section'] && section = CourseSection.find_by_id(limited_id['section'])
course_section_ids = course_hash[:full_course_ids].include?(section.course_id) ?
[limited_id['section']] :
(course_hash[:section_id_hash][section.course_id] || []) & [limited_id['section']]
else
course_section_ids = course_hash[:section_id_hash].values_at(limited_id['course']).flatten.compact
group_section_ids = group_hash[:section_id_hash].values_at(limited_id['group']).flatten.compact
end
else
course_section_ids = course_hash[:section_id_hash].values.flatten
# if we're not searching with a context in mind, include any users we
# have admin access to know about
account_ids = associated_accounts.select{ |a| a.grants_right?(self, nil, :read_roster) }.map(&:id)
account_ids &= options[:account_ids] if options[:account_ids]
end
user_conditions = []
if options[:skip_visibility_checks]
user_conditions << "users.workflow_state != 'deleted'" if options[:ids].blank?
else
user_conditions << messageable_user_clause
end
user_conditions << "users.id IN (#{options[:ids].map(&:to_i).join(', ')})" if options[:ids].present?
user_conditions << "users.id NOT IN (#{options[:exclude_ids].map(&:to_i).join(', ')})" if options[:exclude_ids].present?
if options[:search] && (parts = options[:search].strip.split(/\s+/)).present?
parts.each do |part|
user_conditions << "(#{wildcard('users.name', 'users.short_name', part)})"
end
end
user_condition_sql = user_conditions.present? ? "AND " + user_conditions.join(" AND ") : ""
user_sql = []
# this is redundant (and potentially less restrictive than course_sql),
# but it allows the planner to initially limit enrollments to relevant
# courses much more efficiently than the OR'ed course_sql does
all_course_ids = (course_hash[:full_course_ids] + course_hash[:section_id_hash].keys + restricted_course_hash.keys).compact
course_sql = []
course_sql << "(course_id IN (#{full_course_ids.join(',')}))" if full_course_ids.present?
course_sql << "(course_section_id IN (#{course_section_ids.join(',')}))" if course_section_ids.present?
course_sql << "(course_section_id IN (#{group_section_ids.join(',')}) AND EXISTS(SELECT 1 FROM group_memberships WHERE user_id = users.id AND group_id = #{limited_id['group']}) )" if limited_id['group'] && group_section_ids.present?
course_sql << "(course_id IN (#{restricted_course_hash.keys.join(',')}) AND (enrollments.type = 'TeacherEnrollment' OR enrollments.type = 'TaEnrollment' OR enrollments.user_id IN (#{([self.id] + restricted_course_hash.values.flatten.uniq).join(',')})))" if restricted_course_hash.present?
user_sql << <<-SQL if course_sql.present?
SELECT #{MESSAGEABLE_USER_COLUMN_SQL}, course_id, NULL AS group_id, #{connection.func(:group_concat, :'enrollments.type', ':')} AS roles
FROM users, enrollments, courses
WHERE course_id IN (#{all_course_ids.join(', ')})
AND (#{course_sql.join(' OR ')}) AND users.id = user_id AND courses.id = course_id
AND #{messageable_enrollment_clause(:include_concluded_students => include_concluded_students, :strict_course_state => !options[:skip_visibility_checks])}
#{enrollment_type_sql}
#{user_condition_sql}
GROUP BY #{connection.group_by(['users.id', 'course_id'], *(MESSAGEABLE_USER_COLUMNS[1, MESSAGEABLE_USER_COLUMNS.size]))}
SQL
user_sql << <<-SQL if full_group_ids.present?
SELECT #{MESSAGEABLE_USER_COLUMN_SQL}, NULL AS course_id, group_id, NULL AS roles
FROM users, group_memberships
WHERE group_id IN (#{full_group_ids.join(',')}) AND users.id = user_id
AND group_memberships.workflow_state = 'accepted'
#{user_condition_sql}
SQL
# if this is an account admin who doesn't have any courses/groups in common
# with the user, we want to know the user's highest current enrollment type
highest_enrollment_sql = <<-SQL
SELECT type
FROM enrollments, courses
WHERE
user_id = users.id AND courses.id = course_id
AND (#{self.class.enrollment_conditions(:current_and_invited)})
ORDER BY #{Enrollment.type_rank_sql}
LIMIT 1
SQL
user_sql << <<-SQL if account_ids.present?
SELECT #{MESSAGEABLE_USER_COLUMN_SQL}, 0 AS course_id, NULL AS group_id, (#{highest_enrollment_sql}) AS roles
FROM users, user_account_associations
WHERE user_account_associations.account_id IN (#{account_ids.join(',')})
AND user_account_associations.user_id = users.id
#{user_condition_sql}
SQL
user_sql << <<-SQL unless options[:context]
SELECT #{MESSAGEABLE_USER_COLUMN_SQL}, NULL AS course_id, NULL AS group_id, NULL AS roles
FROM users
WHERE id = #{self.id}
#{user_condition_sql}
SQL
if options[:ids]
# provides a way for this user to start a conversation with someone
# that isn't normally messageable (requires that they already be in a
# conversation with that user)
if options[:conversation_id].present?
user_sql << <<-SQL
SELECT #{MESSAGEABLE_USER_COLUMN_SQL}, NULL AS course_id, NULL AS group_id, NULL AS roles
FROM users, conversation_participants
WHERE conversation_participants.user_id = users.id
AND conversation_participants.conversation_id = #{options[:conversation_id].to_i}
#{user_condition_sql}
SQL
elsif options[:skip_visibility_checks] # we don't care about the contexts, we've passed in ids
user_sql << <<-SQL
SELECT #{MESSAGEABLE_USER_COLUMN_SQL}, NULL AS course_id, NULL AS group_id, NULL AS roles
FROM users
#{user_condition_sql.sub(/\AAND/, "WHERE")}
SQL
end
end
# if none of our potential sources was included, we're done
return [] if user_sql.empty?
concat_sql = connection.adapter_name =~ /postgres/i ? :"course_id::text || ':' || roles::text" : :"course_id || ':' || roles"
users = User.find_by_sql(<<-SQL)
SELECT #{MESSAGEABLE_USER_COLUMN_SQL},
#{connection.func(:group_concat, concat_sql)} AS common_courses,
#{connection.func(:group_concat, :group_id)} AS common_groups
FROM (
#{user_sql.join(' UNION ')}
) users
GROUP BY #{connection.group_by(*MESSAGEABLE_USER_COLUMNS)}
ORDER BY #{options[:rank_results] ? "(COUNT(course_id) + COUNT(group_id)) DESC," : ""}
LOWER(COALESCE(short_name, name)),
id
#{options[:limit] && options[:limit] > 0 ? "LIMIT #{options[:limit].to_i}" : ""}
#{options[:offset] && options[:offset] > 0 ? "OFFSET #{options[:offset].to_i}" : ""}
SQL
users.each do |user|
user.common_courses = user.common_courses.to_s.split(",").inject({}){ |hash, info|
roles = info.split(/:/)
hash[roles.shift.to_i] = roles
hash
}
user.common_groups = user.common_groups.to_s.split(",").inject({}){ |hash, info|
roles = info.split(/:/)
hash[roles.shift.to_i] = ['Member']
hash
}
end
end
def short_name_with_shared_contexts(user)
if (contexts = shared_contexts(user)).present?
"#{short_name} (#{contexts[0, 2].to_sentence})"
else
short_name
end
end
def shared_contexts(user)
contexts = []
if info = messageable_users(:ids => [user.id]).first
contexts += Course.find(:all, :conditions => {:id => info.common_courses.keys}) if info.common_courses.present?
contexts += Group.find(:all, :conditions => {:id => info.common_groups.keys}) if info.common_groups.present?
end
contexts.map(&:name).sort_by{|c|c.downcase}
end
def mark_all_conversations_as_read!
conversations.unread.update_all(:workflow_state => 'read')
User.update_all 'unread_conversations_count = 0', :id => id
end
def conversation_participant(conversation_id)
all_conversations.find_by_conversation_id(conversation_id)
end
# association with dynamic, filtered join condition for submissions.
# This is messy, but in ActiveRecord 2 this is the only way to do an eager
# loading :include condition that has dynamic join conditions. It looks like
# there's better solutions in AR 3.
# See also e.g., http://makandra.com/notes/983-dynamic-conditions-for-belongs_to-has_many-and-has_one-associations
has_many :submissions_for_given_assignments, :include => [:assignment, :submission_comments], :conditions => 'submissions.assignment_id IN (#{Api.assignment_ids_for_students_api.join(",")})', :class_name => 'Submission'
def set_menu_data(enrollment_uuid)
return @menu_data if @menu_data
coalesced_enrollments = []
cached_enrollments = self.cached_current_enrollments(:include_enrollment_uuid => enrollment_uuid)
cached_enrollments.each do |e|
next if e.state_based_on_date == :inactive
if e.state_based_on_date == :completed
has_completed_enrollment = true
next
end
if !e.course
coalesced_enrollments << {
:enrollment => e,
:sortable => [e.rank_sortable, e.state_sortable, e.long_name],
:types => [ e.readable_type ]
}
end
existing_enrollment_info = coalesced_enrollments.find { |en|
# coalesce together enrollments for the same course and the same state
!e.course.nil? && en[:enrollment].course == e.course && en[:enrollment].workflow_state == e.workflow_state
}
if existing_enrollment_info
existing_enrollment_info[:types] << e.readable_type
existing_enrollment_info[:sortable] = [existing_enrollment_info[:sortable] || [999,999, 999], [e.rank_sortable, e.state_sortable, 0 - e.id]].min
else
coalesced_enrollments << { :enrollment => e, :sortable => [e.rank_sortable, e.state_sortable, 0 - e.id], :types => [ e.readable_type ] }
end
end
coalesced_enrollments = coalesced_enrollments.sort_by{|e| e[:sortable] || [999,999, 999] }
active_enrollments = coalesced_enrollments.map{ |e| e[:enrollment] }
cached_group_memberships = self.cached_current_group_memberships
coalesced_group_memberships = cached_group_memberships.
select{ |gm| gm.active_given_enrollments?(active_enrollments) }.
sort_by{ |gm| gm.group.name }
@menu_data = {
:group_memberships => coalesced_group_memberships,
:group_memberships_count => cached_group_memberships.length,
:accounts => self.accounts,
:accounts_count => self.accounts.length,
}
end
def menu_courses(enrollment_uuid = nil)
return @menu_courses if @menu_courses
favorites = self.courses_with_primary_enrollment(:favorite_courses, enrollment_uuid)
return (@menu_courses = favorites) if favorites.length > 0
@menu_courses = self.courses_with_primary_enrollment(:current_and_invited_courses, enrollment_uuid).first(12)
end
def user_can_edit_name?
associated_root_accounts.any? { |a| a.settings[:users_can_edit_name] != false } || associated_root_accounts.empty?
end
def sections_for_course(course)
course.student_enrollments.active.for_user(self).map { |e| e.course_section }
end
def can_create_enrollment_for?(course, session, type)
can_add = %w{StudentEnrollment ObserverEnrollment}.include?(type) && course.grants_right?(self, session, :manage_students)
can_add ||= type == 'TeacherEnrollment' && course.teacherless? && course.grants_right?(self, session, :manage_students)
can_add ||= course.grants_right?(self, session, :manage_admin_users)
can_add
end
def group_member_json(context)
h = { :user_id => self.id, :name => self.last_name_first, :display_name => self.short_name }
if context && context.is_a?(Course)
self.sections_for_course(context).each do |section|
h[:sections] ||= []
h[:sections] << { :section_id => section.id, :section_code => section.section_code }
end
end
h
end
def find_pseudonym_for_account(account, allow_implicit = false)
# try to find one that's already loaded if possible
if self.pseudonyms.loaded?
p = self.pseudonyms.detect { |p| p.active? && p.works_for_account?(account, allow_implicit) }
return p if p
end
self.all_active_pseudonyms.detect { |p| p.works_for_account?(account, allow_implicit) }
end
# account = the account that you want a pseudonym for
# preferred_template_account = pass in an actual account if you have a preference for which account the new pseudonym gets copied from
# this may not be able to find a suitable pseudonym to copy, so would still return nil
# if a pseudonym is created, it is *not* saved, and *not* added to the pseudonyms collection
def find_or_initialize_pseudonym_for_account(account, preferred_template_account = nil)
pseudonym = find_pseudonym_for_account(account)
if !pseudonym
# list of copyable pseudonyms
active_pseudonyms = self.all_active_pseudonyms(:reload).select { |p|!p.password_auto_generated? && !p.account.delegated_authentication? }
templates = []
# re-arrange in the order we prefer
templates.concat active_pseudonyms.select { |p| p.account_id == preferred_template_account.id } if preferred_template_account
templates.concat active_pseudonyms.select { |p| p.account_id == Account.site_admin.id }
templates.concat active_pseudonyms.select { |p| p.account_id == Account.default.id }
templates.concat active_pseudonyms
templates.uniq!
template = templates.detect { |template| !account.pseudonyms.custom_find_by_unique_id(template.unique_id) }
if template
# creating this not attached to the user's pseudonyms is intentional
pseudonym = account.pseudonyms.build
pseudonym.user = self
pseudonym.unique_id = template.unique_id
pseudonym.password_salt = template.password_salt
pseudonym.crypted_password = template.crypted_password
end
end
pseudonym
end
# Public: Add this user as an admin in the given account.
#
# account - The account model to create the admin in.
# role - String name of the role to add the user to. If nil,
# 'AccountAdmin' will be used (default: nil).
# send_notification - If set to false, do not send any email
# notifications (default: true).
#
# Returns an AccountUser model object.
def flag_as_admin(account, role=nil, send_notification = true)
admin = account.add_user(self, role)
return admin unless send_notification
if self.registered?
admin.account_user_notification!
else
admin.account_user_registration!
end
admin
end
student view; closes #6995 allows course admins to view the course from a student perspective. this is accessible from a button on the course/settings page. They should be able to interact with the course as a student would, including submitting homework and quizzes. Right now there is one student view student per course, so if the course has multiple administrators, they will all share the same student view student. There are a few things that won't work in student view the way the would for a normal student, most notably access to conversations is disabled. Additionally, any publicly visible action that the teacher takes while in student view will still be publicly visible -- for example if the teacher posts a discussion topic/reply as the student view student, it will be visible to the whole class. test-plan: - (the following should be tried both as a full teacher and as a section-limited course admin) - set up a few assignments, quizzes, discussions, and module progressions in a course. - enter student view from the coures settings page. - work through the things you set up above. - leave student view from the upper right corner of the page. - as a teacher you should be able to grade the fake student so that they can continue to progress. - the student should not show up in the course users list - the student should not show up at the account level at all: * total user list * statistics Change-Id: I886a4663777f3ef2bdae594349ff6da6981e14ed Reviewed-on: https://gerrit.instructure.com/9484 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com>
2012-03-14 04:08:19 +08:00
def fake_student?
self.preferences[:fake_student] && !!self.enrollments.find(:first, :conditions => {:type => "StudentViewEnrollment"})
end
def private?
not public?
end
def default_collection_name
t :default_collection_name, "%{user_name}'s Collection", :user_name => self.short_name
end
def profile(force_reload = false)
orig_profile(force_reload) || build_profile
end
multi-factor authentication closes #9532 test plan: * enable optional MFA, and check the following: * normal log in should not be affected * you can enroll in MFA from your profile page * you can re-enroll in MFA from your profile page * you can disable MFA from your profile page * MFA can be reset by an admin on your user page * when enrolled, you are asked for verification code after username/password when logging in * you can't access any other part of the site directly until until entering your verification code * enable required MFA, and check the following * when not enrolled in MFA, and you log in, you are forced to enroll * you cannot disable MFA from your profile page * you can re-enroll in MFA from your profile page * an admin (other than himself) can reset MFA from the user page * for enrolling in MFA * use Google Authenticator and scan the QR code; you should have 30-seconds or so of extra leeway to enter your code * having no SMS communication channels on your profile, the enrollment page should just have a form to add a new phone * having one or more SMS communication channels on your profile, the enrollment page should list them, or allow you to create a new one (and switch back) * having more than one SMS communication channel on your profile, the enrollment page should remember which one you have selected after you click "send" * an unconfirmed SMS channel should go to confirmed when it's used to enroll in MFA * you should not be able to go directly to /login/otp to enroll if you used "Remember me" token to log in * MFA login flow * if configured with SMS, it should send you an SMS after you put in your username/password; you should have about 5 minutes of leeway to put it in * if you don't check "remember computer" checkbox, you should have to enter a verification code each time you log in * if you do check it, you shouldn't have to enter your code anymore (for three days). it also shouldn't SMS you a verification code each time you log in * setting MFA to required for admins should make it required for admins, optional for other users * with MFA enabled, directly go to /login/otp after entering username/password but before entering a verification code; it should send you back to the main login page * if you enrolled via SMS, you should not be able to remove that SMS from your profile * there should not be a reset MFA link on a user page if they haven't enrolled * test a login or required enrollment sequence with CAS and/or SAML Change-Id: I692de7405bf7ca023183e717930ee940ccf0d5e6 Reviewed-on: https://gerrit.instructure.com/12700 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Brian Palmer <brianp@instructure.com>
2012-08-03 05:17:50 +08:00
def otp_secret_key_remember_me_cookie(time)
"#{time.to_i}.#{Canvas::Security.hmac_sha1("#{time.to_i}.#{self.otp_secret_key}")}"
end
def validate_otp_secret_key_remember_me_cookie(value)
value =~ /^(\d+)\.[0-9a-f]+/ &&
$1.to_i >= (Time.now.utc - 30.days).to_i &&
value == otp_secret_key_remember_me_cookie($1)
end
def otp_secret_key
return nil unless otp_secret_key_enc
Canvas::Security::decrypt_password(otp_secret_key_enc, otp_secret_key_salt, 'otp_secret_key', self.shard.settings[:encryption_key]) if otp_secret_key_enc
end
def otp_secret_key=(key)
if key
self.otp_secret_key_enc, self.otp_secret_key_salt = Canvas::Security::encrypt_password(key, 'otp_secret_key', self.shard.settings[:encryption_key])
else
self.otp_secret_key_enc = self.otp_secret_key_salt = nil
end
key
end
def crocodoc_id!
cid = read_attribute(:crocodoc_id)
return cid if cid
Setting.transaction do
s = Setting.find_by_name('crocodoc_counter', :lock => true)
cid = s.value = s.value.to_i + 1
s.save!
end
update_attribute(:crocodoc_id, cid)
cid
end
def crocodoc_user
"#{crocodoc_id!},#{short_name.gsub(",","")}"
end
multi-factor authentication closes #9532 test plan: * enable optional MFA, and check the following: * normal log in should not be affected * you can enroll in MFA from your profile page * you can re-enroll in MFA from your profile page * you can disable MFA from your profile page * MFA can be reset by an admin on your user page * when enrolled, you are asked for verification code after username/password when logging in * you can't access any other part of the site directly until until entering your verification code * enable required MFA, and check the following * when not enrolled in MFA, and you log in, you are forced to enroll * you cannot disable MFA from your profile page * you can re-enroll in MFA from your profile page * an admin (other than himself) can reset MFA from the user page * for enrolling in MFA * use Google Authenticator and scan the QR code; you should have 30-seconds or so of extra leeway to enter your code * having no SMS communication channels on your profile, the enrollment page should just have a form to add a new phone * having one or more SMS communication channels on your profile, the enrollment page should list them, or allow you to create a new one (and switch back) * having more than one SMS communication channel on your profile, the enrollment page should remember which one you have selected after you click "send" * an unconfirmed SMS channel should go to confirmed when it's used to enroll in MFA * you should not be able to go directly to /login/otp to enroll if you used "Remember me" token to log in * MFA login flow * if configured with SMS, it should send you an SMS after you put in your username/password; you should have about 5 minutes of leeway to put it in * if you don't check "remember computer" checkbox, you should have to enter a verification code each time you log in * if you do check it, you shouldn't have to enter your code anymore (for three days). it also shouldn't SMS you a verification code each time you log in * setting MFA to required for admins should make it required for admins, optional for other users * with MFA enabled, directly go to /login/otp after entering username/password but before entering a verification code; it should send you back to the main login page * if you enrolled via SMS, you should not be able to remove that SMS from your profile * there should not be a reset MFA link on a user page if they haven't enrolled * test a login or required enrollment sequence with CAS and/or SAML Change-Id: I692de7405bf7ca023183e717930ee940ccf0d5e6 Reviewed-on: https://gerrit.instructure.com/12700 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Brian Palmer <brianp@instructure.com>
2012-08-03 05:17:50 +08:00
# mfa settings for a user are the most restrictive of any pseudonyms the user has
# a login for
def mfa_settings
result = self.all_pseudonyms(:include => :account).map(&:account).uniq.map do |account|
multi-factor authentication closes #9532 test plan: * enable optional MFA, and check the following: * normal log in should not be affected * you can enroll in MFA from your profile page * you can re-enroll in MFA from your profile page * you can disable MFA from your profile page * MFA can be reset by an admin on your user page * when enrolled, you are asked for verification code after username/password when logging in * you can't access any other part of the site directly until until entering your verification code * enable required MFA, and check the following * when not enrolled in MFA, and you log in, you are forced to enroll * you cannot disable MFA from your profile page * you can re-enroll in MFA from your profile page * an admin (other than himself) can reset MFA from the user page * for enrolling in MFA * use Google Authenticator and scan the QR code; you should have 30-seconds or so of extra leeway to enter your code * having no SMS communication channels on your profile, the enrollment page should just have a form to add a new phone * having one or more SMS communication channels on your profile, the enrollment page should list them, or allow you to create a new one (and switch back) * having more than one SMS communication channel on your profile, the enrollment page should remember which one you have selected after you click "send" * an unconfirmed SMS channel should go to confirmed when it's used to enroll in MFA * you should not be able to go directly to /login/otp to enroll if you used "Remember me" token to log in * MFA login flow * if configured with SMS, it should send you an SMS after you put in your username/password; you should have about 5 minutes of leeway to put it in * if you don't check "remember computer" checkbox, you should have to enter a verification code each time you log in * if you do check it, you shouldn't have to enter your code anymore (for three days). it also shouldn't SMS you a verification code each time you log in * setting MFA to required for admins should make it required for admins, optional for other users * with MFA enabled, directly go to /login/otp after entering username/password but before entering a verification code; it should send you back to the main login page * if you enrolled via SMS, you should not be able to remove that SMS from your profile * there should not be a reset MFA link on a user page if they haven't enrolled * test a login or required enrollment sequence with CAS and/or SAML Change-Id: I692de7405bf7ca023183e717930ee940ccf0d5e6 Reviewed-on: https://gerrit.instructure.com/12700 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Brian Palmer <brianp@instructure.com>
2012-08-03 05:17:50 +08:00
case account.mfa_settings
when :disabled
0
when :optional
1
when :required_for_admins
if account.all_account_users_for(self).empty?
1
else
# short circuit the entire method
return :required
end
when :required
# short circuit the entire method
return :required
end
end.max
return :disabled if result.nil?
[ :disabled, :optional ][result]
end
spread weekly notifications over saturdays notifications were happening on monday because TimeWithZone.advance(:days => x) does nothing. move them to saturday for real and also spread them over the Eastern-time day instead of lumping them all at 8pm (admittedly by timezone, but that's only 4 hours for the continental US, which is the current majority of users). add an indicator to the notification preference page to show a rough time block in which they can expect their weekly messages to be sent. fixes #8296 test-plan: setup: - create two accounts the same id but on different shards (shard ids should not differ by a multiple of four) - in each a account, create a user with a communication channel; call them Alice and Bob. - create a third account on the same shard as Bob's account but with a different id (account ids should not differ by a multiple of four) - in this account create two users with a communication channel each; call them Charles and David. - for each of the four users, assign a notification to deliver to the user's communication channel weekly - on Friday, trigger the associated notifications for each user - on Sunday, trigger David's notification again expectations: - each user should receive the messages on saturday (Eastern-time), not monday - Alice's and Bob's emails should arrive in different "quarter days" - Bob's and Charle's emails should arrive in different "quarter days" - Charles' email and David's first email should arrive in the same "quarter day" but in different hours - David's emails should come a week apart, but in the same hour both times UI: - go to the notifications preference page - should see note at bottom indicating two hour block during which their weekly notifications will be sent - actual send time of weekly notifications should fall within that block Change-Id: I97bb75762ef8c03fae99ad5499b441f7c026d2c8 Reviewed-on: https://gerrit.instructure.com/13963 Reviewed-by: Cody Cutrer <cody@instructure.com> Reviewed-by: Brian Palmer <brianp@instructure.com> Tested-by: Jenkins <jenkins@instructure.com>
2012-09-27 04:19:05 +08:00
def weekly_notification_bucket
# place in the next 24 hours after saturday morning midnight is
# determined by account and user. messages for any user in the same
# account (on the same shard) map into the same 6-hour window, and then
# are spread within that window by user. this is specifically 24 real
# hours, not 1 day, because DST sucks. so it'll go to 1am sunday
# morning and 11pm saturday night on the DST transition days, but
# midnight sunday morning the rest of the time.
account_bucket = (shard.id.to_i + pseudonym.try(:account_id).to_i) % DelayedMessage::WEEKLY_ACCOUNT_BUCKETS
user_bucket = self.id % DelayedMessage::MINUTES_PER_WEEKLY_ACCOUNT_BUCKET
account_bucket * DelayedMessage::MINUTES_PER_WEEKLY_ACCOUNT_BUCKET + user_bucket
end
def weekly_notification_time
# weekly notification scheduling happens in Eastern-time
time_zone = ActiveSupport::TimeZone.us_zones.find{ |zone| zone.name == 'Eastern Time (US & Canada)' }
# start at midnight saturday morning before next monday
target = time_zone.now.next_week - 2.days
minutes = weekly_notification_bucket.minutes
# if we're already past that (e.g. it's sunday or late saturday),
# advance by a week
target += 1.week if target + minutes < time_zone.now
# move into the 24 hours after midnight saturday morning and return
target + minutes
end
def weekly_notification_range
# weekly notification scheduling happens in Eastern-time
time_zone = ActiveSupport::TimeZone.us_zones.find{ |zone| zone.name == 'Eastern Time (US & Canada)' }
# start on January first instead of "today" to avoid DST, but still move to
# a saturday from there so we get the right day-of-week on start_hour
target = time_zone.now.change(:month => 1, :day => 1).next_week - 2.days + weekly_notification_bucket.minutes
# 2 hour on-the-hour span around the target such that distance from the
# start hour is at least 30 minutes.
start_hour = target - 30.minutes
start_hour = start_hour.change(:hour => start_hour.hour)
end_hour = start_hour + 2.hours
[start_hour, end_hour]
end
# Given a text string, return a value suitable for the user's initial_enrollment_type.
# It supports strings formatted as enrollment types like "StudentEnrollment" and
# it also supports text like "student", "teacher", "observer" and "ta".
#
# Any unsupported types have +nil+ returned.
def self.initial_enrollment_type_from_text(type)
# Convert the string "StudentEnrollment" to "student".
# Return only valid matching types. Otherwise, nil.
type = type.to_s.downcase.sub(/(view)?enrollment/, '')
%w{student teacher ta observer}.include?(type) ? type : nil
end
def associated_shards
[Shard.default]
end
def accounts
self.account_users.with_each_shard(:include => :account).map(&:account).uniq
end
memoize :accounts
def all_pseudonyms(options = {})
self.pseudonyms.with_each_shard(options)
end
memoize :all_pseudonyms
def all_active_pseudonyms(*args)
args.unshift(:conditions => {:workflow_state => 'active'})
all_pseudonyms(*args)
end
def prefers_gradebook2?
preferences[:use_gradebook2] != false
end
2011-02-01 09:57:29 +08:00
end