canvas-lms/app/models/group.rb

557 lines
21 KiB
Ruby
Raw Normal View History

2011-02-01 09:57:29 +08:00
#
# Copyright (C) 2011 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class Group < ActiveRecord::Base
include Context
include Workflow
include CustomValidations
include UserFollow::FollowedItem
attr_accessible :name, :context, :max_membership, :group_category, :join_level, :default_view, :description, :is_public, :avatar_attachment
validates_allowed_transitions :is_public, false => true
2011-02-01 09:57:29 +08:00
has_many :group_memberships, :dependent => :destroy, :conditions => ['group_memberships.workflow_state != ?', 'deleted']
has_many :users, :through => :group_memberships, :conditions => ['users.workflow_state != ?', 'deleted']
2011-02-01 09:57:29 +08:00
has_many :participating_group_memberships, :class_name => "GroupMembership", :conditions => ['group_memberships.workflow_state = ?', 'accepted']
has_many :participating_users, :source => :user, :through => :participating_group_memberships
belongs_to :context, :polymorphic => true
belongs_to :group_category
2011-02-01 09:57:29 +08:00
belongs_to :account
belongs_to :root_account, :class_name => "Account"
2011-02-01 09:57:29 +08:00
has_many :calendar_events, :as => :context, :dependent => :destroy
has_many :discussion_topics, :as => :context, :conditions => ['discussion_topics.workflow_state != ?', 'deleted'], :include => :user, :dependent => :destroy, :order => 'discussion_topics.position DESC, discussion_topics.created_at DESC'
has_many :active_discussion_topics, :as => :context, :class_name => 'DiscussionTopic', :conditions => ['discussion_topics.workflow_state != ?', 'deleted'], :include => :user
has_many :all_discussion_topics, :as => :context, :class_name => "DiscussionTopic", :include => :user, :dependent => :destroy
has_many :discussion_entries, :through => :discussion_topics, :include => [:discussion_topic, :user], :dependent => :destroy
has_many :announcements, :as => :context, :class_name => 'Announcement', :dependent => :destroy
has_many :active_announcements, :as => :context, :class_name => 'Announcement', :conditions => ['discussion_topics.workflow_state != ?', 'deleted']
has_many :attachments, :as => :context, :dependent => :destroy, :extend => Attachment::FindInContextAssociation
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, :dependent => :destroy, :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 :collaborators
2011-02-01 09:57:29 +08:00
has_many :external_feeds, :as => :context, :dependent => :destroy
has_many :messages, :as => :context, :dependent => :destroy
belongs_to :wiki
has_many :web_conferences, :as => :context, :dependent => :destroy
has_many :collaborations, :as => :context, :order => 'title, created_at', :dependent => :destroy
has_one :scribd_account, :as => :scribdable
has_many :media_objects, :as => :context
has_many :zip_file_imports, :as => :context
has_many :collections, :as => :context
belongs_to :avatar_attachment, :class_name => "Attachment"
has_many :following_user_follows, :class_name => 'UserFollow', :as => :followed_item
has_many :user_follows, :foreign_key => 'following_user_id'
before_save :ensure_defaults, :maintain_category_attribute
2011-02-01 09:57:29 +08:00
after_save :close_memberships_if_deleted
include StickySisFields
are_sis_sticky :name
alias_method :participating_users_association, :participating_users
def participating_users(user_ids = nil)
user_ids ?
participating_users_association.scoped(:conditions => {:id => user_ids}) :
participating_users_association
end
def wiki_with_create
Wiki.wiki_for_context(self)
2011-02-01 09:57:29 +08:00
end
alias_method_chain :wiki, :create
def auto_accept?
self.group_category &&
self.group_category.allows_multiple_memberships? &&
self.join_level == 'parent_context_auto_join'
2011-02-01 09:57:29 +08:00
end
def allow_join_request?
self.group_category &&
self.group_category.allows_multiple_memberships? &&
['parent_context_auto_join', 'parent_context_request'].include?(self.join_level)
2011-02-01 09:57:29 +08:00
end
def allow_self_signup?(user)
self.group_category &&
(self.group_category.unrestricted_self_signup? ||
(self.group_category.restricted_self_signup? && self.has_common_section_with_user?(user)))
end
def free_association?(user)
auto_accept? || allow_join_request? || allow_self_signup?(user)
end
def allow_student_forum_attachments
context.respond_to?(:allow_student_forum_attachments) && context.allow_student_forum_attachments
end
def participants(include_observers=false)
# argument needed because #participants is polymorphic for contexts
2011-02-01 09:57:29 +08:00
participating_users.uniq
end
2011-02-01 09:57:29 +08:00
def context_code
raise "DONT USE THIS, use .short_name instead" unless ENV['RAILS_ENV'] == "production"
end
def appointment_context_codes
{:primary => [context_string], :secondary => [group_category.asset_string]}
end
2011-02-01 09:57:29 +08:00
def membership_for_user(user)
return nil unless user.present?
self.shard.activate { self.group_memberships.find_by_user_id(user.id) }
2011-02-01 09:57:29 +08:00
end
def has_member?(user)
return nil unless user.present?
self.shard.activate { self.participating_group_memberships.find_by_user_id(user.id) }
end
def has_moderator?(user)
return nil unless user.present?
self.shard.activate { self.participating_group_memberships.moderators.find_by_user_id(user.id) }
end
def should_add_creator?
self.group_category && (self.group_category.communities? || self.group_category.student_organized?)
end
2011-02-01 09:57:29 +08:00
def short_name
name
end
2011-02-01 09:57:29 +08:00
def self.find_all_by_context_code(codes)
ids = codes.map{|c| c.match(/\Agroup_(\d+)\z/)[1] rescue nil }.compact
Group.find(ids)
end
def self.not_in_group_sql_fragment(groups)
"AND NOT EXISTS (SELECT * FROM group_memberships gm
WHERE gm.user_id = u.id AND
gm.workflow_state != 'deleted' AND
gm.group_id IN (#{groups.map(&:id).join ','}))" unless groups.empty?
end
2011-02-01 09:57:29 +08:00
workflow do
state :available do
event :complete, :transitions_to => :completed
event :close, :transitions_to => :closed
end
2011-02-01 09:57:29 +08:00
# Closed to new entrants
state :closed do
event :complete, :transitions_to => :completed
event :open, :transitions_to => :available
end
2011-02-01 09:57:29 +08:00
state :completed
state :deleted
end
2011-02-01 09:57:29 +08:00
def active?
self.available? || self.closed?
end
alias_method :destroy!, :destroy
def destroy
self.workflow_state = 'deleted'
self.deleted_at = Time.now
self.save
end
2011-02-01 09:57:29 +08:00
def close_memberships_if_deleted
return unless self.deleted?
memberships = self.group_memberships
User.update_all({:updated_at => Time.now.utc}, {:id => memberships.map(&:user_id).uniq})
2011-02-01 09:57:29 +08:00
GroupMembership.update_all({:workflow_state => 'deleted'}, {:id => memberships.map(&:id).uniq})
end
2011-02-01 09:57:29 +08:00
named_scope :active, :conditions => ['groups.workflow_state != ?', 'deleted']
2011-02-01 09:57:29 +08:00
def full_name
res = before_label(self.name) + " "
res += (self.context.course_code rescue self.context.name) if self.context
2011-02-01 09:57:29 +08:00
end
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 => "/groups/#{self.id}")
end
end
# this method is idempotent
def add_user(user, new_record_state=nil, moderator=nil)
2011-02-01 09:57:29 +08:00
return nil if !user
attrs = { :user => user, :moderator => !!moderator }
new_record_state ||= case self.join_level
when 'invitation_only' then 'invited'
when 'parent_context_request' then 'requested'
when 'parent_context_auto_join' then 'accepted'
end
attrs[:workflow_state] = new_record_state if new_record_state
if member = self.group_memberships.find_by_user_id(user.id)
member.workflow_state = new_record_state unless member.active?
# only update moderator if true/false is explicitly passed in
member.moderator = moderator unless moderator.nil?
member.save if member.changed?
else
member = self.group_memberships.create(attrs)
end
# permissions for this user in the group are probably different now
Rails.cache.delete(permission_cache_key_for(user))
2011-02-01 09:57:29 +08:00
return member
end
2011-02-01 09:57:29 +08:00
def invite_user(user)
self.add_user(user, 'invited')
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def request_user(user)
self.add_user(user, 'requested')
2011-02-01 09:57:29 +08:00
end
2011-02-01 09:57:29 +08:00
def invitees=(params)
invitees = []
(params || {}).each do |key, val|
if self.context
invitees << self.context.users.find_by_id(key.to_i) if val != '0'
else
invitees << User.find_by_id(key.to_i) if val != '0'
end
end
invitees.compact.map{|i| self.invite_user(i) }.compact
end
2011-02-01 09:57:29 +08:00
def peer_groups
return [] if !self.context || !self.group_category || self.group_category.allows_multiple_memberships?
self.group_category.groups.find(:all, :conditions => ["id != ?", self.id])
2011-02-01 09:57:29 +08:00
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 student_organized?
self.group_category && self.group_category.student_organized?
end
2011-02-01 09:57:29 +08:00
def ensure_defaults
self.name ||= AutoHandle.generate_securish_uuid
self.uuid ||= AutoHandle.generate_securish_uuid
self.group_category ||= GroupCategory.student_organized_for(self.context)
2011-02-01 09:57:29 +08:00
self.join_level ||= 'invitation_only'
self.is_public ||= false
self.is_public = false unless self.group_category.try(:communities?)
2011-02-01 09:57:29 +08:00
if self.context && self.context.is_a?(Course)
self.account = self.context.account
2011-02-01 09:57:29 +08:00
elsif self.context && self.context.is_a?(Account)
self.account = self.context
end
end
private :ensure_defaults
# update root account when account changes
def account=(new_account)
self.account_id = new_account.id
end
def account_id=(new_account_id)
write_attribute(:account_id, new_account_id)
if self.account_id_changed?
self.root_account = self.account(true).try(:root_account)
end
end
# if you modify this set_policy block, note that we've denormalized this
# permission check for efficiency -- see User#cached_contexts
2011-02-01 09:57:29 +08:00
set_policy do
given { |user| user && self.has_member?(user) }
can :create_collaborations and
can :create_conferences and
can :manage_calendar and
can :manage_content and
can :manage_files and
can :manage_wiki and
can :post_to_forum and
can :read and
can :read_roster and
can :send_messages and
can :send_messages_all and
can :follow
# if I am a member of this group and I can moderate_forum in the group's context
# (makes it so group members cant edit each other's discussion entries)
given { |user, session| user && self.has_member?(user) && (!self.context || self.context.grants_right?(user, session, :moderate_forum)) }
can :moderate_forum
given { |user| user && self.has_moderator?(user) }
can :delete and
can :manage and
can :manage_admin_users and
can :manage_students and
can :moderate_forum and
can :update
given { |user| self.group_category.try(:communities?) }
can :create
2011-02-01 09:57:29 +08:00
given { |user, session| self.context && self.context.grants_right?(user, session, :participate_as_student) && self.context.allow_student_organized_groups }
can :create
2011-02-01 09:57:29 +08:00
given { |user, session| self.context && self.context.grants_right?(user, session, :manage_groups) }
can :create and
can :create_collaborations and
can :create_conferences and
can :delete and
can :manage and
can :manage_admin_users and
can :manage_content and
can :manage_files and
can :manage_students and
can :manage_wiki and
can :moderate_forum and
can :post_to_forum and
can :read and
can :read_roster and
can :update
2011-02-01 09:57:29 +08:00
given { |user, session| self.context && self.context.grants_right?(user, session, :view_group_pages) }
can :read and can :read_roster
given { |user| user && self.is_public? }
can :follow
# Participate means the user is connected to the group somehow and can be
given { |user| user && can_participate?(user) }
can :participate
# Join is participate + the group being in a state that allows joining directly (free_association)
given { |user| user && can_participate?(user) && free_association?(user)}
can :join and can :read_roster
given { |user| user && (self.group_category.try(:allows_multiple_memberships?) || allow_self_signup?(user)) }
can :leave
end
# Helper needed by several permissions, use grants_right?(user, :participate)
def can_participate?(user)
return false unless user.present? && self.context.present?
return true if self.group_category.try(:communities?)
if self.context.is_a?(Course)
return self.context.enrollments.not_fake.where(:user_id => user.id).first.present?
elsif self.context.is_a?(Account)
return self.context.user_account_associations.where(:user_id => user.id).first.present?
end
return false
2011-02-01 09:57:29 +08:00
end
private :can_participate?
# courses lock this down a bit, but in a group, the fact that you are a
# member is good enough
def user_can_manage_own_discussion_posts?(user)
true
end
2011-02-01 09:57:29 +08:00
def file_structure_for(user)
User.file_structure_for(self, user)
end
2011-02-01 09:57:29 +08:00
def is_a_context?
true
end
def members_json_cached
Rails.cache.fetch(['group_members_json', self].cache_key) do
self.users.map{ |u| u.group_member_json(self.context) }
2011-02-01 09:57:29 +08:00
end
end
def members_count_cached
Rails.cache.fetch(['group_members_count', self].cache_key) do
self.members_json_cached.length
end
end
def members_count
self.participating_group_memberships.count
end
def quota
self.storage_quota || Setting.get_cached('group_default_quota', 50.megabytes.to_s).to_i
end
TAB_HOME, TAB_PAGES, TAB_PEOPLE, TAB_DISCUSSIONS, TAB_CHAT, TAB_FILES,
TAB_CONFERENCES, TAB_ANNOUNCEMENTS, TAB_PROFILE, TAB_SETTINGS, TAB_COLLABORATIONS = *1..20
2011-02-01 09:57:29 +08:00
def tabs_available(user=nil, opts={})
available_tabs = [
{ :id => TAB_HOME, :label => t("#group.tabs.home", "Home"), :css_class => 'home', :href => :group_path },
{ :id => TAB_ANNOUNCEMENTS, :label => t('#tabs.announcements', "Announcements"), :css_class => 'announcements', :href => :group_announcements_path },
{ :id => TAB_PAGES, :label => t("#group.tabs.pages", "Pages"), :css_class => 'pages', :href => :group_wiki_pages_path },
{ :id => TAB_PEOPLE, :label => t("#group.tabs.people", "People"), :css_class => 'people', :href => :group_users_path },
{ :id => TAB_DISCUSSIONS, :label => t("#group.tabs.discussions", "Discussions"), :css_class => 'discussions', :href => :group_discussion_topics_path },
{ :id => TAB_CHAT, :label => t("#group.tabs.chat", "Chat"), :css_class => 'chat', :href => :group_chat_path },
{ :id => TAB_FILES, :label => t("#group.tabs.files", "Files"), :css_class => 'files', :href => :group_files_path },
2011-02-01 09:57:29 +08:00
]
if root_account.try :canvas_network_enabled?
available_tabs << {:id => TAB_PROFILE, :label => t('#tabs.profile', 'Profile'), :css_class => 'profile', :href => :group_profile_path}
end
available_tabs << { :id => TAB_CONFERENCES, :label => t('#tabs.conferences', "Conferences"), :css_class => 'conferences', :href => :group_conferences_path } if user && self.grants_right?(user, nil, :read)
available_tabs << { :id => TAB_COLLABORATIONS, :label => t('#tabs.collaborations', "Collaborations"), :css_class => 'collaborations', :href => :group_collaborations_path } if user && self.grants_right?(user, nil, :read)
if root_account.try(:canvas_network_enabled?) && user && grants_right?(user, nil, :manage)
available_tabs << { :id => TAB_SETTINGS, :label => t('#tabs.settings', 'Settings'), :css_class => 'settings', :href => :edit_group_path }
end
available_tabs
2011-02-01 09:57:29 +08:00
end
def self.serialization_excludes; [:uuid]; end
2011-02-01 09:57:29 +08:00
def self.process_migration(data, migration)
groups = data['groups'] || []
2011-02-01 09:57:29 +08:00
groups.each do |group|
if migration.import_object?("groups", group['migration_id'])
begin
import_from_migration(group, migration.context)
rescue
migration.add_warning("Couldn't import group \"#{group[:title]}\"", $!)
end
2011-02-01 09:57:29 +08:00
end
end
end
2011-02-01 09:57:29 +08:00
def self.import_from_migration(hash, context, item=nil)
hash = hash.with_indifferent_access
return nil if hash[:migration_id] && hash[:groups_to_import] && !hash[:groups_to_import][hash[:migration_id]]
item ||= find_by_context_id_and_context_type_and_id(context.id, context.class.to_s, hash[:id])
item ||= find_by_context_id_and_context_type_and_migration_id(context.id, context.class.to_s, hash[:migration_id]) if hash[:migration_id]
item ||= context.groups.new
context.imported_migration_items << item if context.imported_migration_items && item.new_record?
item.migration_id = hash[:migration_id]
item.name = hash[:title]
item.group_category = hash[:group_category].present? ?
context.group_categories.find_or_initialize_by_name(hash[:group_category]) :
GroupCategory.imported_for(context)
2011-02-01 09:57:29 +08:00
item.save!
context.imported_migration_items << item
item
end
def allow_media_comments?
true
end
def group_category_name
self.read_attribute(:category)
end
def maintain_category_attribute
# keep this field up to date even though it's not used (group_category_name
# exists solely for the migration that introduces the GroupCategory model).
# this way group_category_name is correct if someone mistakenly uses it
# (modulo category renaming in the GroupCategory model).
self.write_attribute(:category, self.group_category && self.group_category.name)
end
def as_json(options=nil)
json = super(options)
if json && json['group']
# remove anything coming automatically from deprecated db column
json['group'].delete('category')
if self.group_category
# put back version from association
json['group']['group_category'] = self.group_category.name
end
end
json
end
def has_common_section?
self.context && self.context.is_a?(Course) &&
self.context.course_sections.active.any?{ |section| section.common_to_users?(self.users) }
end
def has_common_section_with_user?(user)
return false unless self.context && self.context.is_a?(Course)
users = self.users + [user]
self.context.course_sections.active.any?{ |section| section.common_to_users?(users) }
end
def self.join_levels
[
["invitation_only", "Invite only"],
["parent_context_auto_join", "Auto join"],
["parent_context_request", "Request to join"]
]
end
def default_collection_name
t "#group.default_collection_name", "%{group_name}'s Collection", :group_name => self.name
end
def associated_shards
[Shard.default]
end
bookmarked pagination, including multi-shard introduces a new BookmarkedCollection module with behavior similar to PaginatedCollection in the simple case. the primary advantage is that assigning to current_page (e.g. from the :page parameter to paginate) expects a bookmark token value and automatically deserializes into current_bookmark. the library client can then use current_bookmark to skip forward in the collection, rather than using (current_page - 1) * per_page as the number of items to skip. the client then calls set_next_bookmark on the pager if there's more results, and it automatically derives the bookmark for the next page and serializes it into next_page, for use by Api.paginate, etc. in addition to the PaginatedCollection.build analog, you can simply wrap an existing scope to change it from something that will paginate by page number into something that will paginate by bookmark. finally, the key reason to use bookmarked pagination is to enable composition of collections. you can merge multiple collections into one collection which when paginated will pull results from each subcollection, in order, to produce the page of results. you can also concatenate multiple collections into one collection which when paginated will exhaust the collections in order with seamless transition from one to the next when a page spans both. with collection merging available, you can paginate an association where you'd like to use with_each_shard. one collection is created per shard, and then they are merged together. this process is automated for you in the BookmarkedCollection.with_each_shard method. fixes CNVS-1169 Change-Id: Ib998eee53c33604cb6f7e338153428a157928a6d Reviewed-on: https://gerrit.instructure.com/16039 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: Jacob Fugal <jacob@instructure.com> QA-Review: Clare Hetherington <clare@instructure.com>
2012-12-13 06:50:04 +08:00
class Bookmarker
def self.bookmark_for(group)
group.id
end
def self.validate(bookmark)
bookmark.is_a?(Fixnum)
end
def self.restrict_scope(scope, pager)
if bookmark = pager.current_bookmark
comparison = (pager.include_bookmark ? 'groups.id >= ?' : 'groups.id > ?')
scope = scope.scoped(:conditions => [comparison, bookmark])
end
scope.scoped(:order => "groups.id ASC")
end
end
2011-02-01 09:57:29 +08:00
end