OpenProject is the leading open source project management software.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
openproject/app/models/timelines/planning_element.rb

320 lines
12 KiB

class Timelines::PlanningElement < ActiveRecord::Base
unloadable
self.table_name = 'timelines_planning_elements'
acts_as_tree
include Timelines::TimestampsCompatibility
include Timelines::NestedAttributesForApi
include ActiveModel::ForbiddenAttributesProtection
belongs_to :project, :class_name => "Project"
belongs_to :responsible, :class_name => "User",
:foreign_key => "responsible_id"
belongs_to :planning_element_type, :class_name => "Timelines::PlanningElementType",
:foreign_key => 'planning_element_type_id'
belongs_to :planning_element_status, :class_name => "Timelines::PlanningElementStatus",
:foreign_key => 'planning_element_status_id'
has_many :alternate_dates, :class_name => "Timelines::AlternateDate",
:foreign_key => 'planning_element_id',
:autosave => true,
:dependent => :delete_all
accepts_nested_attributes_for_apis_for :parent,
:planning_element_status,
:planning_element_type,
:project
acts_as_watchable
acts_as_journalized :activity_type => 'timelines_planning_elements',
:activity_permission => :view_planning_elements,
:event_url => Proc.new { |j| {:controller => 'timelines_planning_elements',
:action => 'show',
:id => j.journaled,
:project_id => j.project,
:anchor => ("note-#{j.anchor}" unless j.initial?)} }
# This SQL only works when there are no two updates in the same
# millisecond. As soon as updates happen in rapid succession, multiple
# instances of one planning element are returned.
SQL_FOR_AT = {
:select => "#{Timelines::PlanningElement.quoted_table_name}.id,
#{Timelines::PlanningElement.quoted_table_name}.name,
#{Timelines::PlanningElement.quoted_table_name}.description,
#{Timelines::PlanningElement.quoted_table_name}.planning_element_status_comment,
#{Timelines::AlternateDate.quoted_table_name }.start_date,
#{Timelines::AlternateDate.quoted_table_name }.end_date,
#{Timelines::PlanningElement.quoted_table_name}.parent_id,
#{Timelines::PlanningElement.quoted_table_name}.project_id,
#{Timelines::PlanningElement.quoted_table_name}.responsible_id,
#{Timelines::PlanningElement.quoted_table_name}.planning_element_type_id,
#{Timelines::PlanningElement.quoted_table_name}.planning_element_status_id,
#{Timelines::PlanningElement.quoted_table_name}.created_at,
#{Timelines::PlanningElement.quoted_table_name}.deleted_at,
#{Timelines::AlternateDate.quoted_table_name }.updated_at",
:joins => "LEFT JOIN (
SELECT
#{Timelines::AlternateDate.quoted_table_name}.planning_element_id,
MAX(#{Timelines::AlternateDate.quoted_table_name}.updated_at) AS updated_at
FROM #{Timelines::AlternateDate.quoted_table_name}
WHERE
#{Timelines::AlternateDate.quoted_table_name}.created_at <= ?
GROUP BY
#{Timelines::AlternateDate.quoted_table_name}.planning_element_id
) AS timelines_alternate_dates_sub
ON #{Timelines::PlanningElement.quoted_table_name}.id = timelines_alternate_dates_sub.planning_element_id
INNER JOIN
#{Timelines::AlternateDate.quoted_table_name}
ON #{Timelines::AlternateDate.quoted_table_name}.planning_element_id = timelines_alternate_dates_sub.planning_element_id
AND #{Timelines::AlternateDate.quoted_table_name}.updated_at = timelines_alternate_dates_sub.updated_at"
}
scope :without_deleted, :conditions => "#{Timelines::PlanningElement.quoted_table_name}.deleted_at IS NULL"
scope :deleted, :conditions => "#{Timelines::PlanningElement.quoted_table_name}.deleted_at IS NOT NULL"
scope :visible, lambda {|*args| { :include => :project,
:conditions => Timelines::PlanningElement.visible_condition(args.first || User.current) } }
alias_method :destroy!, :destroy
scope :at_time, lambda { |time|
{:select => SQL_FOR_AT[:select],
:conditions => ["(#{Timelines::PlanningElement.quoted_table_name}.deleted_at IS NULL
OR #{Timelines::PlanningElement.quoted_table_name}.deleted_at >= ?)", time],
:joins => sanitize_sql([SQL_FOR_AT[:joins], time]),
:readonly => true
}
}
scope :for_projects, lambda { |projects|
{:conditions => {:project_id => projects}}
}
def self.visible_condition(user, options={})
Project.allowed_to_condition(user, :view_planning_elements, options)
end
# Used for journal entry / activities list
def activity_type
'timelines_planning_elements'
end
# Used for activities list
def title
title = ''
title << name
title << ' ('
title << planning_element_type.name << ' ' if planning_element_type
title << '*'
title << id.to_s
title << ')'
end
register_on_journal_formatter :plaintext, :name, :description,
:planning_element_status_comment
register_on_journal_formatter :named_association, :parent_id, :project_id,
:planning_element_type_id,
:planning_element_status_id,
:responsible_id
register_on_journal_formatter :datetime, :start_date, :end_date, :deleted_at
register_on_journal_formatter :scenario_date, /^scenario_(\d+)_(start|end)_date$/
# Overriding Journal Class to provide extended information in activity view
journal_class.class_eval do
def event_title
if initial?
I18n.t("timelines.planning_element_creation", :title => journalized.title)
else
I18n.t("timelines.planning_element_update", :title => journalized.title)
end
end
end
def append_scenario_dates_to_journal
changes = {}
alternate_dates.each do |d|
if d.scenario.present? && (!(alternate_date_changes = d.changes).empty? || d.marked_for_destruction?)
["start_date", "end_date"].each do |field|
old_value = if (scenario_changes = alternate_date_changes["scenario_id"])
scenario_changes.first.nil? ? nil : d.send(field)
else
alternate_date_changes[field].nil? ? d.send(field) : alternate_date_changes[field].first
end
new_value = d.marked_for_destruction? ? nil : d.send(field)
changes.merge!({ "scenario_#{d.scenario.id}_#{field}" => [old_value, new_value] }) unless new_value == old_value
end
end
end
journal_changes.append_changes!(changes)
end
before_save :append_scenario_dates_to_journal
after_save :update_parent_attributes
after_save :create_alternate_date
validates_presence_of :name, :start_date, :end_date, :project
validates_length_of :name, :maximum => 255, :unless => lambda { |e| e.name.blank? }
def duration
if start_date >= end_date
1
else
end_date - start_date + 1
end
end
def is_milestone?
planning_element_type && planning_element_type.is_milestone?
end
validate do
if self.end_date and self.start_date and self.end_date < self.start_date
errors.add :end_date, :greater_than_start_date
end
if self.is_milestone?
if self.end_date and self.start_date and self.start_date != self.end_date
errors.add :end_date, :not_start_date
end
end
if self.parent
errors.add :parent, :cannot_be_milestone if parent.is_milestone?
errors.add :parent, :cannot_be_in_another_project if parent.project != project
errors.add :parent, :cannot_be_in_recycle_bin if parent.deleted?
end
end
def leaf?
self.children.count == 0
end
def all_scenarios
project.timelines_scenarios.sort_by(&:id).map do |scenario|
alternate_date = alternate_dates.to_a.find { |a| a.scenario_id.to_s == scenario.id.to_s }
alternate_date ||= alternate_dates.build.tap { |ad| ad.scenario_id = scenario.id }
Timelines::PlanningElementScenario.new(alternate_date)
end
end
def scenarios
alternate_dates.scenaric.sort_by(&:scenario_id).map do |alternate_date|
Timelines::PlanningElementScenario.new(alternate_date)
end
end
# Expecting pe_scenarios to be an Array of Hashes or a Hash of Hashes with
# arbitrary keys following the following schema:
#
# {
# 'id' => 1,
# 'start_date => '2012-01-01',
# 'end_date' => '2012-01-03'
# }
#
# The id attribute is required. If both date fields are empty or missing, the
# alternate date will be deleted. The alternate date will also be deleted,
# when the "_destroy" key is present and set to "1".
#
# Other attributes will be silently ignored.
#
def scenarios=(pe_scenarios)
pe_scenarios = pe_scenarios.values if pe_scenarios.is_a? Hash
pe_scenarios.each do |pe_scenario|
alternate_date = alternate_dates.to_a.find { |date| date.scenario_id.to_s == pe_scenario['id'].to_s }
unless alternate_date
if self.new_record?
alternate_date = Timelines::AlternateDate.new.tap { |ad| ad.scenario_id = pe_scenario['id'] }
alternate_date.planning_element = self
alternate_dates << alternate_date
else
alternate_date = alternate_dates.build.tap { |ad| ad.scenario_id = pe_scenario['id'] }
end
end
if (pe_scenario['start_date'].blank? and pe_scenario['end_date'].blank?) or
pe_scenario['_destroy'] == '1'
alternate_date.mark_for_destruction
else
alternate_date.attributes = {'start_date' => pe_scenario['start_date'],
'end_date' => pe_scenario['end_date']}
end
end
end
def note
@journal_notes
end
def note=(text)
@journal_notes = text
end
def destroy
unless new_record?
self.children.each{|child| child.destroy}
self.deleted_at = Time.now
self.save!
end
freeze
end
def has_many_dependent_for_children
# Overwrites :dependent => :destroy - before_destroy callback
# since we need to call the destroy! method instead of the destroy
# method which just moves the element to the recycle bin
children.each {|child| child.destroy!}
end
def restore!
unless parent && parent.deleted?
self.deleted_at = nil
self.save
else
raise "You cannot restore an element whose parent is deleted. Restore the parent first!"
end
end
def deleted?
!!read_attribute(:deleted_at)
end
protected
def update_parent_attributes
if parent.present?
parent.reload
unless parent.children.without_deleted.empty?
parent.start_date = parent.children.without_deleted.minimum(:start_date)
parent.end_date = parent.children.without_deleted.maximum(:end_date)
if parent.changes.present?
parent.note = I18n.t('timelines.planning_element_updated_automatically_by_child_changes', :child => "*#{id}")
# Ancestors will be updated by parent's after_save hook.
parent.save(:validate => false)
end
end
end
end
def create_alternate_date
if start_date_changed? or end_date_changed?
alternate_dates.create(:start_date => start_date, :end_date => end_date)
end
end
end