require 'lockfile' class Rate < ActiveRecord::Base unloadable class InvalidParameterException < Exception; end CACHING_LOCK_FILE_NAME = 'rate_cache' belongs_to :project belongs_to :user has_many :time_entries validates_presence_of :user_id validates_presence_of :date_in_effect validates_numericality_of :amount before_save :unlocked? after_save :update_time_entry_cost_cache before_destroy :unlocked? after_destroy :update_time_entry_cost_cache named_scope :history_for_user, lambda { |user, order| { :conditions => { :user_id => user.id }, :order => order, :include => :project } } def locked? return self.time_entries.length > 0 end def unlocked? return !self.locked? end def default? return self.project.nil? end def specific? return !self.default? end def update_time_entry_cost_cache TimeEntry.update_cost_cache(user, project) end # API to find the Rate for a +user+ on a +project+ at a +date+ def self.for(user, project = nil, date = Date.today.to_s) # Check input since it's a "public" API if Object.const_defined? 'Group' # 0.8.x compatibility raise Rate::InvalidParameterException.new("user must be a Principal instance") unless user.is_a?(Principal) else raise Rate::InvalidParameterException.new("user must be a User instance") unless user.is_a?(User) end raise Rate::InvalidParameterException.new("project must be a Project instance") unless project.nil? || project.is_a?(Project) Rate.check_date_string(date) rate = self.for_user_project_and_date(user, project, date) # Check for a default (non-project) rate rate = self.default_for_user_and_date(user, date) if rate.nil? && project rate end # API to find the amount for a +user+ on a +project+ at a +date+ def self.amount_for(user, project = nil, date = Date.today.to_s) rate = self.for(user, project, date) return nil if rate.nil? return rate.amount end def self.update_all_time_entries_with_missing_cost(options={}) with_common_lockfile(options[:force]) do TimeEntry.all(:conditions => {:cost => nil}).each do |time_entry| begin time_entry.save_cached_cost rescue Rate::InvalidParameterException => ex puts "Error saving #{time_entry.id}: #{ex.message}" end end end store_cache_timestamp('last_caching_run', Time.now.utc.to_s) end def self.update_all_time_entries_to_refresh_cache(options={}) with_common_lockfile(options[:force]) do TimeEntry.find_each do |time_entry| # batch find begin time_entry.save_cached_cost rescue Rate::InvalidParameterException => ex puts "Error saving #{time_entry.id}: #{ex.message}" end end end store_cache_timestamp('last_cache_clearing_run', Time.now.utc.to_s) end private def self.for_user_project_and_date(user, project, date) if project.nil? return Rate.find(:first, :order => 'date_in_effect DESC', :conditions => [ "user_id IN (?) AND date_in_effect <= ? AND project_id IS NULL", user.id, date ]) else return Rate.find(:first, :order => 'date_in_effect DESC', :conditions => [ "user_id IN (?) AND project_id IN (?) AND date_in_effect <= ?", user.id, project.id, date ]) end end def self.default_for_user_and_date(user, date) self.for_user_project_and_date(user, nil, date) end # Checks a date string to make sure it is in format of +YYYY-MM-DD+, throwing # a Rate::InvalidParameterException otherwise def self.check_date_string(date) raise Rate::InvalidParameterException.new("date must be a valid Date string (e.g. YYYY-MM-DD)") unless date.is_a?(String) begin Date.parse(date) rescue ArgumentError raise Rate::InvalidParameterException.new("date must be a valid Date string (e.g. YYYY-MM-DD)") end end def self.store_cache_timestamp(cache_name, timestamp) Setting.plugin_redmine_rate = Setting.plugin_redmine_rate.merge({cache_name => timestamp}) end def self.with_common_lockfile(force = false, &block) # Wait 1 second after stealing a forced lock options = {:retries => 0, :suspend => 1} options[:max_age] = 1 if force Lockfile(lock_file, options) do block.call end end if Rails.env.test? require 'object_daddy' include ObjectDaddy public generator_for :date_in_effect => Date.today end def self.lock_file Rails.root + 'tmp' + Rate::CACHING_LOCK_FILE_NAME end end