canvas-lms/app/models/user.rb

2533 lines
95 KiB
Ruby
Raw Normal View History

2011-02-01 09:57:29 +08:00
#
# Copyright (C) 2011 - 2013 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 TimeZoneHelper
time_zone_attribute :time_zone
2011-02-01 09:57:29 +08:00
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)
#
# the course_workflow_state parameter can be used to simplify the query when
# the enrollments are all known to come from one course whose workflow state
# is already known. when provided, the method may return nil, in which case
# the condition should be treated as 'always false'.
def self.enrollment_conditions(state, strict_course_state=true, course_workflow_state=nil)
#strict_course_state = true
case state
when :active
if strict_course_state
case course_workflow_state
when 'available'
# all active enrollments in a published and active course count
"enrollments.workflow_state='active'"
when 'claimed'
# student and observer enrollments don't count as active if the
# course is unpublished
"enrollments.workflow_state='active' AND enrollments.type IN ('TeacherEnrollment','TaEnrollment','DesignerEnrollment','StudentViewEnrollment')"
when nil
# combine the other branches dynamically based on joined course's
# workflow_state
"enrollments.workflow_state='active' AND (courses.workflow_state='available' OR courses.workflow_state='claimed' AND enrollments.type IN ('TeacherEnrollment','TaEnrollment','DesignerEnrollment','StudentViewEnrollment'))"
else
# never include enrollments from unclaimed/completed/deleted
# courses
nil
end
else
case course_workflow_state
when 'deleted'
# never include enrollments from deleted courses, even without
# strict checks
nil
when nil
# combine the other branches dynamically based on joined course's
# workflow_state
"enrollments.workflow_state='active' AND courses.workflow_state<>'deleted'"
else
# all active enrollments in a non-deleted course count
"enrollments.workflow_state='active'"
end
end
when :invited
if strict_course_state
case course_workflow_state
when 'available'
# all invited enrollments in a published and active course count
"enrollments.workflow_state='invited'"
when 'deleted'
# never include enrollments from deleted courses
nil
when nil
# combine the other branches dynamically based on joined course's
# workflow_state
"enrollments.workflow_state='invited' AND (courses.workflow_state='available' OR courses.workflow_state<>'deleted' AND enrollments.type IN ('TeacherEnrollment','TaEnrollment','DesignerEnrollment','StudentViewEnrollment'))"
else
# student and observer enrollments don't count as invited if
# the course is unclaimed/unpublished/completed
"enrollments.workflow_state='invited' AND enrollments.type IN ('TeacherEnrollment','TaEnrollment','DesignerEnrollment','StudentViewEnrollment')"
end
else
case course_workflow_state
when 'deleted'
# never include enrollments from deleted courses
nil
when nil
# combine the other branches dynamically based on joined course's
# workflow_state
"enrollments.workflow_state IN ('invited','creation_pending') AND courses.workflow_state<>'deleted'"
else
# all invited and creation_pending enrollments in a non-deleted
# course count
"enrollments.workflow_state IN ('invited','creation_pending')"
end
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, course_workflow_state) +
" OR " +
enrollment_conditions(:invited, strict_course_state, course_workflow_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)
self enrollment refactor to facilitate CN integration fixes #CNVS-1119, potentially supersedes https://gerrit.instructure.com/14501 with a little work. simpler flow that is more consistent with FFT signup. whether you click the "join course" button (popup) or go to the join url, the workflow is the same: 1. if you are authenticated, you just click the enroll button. 2. if you are not authenticated, you can either: 1. enter your (canvas/ldap) credentials and submit to join the course. 2. register and join the course (single form). you will then be dropped on the course dashboard in the pre_registered state just like a /register signup (you have to follow the link in your email to set a password). note that if open registration is turned off, option 2.2 is not available. other items of interest: * fix CSRF vulnerabilities where you can enroll authenticated users in open courses, or un-enroll them if you know their enrollment's UUID * move to shorter course-id-less route (w/ join code) * reuse UserController#create * handy openAsDialog behavior and embedded view mode * better json support in PseudonymSessionsController#create * extract markdown helper from mt * show "you need to confirm your email" popup when you land on the course page the first time (already showed on dashboard) test plan: 1. test the authenticated/unauthenticated scenarios above, for both the popup and join pages 2. regression test of /registration forms Change-Id: I0d8351695356d437bdbba72cb66c23ed268b0d1a Reviewed-on: https://gerrit.instructure.com/15902 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Joe Tanner <joe@instructure.com> QA-Review: Jon Jensen <jon@instructure.com>
2012-12-07 14:28:37 +08:00
has_many :current_and_future_enrollments, :class_name => 'Enrollment', :include => [:course], :order => 'enrollments.created_at',
:conditions => enrollment_conditions(:current_and_invited, false)
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
turning in homework from LTI tools adds a new LTI extension, "content", that defines the interaction for sending content from a tool provider to the tool consumer. This extension will replace the "embed_content" and '"select_link" selection_directives, as well as adding allowing am external tool to submit content for a homework submission. also starts sending intended_use, return_types, return_url and file_extensions as part of the LTI launch with the new extension. test plan: - make sure the "more" tab only shows up when there are valid tools - install at least one valid tool (make a homework_submission tool by taking the xml for a resource_selection tool and replace "resource_selection" with "homework_submission") - click "more" - make sure you can't submit the assignment when no resource has been selected - set an assignment that only allows file uploads - try selecting a url from the tool - make sure it errors out - set an assignment that only allows file uploads - limit the file types - try selecting a file with a non-supported file extension - make sure it errors out - set an assignment that only allows file uploads - try selecting an invalid file from the tool - try submitting the homework - make sure it errors out gracefully - set an assignment that only allows file uploads - try selecting a file from the tool - make sure the submission works correctly - set an assignment that only allows urls - try selecting a file from the tool - make sure it errors out - set an assignment that only allows urls - try selecting a url from the tool - make sure the submission works correctly Change-Id: I8df682bc73087681159110ab02f77f0e5a2b3911 Reviewed-on: https://gerrit.instructure.com/13419 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Adam Phillipps <adam@instructure.com> Product-Review: Brad Humphrey <brad@instructure.com> Reviewed-by: Bracken Mosbacker <bracken@instructure.com>
2013-01-30 02:52:36 +08:00
has_many :context_external_tools, :as => :context, :dependent => :destroy, :order => 'name'
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, :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, :conditions => ['workflow_state != ?', 'deleted']
2011-02-01 09:57:29 +08:00
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
has_many :progresses, :as => :context
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.order("last_message_at DESC, conversation_id DESC")
end
2011-02-01 09:57:29 +08:00
def page_views(options={})
PageView.for_user(self, options)
end
scope :of_account, lambda { |account| where("EXISTS (#{account.user_account_associations.select("1").where("user_account_associations.user_id=users.id").to_sql})") }
scope :recently_logged_in, lambda {
includes(:pseudonyms).
where("pseudonyms.current_login_at>?", 1.month.ago).
order("pseudonyms.current_login_at DESC").
limit(25)
2011-02-01 09:57:29 +08:00
}
scope :include_pseudonym, includes(:pseudonym)
scope :restrict_to_sections, lambda { |sections|
if sections.empty?
scoped
else
where("enrollments.limit_privileges_to_course_section IS NULL OR enrollments.limit_privileges_to_course_section<>? OR enrollments.course_section_id IN (?)", true, sections)
end
2011-02-01 09:57:29 +08:00
}
scope :name_like, lambda { |name|
where("#{wildcard('users.name', 'users.short_name', name)} OR EXISTS (#{Pseudonym.select("1").where(wildcard('pseudonyms.sis_user_id', 'pseudonyms.unique_id', name)).where("pseudonyms.user_id=users.id").active.to_sql})")
2011-02-01 09:57:29 +08:00
}
scope :active, where("users.workflow_state<>'deleted'")
scope :has_current_student_enrollments, where("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')")
def self.order_by_sortable_name(options = {})
order_clause = clause = sortable_name_order_by_clause
order_clause = "#{clause} DESC" if options[:direction] == :descending
scope = self.order(order_clause)
if (scope.scope(:find, :select))
scope = scope.select(clause)
end
if scope.scope(:find, :group)
scope = scope.group(clause)
end
scope
end
def self.by_top_enrollment
scope = self
if (!scope.scope(:find, :select))
scope = scope.select("users.*")
end
scope.select("MIN(#{Enrollment.type_rank_sql(:student)}) AS enrollment_rank").
group(User.connection.group_by(User)).
order("enrollment_rank").
order_by_sortable_name
end
scope :enrolled_in_course_between, lambda { |course_ids, start_at, end_at| joins(:enrollments).where(:enrollments => { :course_id => course_ids, :created_at => start_at..end_at }) }
scope :for_course_with_last_login, lambda { |course, root_account_id, enrollment_type|
# add a field to each user that is the aggregated max from current_login_at and last_login_at from their pseudonyms
scope = 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])).where(:enrollments => { :course_id => course })
LEFT OUTER JOIN pseudonyms ON pseudonyms.user_id = users.id AND pseudonyms.account_id = ?
INNER JOIN enrollments ON enrollments.user_id = users.id
SQL
scope = scope.where("enrollments.workflow_state<>'deleted'")
scope = scope.where(:enrollments => { :type => enrollment_type }) if enrollment_type
# the trick to get unique users
scope.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, :self_enrollment_code,
:self_enrollment_course, :validation_root_account
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 :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
self enrollment refactor to facilitate CN integration fixes #CNVS-1119, potentially supersedes https://gerrit.instructure.com/14501 with a little work. simpler flow that is more consistent with FFT signup. whether you click the "join course" button (popup) or go to the join url, the workflow is the same: 1. if you are authenticated, you just click the enroll button. 2. if you are not authenticated, you can either: 1. enter your (canvas/ldap) credentials and submit to join the course. 2. register and join the course (single form). you will then be dropped on the course dashboard in the pre_registered state just like a /register signup (you have to follow the link in your email to set a password). note that if open registration is turned off, option 2.2 is not available. other items of interest: * fix CSRF vulnerabilities where you can enroll authenticated users in open courses, or un-enroll them if you know their enrollment's UUID * move to shorter course-id-less route (w/ join code) * reuse UserController#create * handy openAsDialog behavior and embedded view mode * better json support in PseudonymSessionsController#create * extract markdown helper from mt * show "you need to confirm your email" popup when you land on the course page the first time (already showed on dashboard) test plan: 1. test the authenticated/unauthenticated scenarios above, for both the popup and join pages 2. regression test of /registration forms Change-Id: I0d8351695356d437bdbba72cb66c23ed268b0d1a Reviewed-on: https://gerrit.instructure.com/15902 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Joe Tanner <joe@instructure.com> QA-Review: Jon Jensen <jon@instructure.com>
2012-12-07 14:28:37 +08:00
course = record.validation_root_account.self_enrollment_course_for(value)
record.self_enrollment_course = course
if course && course.self_enrollment?
record.errors.add(attr, "full") if course.self_enrollment_limit_met?
record.errors.add(attr, "already_enrolled") if course.user_is_student?(record, :include_future => true)
else
record.errors.add(attr, "invalid")
end
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
before_save :record_acceptance_of_terms
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? && self.id_was
end
def update_account_associations(opts = nil)
opts ||= {:all_shards => true}
# incremental is only for the current shard
return User.update_account_associations([self], opts) if opts[:incremental]
self.shard.activate do
User.update_account_associations([self], opts)
end
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 self.calculate_account_associations(user, data, account_chain_cache)
return [] if %w{creation_pending deleted}.include?(user.workflow_state) || user.fake_student?
enrollments = data[:enrollments][user.id] || []
sections = enrollments.map { |e| data[:sections][e.course_section_id] }
courses = sections.map { |s| data[:courses][s.course_id] }
courses += sections.select(&:nonxlist_course_id).map { |s| data[:courses][s.nonxlist_course_id] }
starting_account_ids = courses.map(&:account_id)
starting_account_ids += (data[:pseudonyms][user.id] || []).map(&:account_id)
starting_account_ids += (data[:account_users][user.id] || []).map(&:account_id)
starting_account_ids.uniq!
result = 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)
shards = [Shard.current]
if !precalculated_associations
if !users_or_user_ids.first.is_a?(User)
users = users_or_user_ids = User.select([:id, :preferences, :workflow_state]).where(:id =>user_ids).all
else
users = users_or_user_ids
end
if opts[:all_shards]
shards = Set.new
users.each { |u| shards += u.associated_shards }
shards = shards.to_a
end
# basically we're going to do a huge preload here, but custom sql to only load the columns we need
data = {:enrollments => [], :sections => [], :courses => [], :pseudonyms => [], :account_users => []}
Shard.with_each_shard(shards) do
shard_user_ids = users.map(&:id)
data[:enrollments] += shard_enrollments =
Enrollment.where("workflow_state<>'deleted' AND type<>'StudentViewEnrollment'").
where(:user_id => shard_user_ids).
select([:user_id, :course_id, :course_section_id]).
uniq.
all
# probably a lot of dups, so more efficient to use a set than uniq an array
course_section_ids = Set.new
shard_enrollments.each { |e| course_section_ids << e.course_section_id }
data[:sections] += shard_sections = CourseSection.select([:id, :course_id, :nonxlist_course_id]).
where(:id => course_section_ids.to_a).all unless course_section_ids.empty?
shard_sections ||= []
course_ids = Set.new
shard_sections.each do |s|
course_ids << s.course_id
course_ids << s.nonxlist_course_id if s.nonxlist_course_id
end
data[:courses] += Course.select([:id, :account_id]).where(:id => course_ids.to_a).all unless course_ids.empty?
data[:pseudonyms] += Pseudonym.active.select([:user_id, :account_id]).uniq.where(:user_id => shard_user_ids).all
data[:account_users] += AccountUser.select([:user_id, :account_id]).uniq.where(:user_id => shard_user_ids).all
end
# now make it easy to get the data by user id
data[:enrollments] = data[:enrollments].group_by(&:user_id)
data[:sections] = data[:sections].index_by(&:id)
data[:courses] = data[:courses].index_by(&:id)
data[:pseudonyms] = data[:pseudonyms].group_by(&:user_id)
data[:account_users] = data[:account_users].group_by(&:user_id)
end
# TODO: transaction on each shard?
UserAccountAssociation.transaction do
current_associations = {}
to_delete = []
Shard.with_each_shard(shards) do
# if shards is more than just the current shard, users will be set; otherwise
# we never loaded users, but it doesn't matter, cause it's all the current shard
shard_user_ids = users ? users.map(&:id) : user_ids
UserAccountAssociation.where(:user_id => shard_user_ids).all
end.each do |aa|
key = [aa.user_id, aa.account_id]
# duplicates. the unique index prevents these now, but this code
# needs to hang around for the migration itself
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
end
account_ids_with_depth = precalculated_associations
if account_ids_with_depth.nil?
user ||= User.find(user_id)
account_ids_with_depth = calculate_account_associations(user, data, account_chain_cache)
end
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
aa = UserAccountAssociation.new
aa.user_id = user_id
aa.account_id = account_id
aa.depth = depth
aa.shard = Shard.shard_for(account_id)
aa.shard.activate do
begin
UserAccountAssociation.transaction(:requires_new => true) do
aa.save!
end
rescue ActiveRecord::Base::UniqueConstraintViolation
# race condition - someone else created the UAA after we queried for existing ones
old_aa = UserAccountAssociation.find_by_user_id_and_account_id(aa.user_id, aa.account_id)
raise unless old_aa # wtf!
# make sure we don't need to change the depth
if depth < old_aa.depth
old_aa.depth = depth
old_aa.save!
end
end
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
if Rails.version < '3.0'
UserAccountAssociation.update_all({ :depth => depth }, :id => association[0])
else
UserAccountAssociation.where(:id => association[0]).update_all(:depth => depth)
end
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] }
if Rails.version < '3.0'
UserAccountAssociation.delete_all(:id => to_delete) unless incremental || to_delete.empty?
else
UserAccountAssociation.where(:id => to_delete).delete_all unless incremental || to_delete.empty?
end
2011-02-01 09:57:29 +08:00
end
end
self enrollment refactor to facilitate CN integration fixes #CNVS-1119, potentially supersedes https://gerrit.instructure.com/14501 with a little work. simpler flow that is more consistent with FFT signup. whether you click the "join course" button (popup) or go to the join url, the workflow is the same: 1. if you are authenticated, you just click the enroll button. 2. if you are not authenticated, you can either: 1. enter your (canvas/ldap) credentials and submit to join the course. 2. register and join the course (single form). you will then be dropped on the course dashboard in the pre_registered state just like a /register signup (you have to follow the link in your email to set a password). note that if open registration is turned off, option 2.2 is not available. other items of interest: * fix CSRF vulnerabilities where you can enroll authenticated users in open courses, or un-enroll them if you know their enrollment's UUID * move to shorter course-id-less route (w/ join code) * reuse UserController#create * handy openAsDialog behavior and embedded view mode * better json support in PseudonymSessionsController#create * extract markdown helper from mt * show "you need to confirm your email" popup when you land on the course page the first time (already showed on dashboard) test plan: 1. test the authenticated/unauthenticated scenarios above, for both the popup and join pages 2. regression test of /registration forms Change-Id: I0d8351695356d437bdbba72cb66c23ed268b0d1a Reviewed-on: https://gerrit.instructure.com/15902 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Joe Tanner <joe@instructure.com> QA-Review: Jon Jensen <jon@instructure.com>
2012-12-07 14:28:37 +08:00
# These methods can be overridden by a plugin if you want to have an approval
# process or implement additional tracking for new users
2011-02-01 09:57:29 +08:00
def registration_approval_required?; false; end
self enrollment refactor to facilitate CN integration fixes #CNVS-1119, potentially supersedes https://gerrit.instructure.com/14501 with a little work. simpler flow that is more consistent with FFT signup. whether you click the "join course" button (popup) or go to the join url, the workflow is the same: 1. if you are authenticated, you just click the enroll button. 2. if you are not authenticated, you can either: 1. enter your (canvas/ldap) credentials and submit to join the course. 2. register and join the course (single form). you will then be dropped on the course dashboard in the pre_registered state just like a /register signup (you have to follow the link in your email to set a password). note that if open registration is turned off, option 2.2 is not available. other items of interest: * fix CSRF vulnerabilities where you can enroll authenticated users in open courses, or un-enroll them if you know their enrollment's UUID * move to shorter course-id-less route (w/ join code) * reuse UserController#create * handy openAsDialog behavior and embedded view mode * better json support in PseudonymSessionsController#create * extract markdown helper from mt * show "you need to confirm your email" popup when you land on the course page the first time (already showed on dashboard) test plan: 1. test the authenticated/unauthenticated scenarios above, for both the popup and join pages 2. regression test of /registration forms Change-Id: I0d8351695356d437bdbba72cb66c23ed268b0d1a Reviewed-on: https://gerrit.instructure.com/15902 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Joe Tanner <joe@instructure.com> QA-Review: Jon Jensen <jon@instructure.com>
2012-12-07 14:28:37 +08:00
def new_registration(form_params = {}); end
# DEPRECATED, override new_registration instead
def new_teacher_registration(form_params = {}); new_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
scope :with_service, lambda { |service|
service = service.service if service.is_a?(UserService)
includes(:user_services).where(:user_services => { :service => service.to_s })
2011-02-01 09:57:29 +08:00
}
scope :enrolled_before, lambda { |date| where("enrollments.created_at<?", date) }
2011-02-01 09:57:29 +08:00
def group_memberships_for(context)
groups.where('groups.context_id' => context,
'groups.context_type' => context.class.to_s,
'group_memberships.workflow_state' => 'accepted').
where("groups.workflow_state <> 'deleted'")
2011-02-01 09:57:29 +08:00
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"
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
self.name ||= self.email || t('#user.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).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.sms.first
2011-02-01 09:57:29 +08:00
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
# 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.order(:created_at).where(:user_id => id).active.last
2011-02-01 09:57:29 +08:00
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.with_each_shard.select{|c| c.grants_right?(self, nil, :participate_as_student)}
2011-02-01 09:57:29 +08:00
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 && (
# by default 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, or
# an admin (teacher/ta/designer) in the course
2011-02-01 09:57:29 +08:00
self.all_courses.any? { |c| c.grants_right?(user, nil, :read_reports) }
)
end
can :rename and can :remove_avatar and can :read_reports
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
given do |user|
user && (
Account.site_admin.grants_right?(user, :view_statistics) ||
self.associated_accounts.any?{|a| a.grants_right?(user, nil, :view_statistics) }
)
end
can :view_statistics
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 and can :read_reports
given do |user|
user && (
Account.site_admin.grants_right?(user, :manage_user_logins) ||
self.associated_accounts.any?{|a| a.grants_right?(user, nil, :manage_user_logins) }
)
end
can :view_statistics and can :read and can :read_reports
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) } &&
self.accounts.select(&:root_account?).all? {|a| has_subset_of_account_permissions?(user, a) } )
2011-02-01 09:57:29 +08:00
)
end
can :manage_user_details and can :manage_logins and can :rename
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)
has_subset_of_account_permissions?(masquerader, account)
end
def has_subset_of_account_permissions?(user, account)
return true if user == self
return false unless account.root_account?
account_users = account.all_account_users_for(self)
return true if account_users.empty?
add support for applying role overrides to just self or just descendants * wrap RoleOverride#permission_for with enabled_for? that also takes a context of where the permission is being applied, and recalculates its enabled-ness relative to that context; use that for checking account admin and enrollment permissions * refactor User#can_masquerade to properly check for descendant permissions test plan: * create a custom role in site admin. give it permission to manage permissions * in script/console, find that override and set apply_to_self=false * add a user to that role, and login as that user * the user should not be able to change permissions in site admin * the user should be able to change permissions in the default account * add another role in site admin. give it permission to manage permissions * in script/console, find the override and set apply_to_self=true, apply_to_descendants=false * add another user to that role, and login as that user * the user should be able to change permissions in site admin * the user should not be able to change permissions in the default account * the first user should not be able to masquerade as the second user and vice versa * an Account Admin should be able to masquerade as either user * create a custom role in the default account, give it permission to manage permissions, and add a user to that role * the first user should be able to masquerade as the new user; the second user should not be able to masquerade as the new user * general regression tests on permissions and masquerading Change-Id: I20a1183b7dfec419634a92cda498f245187060ef Reviewed-on: https://gerrit.instructure.com/15896 Reviewed-by: Cody Cutrer <cody@instructure.com> QA-Review: Cody Cutrer <cody@instructure.com> Tested-by: Cody Cutrer <cody@instructure.com>
2012-12-07 07:15:53 +08:00
account_users.all? do |account_user|
account_user.is_subset_of?(user)
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
# only used by ContextModuleProgression#deep_evaluate
2011-02-01 09:57:29 +08:00
def submitted_submission_for(assignment_id)
@submissions ||= self.submissions.having_submission.except(:includes).select([:id, :score, :assignment_id]).all
2011-02-01 09:57:29 +08:00
@submissions.detect{|s| s.assignment_id == assignment_id }
end
# only used by ContextModuleProgression#deep_evaluate
def attempted_quiz_submission_for(quiz_id)
@quiz_submissions ||= self.quiz_submissions.select([:id, :kept_score, :quiz_id, :workflow_state]).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
def record_acceptance_of_terms
accept_terms if @require_acceptance_of_terms && @terms_of_use
end
def accept_terms
preferences[:accepted_terms] = Time.now.utc
end
2011-02-01 09:57:29 +08:00
def self.max_messages_per_day
Setting.get_cached('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
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
scope :with_avatar_state, lambda { |state|
scope = where("avatar_image_url IS NOT NULL").order("avatar_image_updated_at DESC")
2011-02-01 09:57:29 +08:00
if state == 'any'
scope.where("avatar_state IS NOT NULL AND avatar_state<>'none'")
2011-02-01 09:57:29 +08:00
else
scope.where(:avatar_state => state)
2011-02-01 09:57:29 +08:00
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', Canvas::ICU.collation_key(r.title || 'zzzzz')]}
2011-02-01 09:57:29 +08:00
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
def manual_mark_as_read?
!!preferences[:manual_mark_as_read]
end
def use_new_conversations?
preferences[:use_new_conversations] == true
end
def ignore_item!(asset, purpose, permanent = false)
begin
# more likely this doesn't exist, so try the create first
asset.ignores.create!(:user => self, :purpose => purpose, :permanent => permanent)
rescue ActiveRecord::Base::UniqueConstraintViolation
asset.shard.activate do
ignore = asset.ignores.find_by_user_id_and_purpose(self.id, purpose)
ignore.permanent = permanent
ignore.save!
end
2011-02-01 09:57:29 +08:00
end
self.touch
2011-02-01 09:57:29 +08:00
end
def assignments_needing_submitting(opts={})
Shackles.activate(:slave) do
course_ids = if opts[:contexts]
(Array(opts[:contexts]).map(&:id) &
current_student_enrollment_course_ids)
else
current_student_enrollment_course_ids
end
# allow explicitly passing a nil limit
limit = opts[:limit]
limit = 15 unless opts.key?(:limit)
due_after = opts[:due_after] || 4.weeks.ago
result = Shard.partition_by_shard(course_ids) do |shard_course_ids|
Assignment.for_course(shard_course_ids).
active.
due_between_with_overrides(due_after,1.week.from_now).
not_ignored_by(self, 'submitting').
expecting_submission.
need_submitting_info(id, limit).
not_locked
end
# outer limit, since there could be limit * n_shards results
result = result[0..(limit - 1)] if limit
result
end
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={})
assignments_needing_submitting(opts.merge(:limit => nil)).size
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def assignments_needing_grading(opts={})
Shackles.activate(:slave) do
course_ids = if opts[:contexts]
(Array(opts[:contexts]).map(&:id) &
current_admin_enrollment_course_ids)
else
current_admin_enrollment_course_ids
end
# allow explicitly passing a nil limit
limit = opts[:limit]
limit = 15 unless opts.key?(:limit)
result = Shard.partition_by_shard(course_ids) do |shard_course_ids|
Assignment.for_course(shard_course_ids).active.
expecting_submission.
not_ignored_by(self, 'grading').
need_grading_info(limit).
reject{|a| a.needs_grading_count_for_user(self) == 0}
end
# outer limit, since there could be limit * n_shards results
result = result[0..(limit - 1)] if limit
result
end
2011-02-01 09:57:29 +08:00
end
memoize :assignments_needing_grading
def assignments_needing_grading_total_count(opts={})
assignments_needing_grading(opts.merge(:limit => nil)).size
end
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,
:collkey
]
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 secondary_identifier
self.email || self.id
end
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.where("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
send(association).with_each_shard do |scope|
courses = 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}")
unless options[:include_completed_courses]
enrollments = Enrollment.where(:id => courses.map(&:primary_enrollment_id)).all
courses_hash = courses.index_by(&:id)
# prepopulate the reverse association
enrollments.each { |e| e.course = courses_hash[e.course_id] }
Canvas::Builders::EnrollmentDateBuilder.preload(enrollments)
date_restricted_ids = enrollments.select{ |e| e.completed? || e.inactive? }.map(&:id)
courses.reject! { |course| date_restricted_ids.include?(course.primary_enrollment_id.to_i) }
end
courses
end
end.dup
end
if association == :current_and_invited_courses
if enrollment_uuid && pending_course = Course.
select("courses.*, enrollments.type AS primary_enrollment, #{Enrollment.type_rank_sql} AS primary_enrollment_rank, enrollments.workflow_state AS primary_enrollment_state").
joins(:enrollments).
where(:enrollments => { :uuid => enrollment_uuid, :workflow_state => 'invited' }).first
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, Canvas::ICU.collation_key(c.name)] }
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_enrollments2', opts[:include_enrollment_uuid], opts[:include_future] ].cache_key) do
self enrollment refactor to facilitate CN integration fixes #CNVS-1119, potentially supersedes https://gerrit.instructure.com/14501 with a little work. simpler flow that is more consistent with FFT signup. whether you click the "join course" button (popup) or go to the join url, the workflow is the same: 1. if you are authenticated, you just click the enroll button. 2. if you are not authenticated, you can either: 1. enter your (canvas/ldap) credentials and submit to join the course. 2. register and join the course (single form). you will then be dropped on the course dashboard in the pre_registered state just like a /register signup (you have to follow the link in your email to set a password). note that if open registration is turned off, option 2.2 is not available. other items of interest: * fix CSRF vulnerabilities where you can enroll authenticated users in open courses, or un-enroll them if you know their enrollment's UUID * move to shorter course-id-less route (w/ join code) * reuse UserController#create * handy openAsDialog behavior and embedded view mode * better json support in PseudonymSessionsController#create * extract markdown helper from mt * show "you need to confirm your email" popup when you land on the course page the first time (already showed on dashboard) test plan: 1. test the authenticated/unauthenticated scenarios above, for both the popup and join pages 2. regression test of /registration forms Change-Id: I0d8351695356d437bdbba72cb66c23ed268b0d1a Reviewed-on: https://gerrit.instructure.com/15902 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Joe Tanner <joe@instructure.com> QA-Review: Jon Jensen <jon@instructure.com>
2012-12-07 14:28:37 +08:00
res = (opts[:include_future] ? current_and_future_enrollments : 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_enrollments2'].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
def current_student_enrollment_course_ids
@current_student_enrollments ||= Rails.cache.fetch([self, 'current_student_enrollments'].cache_key) do
self.enrollments.with_each_shard { |scope| scope.student.select(:course_id) }
2011-02-01 09:57:29 +08:00
end
@current_student_enrollments.map(&:course_id)
2011-02-01 09:57:29 +08:00
end
def current_admin_enrollment_course_ids
@current_admin_enrollments ||= Rails.cache.fetch([self, 'current_admin_enrollments'].cache_key) do
self.enrollments.with_each_shard { |scope| scope.admin.select(:course_id) }
2011-02-01 09:57:29 +08:00
end
@current_admin_enrollments.map(&:course_id)
2011-02-01 09:57:29 +08:00
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
Shackles.activate(: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]
context_codes ||= if opts[:contexts]
setup_context_lookups(opts[:contexts])
else
self.current_student_enrollment_course_ids.map { |id| "course_#{id}" }
end
2011-02-01 09:57:29 +08:00
submissions_for_context_codes(context_codes, opts)
end
memoize :recent_feedback
def visible_stream_item_instances(opts={})
instances = stream_item_instances.where(: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])
instances = instances.where(conditions) unless conditions.first.empty?
elsif opts[:context]
instances = instances.where(:context_type => opts[:context].class.base_class.name, :context_id => opts[:context])
2011-02-01 09:57:29 +08:00
end
instances
end
2011-02-01 09:57:29 +08:00
# NOTE: excludes submission stream items
def cached_recent_stream_items(opts={})
expires_in = 1.day
fix cross-shard stream item dismissal, fixes CNVS-1404 1. make sure the returned stream item ids are relative to the user, not the domain, since we need to look up the instances by those ids from the user's shard later 2. make sure we actually handle shortened global ids, rather than asploding 3. just cache stream items on the user's shard, not on every shard the user visits. makes cache invalidation practical/possible test plan: 1. set up canvas with redis and sharding 2. set up two additional shards (your user is in the initial/primary one) 3. enroll your user in a course in the second shard 4. as another user, do something that creates a stream item for the course (e.g. create an announcement) 5. as the original user, confirm that: 1. you see the stream item on your shard's dashboard 2. you can dismiss the stream item 3. when you refresh the page, it is still dismissed 6. repeat step 4. 7. as the original user, confirm that: 1. you can see the stream item on the shard 3 dashboard 2. you can dismiss the stream item 3. when you refresh the page, it is still dismissed 8. as the original user, confirm that the items are dismissed from your dashboard on all shards Change-Id: I2c600685015640af36d9e33ac71e25cd536d7391 Reviewed-on: https://gerrit.instructure.com/24155 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Mark Ericksen <marke@instructure.com> QA-Review: Jeremy Putnam <jeremyp@instructure.com> Reviewed-by: Cody Cutrer <cody@instructure.com> Product-Review: Jon Jensen <jon@instructure.com>
2013-09-07 04:01:21 +08:00
# just cache on the user's shard... makes cache invalidation much
# easier if we visit other shards
shard.activate do
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
end
# NOTE: excludes submission stream items
def recent_stream_items(opts={})
self.shard.activate do
Shackles.activate(:slave) do
visible_instances = visible_stream_item_instances(opts).
includes(:stream_item).
limit(Setting.get('recent_stream_item_limit', 100))
visible_instances.map do |sii|
si = sii.stream_item
next unless si.present?
next if si.asset_type == 'Submission'
si.data.write_attribute(:unread, sii.unread?)
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
events.sort_by{|e| [e.start_at, Canvas::ICU.collation_key(e.title || "")] }.uniq
2011-02-01 09:57:29 +08:00
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?)
now = Time.zone.now
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(now, opts[:end_at]).limit(opts[:limit]).reject(&:hidden?)
events += select_upcoming_assignments(Assignment.
active.
for_context_codes(context_codes).
due_between_with_overrides(now, opts[:end_at]).
include_submitted_count.
map {|a| a.overridden_for(self)},opts.merge(:time => now)).
first(opts[:limit])
events.sort_by{|e| [e.start_at ? 0: 1,e.start_at || 0, Canvas::ICU.collation_key(e.title)] }.uniq.first(opts[:limit])
2011-02-01 09:57:29 +08:00
end
def select_upcoming_assignments(assignments,opts)
time = opts[:time] || Time.zone.now
assignments.select do |a|
if a.grants_right?(self, nil, :delete)
a.all_dates_visible_to(self).any? do |due_hash|
due_hash[:due_at] && due_hash[:due_at] >= time && due_hash[:due_at] <= opts[:end_at]
end
else
a.due_at && a.due_at >= time && a.due_at <= opts[:end_at]
end
end
end
2011-02-01 09:57:29 +08:00
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
Canvas::ICU.collate_by(undated_events, &:title)
2011-02-01 09:57:29 +08:00
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.includes(: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
# Public: Return an array of context codes this user belongs to.
#
# include_concluded_codes - If true, include concluded courses (default: true).
#
# Returns an array of context code strings.
def conversation_context_codes(include_concluded_codes = true)
return @conversation_context_codes[include_concluded_codes] if @conversation_context_codes
Rails.cache.fetch([self, include_concluded_codes, 'conversation_context_codes4'].cache_key, :expires_in => 1.day) do
Shard.birth.activate do
associations = %w{courses concluded_courses current_groups}
associations.slice!(1) unless include_concluded_codes
associations.inject([]) do |result, association|
association_type = association.split('_')[-1].slice(0..-2)
result.concat(send(association).with_each_shard.map { |x| "#{association_type}_#{x.id}" })
end.uniq
end
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 self.preload_conversation_context_codes(users)
users = users.reject { |u| u.instance_variable_get(:@conversation_context_codes) }
return if users.length < Setting.get_cached("min_users_for_conversation_context_codes_preload", 5).to_i
preload_shard_associations(users)
shards = Set.new
users.each do |user|
shards.merge(user.associated_shards)
end
courses = []
concluded_courses = []
groups = []
Shard.with_each_shard(shards.to_a) do
courses.concat(
Enrollment.joins(:course).
where(enrollment_conditions(:active)).
where(user_id: users).
select([:user_id, :course_id]).
uniq.
all)
concluded_courses.concat(
Enrollment.
where(enrollment_conditions(:completed)).
where(user_id: users).
select([:user_id, :course_id]).
uniq.
all)
groups.concat(
GroupMembership.joins(:group).
where(User.reflections[:current_group_memberships].options[:conditions]).
where(user_id: users).
select([:user_id, :group_id]).
uniq.
all)
end
Shard.birth.activate do
courses = courses.group_by(&:user_id)
concluded_courses = concluded_courses.group_by(&:user_id)
groups = groups.group_by(&:user_id)
users.each do |user|
active_contexts = (courses[user.id] || []).map { |e| "course_#{e.course_id}" } +
(groups[user.id] || []).map { |gm| "group_#{gm.group_id}" }
concluded_courses = (concluded_courses[user.id] || []).map { |e| "course_#{e.course_id}" }
user.instance_variable_set(:@conversation_context_codes, {
true => (active_contexts + concluded_courses).uniq,
false => active_contexts
})
end
end
end
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.order('user_notes.created_at').last
2011-02-01 09:57:29 +08:00
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 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(users, private = nil, options = {})
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
users = ([self] + users).uniq_by(&:id)
private = users.size <= 2 if private.nil?
Conversation.initiate(users, private, options).conversation_participants.find_by_user_id(self)
end
def messageable_user_calculator
@messageable_user_calculator ||= MessageableUser::Calculator.new(self)
end
def load_messageable_user(user, options={})
messageable_user_calculator.load_messageable_user(user, options)
end
def load_messageable_users(users, options={})
messageable_user_calculator.load_messageable_users(users, options)
end
def messageable_users_in_context(asset_string)
messageable_user_calculator.messageable_users_in_context(asset_string)
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
end
def count_messageable_users_in_context(asset_string)
messageable_user_calculator.count_messageable_users_in_context(asset_string)
end
def messageable_users_in_course(course_or_id)
messageable_user_calculator.messageable_users_in_course(course_or_id)
end
def count_messageable_users_in_course(course_or_id)
messageable_user_calculator.count_messageable_users_in_course(course_or_id)
end
def messageable_users_in_section(section_or_id)
messageable_user_calculator.messageable_users_in_section(section_or_id)
end
def count_messageable_users_in_section(section_or_id)
messageable_user_calculator.count_messageable_users_in_section(section_or_id)
end
def messageable_users_in_group(group_or_id)
messageable_user_calculator.messageable_users_in_group(group_or_id)
end
def count_messageable_users_in_group(group_or_id)
messageable_user_calculator.count_messageable_users_in_group(group_or_id)
end
def search_messageable_users(options={})
messageable_user_calculator.search_messageable_users(options)
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
end
def messageable_sections
messageable_user_calculator.messageable_sections
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
end
def messageable_groups
messageable_user_calculator.messageable_groups
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
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 = []
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
if info = load_messageable_user(user)
if Rails.version < '3.0'
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?
else
contexts += Course.where(:id => info.common_courses.keys).all if info.common_courses.present?
contexts += Group.where(:id => info.common_groups.keys).all if info.common_groups.present?
end
end
Canvas::ICU.collate(contexts.map(&:name))
end
def mark_all_conversations_as_read!
conversations.unread.update_all(:workflow_state => 'read')
User.where(:id => id).update_all(:unread_conversations_count => 0)
end
def conversation_participant(conversation_id)
all_conversations.find_by_conversation_id(conversation_id)
end
# Public: Reset the user's cached unread conversations count.
#
# Returns nothing.
def reset_unread_conversations_counter
self.class.where(:id => id).update_all(:unread_conversations_count => conversations.unread.count)
end
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, Canvas::ICU.collation_key(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 = Canvas::ICU.collate_by(cached_group_memberships.
select{ |gm| gm.active_given_enrollments?(active_enrollments) }) { |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?
result = self.pseudonyms.detect { |p| p.active? && p.works_for_account?(account, allow_implicit) }
return result if result || self.associated_shards.length == 1
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.where(:type => 'StudentViewEnrollment').first
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
end
def private?
not public?
end
def default_collection_name
MessageableUser refactor with sharding Separates, streamlines, and makes shard-aware all use cases of User#messageable_users *other* than searching (call site in SearchController#matching_participants). Produces three new methods that take the bulk of that responsibility: * User#load_messageable_users -- given a set of users, filter out the ones that aren't messageable, and load any common contexts for those that are. * User#load_messageable_user -- as User#load_messageable_users, but for just one user. * User#messageable_users_in_context -- given a context (a course, section, or group), return the list of messageable users in that context. refs CNVS-2519 remaining on CNVS-2519 is to tackle the search application of User#messageable_user. mostly there, but reconciling pagination with ranking by number of shared contexts is proving problematic, so I'm going to separate that into another commit. meanwhile, I've renamed User#messageable_users to User#deprecated_search_messageable_users to discourage any accidental new uses and left it otherwise untouched. searching for users on the same shard should be unaffected. You can still locate messageable users on other shards to insert into conversations by browsing the shared contexts. test-plan: * create user A in shard X * create user B in shard Y * for situations where A could message B if on the same shard: - setup the situation where the common tie is on shard X (e.g. course on shard X and both A and B in it). run exercises below - setup the situation where the common tie is on shard Y. run exercises. - if appropriate, setup the situation where the common tie is on shard Z. run exercises. * for each setup described above, login as A: - A should see the "message this user" button on B's profile - if the common tie is a course, section, or group, A should see B under that context when the context is selected in the recipient browser - if a conversation exists involving both A and B, when A loads the conversation they should see B tagged with the common contexts * regression test searching for messageable users from the same shard Change-Id: Ibba5551f8afc2435fd14a2e827a400bf95eae76a Reviewed-on: https://gerrit.instructure.com/17569 Tested-by: Jenkins <jenkins@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com> Reviewed-by: Jon Jensen <jon@instructure.com>
2013-02-05 06:18:20 +08:00
t('#user.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.pseudonyms.with_each_shard { |scope| scope.includes(: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 self.preload_shard_associations(users)
end
def associated_shards
[Shard.default]
end
def accounts
self.account_users.with_each_shard { |scope| scope.includes(:account) }.map(&:account).uniq
end
memoize :accounts
def all_pseudonyms
self.pseudonyms.with_each_shard
end
memoize :all_pseudonyms
def all_active_pseudonyms
self.pseudonyms.with_each_shard { |scope| scope.active }
end
memoize :all_active_pseudonyms
def prefers_gradebook2?
preferences[:use_gradebook2] != false
end
def stamp_logout_time!
if Rails.version < '3.0'
User.update_all({ :last_logged_out => Time.zone.now }, :id => self)
else
User.where(:id => self).update_all(:last_logged_out => Time.zone.now)
end
end
2011-02-01 09:57:29 +08:00
end