488 lines
18 KiB
Ruby
488 lines
18 KiB
Ruby
#
|
|
# 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 AppointmentGroup < ActiveRecord::Base
|
|
include Workflow
|
|
include TextHelper
|
|
include HtmlTextHelper
|
|
|
|
has_many :appointments, opts = {:class_name => 'CalendarEvent', :as => :context, :order => :start_at, :include => :child_events, :conditions => "calendar_events.workflow_state <> 'deleted'", :inverse_of => :context }
|
|
# has_many :through on the same table does not alias columns in condition
|
|
# strings, just hashes. we create this helper association to ensure
|
|
# appointments_participants conditions have the correct table alias
|
|
has_many :_appointments, opts.merge(:conditions => opts[:conditions].gsub(/calendar_events\./, '_appointments_appointments_participants_join.'))
|
|
has_many :appointments_participants, :through => :_appointments, :source => :child_events, :conditions => "calendar_events.workflow_state <> 'deleted'", :order => :start_at
|
|
has_many :appointment_group_contexts
|
|
has_many :appointment_group_sub_contexts, :include => :sub_context
|
|
|
|
EXPORTABLE_ATTRIBUTES = [
|
|
:id, :title, :description, :location_name, :location_address, :context_id, :context_type, :context_code, :sub_context_id, :sub_context_type,
|
|
:sub_context_code, :workflow_state, :created_at, :updated_at, :start_at, :end_at, :participants_per_appointment, :max_appointments_per_participant,
|
|
:min_appointments_per_participant, :participant_visibility
|
|
]
|
|
|
|
EXPORTABLE_ASSOCIATIONS = [:appointments, :appointment_participants, :appointment_group_contexts, :appointment_group_sub_contexts]
|
|
|
|
def context
|
|
appointment_group_contexts.first.context
|
|
end
|
|
|
|
def contexts
|
|
appointment_group_contexts.map &:context
|
|
end
|
|
|
|
def active_contexts
|
|
contexts.reject { |context| context.workflow_state == 'deleted' }
|
|
end
|
|
|
|
def sub_contexts
|
|
# I wonder how rails is adding multiples of the same sub_contexts
|
|
appointment_group_sub_contexts.uniq.map &:sub_context
|
|
end
|
|
|
|
validates_presence_of :workflow_state
|
|
before_validation :default_values
|
|
before_validation :update_contexts_and_sub_contexts
|
|
before_save :update_cached_values
|
|
after_save :update_appointments
|
|
|
|
validates_length_of :title, :maximum => maximum_string_length
|
|
validates_length_of :description, :maximum => maximum_long_text_length, :allow_nil => true, :allow_blank => true
|
|
validates_inclusion_of :participant_visibility, :in => ['private', 'protected'] # presumably we might add public if we decide to show appointments on the public calendar feed
|
|
validates_each :appointments do |record, attr, value|
|
|
next unless record.new_appointments.present? || record.validation_event_override
|
|
appointments = value
|
|
if record.validation_event_override
|
|
appointments = appointments.select{ |a| a.new_record? || a.id != record.validation_event_override.id} << record.validation_event_override
|
|
end
|
|
appointments.sort_by(&:start_at).inject(nil) do |prev, appointment|
|
|
record.errors.add(attr, t('errors.invalid_end_at', "Appointment end time precedes start time")) if appointment.end_at < appointment.start_at
|
|
record.errors.add(attr, t('errors.overlapping_appointments', "Appointments overlap")) if prev && appointment.start_at < prev.end_at
|
|
appointment
|
|
end
|
|
end
|
|
|
|
def validate
|
|
if appointment_group_contexts.empty?
|
|
errors.add :appointment_group_contexts,
|
|
t('errors.needs_contexts', 'Must have at least one context')
|
|
end
|
|
end
|
|
|
|
attr_accessible :title, :description, :location_name, :location_address, :contexts, :sub_context_codes, :participants_per_appointment, :min_appointments_per_participant, :max_appointments_per_participant, :new_appointments, :participant_visibility, :cancel_reason
|
|
|
|
# when creating/updating an appointment, you can give it a list of (new)
|
|
# appointment times. these will be added to the existing appointment times
|
|
# format is [[start, end], [start, end], ...]
|
|
attr_reader :new_appointments
|
|
def new_appointments=(appointments)
|
|
appointments = appointments.values if appointments.is_a?(Hash)
|
|
@new_appointments = appointments.map { |start_at, end_at|
|
|
next unless start_at && end_at
|
|
a = self.appointments.build(:start_at => start_at, :end_at => end_at)
|
|
a.context = self
|
|
a
|
|
}
|
|
end
|
|
attr_accessor :validation_event_override
|
|
attr_accessor :cancel_reason
|
|
|
|
def reload
|
|
remove_instance_variable :@new_appointments if @new_appointments
|
|
super
|
|
end
|
|
|
|
# TODO: someday this should become context_codes= for consistency (in
|
|
# conjunction with checking permissions in update_contexts_and_sub_contexts)
|
|
def contexts=(new_contexts)
|
|
@new_contexts ||= []
|
|
@new_contexts += new_contexts.compact
|
|
end
|
|
|
|
def sub_context_codes=(codes)
|
|
@new_sub_context_codes ||= []
|
|
@new_sub_context_codes += codes.compact
|
|
end
|
|
|
|
def update_contexts_and_sub_contexts
|
|
# TODO: validate the updating user has manage rights for all contexts /
|
|
# sub_contexts. currently this is done in the controller level, since
|
|
# we validate contexts beforehand
|
|
@new_sub_context_codes -= sub_context_codes if @new_sub_context_codes
|
|
new_sub_contexts = []
|
|
if @new_sub_context_codes.present?
|
|
if new_record? &&
|
|
@new_contexts.size == 1 &&
|
|
@new_sub_context_codes.size == 1 &&
|
|
@new_sub_context_codes.first =~ /\Agroup_category_(.*)/
|
|
# a group category can only be assigned at creation time to
|
|
# appointment groups with one course
|
|
gc = GroupCategory.where(id: $1).first
|
|
code = @new_sub_context_codes.first
|
|
self.appointment_group_sub_contexts = [
|
|
AppointmentGroupSubContext.new(:appointment_group => self,
|
|
:sub_context => gc,
|
|
:sub_context_code => code)
|
|
]
|
|
else
|
|
# right now we don't support changing the sub contexts for a context
|
|
# on an appointment group after it has been saved
|
|
disallowed_sub_context_codes = contexts.map(&:course_sections).
|
|
flatten.map(&:asset_string)
|
|
@new_sub_context_codes -= disallowed_sub_context_codes
|
|
|
|
new_sub_contexts = @new_sub_context_codes.map { |code|
|
|
next unless code =~ /\Acourse_section_(.*)/
|
|
cs = CourseSection.where(id: $1).first
|
|
AppointmentGroupSubContext.new(:appointment_group => self,
|
|
:sub_context => cs,
|
|
:sub_context_code => code)
|
|
}.compact
|
|
end
|
|
end
|
|
|
|
# contexts
|
|
@new_contexts -= contexts if @new_contexts
|
|
if @new_contexts.present?
|
|
unless (appointment_group_sub_contexts + new_sub_contexts).size == 1 &&
|
|
(appointment_group_sub_contexts + new_sub_contexts).first.sub_context_type == 'GroupCategory' &&
|
|
!new_record?
|
|
self.appointment_group_contexts += @new_contexts.map { |c|
|
|
AppointmentGroupContext.new :context => c, :appointment_group => self
|
|
}
|
|
@contexts_changed = true
|
|
end
|
|
end
|
|
|
|
if new_sub_contexts.present?
|
|
# the sub_contexts get validated as soon as we add them in Rails 3,
|
|
# so we need to add them after we have updated the contexts
|
|
self.appointment_group_sub_contexts += new_sub_contexts
|
|
end
|
|
end
|
|
|
|
def sub_context_codes
|
|
appointment_group_sub_contexts.map &:sub_context_code
|
|
end
|
|
|
|
# complements :reserve permission
|
|
scope :reservable_by, lambda { |*options|
|
|
user = options.shift
|
|
restrict_to_codes = options.shift
|
|
|
|
codes = user.appointment_context_codes.dup
|
|
if restrict_to_codes
|
|
codes[:primary] &= restrict_to_codes
|
|
end
|
|
uniq.
|
|
joins("JOIN appointment_group_contexts agc " \
|
|
"ON appointment_groups.id = agc.appointment_group_id " \
|
|
"LEFT JOIN appointment_group_sub_contexts sc " \
|
|
"ON appointment_groups.id = sc.appointment_group_id").
|
|
where(<<-COND, codes[:primary], codes[:secondary])
|
|
workflow_state = 'active'
|
|
AND agc.context_code IN (?)
|
|
AND (
|
|
sc.sub_context_code IS NULL
|
|
OR sc.sub_context_code IN (?)
|
|
)
|
|
COND
|
|
}
|
|
# complements :manage permission
|
|
scope :manageable_by, lambda { |*options|
|
|
user = options.shift
|
|
restrict_to_codes = options.shift
|
|
|
|
codes = user.manageable_appointment_context_codes.dup
|
|
if restrict_to_codes
|
|
codes[:full] &= restrict_to_codes
|
|
codes[:limited] &= restrict_to_codes
|
|
end
|
|
uniq.
|
|
joins("JOIN appointment_group_contexts agc " \
|
|
"ON appointment_groups.id = agc.appointment_group_id " \
|
|
"LEFT JOIN appointment_group_sub_contexts sc " \
|
|
"ON appointment_groups.id = sc.appointment_group_id").
|
|
where(<<-COND, codes[:full] + codes[:limited], codes[:full], codes[:secondary])
|
|
workflow_state <> 'deleted'
|
|
AND agc.context_code IN (?)
|
|
AND (
|
|
agc.context_code IN (?)
|
|
OR sc.sub_context_code IN (?)
|
|
)
|
|
COND
|
|
}
|
|
scope :current, -> { where("end_at>=?", Time.zone.now.midnight) }
|
|
scope :current_or_undated, -> { where("end_at>=? OR end_at IS NULL", Time.zone.now.midnight) }
|
|
scope :intersecting, lambda { |start_date, end_date| where("start_at<? AND end_at>?", end_date, start_date) }
|
|
|
|
set_policy do
|
|
given { |user|
|
|
active? && participant_for(user)
|
|
}
|
|
can :reserve and can :read
|
|
|
|
given { |user|
|
|
next false if deleted?
|
|
next false unless active_contexts.all? { |c| c.grants_right? user, :manage_calendar }
|
|
if appointment_group_sub_contexts.present? && appointment_group_sub_contexts.first.sub_context_type == 'CourseSection'
|
|
sub_context_ids = appointment_group_sub_contexts.map(&:sub_context_id)
|
|
user_visible_section_ids = contexts.map { |c|
|
|
c.section_visibilities_for(user).map { |v| v[:course_section_id] }
|
|
}.flatten
|
|
next true if (sub_context_ids - user_visible_section_ids).empty?
|
|
end
|
|
contexts.any? { |c| c.enrollment_visibility_level_for(user) == :full }
|
|
}
|
|
can :manage and can :manage_calendar and can :read and can :read_appointment_participants and
|
|
can :create and can :update and can :delete
|
|
|
|
given { |user|
|
|
participant_visibility == 'protected' && grants_right?(user, :reserve)
|
|
}
|
|
can :read_appointment_participants
|
|
end
|
|
|
|
has_a_broadcast_policy
|
|
|
|
set_broadcast_policy do
|
|
dispatch :appointment_group_published
|
|
to { possible_users }
|
|
whenever { contexts.any?(&:available?) && active? && workflow_state_changed? }
|
|
|
|
dispatch :appointment_group_updated
|
|
to { possible_users }
|
|
whenever { contexts.any?(&:available?) && active? && new_appointments && !workflow_state_changed? }
|
|
|
|
dispatch :appointment_group_deleted
|
|
to { possible_users }
|
|
whenever { contexts.any?(&:available?) && changed_state(:deleted, :active) }
|
|
data { {:cancel_reason => @cancel_reason} }
|
|
end
|
|
|
|
def possible_users
|
|
participant_type == 'User' ?
|
|
possible_participants.uniq :
|
|
possible_participants.flatten.map(&:participants).flatten.uniq
|
|
end
|
|
|
|
def instructors
|
|
if sub_context_type == "CourseSection"
|
|
contexts.map { |c| c.participating_instructors.restrict_to_sections(sub_context_id) }.flatten.uniq
|
|
else
|
|
contexts.map(&:participating_instructors).flatten.uniq
|
|
end
|
|
end
|
|
|
|
def possible_participants(registration_status=nil)
|
|
participants = if participant_type == 'User'
|
|
sub_contexts.empty? ?
|
|
contexts.map(&:participating_students).flatten :
|
|
sub_contexts.map(&:participating_students).flatten
|
|
else
|
|
# FIXME?
|
|
sub_contexts.map(&:groups).flatten
|
|
end
|
|
participant_ids = self.participant_ids
|
|
registered = participants.select { |p| participant_ids.include?(p.id) }
|
|
|
|
participants = case registration_status
|
|
when 'registered'; registered
|
|
when 'unregistered'; participants - registered
|
|
else participants
|
|
end
|
|
|
|
if participant_type == 'User'
|
|
participants.sort_by { |p| [Canvas::ICU.collation_key(p.sortable_name), p.id] }
|
|
else
|
|
participants.sort_by { |p| [Canvas::ICU.collation_key(p.name), p.id] }
|
|
end
|
|
end
|
|
|
|
def participant_ids
|
|
appointments_participants.
|
|
except(:order).
|
|
where(:context_type => participant_type).
|
|
pluck(:context_id)
|
|
end
|
|
|
|
def participant_table
|
|
Kernel.const_get(participant_type).table_name
|
|
end
|
|
|
|
def eligible_participant?(participant)
|
|
return false unless participant && participant.class.base_class.name == participant_type
|
|
codes = participant.appointment_context_codes
|
|
return false unless (codes[:primary] & appointment_group_contexts.map(&:context_code)).present?
|
|
return false unless sub_context_codes.empty? || (codes[:secondary] & sub_context_codes).present?
|
|
true
|
|
end
|
|
|
|
# TODO: create a scope that does this
|
|
# students would generally call this with the user as the argument
|
|
# instructors would call it with the user or group (depending on participant_type)
|
|
def requiring_action?(user_or_participant)
|
|
participant = user_or_participant
|
|
participant = participant_for(user_or_participant) if participant_type == 'Group' && participant.is_a?(User)
|
|
return false unless eligible_participant?(participant)
|
|
return false unless min_appointments_per_participant
|
|
return false if participants_per_appointment \
|
|
&& appointments \
|
|
&& appointments_participants.count >= (participants_per_appointment * appointments.length)
|
|
return reservations_for(participant).size < min_appointments_per_participant
|
|
end
|
|
|
|
def participant_for(user)
|
|
@participant_for ||= {}
|
|
return @participant_for[user.global_id] if @participant_for.has_key?(user.global_id)
|
|
@participant_for[user.global_id] = begin
|
|
participant = if participant_type == 'User'
|
|
user
|
|
else
|
|
# can't have more than one group_category
|
|
group_categories = sub_contexts.find_all{|sc| sc.instance_of? GroupCategory }
|
|
raise %Q{inconsistent appointment group: #{self.id} #{group_categories}} if group_categories.length > 1
|
|
group_category_id = group_categories.first.id
|
|
user.groups.detect{ |g| g.group_category_id == group_category_id }
|
|
end
|
|
participant if participant && eligible_participant?(participant)
|
|
end
|
|
end
|
|
|
|
def reservations_for(participant)
|
|
appointments_participants.for_context_codes(participant.asset_string)
|
|
end
|
|
|
|
def update_cached_values
|
|
self.start_at = appointments.map(&:start_at).min
|
|
self.end_at = appointments.map(&:end_at).max
|
|
clear_cached_available_slots! if participants_per_appointment_changed?
|
|
true
|
|
end
|
|
|
|
EVENT_ATTRIBUTES = [
|
|
:title,
|
|
:description,
|
|
:location_name,
|
|
:location_address
|
|
]
|
|
|
|
def description_html
|
|
format_message(description).first if description
|
|
end
|
|
|
|
def update_appointments
|
|
changed = Hash[
|
|
EVENT_ATTRIBUTES.select{ |attr| send("#{attr}_changed?") }.
|
|
map{ |attr| [attr, attr == :description ? description_html : send(attr)] }
|
|
]
|
|
|
|
if @contexts_changed
|
|
changed[:effective_context_code] = contexts.map(&:asset_string).join(",")
|
|
end
|
|
|
|
return unless changed.present?
|
|
|
|
desc = changed.delete :description
|
|
|
|
if changed.present?
|
|
appointments.update_all(changed)
|
|
changed.delete(:effective_context_code)
|
|
end
|
|
|
|
if changed.present?
|
|
CalendarEvent.joins(:parent_event).where(workflow_state: ['active', 'locked'], parent_events_calendar_events: { context_id: self, context_type: 'AppointmentGroup' }).update_all(changed)
|
|
end
|
|
|
|
if desc
|
|
appointments.where(:description => description_was).update_all(:description => desc)
|
|
CalendarEvent.joins(:parent_event).where(workflow_state: ['active', 'locked'], parent_events_calendar_events: { context_id: self, context_type: 'AppointmentGroup' }, description: description_was).update_all(:description => desc)
|
|
end
|
|
|
|
@new_appointments.each(&:reload) if @new_appointments.present?
|
|
end
|
|
|
|
def participant_type
|
|
types = appointment_group_sub_contexts.map(&:participant_type).uniq
|
|
raise "inconsistent participant types in appointment group" if types.size > 1
|
|
types.first || 'User'
|
|
end
|
|
|
|
def available_slots
|
|
return nil unless participants_per_appointment
|
|
Rails.cache.fetch([self, 'available_slots'].cache_key) do
|
|
# participants_per_appointment can change after the fact, so a given
|
|
# could exceed it and we can't just say:
|
|
# appointments.size * participants_per_appointment
|
|
appointments.inject(0){ |total, appointment|
|
|
total + [participants_per_appointment - appointment.child_events.size, 0].max
|
|
}
|
|
end
|
|
end
|
|
|
|
def clear_cached_available_slots!
|
|
Rails.cache.delete([self, 'available_slots'].cache_key)
|
|
end
|
|
|
|
def default_values
|
|
self.participant_visibility ||= 'private'
|
|
end
|
|
|
|
workflow do
|
|
state :pending do
|
|
event :publish, :transitions_to => :active
|
|
end
|
|
state :active
|
|
state :deleted
|
|
end
|
|
|
|
alias_method :destroy!, :destroy
|
|
def destroy
|
|
transaction do
|
|
self.workflow_state = 'deleted'
|
|
save!
|
|
self.appointments.map{ |a| a.destroy(false) }
|
|
end
|
|
end
|
|
|
|
def contexts_for_user(user)
|
|
@contexts_for_user ||= {}
|
|
return @contexts_for_user[user.global_id] if @contexts_for_user.has_key?(user.global_id)
|
|
@contexts_for_user[user.global_id] = begin
|
|
context_codes = context_codes_for_user(user)
|
|
course_ids = appointment_group_contexts.select{|agc| context_codes.include? agc.context_code }.map(&:context_id)
|
|
Course.where(:id => course_ids).all
|
|
end
|
|
end
|
|
|
|
def context_codes_for_user(user)
|
|
@context_codes_for_user ||= {}
|
|
@context_codes_for_user[user.global_id] if @context_codes_for_user.has_key?(user.global_id)
|
|
@context_codes_for_user[user.global_id] = begin
|
|
manageable_codes = user.manageable_appointment_context_codes
|
|
user_codes = user.appointment_context_codes[:primary] |
|
|
manageable_codes[:full] | manageable_codes[:limited]
|
|
context_codes & user_codes
|
|
end
|
|
end
|
|
|
|
def context_codes
|
|
appointment_group_contexts.map(&:context_code)
|
|
end
|
|
end
|