canvas-lms/app/models/calendar_event.rb

660 lines
25 KiB
Ruby

#
# Copyright (C) 2011 - 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'atom'
require 'date'
require 'icalendar'
Icalendar::Event.ical_property :x_alt_desc
class CalendarEvent < ActiveRecord::Base
include CopyAuthorizedLinks
include TextHelper
include HtmlTextHelper
attr_accessible :title, :description, :start_at, :end_at, :location_name,
:location_address, :time_zone_edited, :cancel_reason,
:participants_per_appointment, :child_event_data,
:remove_child_events, :all_day
attr_accessor :cancel_reason, :imported
EXPORTABLE_ATTRIBUTES = [
:id, :title, :description, :location_name, :location_address, :start_at, :end_at, :context_id, :context_type, :workflow_state, :created_at, :updated_at,
:user_id, :all_day, :all_day_date, :deleted_at, :cloned_item_id, :context_code, :time_zone_edited, :parent_calendar_event_id, :effective_context_code,
:participants_per_appointment, :override_participants_per_appointment
]
EXPORTABLE_ASSOCIATIONS = [:context, :user, :child_events]
sanitize_field :description, CanvasSanitize::SANITIZE
copy_authorized_links(:description) { [self.effective_context, nil] }
include Workflow
belongs_to :context, :polymorphic => true
validates_inclusion_of :context_type, :allow_nil => true, :in => ['Course', 'User', 'Group', 'AppointmentGroup', 'CourseSection']
belongs_to :user
belongs_to :parent_event, :class_name => 'CalendarEvent', :foreign_key => :parent_calendar_event_id, :inverse_of => :child_events
has_many :child_events, :class_name => 'CalendarEvent', :foreign_key => :parent_calendar_event_id, :conditions => "calendar_events.workflow_state <> 'deleted'", :inverse_of => :parent_event
validates_presence_of :context, :workflow_state
validates_associated :context, :if => lambda { |record| record.validate_context }
validates_length_of :description, :maximum => maximum_long_text_length, :allow_nil => true, :allow_blank => true
validates_length_of :title, :maximum => maximum_string_length, :allow_nil => true, :allow_blank => true
before_save :default_values
after_save :touch_context
after_save :replace_child_events
after_save :sync_parent_event
after_update :sync_child_events
# when creating/updating a calendar_event, you can give it a list of child
# events. these will update/replace any existing child events. the format is:
# [{:start_at => start_at, :end_at => end_at, :context_code => context_code},
# {:start_at => start_at, :end_at => end_at, :context_code => context_code},
# ...]
# the context for each child event must have this event's context as its
# parent_event_context, and there can only be one event per context.
# remove_child_events can be set to remove all existing events (since rails
# form handling mechanism doesn't let you specify an empty array)
attr_accessor :child_event_data, :remove_child_events, :child_event_contexts
validates_each :child_event_data do |record, attr, events|
next unless events || Canvas::Plugin.value_to_boolean(record.remove_child_events)
events ||= []
events = events.values if events.is_a?(Hash)
next record.errors.add(attr, t('errors.no_updating_user', "Can't update child events unless an updating_user is set")) if events.present? && !record.updating_user
context_codes = events.map{ |e| e[:context_code] }
next record.errors.add(attr, t('errors.duplicate_child_event_contexts', "Duplicate child event contexts")) if context_codes != context_codes.uniq
contexts = find_all_by_asset_string(context_codes).group_by(&:asset_string)
context_codes.each do |code|
context = contexts[code] && contexts[code][0]
next if context && context.grants_right?(record.updating_user, :manage_calendar) && context.try(:parent_event_context) == record.context
break record.errors.add(attr, t('errors.invalid_child_event_context', "Invalid child event context"))
end
record.child_event_contexts = contexts
record.child_event_data = events
end
def replace_child_events
return unless @child_event_data
current_events = child_events.group_by{ |e| e[:context_code] }
@child_event_data.each do |data|
if event = current_events.delete(data[:context_code]) and event = event[0]
event.updating_user = @updating_user
event.update_attributes(:start_at => data[:start_at], :end_at => data[:end_at])
else
context = @child_event_contexts[data[:context_code]][0]
event = child_events.build(:start_at => data[:start_at], :end_at => data[:end_at])
event.updating_user = @updating_user
event.context = context
event.skip_sync_parent_event = true
event.save
end
end
current_events.values.flatten.each(&:destroy)
cache_child_event_ranges!
@child_event_data = nil
end
def hidden?
!appointment_group? && child_events.size > 0
end
def effective_context
effective_context_code && ActiveRecord::Base.find_by_asset_string(effective_context_code) || context
end
scope :order_by_start_at, -> { order(:start_at) }
scope :active, -> { where("calendar_events.workflow_state<>'deleted'") }
scope :are_locked, -> { where(:workflow_state => 'locked') }
scope :are_unlocked, -> { where("calendar_events.workflow_state NOT IN ('deleted', 'locked')") }
# controllers/apis/etc. should generally use for_user_and_context_codes instead
scope :for_context_codes, lambda { |codes| where(:context_code => codes) }
# appointments and appointment_participants have the appointment_group and
# the user as the context, respectively. we are actually interested in
# grouping them under the effective context (i.e. appointment_group.context).
# it's the responsibility of the caller to ensure the user has rights to the
# specified codes (e.g. using User#appointment_context_codes)
scope :for_user_and_context_codes, lambda { |user, *args|
codes = args.shift
section_codes = args.shift || user.section_context_codes(codes)
effectively_courses_codes = [user.asset_string] + section_codes
# the all_codes check is redundant, but makes the query more efficient
all_codes = codes | effectively_courses_codes
group_codes = codes.grep(/\Aappointment_group_\d+\z/)
codes -= group_codes
codes_conditions = codes.map { |code|
wildcard(quoted_table_name + '.effective_context_code', code, :delimiter => ',')
}.join(" OR ")
codes_conditions = self.connection.quote(false) if codes_conditions.blank?
where(<<-SQL, all_codes, codes, group_codes, effectively_courses_codes)
calendar_events.context_code IN (?)
AND (
( -- explicit contexts (e.g. course_123)
calendar_events.context_code IN (?)
AND calendar_events.effective_context_code IS NULL
)
OR ( -- appointments (manageable or reservable)
calendar_events.context_code IN (?)
)
OR ( -- own appointment_participants, or section events in the course
calendar_events.context_code IN (?)
AND (#{codes_conditions})
)
)
SQL
}
scope :undated, -> { where(:start_at => nil, :end_at => nil) }
scope :between, lambda { |start, ending| where(:start_at => start..ending) }
scope :current, -> { where("calendar_events.end_at>=?", Time.zone.now.midnight) }
scope :updated_after, lambda { |*args|
if args.first
where("calendar_events.updated_at IS NULL OR calendar_events.updated_at>?", args.first)
else
scoped
end
}
scope :events_without_child_events, -> { where("NOT EXISTS (SELECT 1 FROM calendar_events children WHERE children.parent_calendar_event_id = calendar_events.id AND children.workflow_state<>'deleted')") }
scope :events_with_child_events, -> { where("EXISTS (SELECT 1 FROM calendar_events children WHERE children.parent_calendar_event_id = calendar_events.id AND children.workflow_state<>'deleted')") }
def validate_context!
@validate_context = true
context.validation_event_override = self
end
attr_reader :validate_context
def default_values
self.context_code = "#{self.context_type.underscore}_#{self.context_id}"
self.title ||= (self.context_type.to_s + " Event") rescue "Event"
populate_missing_dates
populate_all_day_flag unless self.imported
if parent_event
populate_with_parent_event
elsif context.is_a?(AppointmentGroup)
populate_appointment_group_defaults
end
end
protected :default_values
def populate_appointment_group_defaults
self.effective_context_code = context.appointment_group_contexts.map(&:context_code).join(",")
if new_record?
AppointmentGroup::EVENT_ATTRIBUTES.each { |attr| send("#{attr}=", attr == :description ? context.description_html : context.send(attr)) }
if locked?
self.start_at = start_at_was if !new_record? && start_at_changed?
self.end_at = end_at_was if !new_record? && end_at_changed?
end
else
# we only allow changing the description
(AppointmentGroup::EVENT_ATTRIBUTES - [:description]).each { |attr| send("#{attr}=", send("#{attr}_was")) if send("#{attr}_changed?") }
end
end
protected :populate_appointment_group_defaults
def populate_with_parent_event
self.effective_context_code = if appointment_group # appointment participant
appointment_group.appointment_group_contexts.map(&:context_code).join(',') if appointment_group.participant_type == 'User'
else # e.g. section-level event
parent_event.context_code
end
(locked? ? LOCKED_ATTRIBUTES : CASCADED_ATTRIBUTES).each{ |attr| send("#{attr}=", parent_event.send(attr)) }
end
protected :populate_with_parent_event
# Populate the start and end dates if they are not set, or if they are invalid
def populate_missing_dates
self.end_at ||= self.start_at
self.start_at ||= self.end_at
if self.start_at && self.end_at && self.end_at < self.start_at
self.end_at = self.start_at
end
end
protected :populate_missing_dates
def populate_all_day_flag
# If the all day flag has been changed to all day, set the times to 00:00
if self.all_day_changed? && self.all_day?
self.start_at = self.end_at = zoned_start_at.beginning_of_day rescue nil
elsif self.start_at_changed? || self.end_at_changed?
if self.start_at && self.start_at == self.end_at && zoned_start_at.strftime("%H:%M") == '00:00'
self.all_day = true
else
self.all_day = false
end
end
if self.all_day && (!self.all_day_date || self.start_at_changed? || self.all_day_date_changed?)
self.start_at = self.end_at = zoned_start_at.beginning_of_day rescue nil
self.all_day_date = (zoned_start_at.to_date rescue nil)
end
end
protected :populate_all_day_flag
# Localized start_at
def zoned_start_at
self.start_at && ActiveSupport::TimeWithZone.new(self.start_at.utc,
((ActiveSupport::TimeZone.new(self.time_zone_edited) rescue nil) || Time.zone))
end
CASCADED_ATTRIBUTES = [
:title,
:description,
:location_name,
:location_address
]
LOCKED_ATTRIBUTES = CASCADED_ATTRIBUTES + [
:start_at,
:end_at
]
def sync_child_events
locked_changes = LOCKED_ATTRIBUTES.select { |attr| send("#{attr}_changed?") }
cascaded_changes = CASCADED_ATTRIBUTES.select { |attr| send("#{attr}_changed?") }
child_events.are_locked.update_all Hash[locked_changes.map{ |attr| [attr, send(attr)] }] if locked_changes.present?
child_events.are_unlocked.update_all Hash[cascaded_changes.map{ |attr| [attr, send(attr)] }] if cascaded_changes.present?
end
attr_writer :skip_sync_parent_event
def sync_parent_event
return unless parent_event
return if appointment_group
return unless start_at_changed? || end_at_changed? || workflow_state_changed?
return if @skip_sync_parent_event
parent_event.cache_child_event_ranges! unless workflow_state == 'deleted'
end
def cache_child_event_ranges!
events = child_events(true)
if events.present?
CalendarEvent.where(:id => self).
update_all(:start_at => events.map(&:start_at).compact.min,
:end_at => events.map(&:end_at).compact.max)
reload
end
end
workflow do
state :active
state :locked do # locked events may only be deleted, they cannot be edited directly
event :unlock, :transitions_to => :active
end
state :deleted
end
alias_method :destroy!, :destroy
def destroy(update_context_or_parent=true)
transaction do
self.workflow_state = 'deleted'
self.deleted_at = Time.now.utc
save!
child_events.find_each do |e|
e.cancel_reason = cancel_reason
e.updating_user = updating_user
e.destroy(false)
end
return true unless update_context_or_parent
if appointment_group
context.touch if context_type == 'AppointmentGroup' # ensures end_at/start_at get updated
# when deleting an appointment or appointment_participant, make sure we reset the cache
appointment_group.clear_cached_available_slots!
end
if parent_event && parent_event.locked? && parent_event.child_events.size == 0
parent_event.workflow_state = 'active'
parent_event.save!
end
true
end
end
def time_zone_edited
CGI::unescapeHTML(read_attribute(:time_zone_edited) || "")
end
has_a_broadcast_policy
set_broadcast_policy do
dispatch :new_event_created
to { participants - [@updating_user] }
whenever {
!appointment_group && context.available? && just_created && !hidden?
}
dispatch :event_date_changed
to { participants - [@updating_user] }
whenever {
!appointment_group &&
context.available? && (
changed_in_state(:active, :fields => :start_at) ||
changed_in_state(:active, :fields => :end_at)
) && !hidden?
}
dispatch :appointment_reserved_by_user
to { appointment_group.instructors }
whenever {
user && appointment_group && parent_event &&
just_created &&
context == appointment_group.participant_for(user)
}
data { {:updating_user => @updating_user} }
dispatch :appointment_canceled_by_user
to { appointment_group.instructors }
whenever {
appointment_group && parent_event &&
deleted? &&
workflow_state_changed? &&
@updating_user &&
context == appointment_group.participant_for(@updating_user)
}
data { {
:updating_user => @updating_user,
:cancel_reason => @cancel_reason
} }
dispatch :appointment_reserved_for_user
to { participants - [@updating_user] }
whenever {
appointment_group && parent_event &&
just_created
}
data { {:updating_user => @updating_user} }
dispatch :appointment_deleted_for_user
to { participants - [@updating_user] }
whenever {
appointment_group && parent_event &&
deleted? &&
workflow_state_changed?
}
data { {
:updating_user => @updating_user,
:cancel_reason => @cancel_reason
} }
end
def participants
# TODO: User#participants should probably be fixed to return [self],
# then we can simplify this again
context_type == 'User' ? [context] : context.participants
end
attr_reader :updating_user
def updating_user=(user)
self.user ||= user
@updating_user = user
content_being_saved_by(user)
end
def user
read_attribute(:user) || (context_type == 'User' ? context : nil)
end
def appointment_group?
context_type == 'AppointmentGroup' || parent_event.try(:context_type) == 'AppointmentGroup'
end
def appointment_group
if parent_event.try(:context).is_a?(AppointmentGroup)
parent_event.context
elsif context_type == 'AppointmentGroup'
context
end
end
class ReservationError < StandardError; end
def reserve_for(participant, user, options = {})
raise ReservationError, "not an appointment" unless context_type == 'AppointmentGroup'
raise ReservationError, "ineligible participant" unless context.eligible_participant?(participant)
transaction do
lock! # in case two people two participants try to grab the same slot
participant.lock! # in case two people try to make a reservation for the same participant
if options[:cancel_existing]
context.reservations_for(participant).lock.each do |reservation|
reservation.updating_user = user
reservation.destroy
end
end
raise ReservationError, "participant has met per-participant limit" if context.max_appointments_per_participant && context.reservations_for(participant).size >= context.max_appointments_per_participant
raise ReservationError, "all slots filled" if participants_per_appointment && child_events.size >= participants_per_appointment
raise ReservationError, "participant has already reserved this appointment" if child_events_for(participant).present?
event = child_events.build
event.updating_user = user
event.context = participant
event.workflow_state = :locked
event.save!
if active?
self.workflow_state = 'locked'
save!
end
context.clear_cached_available_slots!
event
end
end
def child_events_for(participant)
if child_events.loaded?
child_events.select { |e| e.has_asset?(participant) }
else
child_events.where(context_type: participant.class.name, context_id: participant)
end
end
def participants_per_appointment
if override_participants_per_appointment?
read_attribute(:participants_per_appointment)
else
context.is_a?(AppointmentGroup) ? context.participants_per_appointment : nil
end
end
def participants_per_appointment=(limit)
# if the given limit is the same as the context's limit, we should not override
if limit == context.participants_per_appointment && override_participants_per_appointment?
self.override_participants_per_appointment = false
write_attribute(:participants_per_appointment, nil)
else
write_attribute(:participants_per_appointment, limit)
self.override_participants_per_appointment = true
end
limit
end
def update_matching_days=(update)
@update_matching_days = update == '1' || update == true || update == 'true'
end
def all_day
read_attribute(:all_day) || (self.new_record? && self.start_at && self.start_at.strftime("%H:%M") == '00:00')
end
def to_atom(opts={})
extend ApplicationHelper
Atom::Entry.new do |entry|
entry.title = t(:feed_item_title, "Calendar Event: %{event_title}", :event_title => self.title) unless opts[:include_context]
entry.title = t(:feed_item_title_with_context, "Calendar Event, %{course_or_account_name}: %{event_title}", :course_or_account_name => self.context.name, :event_title => self.title) if opts[:include_context]
entry.authors << Atom::Person.new(:name => self.context.name)
entry.updated = self.updated_at.utc
entry.published = self.created_at.utc
entry.links << Atom::Link.new(:rel => 'alternate',
:href => "http://#{HostUrl.context_host(self.context)}/#{context_url_prefix}/calendar?month=#{self.start_at.strftime("%m") rescue ""}&year=#{self.start_at.strftime("%Y") rescue ""}#calendar_event_#{self.id}")
entry.id = "tag:#{HostUrl.default_host},#{self.created_at.strftime("%Y-%m-%d")}:/calendar_events/#{self.feed_code}_#{self.start_at.strftime("%Y-%m-%d-%H-%M") rescue "none"}_#{self.end_at.strftime("%Y-%m-%d-%H-%M") rescue "none"}"
entry.content = Atom::Content::Html.new("#{datetime_string(self.start_at, self.end_at)}<br/>#{self.description}")
end
end
def to_ics(in_own_calendar=true)
return CalendarEvent::IcalEvent.new(self).to_ics(in_own_calendar)
end
def self.max_visible_calendars
10
end
set_policy do
given { |user, session| self.context.grants_right?(user, session, :read) }#students.include?(user) }
can :read
given { |user, session| !appointment_group? ^ context.grants_right?(user, session, :read_appointment_participants) }
can :read_child_events
given { |user, session| parent_event && appointment_group? && parent_event.grants_right?(user, session, :manage) }
can :read and can :delete
given { |user, session| appointment_group? && context.grants_right?(user, session, :manage) }
can :manage
given { |user, session|
appointment_group? && (
grants_right?(user, session, :manage) ||
context.grants_right?(user, :reserve) && context.participant_for(user).present?
)
}
can :reserve
given { |user, session| self.context.grants_right?(user, session, :manage_calendar) }#admins.include?(user) }
can :read and can :create
given { |user, session| (!locked? || context.is_a?(AppointmentGroup)) && !deleted? && self.context.grants_right?(user, session, :manage_calendar) }#admins.include?(user) }
can :update and can :update_content
given { |user, session| !deleted? && self.context.grants_right?(user, session, :manage_calendar) }
can :delete
end
class IcalEvent
include Api
include Rails.application.routes.url_helpers
include HtmlTextHelper
def initialize(event)
@event = event
end
def location
end
def to_ics(in_own_calendar)
cal = Icalendar::Calendar.new
# to appease Outlook
cal.custom_property("METHOD","PUBLISH")
event = Icalendar::Event.new
event.klass = "PUBLIC"
start_at = @event.is_a?(CalendarEvent) ? @event.start_at : @event.due_at
end_at = @event.is_a?(CalendarEvent) ? @event.end_at : @event.due_at
if start_at
event.start = start_at.utc_datetime
event.start.icalendar_tzid = 'UTC'
end
if end_at
event.end = end_at.utc_datetime
event.end.icalendar_tzid = 'UTC'
end
if @event.all_day
event.start = Date.new(@event.all_day_date.year, @event.all_day_date.month, @event.all_day_date.day)
event.start.ical_params = {"VALUE"=>["DATE"]}
event.end = event.start
event.end.ical_params = {"VALUE"=>["DATE"]}
end
event.summary = @event.title
if @event.is_a?(CalendarEvent) && @event.description
html = api_user_content(@event.description, @event.context)
event.description html_to_text(html)
event.x_alt_desc(html, { 'FMTTYPE' => 'text/html' })
end
if @event.is_a?(CalendarEvent)
loc_string = ""
loc_string << @event.location_name + ", " if @event.location_name.present?
loc_string << @event.location_address if @event.location_address.present?
else
loc_string = @event.location
end
event.location = loc_string
event.dtstamp = @event.updated_at.utc_datetime if @event.updated_at
event.dtstamp.icalendar_tzid = 'UTC' if event.dtstamp
tag_name = @event.class.name.underscore
# This will change when there are other things that have calendars...
# can't call calendar_url or calendar_url_for here, have to do it manually
event.url "http://#{HostUrl.context_host(@event.context)}/calendar?include_contexts=#{@event.context.asset_string}&month=#{start_at.try(:strftime, "%m")}&year=#{start_at.try(:strftime, "%Y")}##{tag_name}_#{@event.id}"
event.uid "event-#{tag_name.gsub('_', '-')}-#{@event.id}"
event.sequence 0
if @event.respond_to?(:applied_overrides)
@event.applied_overrides.try(:each) do |override|
next unless override.due_at_overridden
tag_name = override.class.name.underscore
event.uid = "event-#{tag_name.gsub('_', '-')}-#{override.id}"
event.summary = "#{@event.title} (#{override.title})"
#TODO: event.url
end
end
# make an effort to find an associated course without diving too deep down the rabbit hole
associated_course = nil
if @event.is_a?(CalendarEvent)
if @event.effective_context.is_a?(Course)
associated_course = @event.effective_context
elsif @event.effective_context.respond_to?(:context) && @event.effective_context.context.is_a?(Course)
associated_course = @event.effective_context.context
end
elsif @event.respond_to?(:context) && @event.context_type == "Course"
associated_course = @event.context
end
event.summary += " [#{associated_course.course_code}]" if associated_course
event = nil unless start_at
return event unless in_own_calendar
cal.add_event(event) if event
return cal.to_ical
end
end
end