71 Commits

Author SHA1 Message Date
Richard Říman
bbe55e098a Merge branch 'master' of github.com:railsformers/redmine_rate 2012-09-12 16:45:37 +02:00
Richard Říman
dd3bcd9dce added missing czech translations 2012-09-12 16:44:57 +02:00
Richard Říman
a9dcecc838 Merge https://github.com/edavis10/redmine_rate 2012-06-12 12:37:11 +02:00
Eric Davis
f42b419bf3 [#6898] Replace SortHelper patch with a plain helper 2012-05-15 09:09:43 -07:00
Richard Říman
8288455356 add original github source location for automation plugin updates 2012-03-02 15:57:03 +01:00
Richard
dc8c5eefd2 translates 2011-11-30 08:29:02 +01:00
Eric Davis
00453672c6 Add Gemfile for bundler 2011-07-30 19:29:08 -07:00
Eric Davis
268b1b7107 Remove Rubyforge 2011-04-28 11:18:47 -07:00
Eric Davis
25c3bf5898 Release v0.2.1 2011-04-28 11:17:06 -07:00
Eric Davis
4c3e6b6b6d [#5734] Fix the cost caching case where Time Entry attributes changed
A cost value wasn't getting recaclulated by TimeEntry#cost when the
attributes were changing. It only was recalculated when .cost changed.
2011-04-06 09:27:20 -07:00
Eric Davis
b20ebe587b Tweak ObjectDaddy so it's loaded into Rate when testing 2011-04-06 09:18:54 -07:00
Eric Davis
e971b13ee9 Update gemspec for release of 0.2.0 2011-03-03 16:27:15 -08:00
Eric Davis
994c7b2306 Fix url in jeweler 2011-03-03 16:26:44 -08:00
Eric Davis
eaccdb34e4 Bump version to 0.2.0 2011-03-03 16:25:00 -08:00
Eric Davis
6f65166afb Update plugin register block 2011-03-03 16:24:39 -08:00
Eric Davis
4933496709 Require Redmine or ChiliProject 1.0.0 2011-03-03 16:23:33 -08:00
Eric Davis
ab175831f2 Readme update 2011-03-03 16:19:55 -08:00
Eric Davis
80b05852e7 Fix tests by forcing ints from select_all 2011-03-03 16:11:34 -08:00
Eric Davis
211e0b6bdf [#4722] Store cache lockfile in RAILS_ROOT/tmp 2010-11-17 09:07:27 -08:00
Eric Davis
920c8cdda5 Merge remote branch 'SylvainLasnier/master' 2010-11-16 09:35:12 -08:00
Eric Davis
5dc6feeb81 [#4687] Add German translations from Alexander Meindl 2010-11-16 09:31:28 -08:00
Eric Davis
65919d382b Update tests for changes in latest Redmine core error pages 2010-11-16 09:30:24 -08:00
Sylvain Lasnier
3fc0eb5a35 Complete french translation 2010-11-07 18:39:57 +01:00
Eric Davis
4e705fd1eb [#4604] Simplify the cost caching system 2010-10-13 16:54:48 -07:00
Eric Davis
5200eacd36 [#4604] Store caching timestamps in the database as UTC 2010-10-13 12:50:20 -07:00
Eric Davis
52622a678e [#4604] Change the caching admin panel to use a split pane 2010-10-13 12:44:33 -07:00
Eric Davis
f9bd3c0e53 [#4604] Force clearing any lockfiles when submitted from the web 2010-10-13 12:41:50 -07:00
Eric Davis
e4f1a623f1 [#4604] Add buttons for load and clear the caches 2010-10-13 12:08:06 -07:00
Eric Davis
7824eadc94 [#4604] Add the last cache timestamps to the caching admin panel 2010-10-13 11:50:24 -07:00
Eric Davis
073dfe1177 [#4604] Add an admin panel for the rate caches 2010-10-13 11:32:32 -07:00
Eric Davis
fb3b0a41ae [#4604] Add a method and rake task to clear and update all TimeEntry cost caches 2010-10-06 17:35:28 -07:00
Eric Davis
16a7a3146f [#4604] Timestamp the last caching run 2010-10-06 17:27:31 -07:00
Eric Davis
33cf4d5c5c [#4604] Add test for Rate#update_all_time_entries_with_missing_cost 2010-10-06 17:18:58 -07:00
Eric Davis
41b1a3cd54 [#4604] Move method to model 2010-10-06 17:10:37 -07:00
Eric Davis
43a8fe27dc [#4604] Add a simple rake task to update missing cost caches 2010-10-06 15:48:55 -07:00
Eric Davis
c97979bd06 [#4604] Clear and recalculate the #cost on Rate#destroy 2010-10-06 13:15:17 -07:00
Eric Davis
f7b03b825f [#4604] Clear and recalculate the #cost on Rate#save 2010-10-06 13:05:57 -07:00
Eric Davis
d656121b20 [#4604] Clear and recalculate the TimeEntry#cost on save 2010-10-06 12:41:56 -07:00
Eric Davis
dbfc6c5335 [#4604] Add basic caching for TimeEntry#cost 2010-10-06 12:12:45 -07:00
Eric Davis
cec35ad3af [#3827] Don't hardcode the core tabs with alias_method_chain. 2010-07-27 06:15:14 -07:00
Eric Davis
f60a5154dd [#2664] Added Russian translation from Ruslan Voloshin. 2010-07-27 06:09:46 -07:00
Eric Davis
593352e317 [#2433] Added French translation from james pic. 2010-07-27 06:07:30 -07:00
Eric Davis
3ccb88e31a [#3832] Remove unneeded extend in the UsersHelperPatch. 2010-07-27 06:03:52 -07:00
Eric Davis
e541270461 [#4289] Ported the billing plugin's timesheet hooks. 2010-07-23 10:16:41 -07:00
Eric Davis
7192adb231 [#4289] Added empty hooks needed for the Timesheet. 2010-07-23 09:35:44 -07:00
Eric Davis
a341be0344 Removed final RSpec code in favor of Test::Unit. 2010-07-23 09:24:55 -07:00
Eric Davis
81158be9b9 Converted RatesController test to Test::Unit. 2010-07-23 09:22:58 -07:00
Eric Davis
44963ac96b Converted Rates routing test to Test::Unit. 2010-07-23 08:18:37 -07:00
Eric Davis
5b372b2bef Converted RateUsersHelperPatch test to Test::Unit. 2010-07-23 08:14:49 -07:00
Eric Davis
0c3d83fe62 Converted RateTimeEntryPatch test to Test::Unit. 2010-07-23 08:14:22 -07:00
Eric Davis
22cbcdf847 Converted RateFor test to Test::Unit. 2010-07-23 08:05:04 -07:00
Eric Davis
3403e74941 Unused cucumber. 2010-07-23 07:58:34 -07:00
Eric Davis
b3b4b92450 Converted RSpec test to Test::Unit. 2010-07-06 15:43:02 -07:00
Eric Davis
8672619f15 Get rake test hooked up. 2010-07-06 15:18:25 -07:00
Eric Davis
585f70c0b2 Added Test::Unit helper 2010-07-06 15:11:53 -07:00
Eric Davis
d99d0add59 Switch Rakefile over to Test::Unit. 2010-07-06 15:11:25 -07:00
Eric Davis
119a4c1557 Added autotest config. 2010-07-06 15:11:11 -07:00
Eric Davis
8058e938ae Fixed loading in the dispatcher. 2010-03-17 14:53:49 -07:00
Eric Davis
8a70287e99 Move the init code. 2010-03-17 14:51:19 -07:00
Eric Davis
7f7b400f15 [#3180 #3266] Compatibility fix for the Groups feature. 2009-11-02 18:30:17 -08:00
Eric Davis
40a7f74aeb Adding gemspec 2009-10-13 21:03:09 -07:00
Eric Davis
23820188b4 Setup init.rb to work as a Rails GemPlugin 2009-10-13 20:47:28 -07:00
Eric Davis
31e3f2be9d Adding gitignore 2009-10-13 20:45:56 -07:00
Eric Davis
2341b99e23 Added jeweler config 2009-10-13 20:45:16 -07:00
Eric Davis
ef394da702 Updated for Rails 2.3. 2009-09-07 16:35:40 -07:00
Eric Davis
5f277b9656 Merge commit 'origin/master' 2009-08-01 11:11:28 -07:00
Eric Davis
724dd470ba Removed the Redmine core template and Redmine core hooks instead. 2009-08-01 11:05:55 -07:00
Eric Davis
43c20ae820 [#2558] Fixed a SystemStackError caused by alias_method_chain 2009-08-01 11:04:39 -07:00
Eric Davis
c0006e1040 [#2558] Check that @memberships is set before using it. 2009-08-01 10:04:20 -07:00
Eric Davis
391f136710 [#2560] Fixed the Project select field so the current project is selected correctly. 2009-05-26 10:45:12 -07:00
Eric Davis
0117e454a3 Small change to the Readme 2009-04-27 13:15:46 -07:00
65 changed files with 1933 additions and 1330 deletions

1
.github_origin Normal file
View File

@@ -0,0 +1 @@
https://github.com/edavis10/redmine_rate.git

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
*.tar.gz
*.zip
.DS_Store
coverage
doc
pkg
rdoc

1
Gemfile Normal file
View File

@@ -0,0 +1 @@
gem 'lockfile'

View File

@@ -1,6 +1,6 @@
= Redmine Rate Plugin = Redmine Rate Plugin
The Rate plugin provides an API that can be used to find the rate for a Member of a Project at a specific date. It also stores historical rate data so calculations will remain correct in the future. The Rate plugin stores billable rates for Users. It also provides an API that can be used to find the rate for a Member of a Project at a specific date.
== Features == Features
@@ -12,6 +12,7 @@ The Rate plugin provides an API that can be used to find the rate for a Member o
* Rate.for API for other plugins * Rate.for API for other plugins
* Integration with the Billing plugin * Integration with the Billing plugin
* Integration with the Budget plugin * Integration with the Budget plugin
* Integration with the Contracts plugin
== Getting the plugin == Getting the plugin
@@ -22,28 +23,30 @@ A copy of the plugin can be downloaded from {Little Stream Software}[https://pro
There are two sets of steps to install this plugin. The first one should be done if you have used version 0.1.0 of the Budget Plugin or 0.2.0 of the Billing Plugin. This is because the rate data needs to be migrated out of the Budget plugin and into this plugin. There are two sets of steps to install this plugin. The first one should be done if you have used version 0.1.0 of the Budget Plugin or 0.2.0 of the Billing Plugin. This is because the rate data needs to be migrated out of the Budget plugin and into this plugin.
=== If you have data from a previous version of Budget or Billing === Option #1: If you have data from a previous version of Budget or Billing
These installation instructions are very specific because the Rate plugin adjusts data inside the Budget plugin so several data integrity checks are needed. These installation instructions are very specific because the Rate plugin adjusts data inside the Budget plugin so several data integrity checks are needed.
0. Backup up your data! Backup your data! 0. Backup up your data! Backup your data!
1. Follow the Redmine plugin installation steps a http://www.redmine.org/wiki/redmine/Plugins Make sure the plugin is installed to +vendor/plugins/redmine_rate+ 1. Install the Lockfile gem
2. Make sure you are running the 0.1.0 version of the Budget plugin and 0.0.1 version of the Billing plugin 2. Follow the Redmine plugin installation steps a http://www.redmine.org/wiki/redmine/Plugins Make sure the plugin is installed to +vendor/plugins/redmine_rate+
3. Run the pre_install_export to export your current budget and billing data to file +rake rate_plugin:pre_install_export+ 3. Make sure you are running the 0.1.0 version of the Budget plugin and 0.0.1 version of the Billing plugin
4. Run the plugin migrations +rake db:migrate_plugins+ in order to get the new tables for Rates 4. Run the pre_install_export to export your current budget and billing data to file +rake rate_plugin:pre_install_export+
5. Upgrade the budget plugin to 0.2.0 and the billing plugin to 0.3.0 5. Run the plugin migrations +rake db:migrate_plugins+ in order to get the new tables for Rates
6. Rerun the plugin migrations +rake db:migrate_plugins+ in order to update to Budget's 0.2.0 schema 6. Upgrade the budget plugin to 0.2.0 and the billing plugin to 0.3.0
7. Run the post_install_check to check your exported data (from #3 above) against the new Rate data. +rake rate_plugin:post_install_check+ 7. Rerun the plugin migrations +rake db:migrate_plugins+ in order to update to Budget's 0.2.0 schema
8. If the script reports no errors, proceed. If errors are found, please file a bug report and revert to your backups 8. Run the post_install_check to check your exported data (from #3 above) against the new Rate data. +rake rate_plugin:post_install_check+
9. Restart your Redmine web servers (e.g. mongrel, thin, mod_rails) 9. If the script reports no errors, proceed. If errors are found, please file a bug report and revert to your backups
10. Setup the "View Rate" permission for any Role that should be allowed to see the user rates in a Project 10. Restart your Redmine web servers (e.g. mongrel, thin, mod_rails)
11. Setup the "View Rate" permission for any Role that should be allowed to see the user rates in a Project
=== If you do not have any data from Budget or Billing === Option #2: If you do not have any data from Budget or Billing
1. Follow the Redmine plugin installation steps a http://www.redmine.org/wiki/redmine/Plugins Make sure the plugin is installed to +vendor/plugins/redmine_rate+ 1. Install the Lockfile gem
2. Run the plugin migrations +rake db:migrate_plugins+ in order to get the new tables for Rates 2. Follow the Redmine plugin installation steps a http://www.redmine.org/wiki/redmine/Plugins Make sure the plugin is installed to +vendor/plugins/redmine_rate+
3. Restart your Redmine web servers (e.g. mongrel, thin, mod_rails) 3. Run the plugin migrations +rake db:migrate_plugins+ in order to get the new tables for Rates
4. Setup the "View Rate" permission for any Role that should be allowed to see the user rates in a Project 4. Restart your Redmine web servers (e.g. mongrel, thin, mod_rails)
5. Setup the "View Rate" permission for any Role that should be allowed to see the user rates in a Project
== Usage == Usage
@@ -76,6 +79,10 @@ A default rate is a user's Rate that doesn't correspond to a specific project.
Currently this feature is only available through the Rate API. A Rate will become locked once a valid TimeEntry is assigned to the Rate. Currently this feature is only available through the Rate API. A Rate will become locked once a valid TimeEntry is assigned to the Rate.
=== Caching
The plugin includes some simple caching for time entries cost. Instead of doing a lookup for each time entry, the rate plugin will cache the total cost for each time entry to the database. The caching is done transparently but you can run and purge the caches from the Administration Panel or using the provided rate tasks (rake rate_plugin:update_cost_cache, rake rate_plugin:refresh_cost_cache).
== License == License
This plugin is licensed under the GNU GPL v2. See COPYRIGHT.txt and GPL.txt for details. This plugin is licensed under the GNU GPL v2. See COPYRIGHT.txt and GPL.txt for details.

View File

@@ -5,5 +5,29 @@ Dir[File.expand_path(File.dirname(__FILE__)) + "/lib/tasks/**/*.rake"].sort.each
RedminePluginSupport::Base.setup do |plugin| RedminePluginSupport::Base.setup do |plugin|
plugin.project_name = 'redmine_rate' plugin.project_name = 'redmine_rate'
plugin.default_task = [:spec, :features] plugin.default_task = [:test]
plugin.tasks = [:db, :doc, :release, :clean, :test, :stats, :metrics]
plugin.redmine_root = File.expand_path(File.dirname(__FILE__) + '/../../../')
end
begin
require 'jeweler'
Jeweler::Tasks.new do |s|
s.name = "redmine_rate"
s.summary = "A Rate plugin for Redmine to store billing rate for user."
s.email = "edavis@littlestreamsoftware.com"
s.homepage = "https://projects.littlestreamsoftware.com/projects/redmine-rate"
s.description = "The Rate plugin stores billing rates for Users. It also provides an API that can be used to find the rate for a Member of a Project at a specific date."
s.authors = ["Eric Davis"]
s.files = FileList[
"[A-Z]*",
"init.rb",
"rails/init.rb",
"{bin,generators,lib,test,app,assets,config,lang}/**/*",
'lib/jeweler/templates/.gitignore'
]
end
Jeweler::GemcutterTasks.new
rescue LoadError
puts "Jeweler, or one of its dependencies, is not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
end end

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2.1

View File

@@ -0,0 +1,35 @@
class RateCachesController < ApplicationController
unloadable
layout 'admin'
before_filter :require_admin
def index
@last_caching_run = if Setting.plugin_redmine_rate['last_caching_run'].present? && Setting.plugin_redmine_rate['last_caching_run'].to_date
format_time(Setting.plugin_redmine_rate['last_caching_run'])
else
l(:text_no_cache_run)
end
@last_cache_clearing_run = if Setting.plugin_redmine_rate['last_cache_clearing_run'].present? && Setting.plugin_redmine_rate['last_cache_clearing_run'].to_date
format_time(Setting.plugin_redmine_rate['last_cache_clearing_run'])
else
l(:text_no_cache_run)
end
end
def update
if params[:cache].present?
if params[:cache].match(/missing/)
Rate.update_all_time_entries_with_missing_cost(:force => true)
flash[:notice] = l(:text_caches_loaded_successfully)
elsif params[:cache].match(/reload/)
Rate.update_all_time_entries_to_refresh_cache(:force => true)
flash[:notice] = l(:text_caches_loaded_successfully)
end
end
redirect_to :action => 'index'
end
end

View File

@@ -1,14 +1,7 @@
require_dependency 'sort_helper' module RateHelper
module RateSortHelperPatch
def self.included(base) # :nodoc:
base.send(:include, InstanceMethods)
end
module InstanceMethods
# Allows more parameters than the standard sort_header_tag # Allows more parameters than the standard sort_header_tag
def rate_sort_header_tag(column, options = {}) def rate_sort_header_tag(column, options = {})
caption = options.delete(:caption) || titleize(Inflector::humanize(column)) caption = options.delete(:caption) || titleize(ActiveSupport::Inflector::humanize(column))
default_order = options.delete(:default_order) || 'asc' default_order = options.delete(:default_order) || 'asc'
options[:title]= l(:label_sort_by, "\"#{caption}\"") unless options[:title] options[:title]= l(:label_sort_by, "\"#{caption}\"") unless options[:title]
content_tag('th', content_tag('th',
@@ -82,7 +75,7 @@ module RateSortHelperPatch
icon = nil icon = nil
order = default_order order = default_order
end end
caption = titleize(Inflector::humanize(column)) unless caption caption = titleize(ActiveSupport::Inflector::humanize(column)) unless caption
sort_options = { :sort_key => column, :sort_order => order } sort_options = { :sort_key => column, :sort_order => order }
# don't reuse params if filters are present # don't reuse params if filters are present
@@ -100,8 +93,4 @@ module RateSortHelperPatch
(icon ? nbsp(2) + image_tag(icon) : '') (icon ? nbsp(2) + image_tag(icon) : '')
end end
end
end end

View File

@@ -1,6 +1,9 @@
require 'lockfile'
class Rate < ActiveRecord::Base class Rate < ActiveRecord::Base
unloadable unloadable
class InvalidParameterException < Exception; end class InvalidParameterException < Exception; end
CACHING_LOCK_FILE_NAME = 'rate_cache'
belongs_to :project belongs_to :project
belongs_to :user belongs_to :user
@@ -11,7 +14,9 @@ class Rate < ActiveRecord::Base
validates_numericality_of :amount validates_numericality_of :amount
before_save :unlocked? before_save :unlocked?
after_save :update_time_entry_cost_cache
before_destroy :unlocked? before_destroy :unlocked?
after_destroy :update_time_entry_cost_cache
named_scope :history_for_user, lambda { |user, order| named_scope :history_for_user, lambda { |user, order|
{ {
@@ -36,11 +41,19 @@ class Rate < ActiveRecord::Base
def specific? def specific?
return !self.default? return !self.default?
end 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+ # 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) def self.for(user, project = nil, date = Date.today.to_s)
# Check input since it's a "public" API # Check input since it's a "public" API
raise Rate::InvalidParameterException.new("user must be a User instance") unless user.is_a?(User) 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) raise Rate::InvalidParameterException.new("project must be a Project instance") unless project.nil? || project.is_a?(Project)
Rate.check_date_string(date) Rate.check_date_string(date)
@@ -57,6 +70,32 @@ class Rate < ActiveRecord::Base
return nil if rate.nil? return nil if rate.nil?
return rate.amount return rate.amount
end 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 private
def self.for_user_project_and_date(user, project, date) def self.for_user_project_and_date(user, project, date)
@@ -96,4 +135,30 @@ class Rate < ActiveRecord::Base
raise Rate::InvalidParameterException.new("date must be a valid Date string (e.g. YYYY-MM-DD)") raise Rate::InvalidParameterException.new("date must be a valid Date string (e.g. YYYY-MM-DD)")
end end
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 end

View File

@@ -0,0 +1,21 @@
<h2><%= l(:text_rate_caches_panel) %></h2>
<div id="caching-run" class="splitcontentleft">
<p>
<%= l(:text_last_caching_run) %><%= h(@last_caching_run) %>
</p>
<p>
<%= button_to(l(:text_load_missing_caches), {:controller => 'rate_caches', :action => 'update', :cache => 'missing'}, :method => :put) %>
</p>
</div>
<div id="cache-clearing-run" class="splitcontentright">
<p>
<%= l(:text_last_cache_clearing_run) %><%= h(@last_cache_clearing_run) %>
</p>
<p>
<%= button_to(l(:text_clear_and_load_all_caches), {:controller => 'rate_caches', :action => 'update', :cache => 'reload'}, :method => :put) %>
</p>
</div>

View File

@@ -0,0 +1,23 @@
<td id="rate_<%= membership.project.id %>_<%= membership.user.id %>">
<% rate = Rate.for(user, membership.project) %>
<% if rate.nil? || rate.default? %>
<% if rate && rate.default? %>
<em><%= number_to_currency(rate.amount) %></em>
<% end %>
<% remote_form_for(:rate, :url => rates_path(:format => 'js')) do |f| %>
<%= f.text_field :amount %>
<%= f.hidden_field :date_in_effect, :value => Date.today.to_s, :id => "" %>
<%= f.hidden_field :project_id, :value => membership.project.id %>
<%= f.hidden_field :user_id, :value => user.id %>
<%= hidden_field_tag "back_url", url_for(:controller => 'users', :action => 'edit', :id => user, :tab => 'memberships') %>
<%= submit_tag(l(:rate_label_set_rate), :class => "small") %>
<% end %>
<% else %>
<strong><%= link_to number_to_currency(rate.amount), { :action => 'edit', :id => user, :tab => 'rates'} %></strong>
<% end %>
</td>

View File

@@ -1,72 +0,0 @@
<%# TODO: Override the default view until post 0.8.x which will have the hooks %>
<% if @memberships.any? %>
<table class="list memberships">
<thead>
<th><%= l(:label_project) %></th>
<th><%= l(:label_role) %></th>
<th style="width:15%"></th>
<%# TODO: Hook %>
<th><%= l(:rate_label_rate) %> <%= l(:rate_label_currency) %></td>
</thead>
<tbody>
<% @memberships.each do |membership| %>
<% next if membership.new_record? %>
<tr class="<%= cycle 'odd', 'even' %>">
<td><%=h membership.project %></td>
<td align="center">
<% form_tag({ :action => 'edit_membership', :id => @user, :membership_id => membership }) do %>
<%= select_tag 'membership[role_id]', options_from_collection_for_select(@roles, "id", "name", membership.role_id) %>
<%= submit_tag l(:button_change), :class => "small" %>
<% end %>
</td>
<td align="center">
<%= link_to l(:button_delete), {:action => 'destroy_membership', :id => @user, :membership_id => membership }, :method => :post, :class => 'icon icon-del' %>
</td>
<%# TODO: Hook %>
<td id="rate_<%= membership.project.id %>_<%= membership.user.id %>">
<% rate = Rate.for(@user, membership.project) %>
<% if rate.nil? || rate.default? %>
<% if rate && rate.default? %>
<em><%= number_to_currency(rate.amount) %></em>
<% end %>
<% remote_form_for(:rate, :url => formatted_rates_path('js')) do |f| %>
<%= f.text_field :amount %>
<%= f.hidden_field :date_in_effect, :value => Date.today.to_s, :id => "" %>
<%= f.hidden_field :project_id, :value => membership.project.id %>
<%= f.hidden_field :user_id, :value => @user.id %>
<%= hidden_field_tag "back_url", url_for(:controller => 'users', :action => 'edit', :id => @user, :tab => 'memberships') %>
<%= submit_tag(l(:rate_label_set_rate), :class => "small") %>
<% end %>
<% else %>
<strong><%= link_to number_to_currency(rate.amount), { :action => 'edit', :id => @user, :tab => 'rates'} %></strong>
<% end %>
</td>
</tr>
</tbody>
<% end; reset_cycle %>
</table>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
<% if @projects.any? %>
<p>
<label><%=l(:label_project_new)%></label><br/>
<% form_tag({ :action => 'edit_membership', :id => @user }) do %>
<%# 0.8.x compatibility %>
<% if respond_to?(:options_for_membership_project_select) %>
<%= select_tag 'membership[project_id]', options_for_membership_project_select(@user, @projects) %>
<% else %>
<%= select_tag 'membership[project_id]', projects_options_for_select(@projects) %>
<% end %>
<%= l(:label_role) %>:
<%= select_tag 'membership[role_id]', options_from_collection_for_select(@roles, "id", "name") %>
<%= submit_tag l(:button_add) %>
<% end %>
</p>
<% end %>

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

3
autotest/discover.rb Normal file
View File

@@ -0,0 +1,3 @@
Autotest.add_discovery do
"rails"
end

18
config/locales/cs.yml Normal file
View File

@@ -0,0 +1,18 @@
cs:
rate_label_rates: Míra
rate_label_rate: Míra
rate_label_rate_history: Historie míry
rate_label_new_rate: Nová míra
rate_label_currency:
rate_error_user_not_found: Uživatel nebyl nalezen
rate_label_set_rate: Nastavit míru
rate_label_default: Výchozí míra
rate_cost: Cena
text_rate_caches_panel: "Míra mezipaměti"
text_no_cache_run: "nebyla nalezena žádná běžící mezipaměť"
text_last_caching_run: "Poslední spuštěná mezipaměť: "
text_last_cache_clearing_run: "Poslední čištění mezipaměti: "
text_load_missing_caches: "Nahrát chybějící mezipaměť"
text_clear_and_load_all_caches: "Vyčistit a nahrát všechny mezipaměti"
text_caches_loaded_successfully: "Mezipaměti byly nahrány"
permission_view_rate: "Zobrazit míry"

18
config/locales/de.yml Normal file
View File

@@ -0,0 +1,18 @@
de:
rate_label_rates: Betraege
rate_label_rate: Betrag
rate_label_rate_history: Betragsverlauf
rate_label_new_rate: Neuer Betrag
rate_label_currency: EUR
rate_error_user_not_found: Benutzer nicht gefunden
rate_label_set_rate: Betrag setzen
rate_label_default: Standard Betrag
rate_cost: Kosten
text_rate_caches_panel: "Betrags Cache"
text_no_cache_run: "kein Cache gefunden"
text_last_caching_run: "Zuletzt Cache erstellt: "
text_last_cache_clearing_run: "Last cache clearing run at: "
text_load_missing_caches: "Load Missing Caches"
text_clear_and_load_all_caches: "Clear and Load All Caches"
text_caches_loaded_successfully: "Caches loaded successfully"

View File

@@ -7,3 +7,12 @@ en:
rate_error_user_not_found: User not found rate_error_user_not_found: User not found
rate_label_set_rate: Set Rate rate_label_set_rate: Set Rate
rate_label_default: Default Rate rate_label_default: Default Rate
rate_cost: Cost
text_rate_caches_panel: "Rate Caches"
text_no_cache_run: "no cache run found"
text_last_caching_run: "Last caching run at: "
text_last_cache_clearing_run: "Last cache clearing run at: "
text_load_missing_caches: "Load Missing Caches"
text_clear_and_load_all_caches: "Clear and Load All Caches"
text_caches_loaded_successfully: "Caches loaded successfully"

20
config/locales/fr.yml Normal file
View File

@@ -0,0 +1,20 @@
fr:
rate_label_rates: Tarifs
rate_label_rate: Tarif
rate_label_rate_history: Historique du tarif
rate_label_new_rate: Nouveau tarif
rate_label_currency:
rate_error_user_not_found: Utilisateur introuvable
rate_label_set_rate: Parametrage du tarif
rate_label_default: Tarif par défaut
rate_error_user_not_found: Utilisateur non trouvé
rate_label_set_rate: Définir le tarif
rate_cost: Coût
text_rate_caches_panel: "Caches des tarifs"
text_no_cache_run: "pas de cache actif trouvé"
text_last_caching_run: "Dernier cache actif à : "
text_last_cache_clearing_run: "Dernier cache purgé à : "
text_load_missing_caches: "Charger les caches manquants"
text_clear_and_load_all_caches: "Recharger tous les caches"
text_caches_loaded_successfully: "Caches chargés avec succés"

9
config/locales/ru.yml Normal file
View File

@@ -0,0 +1,9 @@
ru:
rate_label_rates: Платежи
rate_label_rate: Платеж
rate_label_rate_history: История платежей
rate_label_new_rate: Новый платежe
rate_label_currency: $
rate_error_user_not_found: Пользователь не найден
rate_label_set_rate: Установить платеж
rate_label_default: Платеж по умолчанию

4
config/routes.rb Normal file
View File

@@ -0,0 +1,4 @@
ActionController::Routing::Routes.draw do |map|
map.resources :rates
map.connect 'rate_caches', :conditions => {:method => :put}, :controller => 'rate_caches', :action => 'update'
end

View File

@@ -0,0 +1,9 @@
class AddCostToTimeEntries < ActiveRecord::Migration
def self.up
add_column :time_entries, :cost, :decimal, :precision => 15, :scale => 2
end
def self.down
remove_column :time_entries, :cost
end
end

View File

@@ -1,93 +0,0 @@
# Commonly used webrat steps
# http://github.com/brynary/webrat
When /^I press "(.*)"$/ do |button|
click_button(button)
end
When /^I follow "(.*)"$/ do |link|
click_link(link)
end
When /^I fill in "(.*)" with "(.*)"$/ do |field, value|
fill_in(field, :with => value)
end
When /^I select "(.*)" from "(.*)"$/ do |value, field|
select(value, :from => field)
end
# Use this step in conjunction with Rail's datetime_select helper. For example:
# When I select "December 25, 2008 10:00" as the date and time
When /^I select "(.*)" as the date and time$/ do |time|
select_datetime(time)
end
# Use this step when using multiple datetime_select helpers on a page or
# you want to specify which datetime to select. Given the following view:
# <%= f.label :preferred %><br />
# <%= f.datetime_select :preferred %>
# <%= f.label :alternative %><br />
# <%= f.datetime_select :alternative %>
# The following steps would fill out the form:
# When I select "November 23, 2004 11:20" as the "Preferred" data and time
# And I select "November 25, 2004 10:30" as the "Alternative" data and time
When /^I select "(.*)" as the "(.*)" date and time$/ do |datetime, datetime_label|
select_datetime(datetime, :from => datetime_label)
end
# Use this step in conjuction with Rail's time_select helper. For example:
# When I select "2:20PM" as the time
# Note: Rail's default time helper provides 24-hour time-- not 12 hour time. Webrat
# will convert the 2:20PM to 14:20 and then select it.
When /^I select "(.*)" as the time$/ do |time|
select_time(time)
end
# Use this step when using multiple time_select helpers on a page or you want to
# specify the name of the time on the form. For example:
# When I select "7:30AM" as the "Gym" time
When /^I select "(.*)" as the "(.*)" time$/ do |time, time_label|
select_time(time, :from => time_label)
end
# Use this step in conjuction with Rail's date_select helper. For example:
# When I select "February 20, 1981" as the date
When /^I select "(.*)" as the date$/ do |date|
select_date(date)
end
# Use this step when using multiple date_select helpers on one page or
# you want to specify the name of the date on the form. For example:
# When I select "April 26, 1982" as the "Date of Birth" date
When /^I select "(.*)" as the "(.*)" date$/ do |date, date_label|
select_date(date, :from => date_label)
end
When /^I check "(.*)"$/ do |field|
check(field)
end
When /^I uncheck "(.*)"$/ do |field|
uncheck(field)
end
When /^I choose "(.*)"$/ do |field|
choose(field)
end
When /^I attach the file at "(.*)" to "(.*)" $/ do |path, field|
attach_file(field, path)
end
Then /^I should see "(.*)"$/ do |text|
response.body.should =~ /#{text}/m
end
Then /^I should not see "(.*)"$/ do |text|
response.body.should_not =~ /#{text}/m
end
Then /^the "(.*)" checkbox should be checked$/ do |label|
field_labeled(label).should be_checked
end

View File

@@ -1,22 +0,0 @@
# Sets up the Rails environment for Cucumber
ENV["RAILS_ENV"] = "test"
require File.expand_path(File.dirname(__FILE__) + '/../../../../../config/environment')
require 'cucumber/rails/world'
Cucumber::Rails.use_transactional_fixtures
require 'webrat/rails'
# Comment out the next two lines if you're not using RSpec's matchers (should / should_not) in your steps.
require 'cucumber/rails/rspec'
require 'webrat/rspec-rails'
require 'ruby-debug'
# require the entire app if we're running under coverage testing,
# so we measure 0% covered files in the report
#
# http://www.pervasivecode.com/blog/2008/05/16/making-rcov-measure-your-whole-rails-app-even-if-tests-miss-entire-source-files/
if defined?(Rcov)
all_app_files = Dir.glob('{app,lib}/**/*.rb')
all_app_files.each{|rb| require rb}
end

43
init.rb
View File

@@ -2,28 +2,49 @@ require 'redmine'
# Patches to the Redmine core # Patches to the Redmine core
require 'dispatcher' require 'dispatcher'
require 'rate_sort_helper_patch'
require 'rate_time_entry_patch'
require 'rate_users_helper_patch'
Dispatcher.to_prepare do Dispatcher.to_prepare :redmine_rate do
SortHelper.send(:include, RateSortHelperPatch) gem 'lockfile'
require_dependency 'application_controller'
ApplicationController.send(:include, RateHelper)
ApplicationController.send(:helper, :rate)
require_dependency 'time_entry'
TimeEntry.send(:include, RateTimeEntryPatch) TimeEntry.send(:include, RateTimeEntryPatch)
UsersHelper.send(:include, RateUsersHelperPatch)
require_dependency 'users_helper'
UsersHelper.send(:include, RateUsersHelperPatch) unless UsersHelper.included_modules.include?(RateUsersHelperPatch)
end end
# Hooks # Hooks
require 'rate_project_hook' require 'rate_project_hook'
require 'rate_memberships_hook'
Redmine::Plugin.register :redmine_rate do Redmine::Plugin.register :redmine_rate do
name 'Rate Plugin' name 'Rate'
author 'Eric Davis' author 'Eric Davis'
url 'https://projects.littlestreamsoftware.com/projects/show/redmine-rate' url 'https://projects.littlestreamsoftware.com/projects/redmine-rate'
author_url 'http://www.littlestreamsoftware.com' author_url 'http://www.littlestreamsoftware.com'
description "The Rate plugin provides an API that can be used to find the rate for a Member of a Project at a specific date. It also stores historical rate data so calculations will remain correct in the future." description "The Rate plugin provides an API that can be used to find the rate for a Member of a Project at a specific date. It also stores historical rate data so calculations will remain correct in the future."
version '0.1.0' version '0.2.1'
requires_redmine :version_or_higher => '1.0.0'
# These settings are set automatically when caching
settings(:default => {
'last_caching_run' => nil
})
requires_redmine :version_or_higher => '0.8.0'
permission :view_rate, { } permission :view_rate, { }
menu :admin_menu, :rate_caches, { :controller => 'rate_caches', :action => 'index'}, :caption => :text_rate_caches_panel
end end
require 'redmine_rate/hooks/timesheet_hook_helper'
require 'redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook'
require 'redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook'
require 'redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook'
require 'redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook'
require 'redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook'
require 'redmine_rate/hooks/view_layouts_base_html_head_hook'

9
lang/de.yml Normal file
View File

@@ -0,0 +1,9 @@
# English strings go here
rate_label_rates: Betraege
rate_label_rate: Betrag
rate_label_rate_history: Betragsverlauf
rate_label_new_rate: Neuer Betrag
rate_label_currency: EUR
rate_error_user_not_found: Benutzer nicht gefunden
rate_label_set_rate: Betrag setzen
rate_label_default: Standard Betrag

8
lang/fr.yml Normal file
View File

@@ -0,0 +1,8 @@
rate_label_rates: Tarifs
rate_label_rate: Tarif
rate_label_rate_history: Historique du tarif
rate_label_new_rate: Nouveau tarif
rate_label_currency:
rate_error_user_not_found: Utilisateur introuvable
rate_label_set_rate: Parametrage du tarif
rate_label_default: Tarif par défaut

View File

@@ -0,0 +1,15 @@
class RateMembershipsHook < Redmine::Hook::ViewListener
def view_users_memberships_table_header(context={})
return content_tag(:th, l(:rate_label_rate) + ' ' + l(:rate_label_currency))
end
def view_users_memberships_table_row(context={})
return context[:controller].send(:render_to_string, {
:partial => 'users/membership_rate',
:locals => {
:membership => context[:membership],
:user => context[:user]
}})
end
end

View File

@@ -28,7 +28,13 @@ class RateProjectHook < Redmine::Hook::ViewListener
return '' unless (User.current.allowed_to?(:view_rate, project) || User.current.admin?) return '' unless (User.current.allowed_to?(:view_rate, project) || User.current.admin?)
rate = Rate.for(member.user, project) if Object.const_defined? 'Group' # 0.8.x compatibility
# Groups cannot have a rate
return content_tag(:td,'') if member.principal.is_a? Group
rate = Rate.for(member.principal, project)
else
rate = Rate.for(member.user, project)
end
content = '' content = ''

View File

@@ -1,5 +1,3 @@
require_dependency 'time_entry'
module RateTimeEntryPatch module RateTimeEntryPatch
def self.included(base) # :nodoc: def self.included(base) # :nodoc:
base.extend(ClassMethods) base.extend(ClassMethods)
@@ -10,28 +8,71 @@ module RateTimeEntryPatch
base.class_eval do base.class_eval do
unloadable # Send unloadable so it will not be unloaded in development unloadable # Send unloadable so it will not be unloaded in development
belongs_to :rate belongs_to :rate
before_save :recalculate_cost
end end
end end
module ClassMethods module ClassMethods
# Updated the cached cost of all TimeEntries for user and project
def update_cost_cache(user, project=nil)
c = ARCondition.new
c << ["#{TimeEntry.table_name}.user_id = ?", user]
c << ["#{TimeEntry.table_name}.project_id = ?", project] if project
TimeEntry.all(:conditions => c.conditions).each do |time_entry|
time_entry.save_cached_cost
end
end
end end
module InstanceMethods module InstanceMethods
# Returns the current cost of the TimeEntry based on it's rate and hours # Returns the current cost of the TimeEntry based on it's rate and hours
def cost #
if self.rate.nil? # Is a read-through cache method
amount = Rate.amount_for(self.user, self.project, self.spent_on.to_s) def cost(options={})
else store_to_db = options[:store] || false
amount = rate.amount
unless read_attribute(:cost)
if self.rate.nil?
amount = Rate.amount_for(self.user, self.project, self.spent_on.to_s)
else
amount = rate.amount
end
if amount.nil?
write_attribute(:cost, 0.0)
else
if store_to_db
# Write the cost to the database for caching
update_attribute(:cost, amount.to_f * hours.to_f)
else
# Cache to object only
write_attribute(:cost, amount.to_f * hours.to_f)
end
end
end end
return 0.0 if amount.nil? read_attribute(:cost)
return amount.to_f * hours.to_f
end end
def clear_cost_cache
write_attribute(:cost, nil)
end
def save_cached_cost
clear_cost_cache
update_attribute(:cost, cost)
end
def recalculate_cost
clear_cost_cache
cost(:store => false)
true # for callback
end
end end
end end

View File

@@ -1,9 +1,6 @@
require_dependency 'users_helper'
module RateUsersHelperPatch module RateUsersHelperPatch
def self.included(base) # :nodoc: def self.included(base) # :nodoc:
base.send(:include, InstanceMethods) base.send(:include, InstanceMethods)
base.class_eval do base.class_eval do
alias_method_chain :user_settings_tabs, :rate_tab alias_method_chain :user_settings_tabs, :rate_tab
end end
@@ -22,10 +19,14 @@ module RateUsersHelperPatch
options = content_tag('option', "--- #{l(:rate_label_default)} ---", :value => '') options = content_tag('option', "--- #{l(:rate_label_default)} ---", :value => '')
projects_by_root = projects.group_by(&:root) projects_by_root = projects.group_by(&:root)
projects_by_root.keys.sort.each do |root| projects_by_root.keys.sort.each do |root|
options << content_tag('option', h(root.name), :value => root.id, :disabled => (!projects.include?(root)), :selected => root == selected) root_selected = (root == selected) ? 'selected' : nil
options << content_tag('option', h(root.name), :value => root.id, :disabled => (!projects.include?(root)), :selected => root_selected)
projects_by_root[root].sort.each do |project| projects_by_root[root].sort.each do |project|
next if project == root next if project == root
options << content_tag('option', '&#187; ' + h(project.name), :value => project.id, :selected => project == selected) child_selected = (project == selected) ? 'selected' : nil
options << content_tag('option', '&#187; ' + h(project.name), :value => project.id, :selected => child_selected)
end end
end end
options options

View File

@@ -0,0 +1,11 @@
module RedmineRate
module Hooks
class PluginTimesheetViewTimesheetsReportHeaderTagsHook < Redmine::Hook::ViewListener
def plugin_timesheet_view_timesheets_report_header_tags(context={})
return content_tag(:style,
'tr.missing-rate td.cost { color: red; }',
:type => 'text/css')
end
end
end
end

View File

@@ -0,0 +1,9 @@
module RedmineRate
module Hooks
class PluginTimesheetViewsTimesheetGroupHeaderHook < Redmine::Hook::ViewListener
def plugin_timesheet_views_timesheet_group_header(context={})
return content_tag(:th, l(:rate_cost), :width => '8%')
end
end
end
end

View File

@@ -0,0 +1,18 @@
module RedmineRate
module Hooks
class PluginTimesheetViewsTimesheetTimeEntryHook < Redmine::Hook::ViewListener
include TimesheetHookHelper
def plugin_timesheet_views_timesheet_time_entry(context={})
cost = cost_item(context[:time_entry])
if cost
td_cell(number_to_currency(cost))
else
td_cell('&nbsp;')
end
end
end
end
end

View File

@@ -0,0 +1,17 @@
module RedmineRate
module Hooks
class PluginTimesheetViewsTimesheetTimeEntrySumHook < Redmine::Hook::ViewListener
include TimesheetHookHelper
def plugin_timesheet_views_timesheet_time_entry_sum(context={})
time_entries = context[:time_entries]
costs = time_entries.collect {|time_entry| cost_item(time_entry)}.compact.sum
if costs >= 0
return td_cell(number_to_currency(costs))
else
return td_cell('&nbsp;')
end
end
end
end
end

View File

@@ -0,0 +1,21 @@
module RedmineRate
module Hooks
class PluginTimesheetViewsTimesheetsTimeEntryRowClassHook < Redmine::Hook::ViewListener
include TimesheetHookHelper
def plugin_timesheet_views_timesheets_time_entry_row_class(context={})
time_entry = context[:time_entry]
return "" unless time_entry
cost = cost_item(time_entry)
return "" unless cost # Permissions
if cost && cost <= 0
return "missing-rate"
else
return ""
end
end
end
end
end

View File

@@ -0,0 +1,14 @@
module TimesheetHookHelper
# Returns the cost of a time entry, checking user permissions
def cost_item(time_entry)
if User.current.logged? && (User.current.allowed_to?(:view_rate, time_entry.project) || User.current.admin?)
return time_entry.cost
else
return nil
end
end
def td_cell(html)
return content_tag(:td, html, :align => 'right', :class => 'cost')
end
end

View File

@@ -0,0 +1,9 @@
module RedmineRate
module Hooks
class ViewLayoutsBaseHtmlHeadHook < Redmine::Hook::ViewListener
def view_layouts_base_html_head(context={})
return content_tag(:style, "#admin-menu a.rate-caches { background-image: url('#{image_path('database_refresh.png', :plugin => 'redmine_rate')}'); }", :type => 'text/css')
end
end
end
end

13
lib/tasks/cache.rake Normal file
View File

@@ -0,0 +1,13 @@
namespace :rate_plugin do
namespace :cache do
desc "Update Time Entry cost caches for Time Entries without a cost"
task :update_cost_cache => :environment do
Rate.update_all_time_entries_with_missing_cost
end
desc "Clear and update all Time Entry cost caches"
task :refresh_cost_cache => :environment do
Rate.update_all_time_entries_to_refresh_cache
end
end
end

1
rails/init.rb Normal file
View File

@@ -0,0 +1 @@
require File.dirname(__FILE__) + "/../init"

110
redmine_rate.gemspec Normal file
View File

@@ -0,0 +1,110 @@
# Generated by jeweler
# DO NOT EDIT THIS FILE DIRECTLY
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
# -*- encoding: utf-8 -*-
Gem::Specification.new do |s|
s.name = %q{redmine_rate}
s.version = "0.2.1"
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
s.authors = ["Eric Davis"]
s.date = %q{2011-04-28}
s.description = %q{The Rate plugin stores billing rates for Users. It also provides an API that can be used to find the rate for a Member of a Project at a specific date.}
s.email = %q{edavis@littlestreamsoftware.com}
s.extra_rdoc_files = [
"README.rdoc"
]
s.files = [
"COPYRIGHT.txt",
"CREDITS.txt",
"GPL.txt",
"README.rdoc",
"Rakefile",
"VERSION",
"app/controllers/rate_caches_controller.rb",
"app/controllers/rates_controller.rb",
"app/models/rate.rb",
"app/views/rate_caches/index.html.erb",
"app/views/rates/_form.html.erb",
"app/views/rates/_list.html.erb",
"app/views/rates/create.js.rjs",
"app/views/rates/create_error.js.rjs",
"app/views/rates/edit.html.erb",
"app/views/rates/index.html.erb",
"app/views/rates/new.html.erb",
"app/views/rates/show.html.erb",
"app/views/users/_membership_rate.html.erb",
"app/views/users/_rates.html.erb",
"assets/images/database_refresh.png",
"config/locales/de.yml",
"config/locales/en.yml",
"config/locales/fr.yml",
"config/locales/ru.yml",
"config/routes.rb",
"init.rb",
"lang/de.yml",
"lang/en.yml",
"lang/fr.yml",
"lib/rate_conversion.rb",
"lib/rate_memberships_hook.rb",
"lib/rate_project_hook.rb",
"lib/rate_sort_helper_patch.rb",
"lib/rate_time_entry_patch.rb",
"lib/rate_users_helper_patch.rb",
"lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook.rb",
"lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook.rb",
"lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook.rb",
"lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook.rb",
"lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook.rb",
"lib/redmine_rate/hooks/timesheet_hook_helper.rb",
"lib/redmine_rate/hooks/view_layouts_base_html_head_hook.rb",
"lib/tasks/cache.rake",
"lib/tasks/data.rake",
"rails/init.rb",
"test/functional/rates_controller_test.rb",
"test/integration/admin_panel_test.rb",
"test/integration/routing_test.rb",
"test/test_helper.rb",
"test/unit/lib/rate_time_entry_patch_test.rb",
"test/unit/lib/rate_users_helper_patch_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook_test.rb",
"test/unit/rate_for_test.rb",
"test/unit/rate_test.rb"
]
s.homepage = %q{https://projects.littlestreamsoftware.com/projects/redmine-rate}
s.rdoc_options = ["--charset=UTF-8"]
s.require_paths = ["lib"]
s.rubygems_version = %q{1.3.7}
s.summary = %q{A Rate plugin for Redmine to store billing rate for user.}
s.test_files = [
"test/test_helper.rb",
"test/integration/routing_test.rb",
"test/integration/admin_panel_test.rb",
"test/unit/rate_for_test.rb",
"test/unit/lib/rate_time_entry_patch_test.rb",
"test/unit/lib/rate_users_helper_patch_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook_test.rb",
"test/unit/lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook_test.rb",
"test/unit/rate_test.rb",
"test/functional/rates_controller_test.rb"
]
if s.respond_to? :specification_version then
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
s.specification_version = 3
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
else
end
else
end
end

View File

View File

@@ -1,486 +0,0 @@
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe "unauthorized", :shared => true do
it 'should not be successful' do
do_action
response.should_not be_success
end
it 'should return a 403 status code' do
do_action
response.code.should eql("403")
end
it 'should display the standard unauthorized page' do
do_action
response.should render_template('common/403')
end
describe "with mime type of xml" do
it "should return a 403 error" do
request.env["HTTP_ACCEPT"] = "application/xml"
do_action
response.response_code.should eql(403)
end
end
end
describe RatesController, "as regular user" do
integrate_views
def mock_rate(stubs={})
@mock_rate ||= mock_model(Rate, stubs)
end
before(:each) do
@user = mock_model(User, :logged? => true, :admin? => false, :anonymous? => false, :name => "Normal User", :memberships => [], :allowed_to? => true, :language => :en, :projects => Project)
User.stub!(:current).and_return(@user)
end
describe "responding to GET index" do
def do_action
get :index
end
it_should_behave_like "unauthorized"
end
describe "responding to GET show" do
def do_action
get :show, :id => "37"
end
it_should_behave_like "unauthorized"
end
describe "responding to GET new" do
def do_action
get :new
end
it_should_behave_like "unauthorized"
end
describe "responding to GET edit" do
def do_action
get :edit, :id => "37"
end
it_should_behave_like "unauthorized"
end
describe "responding to POST create" do
def do_action
post :create, :rate => {:these => 'params'}
end
it_should_behave_like "unauthorized"
end
describe "responding to PUT udpate" do
def do_action
put :update, :id => "37", :rate => {:these => 'params'}
end
it_should_behave_like "unauthorized"
end
describe "responding to DELETE destroy" do
def do_action
delete :destroy, :id => "37"
end
it_should_behave_like "unauthorized"
end
end
describe RatesController, "as an administrator" do
integrate_views
def mock_rate(stubs={})
project = mock_model(Project)
stubs = {
:date_in_effect => Date.today,
:project => project,
:project_id => project.id,
:amount => 100.0,
:user => @user,
:user_id => @user.id,
:unlocked? => true,
:locked? => false
}.merge(stubs)
@mock_rate ||= mock_model(Rate, stubs)
end
before(:each) do
@user = mock_model(User, :logged? => true, :admin? => true, :anonymous? => false, :name => "Admin User", :memberships => [], :allowed_to? => true, :language => :en, :projects => Project)
User.stub!(:current).and_return(@user)
controller.stub!(:find_current_user).and_return(@user)
end
describe "responding to GET index" do
it "should redirect to the homepage" do
get :index
response.should redirect_to(home_url)
end
it "should display an error flash message" do
get :index
flash[:error].should_not be_nil
end
describe "with mime type of xml" do
it "should return a 404 error" do
request.env["HTTP_ACCEPT"] = "application/xml"
get :index
response.response_code.should eql(404)
end
end
end
describe "responding to GET index with user" do
before(:each) do
User.stub!(:find).with(@user.id.to_s).and_return(@user)
@default_sort = "#{Rate.table_name}.date_in_effect desc"
controller.stub!(:sort_clause).and_return(@default_sort)
end
it "should expose all historic rates for the user as @rates" do
Rate.should_receive(:history_for_user).with(@user, @default_sort).and_return([mock_rate])
get :index, :user_id => @user.id
assigns[:rates].should == [mock_rate]
end
describe "with mime type of xml" do
it "should render all rates as xml" do
request.env["HTTP_ACCEPT"] = "application/xml"
Rate.should_receive(:history_for_user).with(@user, @default_sort).and_return(rates = mock("Array of Rates"))
rates.should_receive(:to_xml).and_return("generated XML")
get :index, :user_id => @user.id
response.body.should == "generated XML"
end
end
end
describe "responding to GET show" do
it "should expose the requested rate as @rate" do
Rate.should_receive(:find).with("37").and_return(mock_rate)
get :show, :id => "37"
assigns[:rate].should equal(mock_rate)
end
describe "with mime type of xml" do
it "should render the requested rate as xml" do
request.env["HTTP_ACCEPT"] = "application/xml"
Rate.should_receive(:find).with("37").and_return(mock_rate)
mock_rate.should_receive(:to_xml).and_return("generated XML")
get :show, :id => "37"
response.body.should == "generated XML"
end
end
end
describe "responding to GET new" do
it "should redirect to the homepage" do
get :new
response.should redirect_to(home_url)
end
it "should display an error flash message" do
get :new
flash[:error].should_not be_nil
end
describe "with mime type of xml" do
it "should return a 404 error" do
request.env["HTTP_ACCEPT"] = "application/xml"
get :new
response.response_code.should eql(404)
end
end
end
describe "responding to GET new with user" do
before(:each) do
@rate = mock_rate(:user_id => @user.id)
User.stub!(:find).with(@user.id.to_s).and_return(@user)
Rate.stub!(:new).and_return(@rate)
end
it 'should be successful' do
get :new, :user_id => @user.id
response.should be_success
end
it "should expose a new rate as @rate" do
get :new, :user_id => @user.id
assigns[:rate].should equal(@rate)
end
end
describe "responding to GET edit" do
it "should expose the requested rate as @rate" do
Rate.should_receive(:find).with("37").and_return(mock_rate)
get :edit, :id => "37"
assigns[:rate].should equal(mock_rate)
end
describe "on a locked rate" do
it 'should not have a Update button' do
Rate.should_receive(:find).with("37").and_return(mock_rate(:unlocked? => false))
get :edit, :id => "37"
response.should_not have_tag("input[type=submit]")
end
it 'should show the locked icon' do
Rate.should_receive(:find).with("37").and_return(mock_rate(:unlocked? => false))
get :edit, :id => "37"
response.should have_tag("img[src*=locked.png]")
end
end
end
describe "responding to POST create" do
describe "with valid params" do
it "should expose a newly created rate as @rate" do
Rate.should_receive(:new).with({'these' => 'params'}).and_return(mock_rate(:save => true))
post :create, :rate => {:these => 'params'}
assigns(:rate).should equal(mock_rate)
end
it "should redirect to the rate list" do
Rate.stub!(:new).and_return(mock_rate(:save => true))
post :create, :rate => {}
response.should redirect_to(rates_url(:user_id => @user.id))
end
it 'should redirect to the back_url if set' do
back_url = '/rates'
Rate.stub!(:new).and_return(mock_rate(:save => true))
post :create, :rate => {}, :back_url => back_url
response.should redirect_to(back_url)
end
end
describe "with invalid params" do
it "should expose a newly created but unsaved rate as @rate" do
Rate.stub!(:new).with({'these' => 'params'}).and_return(mock_rate(:save => false))
post :create, :rate => {:these => 'params'}
assigns(:rate).should equal(mock_rate)
end
it "should re-render the 'new' template" do
Rate.stub!(:new).and_return(mock_rate(:save => false))
post :create, :rate => {}
response.should render_template('new')
end
end
end
describe "responding to PUT udpate" do
describe "with valid params" do
it "should update the requested rate" do
Rate.should_receive(:find).with("37").and_return(mock_rate)
mock_rate.should_receive(:update_attributes).with({'these' => 'params'})
put :update, :id => "37", :rate => {:these => 'params'}
end
it "should expose the requested rate as @rate" do
Rate.stub!(:find).and_return(mock_rate(:update_attributes => true))
put :update, :id => "1"
assigns(:rate).should equal(mock_rate)
end
it "should redirect to the rate list" do
Rate.stub!(:find).and_return(mock_rate(:update_attributes => true))
put :update, :id => "1"
response.should redirect_to(rates_url(:user_id => @user.id))
end
it 'should redirect to the back_url if set' do
back_url = '/rates'
Rate.stub!(:find).and_return(mock_rate(:update_attributes => true))
put :update, :id => "1", :back_url => back_url
response.should redirect_to(back_url)
end
end
describe "with invalid params" do
it "should update the requested rate" do
Rate.should_receive(:find).with("37").and_return(mock_rate)
mock_rate.should_receive(:update_attributes).with({'these' => 'params'})
put :update, :id => "37", :rate => {:these => 'params'}
end
it "should expose the rate as @rate" do
Rate.stub!(:find).and_return(mock_rate(:update_attributes => false))
put :update, :id => "1"
assigns(:rate).should equal(mock_rate)
end
it "should re-render the 'edit' template" do
Rate.stub!(:find).and_return(mock_rate(:update_attributes => false))
put :update, :id => "1"
response.should render_template('edit')
end
end
describe "on a locked rate" do
def mock_locked_rate(stubs = { })
mock_rate(stubs.merge(:locked? => true,
:unlocked? => false,
:update_attributes => false,
:reload => nil
))
end
it "should try to update the requested rate" do
Rate.should_receive(:find).with("37").and_return(mock_locked_rate)
mock_locked_rate.should_receive(:update_attributes).with({'these' => 'params'})
put :update, :id => "37", :rate => {:these => 'params'}
end
it "should not save the rate" do
Rate.should_receive(:find).with("37").and_return(mock_locked_rate)
mock_locked_rate.should_receive(:update_attributes).and_return(false)
put :update, :id => "37", :rate => {:these => 'params'}
end
it "should reload the locked rate as @rate" do
Rate.stub!(:find).and_return(mock_locked_rate(:id => 37))
mock_locked_rate.should_receive(:reload).and_return(mock_locked_rate(:id => 37))
put :update, :id => "37", :rate => { :amount => 200.0 }
assigns(:rate).should equal(mock_locked_rate)
end
it "should re-render the 'edit' template" do
Rate.stub!(:find).and_return(mock_locked_rate)
put :update, :id => "1"
response.should render_template('edit')
end
it "should render an error message" do
Rate.stub!(:find).and_return(mock_locked_rate)
put :update, :id => "1"
flash[:error].should match(/locked/)
end
end
end
describe "responding to DELETE destroy" do
it "should destroy the requested rate" do
Rate.should_receive(:find).with("37").and_return(mock_rate)
mock_rate.should_receive(:destroy)
delete :destroy, :id => "37"
end
it "should redirect to the user's rates list" do
Rate.stub!(:find).and_return(mock_rate(:destroy => true))
delete :destroy, :id => "1"
response.should redirect_to(rates_url(:user_id => @user.id))
end
it 'should redirect to the back_url if set' do
back_url = '/rates'
Rate.stub!(:find).and_return(mock_rate(:destroy => true))
delete :destroy, :id => "1", :back_url => back_url
response.should redirect_to(back_url)
end
describe "on a locked rate" do
it "should display an error message" do
Rate.stub!(:find).and_return(mock_rate(:destroy => false, :locked? => true))
delete :destroy, :id => "1"
flash[:error].should match(/locked/)
end
end
end
describe "set_back_url (private)" do
it "should set the back_url based on the params" do
controller.params = { :back_url => '/back' }
controller.send(:set_back_url).should eql('/back')
end
end
describe "redirect_back_or_default (private)" do
before(:each) do
@default_url = "/default_response"
end
it "should allow redirecting back to the user's edit panel" do
allowed = "/users/edit"
controller.should_receive(:redirect_to).with(allowed).and_return(true)
controller.params = { :back_url => allowed }
controller.send(:redirect_back_or_default, @default_url)
end
it "should allow redirecting back to /rates" do
controller.should_receive(:redirect_to).with("/rates").and_return(true)
controller.params = { :back_url => '/rates' }
controller.send(:redirect_back_or_default, @default_url)
end
it "should not allow redirecting elsewhere" do
controller.should_receive(:redirect_to).with(@default_url).and_return(true)
controller.params = { :back_url => '/back' }
controller.send(:redirect_back_or_default, @default_url)
end
it "should not allow redirecting to an invalid uri" do
controller.should_receive(:redirect_to).with(@default_url).and_return(true)
controller.params = { :back_url => 'http://' }
controller.send(:redirect_back_or_default, @default_url)
end
end
end

View File

@@ -1,59 +0,0 @@
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe RatesController do
describe "route generation" do
it "should map #index" do
route_for(:controller => "rates", :action => "index").should == "/rates"
end
it "should map #new" do
route_for(:controller => "rates", :action => "new").should == "/rates/new"
end
it "should map #show" do
route_for(:controller => "rates", :action => "show", :id => 1).should == "/rates/1"
end
it "should map #edit" do
route_for(:controller => "rates", :action => "edit", :id => 1).should == "/rates/1/edit"
end
it "should map #update" do
route_for(:controller => "rates", :action => "update", :id => 1).should == "/rates/1"
end
it "should map #destroy" do
route_for(:controller => "rates", :action => "destroy", :id => 1).should == "/rates/1"
end
end
describe "route recognition" do
it "should generate params for #index" do
params_from(:get, "/rates").should == {:controller => "rates", :action => "index"}
end
it "should generate params for #new" do
params_from(:get, "/rates/new").should == {:controller => "rates", :action => "new"}
end
it "should generate params for #create" do
params_from(:post, "/rates").should == {:controller => "rates", :action => "create"}
end
it "should generate params for #show" do
params_from(:get, "/rates/1").should == {:controller => "rates", :action => "show", :id => "1"}
end
it "should generate params for #edit" do
params_from(:get, "/rates/1/edit").should == {:controller => "rates", :action => "edit", :id => "1"}
end
it "should generate params for #update" do
params_from(:put, "/rates/1").should == {:controller => "rates", :action => "update", :id => "1"}
end
it "should generate params for #destroy" do
params_from(:delete, "/rates/1").should == {:controller => "rates", :action => "destroy", :id => "1"}
end
end
end

View File

@@ -1,29 +0,0 @@
require File.dirname(__FILE__) + '/../spec_helper'
describe TimeEntry, 'cost' do
before(:each) do
@user = mock_model(User)
@project = mock_model(Project)
@date = Date.today.to_s
@time_entry = TimeEntry.new({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0})
end
it 'should return 0.0 if there are no rates for the user' do
Rate.should_receive(:amount_for).with(@user, @project, @date).and_return(nil)
@time_entry.cost.should eql(0.0)
end
describe 'should return the product of hours by' do
it 'the results of Rate.amount_for' do
Rate.should_receive(:amount_for).with(@user, @project, @date).and_return(200.0)
@time_entry.cost.should eql(200.0 * @time_entry.hours)
end
it 'the assigned rate' do
rate = mock_model(Rate, :amount => 100.0)
@time_entry.should_receive(:rate).at_least(:twice).and_return(rate)
@time_entry.cost.should eql(rate.amount * @time_entry.hours)
end
end
end

View File

@@ -1,38 +0,0 @@
require File.dirname(__FILE__) + '/../spec_helper'
class UsersHelperWrapper
include UsersHelper
end
describe UsersHelper, 'user_settings' do
it 'should return 3 tabs' do
helper = UsersHelperWrapper.new
helper.user_settings_tabs.should have(3).things
end
it 'should include a rate tab at the end' do
helper = UsersHelperWrapper.new
rate_tab = helper.user_settings_tabs[-1]
rate_tab.should_not be_nil
end
describe 'rate tab' do
before(:each) do
helper = UsersHelperWrapper.new
@rate_tab = helper.user_settings_tabs[-1]
end
it 'should have the name of "rates"' do
@rate_tab[:name].should eql('rates')
end
it 'should use the rates partial' do
@rate_tab[:partial].should eql('users/rates')
end
it 'should use the i18n rates label' do
@rate_tab[:label].should eql(:rate_label_rate_history)
end
end
end

View File

@@ -1,76 +0,0 @@
require File.dirname(__FILE__) + '/../spec_helper'
describe Rate, 'calculated for' do
before(:each) do
@user = User.new(:mail => 'metest@example.com', :lastname => 'Test', :firstname => 'Mr')
@user.login = 'mr-test'
@user.save!
end
after(:each) do
User.destroy_all
end
describe 'a user with no Rates' do
it 'should return nil' do
Rate.for(@user).should be_nil
end
end
describe 'a user with one default Rate' do
it 'should return the Rate if the Rate is effective today' do
rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.today})
Rate.for(@user).should eql(rate)
end
it 'should return nil if the Rate is not effective yet' do
Rate.for(@user).should be_nil
end
it 'should return the same default Rate on all projects' do
project = mock_model(Project)
rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.today})
Rate.for(@user, project).should eql(rate)
end
end
describe 'a user with two default Rates' do
it 'should return the newest Rate before the todays date' do
rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.yesterday})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today})
Rate.for(@user).should eql(rate2)
end
end
describe 'a user with a default Rate and Rate on a project' do
it 'should return the project Rate if its effective today' do
project = mock_model(Project)
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.yesterday})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today})
Rate.for(@user, project).should eql(rate)
end
it 'should return the default Rate if the project Rate isnt effective yet but the default Rate is' do
project = mock_model(Project)
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.tomorrow})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today})
Rate.for(@user, project).should eql(rate2)
end
it 'should return nil if neither Rate is effective yet' do
project = mock_model(Project)
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.tomorrow})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.tomorrow})
Rate.for(@user, project).should be_nil
end
end
describe 'a user with two Rates on a project' do
it 'should return the newest Rate before the todays date' do
project = mock_model(Project)
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.yesterday})
rate2 = Rate.create!({ :user_id => @user.id, :project => project, :amount => 300.0, :date_in_effect => Date.today})
Rate.for(@user, project).should eql(rate2)
end
end
end

View File

@@ -1,298 +0,0 @@
require File.dirname(__FILE__) + '/../spec_helper'
module RateSpecHelper
def rate_valid_attributes
{
:user => mock_model(User),
:project => mock_model(Project),
:date_in_effect => Date.new(Date.today.year, 1, 1),
:amount => 100.50
}
end
end
describe Rate do
include RateSpecHelper
it 'should be valid with a user' do
rate = Rate.new(rate_valid_attributes)
rate.should be_valid
end
it 'should be valid with a project' do
rate = Rate.new(rate_valid_attributes)
rate.should be_valid
end
it 'should be valid without a project' do
rate = Rate.new(rate_valid_attributes.except(:project))
rate.should be_valid
end
it 'should not be valid without a user' do
rate = Rate.new(rate_valid_attributes.except(:user))
rate.should_not be_valid
rate.should have(1).error_on(:user_id)
end
it 'should not be valid without a date_in_effect' do
rate = Rate.new(rate_valid_attributes.except(:date_in_effect))
rate.should_not be_valid
rate.should have(1).error_on(:date_in_effect)
end
end
describe Rate, 'associations' do
it 'should have many time entries' do
Rate.should have_association(:time_entries, :has_many)
end
it 'should belong to a single user' do
Rate.should have_association(:user, :belongs_to)
end
it 'should belong to a single project' do
Rate.should have_association(:project, :belongs_to)
end
end
describe Rate, 'locked?' do
it 'should be true if a Time Entry is associated' do
rate = Rate.new
rate.time_entries << mock_model(TimeEntry)
rate.locked?.should be_true
end
it 'should be false if no Time Entries are associated' do
rate = Rate.new
rate.locked?.should be_false
end
end
describe Rate, 'locked?' do
it 'should be false if a Time Entry is associated' do
rate = Rate.new
rate.time_entries << mock_model(TimeEntry)
rate.unlocked?.should be_false
end
it 'should be true if no Time Entries are associated' do
rate = Rate.new
rate.unlocked?.should be_true
end
end
describe Rate, 'save' do
include RateSpecHelper
it 'should save if a Rate is unlocked' do
rate = Rate.new(rate_valid_attributes)
rate.stub!(:locked?).and_return(false)
rate.save.should eql(true)
end
it 'should not save if a Rate is locked' do
rate = Rate.new(rate_valid_attributes)
rate.stub!(:locked?).and_return(true)
rate.save.should eql(false)
end
end
describe Rate, 'destroy' do
include RateSpecHelper
it 'should destroy the Rate if it is unlocked' do
rate = Rate.create(rate_valid_attributes)
rate.stub!(:locked?).and_return(false)
proc {
rate.destroy
}.should change(Rate, :count).by(-1)
end
it 'should not destroy the Rate if it is locked' do
rate = Rate.create(rate_valid_attributes)
rate.stub!(:locked?).and_return(true)
proc {
rate.destroy
}.should_not change(Rate, :count)
end
end
describe Rate, 'for' do
before(:each) do
@user = mock_model(User)
@project = mock_model(Project)
@date = '2009-01-01'
@rate = mock_model(Rate, :amount => 50.50)
end
describe 'parameters' do
it 'should be passed user' do
lambda {Rate.for}.should raise_error(ArgumentError)
end
it 'can be passed an optional project' do
lambda {Rate.for(@user)}.should_not raise_error(ArgumentError)
lambda {Rate.for(@user, @project)}.should_not raise_error(ArgumentError)
end
it 'can be passed an optional date string' do
lambda {Rate.for(@user)}.should_not raise_error(ArgumentError)
lambda {Rate.for(@user, nil, @date)}.should_not raise_error(ArgumentError)
end
end
describe 'returns' do
it 'a Rate object when there is a rate' do
Rate.stub!(:for_user_project_and_date).with(@user, @project, @date).and_return(@rate)
Rate.for(@user, @project, @date).should eql(@rate)
end
it 'a nil when there is no rate' do
Rate.for(@user, @project, @date).should be_nil
end
end
describe 'with a user, project, and date' do
it 'should find the rate for a user on the project before the date' do
Rate.should_receive(:for_user_project_and_date).with(@user, @project, @date).and_return(@rate)
Rate.for(@user, @project, @date)
end
it 'should return the most recent rate found' do
Rate.should_receive(:for_user_project_and_date).with(@user, @project, @date).and_return(@rate)
Rate.for(@user, @project, @date).should eql(@rate)
end
it 'should check for a default rate if no rate is found' do
Rate.should_receive(:for_user_project_and_date).with(@user, @project, @date).and_return(nil)
Rate.should_receive(:default_for_user_and_date).with(@user, @date).and_return(@rate)
Rate.for(@user, @project, @date).should eql(@rate)
end
it 'should return nil if no set or default rate is found' do
Rate.should_receive(:for_user_project_and_date).with(@user, @project, @date).and_return(nil)
Rate.should_receive(:default_for_user_and_date).with(@user, @date).and_return(nil)
Rate.for(@user, @project, @date).should be_nil
end
end
describe 'with a user and project' do
it 'should find the rate for a user on the project before today' do
Rate.should_receive(:for_user_project_and_date).with(@user, @project, Date.today.to_s).and_return(@rate)
Rate.for(@user, @project)
end
it 'should return the most recent rate found' do
Rate.should_receive(:for_user_project_and_date).with(@user, @project, Date.today.to_s).and_return(@rate)
Rate.for(@user, @project).should eql(@rate)
end
it 'should return nil if no set or default rate is found' do
Rate.should_receive(:for_user_project_and_date).with(@user, @project, Date.today.to_s).and_return(nil)
Rate.should_receive(:default_for_user_and_date).with(@user, Date.today.to_s).and_return(nil)
Rate.for(@user, @project).should be_nil
end
end
describe 'with a user' do
it 'should find the rate without a project for a user on the project before today' do
Rate.should_receive(:for_user_project_and_date).with(@user, nil, Date.today.to_s).and_return(@rate)
Rate.for(@user)
end
it 'should return the most recent rate found' do
Rate.should_receive(:for_user_project_and_date).with(@user, nil, Date.today.to_s).and_return(@rate)
Rate.for(@user).should eql(@rate)
end
it 'should return nil if no set or default rate is found' do
Rate.should_receive(:for_user_project_and_date).with(@user, nil, Date.today.to_s).and_return(nil)
Rate.for(@user).should be_nil
end
end
it 'with an invalid user should raise an InvalidParameterException' do
object = mock('random_object_with_id_attribute')
Rate.should_not_receive(:for_user_project_and_date)
lambda {
Rate.for(object)
}.should raise_error(Rate::InvalidParameterException, "user must be a User instance")
end
it 'with an invalid project should raise an InvalidParameterException' do
object = mock('random_object_with_id_attribute')
Rate.should_not_receive(:for_user_project_and_date)
lambda {
Rate.for(@user, object)
}.should raise_error(Rate::InvalidParameterException, "project must be a Project instance")
end
it 'with an invalid object for date should raise an InvalidParameterException' do
object = mock('random_object_thats_not_a_string')
Rate.should_not_receive(:for_user_project_and_date)
lambda {
Rate.for(@user, @project, object)
}.should raise_error(Rate::InvalidParameterException, "date must be a valid Date string (e.g. YYYY-MM-DD)")
end
it 'with an invalid date string should raise an InvalidParameterException' do
Rate.should_not_receive(:for_user_project_and_date)
lambda {
Rate.for(@user, @project, '2000-13-40')
}.should raise_error(Rate::InvalidParameterException, "date must be a valid Date string (e.g. YYYY-MM-DD)")
end
end
describe Rate, 'for_user_project_and_date (private)' do
before(:each) do
@user = mock_model(User)
@project = mock_model(Project)
@date = '2009-01-01'
@rate = mock_model(Rate, :amount => 50.50)
end
it 'should find the rate for a user on the project before the date' do
Rate.should_receive(:find).with(:first, {
:conditions => ["user_id IN (?) AND project_id IN (?) AND date_in_effect <= ?",
@user.id,
@project.id,
@date
],
:order => 'date_in_effect DESC'
}).and_return(@rate)
Rate.send(:for_user_project_and_date, @user, @project, @date)
end
it 'should return the value of the most recent rate found' do
Rate.should_receive(:find).with(:first, {
:conditions => ["user_id IN (?) AND project_id IN (?) AND date_in_effect <= ?",
@user.id,
@project.id,
@date
],
:order => 'date_in_effect DESC'
}).and_return(@rate1)
Rate.send(:for_user_project_and_date, @user, @project, @date).should eql(@rate1)
end
it 'should search rates without a project when +project+ is nil' do
Rate.should_receive(:find).with(:first, {
:conditions => ["user_id IN (?) AND date_in_effect <= ? AND project_id IS NULL",
@user.id,
@date
],
:order => 'date_in_effect DESC'
}).and_return(@rate1)
Rate.send(:for_user_project_and_date, @user, nil, @date).should eql(@rate1)
end
end

View File

View File

@@ -1,7 +0,0 @@
require File.dirname(__FILE__) + '/spec_helper'
describe Class do
it "should be a class of Class" do
Class.class.should eql(Class)
end
end

View File

@@ -1,7 +0,0 @@
--colour
--format
progress
--loadby
mtime
--reverse
--backtrace

View File

@@ -1,82 +0,0 @@
# This file is copied to ~/spec when you run 'ruby script/generate rspec'
# from the project root directory.
ENV["RAILS_ENV"] = "test"
redmine_root = ENV["REDMINE_ROOT"] || File.dirname(__FILE__) + "/../../../.."
require File.expand_path(redmine_root + "/config/environment")
require 'spec'
require 'spec/rails'
require 'ruby-debug'
Spec::Runner.configure do |config|
# If you're not using ActiveRecord you should remove these
# lines, delete config/database.yml and disable :active_record
# in your config/boot.rb
config.use_transactional_fixtures = true
config.use_instantiated_fixtures = false
config.fixture_path = RAILS_ROOT + '/spec/fixtures/'
# == Fixtures
#
# You can declare fixtures for each example_group like this:
# describe "...." do
# fixtures :table_a, :table_b
#
# Alternatively, if you prefer to declare them only once, you can
# do so right here. Just uncomment the next line and replace the fixture
# names with your fixtures.
#
# config.global_fixtures = :table_a, :table_b
#
# If you declare global fixtures, be aware that they will be declared
# for all of your examples, even those that don't use them.
#
# == Mock Framework
#
# RSpec uses it's own mocking framework by default. If you prefer to
# use mocha, flexmock or RR, uncomment the appropriate line:
#
# config.mock_with :mocha
# config.mock_with :flexmock
# config.mock_with :rr
end
# require the entire app if we're running under coverage testing,
# so we measure 0% covered files in the report
#
# http://www.pervasivecode.com/blog/2008/05/16/making-rcov-measure-your-whole-rails-app-even-if-tests-miss-entire-source-files/
if defined?(Rcov)
all_app_files = Dir.glob('{app,lib}/**/*.rb')
all_app_files.each{|rb| require rb}
end
module AssociationMatcher
class Association
def initialize(field, association_type)
@field = field
@association_type = association_type
end
def matches?(model)
@model=model
association = @model.reflect_on_association(@field)
return false if association.nil?
return association.name == @field && association.macro == @association_type
end
def failure_message
"expected <#{@model.name}> to have a #{@association_type} association for #{@field}"
end
def negative_failure_message
"expected <#{@model.name}> to not have a #{@association_type} association for #{@field} but one was found"
end
end
def have_association(field, association_type)
Association.new(field, association_type)
end
end
include AssociationMatcher

View File

View File

@@ -0,0 +1,401 @@
require File.dirname(__FILE__) + '/../test_helper'
class RatesControllerTest < ActionController::TestCase
def self.should_be_unauthorized(&block)
should 'should return a forbidden status code' do
instance_eval(&block)
assert_response :forbidden
end
should 'should display the standard unauthorized page' do
instance_eval(&block)
assert_template 'common/error'
end
context "with mime type of xml" do
should "should return an forbidden error" do
@request.env["HTTP_ACCEPT"] = "application/xml"
instance_eval(&block)
assert_response :forbidden
end
end
end
def mock_rate(stubs={})
@project = Project.generate!
stubs = {
:date_in_effect => Date.today,
:project => @project,
:amount => 100.0,
:user => @user
}.merge(stubs)
@mock_rate = Rate.generate(stubs)
end
def mock_locked_rate(stubs={})
@mock_rate = mock_rate
@mock_rate.time_entries << TimeEntry.generate!
@mock_rate
end
context "as regular user" do
setup do
@user = User.generate!
@request.session[:user_id] = @user.id
end
context "responding to GET index" do
should_be_unauthorized { get :index }
end
context "responding to GET show" do
should_be_unauthorized { get :show, :id => "37" }
end
context "responding to GET new" do
should_be_unauthorized { get :new }
end
context "responding to GET edit" do
should_be_unauthorized { get :edit, :id => "37" }
end
context "responding to POST create" do
should_be_unauthorized { post :create, :rate => {:these => 'params'} }
end
context "responding to PUT update" do
should_be_unauthorized { put :update, :id => "37", :rate => {:these => 'params'} }
end
context "responding to DELETE destroy" do
should_be_unauthorized { delete :destroy, :id => "37" }
end
end
context "as an administrator" do
setup do
@user = User.generate!(:admin => true)
@request.session[:user_id] = @user.id
end
context "responding to GET index" do
should "should redirect to the homepage" do
get :index
assert_redirected_to home_url
end
should "should display an error flash message" do
get :index
assert_match /not found/, flash[:error]
end
context "with mime type of xml" do
should "should return a 404 error" do
@request.env["HTTP_ACCEPT"] = "application/xml"
get :index
assert_response :not_found
end
end
end
context "responding to GET index with user" do
setup do
mock_rate
end
should "should expose all historic rates for the user as @rates" do
get :index, :user_id => @user.id
assert_equal assigns(:rates), [@mock_rate]
end
context "with mime type of xml" do
should "should render all rates as xml" do
@request.env["HTTP_ACCEPT"] = "application/xml"
get :index, :user_id => @user.id
assert_select 'rates' do
assert_select 'rate' do
assert_select 'id', :text => @mock_rate.id
end
end
end
end
end
context "responding to GET show" do
setup do
mock_rate
end
should "should expose the @requested rate as @rate" do
get :show, :id => @mock_rate.id
assert_equal assigns(:rate), @mock_rate
end
context "with mime type of xml" do
should "should render the requested rate as xml" do
@request.env["HTTP_ACCEPT"] = "application/xml"
get :show, :id => @mock_rate.id
assert_select 'rate' do
assert_select 'id', :text => @mock_rate.id
assert_select 'amount', :text => /100/
end
end
end
end
context "responding to GET new" do
should "should redirect to the homepage" do
get :new
assert_redirected_to home_url
end
should "should display an error flash message" do
get :new
assert_match /not found/, flash[:error]
end
context "with mime type of xml" do
should "should return a 404 error" do
@request.env["HTTP_ACCEPT"] = "application/xml"
get :new
assert_response :not_found
end
end
end
context "responding to GET new with user" do
should 'should be successful' do
get :new, :user_id => @user.id
assert_response :success
end
should "should expose a new rate as @rate" do
get :new, :user_id => @user.id
assert assigns(:rate)
assert assigns(:rate).new_record?
end
end
context "responding to GET edit" do
setup do
mock_rate
end
should "should expose the requested rate as @rate" do
get :edit, :id => @mock_rate.id
assert_equal assigns(:rate), @mock_rate
end
context "on a locked rate" do
setup do
mock_locked_rate
end
should 'should not have a Update button' do
get :edit, :id => @mock_rate.id
assert_select "input[type=submit]", :count => 0
end
should 'should show the locked icon' do
get :edit, :id => @mock_rate.id
assert_select "img[src*=locked.png]"
end
end
end
context "responding to POST create" do
context "with valid params" do
setup do
@project = Project.generate!
end
should "should expose a newly created rate as @rate" do
post :create, :rate => {:project_id => @project.id, :amount => '50', :date_in_effect => Date.today.to_s, :user_id => @user.id}
assert assigns(:rate)
end
should "should redirect to the rate list" do
post :create, :rate => {:project_id => @project.id, :amount => '50', :date_in_effect => Date.today.to_s, :user_id => @user.id}
assert_redirected_to rates_url(:user_id => @user.id)
end
should 'should redirect to the back_url if set' do
back_url = '/rates'
post :create, :rate => {:project_id => @project.id, :amount => '50', :date_in_effect => Date.today.to_s, :user_id => @user.id}, :back_url => back_url
assert_redirected_to back_url
end
end
context "with invalid params" do
should "should expose a newly created but unsaved rate as @rate" do
post :create, :rate => {}
assert assigns(:rate).new_record?
end
should "should re-render the 'new' template" do
post :create, :rate => {}
assert_template 'new'
end
end
end
context "responding to PUT udpate" do
context "with valid params" do
setup do
mock_rate
end
should "should update the requested rate" do
put :update, :id => @mock_rate.id, :rate => {:amount => '150'}
assert_equal 150.0, @mock_rate.reload.amount
end
should "should expose the requested rate as @rate" do
put :update, :id => @mock_rate.id
assert_equal assigns(:rate), @mock_rate
end
should "should redirect to the rate list" do
put :update, :id => "1"
assert_redirected_to rates_url(:user_id => @user.id)
end
should 'should redirect to the back_url if set' do
back_url = '/rates'
put :update, :id => "1", :back_url => back_url
assert_redirected_to back_url
end
end
context "with invalid params" do
setup do
mock_rate
end
should "should not update the requested rate" do
put :update, :id => @mock_rate.id, :rate => {:amount => 'asdf'}
assert_equal 100.0, @mock_rate.reload.amount
end
should "should expose the rate as @rate" do
put :update, :id => @mock_rate.id, :rate => {:amount => 'asdf'}
assert_equal assigns(:rate), @mock_rate
end
should "should re-render the 'edit' template" do
put :update, :id => @mock_rate.id, :rate => {:amount => 'asdf'}
assert_template 'edit'
end
end
context "on a locked rate" do
setup do
mock_locked_rate
end
should "should not save the rate" do
put :update, :id => @mock_rate.id, :rate => {:amount => '150'}
assert_equal 100, @mock_rate.reload.amount
end
should "should set the locked rate as @rate" do
put :update, :id => @mock_rate.id, :rate => { :amount => 200.0 }
assert_equal assigns(:rate), @mock_rate
end
should "should re-render the 'edit' template" do
put :update, :id => @mock_rate.id
assert_template 'edit'
end
should "should render an error message" do
put :update, :id => @mock_rate.id
assert_match /locked/, flash[:error]
end
end
end
context "responding to DELETE destroy" do
setup do
mock_rate
end
should "should destroy the requested rate" do
assert_difference('Rate.count', -1) do
delete :destroy, :id => @mock_rate.id
end
end
should "should redirect to the user's rates list" do
delete :destroy, :id => @mock_rate.id
assert_redirected_to rates_url(:user_id => @user.id)
end
should 'should redirect to the back_url if set' do
back_url = '/rates'
delete :destroy, :id => "1", :back_url => back_url
assert_redirected_to back_url
end
context "on a locked rate" do
setup do
mock_locked_rate
end
should "should display an error message" do
delete :destroy, :id => @mock_rate.id
assert_match /locked/, flash[:error]
end
end
end
end
end

View File

@@ -0,0 +1,81 @@
require 'test_helper'
class AdminPanelTest < ActionController::IntegrationTest
include Redmine::I18n
def setup
@last_caching_run = 4.days.ago.to_s
@last_cache_clearing_run = 7.days.ago.to_s
Setting.plugin_redmine_rate = {
'last_caching_run' => @last_caching_run,
'last_cache_clearing_run' => @last_cache_clearing_run
}
@user = User.generate!(:admin => true, :password => 'rates', :password_confirmation => 'rates')
login_as(@user.login, 'rates')
end
context "Rate Caches admin panel" do
should "be listed in the main Admin section" do
click_link "Administration"
assert_response :success
assert_select "#admin-menu" do
assert_select "a.rate-caches"
end
end
should "show the last run timestamp for the last caching run" do
click_link "Administration"
click_link "Rate Caches"
assert_select '#caching-run' do
assert_select 'p', :text => /#{format_time(@last_caching_run)}/
end
end
should "show the last run timestamp for the last cache clearing run" do
click_link "Administration"
click_link "Rate Caches"
assert_select '#cache-clearing-run' do
assert_select 'p', :text => /#{format_time(@last_cache_clearing_run)}/
end
end
should "have a button to force a caching run" do
click_link "Administration"
click_link "Rate Caches"
click_button "Load Missing Caches"
assert_response :success
appx_clear_time = Date.today.strftime("%m/%d/%Y")
assert_select '#caching-run' do
assert_select 'p', :text => /#{appx_clear_time}/
end
end
should "have a button to force a cache clearing run" do
click_link "Administration"
click_link "Rate Caches"
click_button "Clear and Load All Caches"
assert_response :success
appx_clear_time = Date.today.strftime("%m/%d/%Y")
assert_select '#cache-clearing-run' do
assert_select 'p', :text => /#{appx_clear_time}/
end
end
end
end

View File

@@ -0,0 +1,16 @@
require "#{File.dirname(__FILE__)}/../test_helper"
class RoutingTest < ActionController::IntegrationTest
context "routing rates" do
should_route :get, "/rates", :controller => "rates", :action => "index"
should_route :get, "/rates/new", :controller => "rates", :action => "new"
should_route :get, "/rates/1", :controller => "rates", :action => "show", :id => "1"
should_route :get, "/rates/1/edit", :controller => "rates", :action => "edit", :id => "1"
should_route :post, "/rates", :controller => "rates", :action => "create"
should_route :put, "/rates/1", :controller => "rates", :action => "update", :id => "1"
should_route :delete, "/rates/1", :controller => "rates", :action => "destroy", :id => "1"
end
end

43
test/test_helper.rb Normal file
View File

@@ -0,0 +1,43 @@
# Load the normal Rails helper
require File.expand_path(File.dirname(__FILE__) + '/../../../../test/test_helper')
# Ensure that we are using the temporary fixture path
Engines::Testing.set_fixture_path
require "webrat"
Webrat.configure do |config|
config.mode = :rails
end
module IntegrationTestHelper
def login_as(user="existing", password="existing")
visit "/login"
fill_in 'Login', :with => user
fill_in 'Password', :with => password
click_button 'login'
assert_response :success
assert User.current.logged?
end
def logout
visit '/logout'
assert_response :success
assert !User.current.logged?
end
def assert_forbidden
assert_response :forbidden
assert_template 'common/error'
end
def assert_requires_login
assert_response :success
assert_template 'account/login'
end
end
class ActionController::IntegrationTest
include IntegrationTestHelper
end

View File

@@ -0,0 +1,92 @@
require File.dirname(__FILE__) + '/../../test_helper'
class RateTimeEntryPatchTest < ActiveSupport::TestCase
def setup
@user = User.generate!
@project = Project.generate!
@date = Date.today.to_s
@time_entry = TimeEntry.new({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!})
@rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0)
end
should 'should return 0.0 if there are no rates for the user' do
@rate.destroy
assert_equal 0.0, @time_entry.cost
end
context 'should return the product of hours by' do
should 'the results of Rate.amount_for' do
assert_equal((200.0 * @time_entry.hours), @time_entry.cost)
end
should 'the assigned rate' do
rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 100.0)
@time_entry.rate = rate
assert_equal rate.amount * @time_entry.hours, @time_entry.cost
end
end
context "#cost" do
setup do
@time_entry.save!
end
context "without a cache" do
should "return the calculated cost" do
@time_entry.update_attribute(:cost, nil)
assert_equal 2000.0, @time_entry.cost
end
should "cache the cost to the field" do
@time_entry.update_attribute(:cost, nil)
@time_entry.cost
assert_equal 2000.0, @time_entry.read_attribute(:cost)
assert_equal 2000.0, @time_entry.reload.read_attribute(:cost)
end
end
context "with a cache" do
setup do
@time_entry.update_attribute(:cost, 2000.0)
@time_entry.reload
end
should "return the cached cost" do
assert_equal 2000.0, @time_entry.read_attribute(:cost)
assert_equal 2000.0, @time_entry.cost
end
end
end
context "before save" do
should "clear and recalculate the cache" do
assert_equal nil, @time_entry.read_attribute(:cost)
assert @time_entry.save
assert_equal 2000.0, @time_entry.read_attribute(:cost)
end
should "clear and recalculate the cache when the attribute is already set but stale" do
# Set the cost
assert @time_entry.save
assert_equal 2000.0, @time_entry.read_attribute(:cost)
@time_entry.reload
@time_entry.hours = 20
assert @time_entry.save
assert_equal 4000.0, @time_entry.read_attribute(:cost)
assert_equal 4000.0, @time_entry.reload.cost
end
end
end

View File

@@ -0,0 +1,37 @@
require File.dirname(__FILE__) + '/../../test_helper'
class UsersHelperWrapper
include UsersHelper
end
class RateUsersHelperPatchTest < ActiveSupport::TestCase
should 'should return 3 tabs' do
helper = UsersHelperWrapper.new
assert_equal 3, helper.user_settings_tabs.length
end
should 'should include a rate tab at the end' do
helper = UsersHelperWrapper.new
rate_tab = helper.user_settings_tabs[-1]
assert_not_nil rate_tab
end
context 'rate tab' do
setup do
helper = UsersHelperWrapper.new
@rate_tab = helper.user_settings_tabs[-1]
end
should 'should have the name of "rates"' do
assert_equal 'rates', @rate_tab[:name]
end
should 'should use the rates partial' do
assert_equal 'users/rates', @rate_tab[:partial]
end
should 'should use the i18n rates label' do
assert_equal :rate_label_rate_history, @rate_tab[:label]
end
end
end

View File

@@ -0,0 +1,26 @@
require File.dirname(__FILE__) + '/../../../../test_helper'
class RedmineRate::Hooks::PluginTimesheetViewTimesheetsReportHeaderTagsTest < ActionController::TestCase
include Redmine::Hook::Helper
def controller
@controller ||= ApplicationController.new
@controller.response ||= ActionController::TestResponse.new
@controller
end
def request
@request ||= ActionController::TestRequest.new
end
def hook(args={})
call_hook :plugin_timesheet_view_timesheets_report_header_tags, args
end
context "#plugin_timesheet_view_timesheets_report_header_tags" do
should "return a css string" do
@response.body = hook
assert_select "style", :text => /missing-rate/
end
end
end

View File

@@ -0,0 +1,26 @@
require File.dirname(__FILE__) + '/../../../../test_helper'
class RedmineRate::Hooks::PluginTimesheetViewsTimesheetGroupHeaderTest < ActionController::TestCase
include Redmine::Hook::Helper
def controller
@controller ||= ApplicationController.new
@controller.response ||= ActionController::TestResponse.new
@controller
end
def request
@request ||= ActionController::TestRequest.new
end
def hook(args={})
call_hook :plugin_timesheet_views_timesheet_group_header, args
end
context "#plugin_timesheet_views_timesheet_group_header" do
should "render the cost table header" do
@response.body = hook
assert_select "th", :text => "Cost"
end
end
end

View File

@@ -0,0 +1,47 @@
require File.dirname(__FILE__) + '/../../../../test_helper'
class RedmineRate::Hooks::PluginTimesheetViewsTimesheetTimeEntryTest < ActionController::TestCase
include Redmine::Hook::Helper
def controller
@controller ||= ApplicationController.new
@controller.response ||= ActionController::TestResponse.new
@controller
end
def request
@request ||= ActionController::TestRequest.new
end
def hook(args={})
call_hook :plugin_timesheet_views_timesheet_time_entry, args
end
context "#plugin_timesheet_views_timesheet_time_entry" do
context "for users with view rate permission" do
should "render a cost cell showing the cost for the time entry" do
User.current = User.generate!(:admin => true)
rate = Rate.generate!(:amount => 100)
time_entry = TimeEntry.generate!(:hours => 2, :rate => rate)
@response.body = hook(:time_entry => time_entry)
assert_select 'td', :text => "$200.00"
end
end
context "for users without view rate permission" do
should "render an empty cost cell" do
User.current = nil
rate = Rate.generate!(:amount => 100)
time_entry = TimeEntry.generate!(:hours => 2, :rate => rate)
@response.body = hook(:time_entry => time_entry)
assert_select 'td', :text => '&nbsp;'
end
end
end
end

View File

@@ -0,0 +1,48 @@
require File.dirname(__FILE__) + '/../../../../test_helper'
class RedmineRate::Hooks::PluginTimesheetViewsTimesheetTimeEntrySumTest < ActionController::TestCase
include Redmine::Hook::Helper
def controller
@controller ||= ApplicationController.new
@controller.response ||= ActionController::TestResponse.new
@controller
end
def request
@request ||= ActionController::TestRequest.new
end
def hook(args={})
call_hook :plugin_timesheet_views_timesheet_time_entry_sum, args
end
context "#plugin_timesheet_views_timesheet_time_entry_sum" do
context "for users with view rate permission" do
should "render a cost cell showing the total cost for the time entries" do
User.current = User.generate!(:admin => true)
rate = Rate.generate!(:amount => 100)
time_entry1 = TimeEntry.generate!(:hours => 2, :rate => rate)
time_entry2 = TimeEntry.generate!(:hours => 10, :rate => rate)
@response.body = hook(:time_entries => [time_entry1, time_entry2])
assert_select 'td', :text => "$1,200.00"
end
end
context "for users without view rate permission" do
should "render an empty cost cell" do
User.current = nil
rate = Rate.generate!(:amount => 100)
time_entry = TimeEntry.generate!(:hours => 2, :rate => rate)
@response.body = hook(:time_entries => [time_entry])
assert_select 'td', :text => '$0.00'
end
end
end
end

View File

@@ -0,0 +1,60 @@
require File.dirname(__FILE__) + '/../../../../test_helper'
class RedmineRate::Hooks::PluginTimesheetViewsTimesheetsTimeEntryRowClassTest < ActionController::TestCase
include Redmine::Hook::Helper
def controller
@controller ||= ApplicationController.new
@controller.response ||= ActionController::TestResponse.new
@controller
end
def request
@request ||= ActionController::TestRequest.new
end
def hook(args={})
call_hook :plugin_timesheet_views_timesheets_time_entry_row_class, args
end
context "#plugin_timesheet_views_timesheets_time_entry_row_class" do
context "for users with view rate permission" do
setup do
User.current = User.generate!(:admin => true)
end
should "render a missing rate css class if the time entry has no cost" do
time_entry = TimeEntry.generate!(:hours => 2, :rate => nil)
assert_equal "missing-rate", hook(:time_entry => time_entry)
end
should "render nothing if the time entry has a cost" do
rate = Rate.generate!(:amount => 100)
time_entry = TimeEntry.generate!(:hours => 2, :rate => rate)
assert_equal "", hook(:time_entry => time_entry)
end
end
context "for users without view rate permission" do
setup do
User.current = nil
end
should "render nothing if the time entry has no cost" do
time_entry = TimeEntry.generate!(:hours => 2, :rate => nil)
assert_equal "", hook(:time_entry => time_entry)
end
should "render nothing if the time entry has a cost" do
rate = Rate.generate!(:amount => 100)
time_entry = TimeEntry.generate!(:hours => 2, :rate => rate)
assert_equal "", hook(:time_entry => time_entry)
end
end
end
end

View File

@@ -0,0 +1,74 @@
require File.dirname(__FILE__) + '/../test_helper'
# Test cases for the main Rate#for API
class RateForTest < ActiveSupport::TestCase
context 'calculated for' do
setup do
@user = User.generate!
end
context 'a user with no Rates' do
should 'should return nil' do
assert_nil Rate.for(@user)
end
end
context 'a user with one default Rate' do
should 'should return the Rate if the Rate is effective today' do
rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.today})
assert_equal rate, Rate.for(@user)
end
should 'should return nil if the Rate is not effective yet' do
assert_nil Rate.for(@user)
end
should 'should return the same default Rate on all projects' do
project = Project.generate!
rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.today})
assert_equal rate, Rate.for(@user, project)
end
end
context 'a user with two default Rates' do
should 'should return the newest Rate before the todays date' do
rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.yesterday})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today})
assert_equal rate2, Rate.for(@user)
end
end
context 'a user with a default Rate and Rate on a project' do
should 'should return the project Rate if its effective today' do
project = Project.generate!
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.yesterday})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today})
assert_equal rate, Rate.for(@user, project)
end
should 'should return the default Rate if the project Rate isnt effective yet but the default Rate is' do
project = Project.generate!
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.tomorrow})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today})
assert_equal rate2, Rate.for(@user, project)
end
should 'should return nil if neither Rate is effective yet' do
project = Project.generate!
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.tomorrow})
rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.tomorrow})
assert_nil Rate.for(@user, project)
end
end
context 'a user with two Rates on a project' do
should 'should return the newest Rate before the todays date' do
project = Project.generate!
rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.yesterday})
rate2 = Rate.create!({ :user_id => @user.id, :project => project, :amount => 300.0, :date_in_effect => Date.today})
assert_equal rate2, Rate.for(@user, project)
end
end
end
end

333
test/unit/rate_test.rb Normal file
View File

@@ -0,0 +1,333 @@
require File.dirname(__FILE__) + '/../test_helper'
class RateTest < ActiveSupport::TestCase
def rate_valid_attributes
{
:user => User.generate!,
:project => Project.generate!,
:date_in_effect => Date.new(Date.today.year, 1, 1),
:amount => 100.50
}
end
should_belong_to :project
should_belong_to :user
should_have_many :time_entries
should_validate_presence_of :user_id
should_validate_presence_of :date_in_effect
should_validate_numericality_of :amount
context '#locked?' do
should 'should be true if a Time Entry is associated' do
rate = Rate.new
rate.time_entries << TimeEntry.generate!
assert rate.locked?
end
should 'should be false if no Time Entries are associated' do
rate = Rate.new
assert ! rate.locked?
end
end
context '#unlocked?' do
should 'should be false if a Time Entry is associated' do
rate = Rate.new
rate.time_entries << TimeEntry.generate!
assert ! rate.unlocked?
end
should 'should be true if no Time Entries are associated' do
rate = Rate.new
assert rate.unlocked?
end
end
context '#save' do
should 'should save if a Rate is unlocked' do
rate = Rate.new(rate_valid_attributes)
assert rate.save
end
should 'should not save if a Rate is locked' do
rate = Rate.new(rate_valid_attributes)
rate.time_entries << TimeEntry.generate!
assert !rate.save
end
end
context '#destroy' do
should 'should destroy the Rate if should is unlocked' do
rate = Rate.create(rate_valid_attributes)
assert_difference('Rate.count', -1) do
rate.destroy
end
end
should 'should not destroy the Rate if should is locked' do
rate = Rate.create(rate_valid_attributes)
rate.time_entries << TimeEntry.generate!
assert_difference('Rate.count', 0) do
rate.destroy
end
end
end
context "after save" do
should "recalculate all of the cached cost of all Time Entries for the user" do
@user = User.generate!
@project = Project.generate!
@date = Date.today.to_s
@past_date = 1.month.ago.strftime('%Y-%m-%d')
@rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0)
@time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!})
@time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @past_date, :hours => 20.0, :activity => TimeEntryActivity.generate!})
assert_equal 2000.00, @time_entry1.cost
assert_equal 0, @time_entry2.cost
@old_rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => 2.months.ago.strftime('%Y-%m-%d'), :amount => 10.0)
assert_equal 2000.00, TimeEntry.find(@time_entry1.id).cost
assert_equal 200.00, TimeEntry.find(@time_entry2.id).cost
end
end
context "after destroy" do
should "recalculate all of the cached cost of all Time Entries for the user" do
@user = User.generate!
@project = Project.generate!
@date = Date.today.to_s
@past_date = 1.month.ago.strftime('%Y-%m-%d')
@rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0)
@old_rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => 2.months.ago.strftime('%Y-%m-%d'), :amount => 10.0)
@time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!})
@time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @past_date, :hours => 20.0, :activity => TimeEntryActivity.generate!})
assert_equal 2000.00, @time_entry1.cost
assert_equal 2000.00, @time_entry1.read_attribute(:cost)
assert_equal 200.0, @time_entry2.cost
assert_equal 200.0, @time_entry2.read_attribute(:cost)
@old_rate.destroy
assert_equal 2000.0, TimeEntry.find(@time_entry1.id).cost
assert_equal 0, TimeEntry.find(@time_entry2.id).cost
end
end
context '#for' do
setup do
@user = User.generate!
@project = Project.generate!
@date = '2009-01-01'
@date = Date.new(Date.today.year, 1, 1).to_s
@default_rate = Rate.generate!(:amount => 100.10, :date_in_effect => @date, :project => nil, :user => @user)
@rate = Rate.generate!(:amount => 50.50, :date_in_effect => @date, :project => @project, :user => @user)
end
context 'parameters' do
should 'should be passed user' do
assert_raises ArgumentError do
Rate.for
end
end
should 'can be passed an optional project' do
assert_nothing_raised do
Rate.for(@user)
end
assert_nothing_raised do
Rate.for(@user, @project)
end
end
should 'can be passed an optional date string' do
assert_nothing_raised do
Rate.for(@user)
end
assert_nothing_raised do
Rate.for(@user, nil, @date)
end
end
end
context 'returns' do
should 'a Rate object when there is a rate' do
assert_equal @rate, Rate.for(@user, @project, @date)
end
should 'a nil when there is no rate' do
assert @rate.destroy
assert @default_rate.destroy
assert_equal nil, Rate.for(@user, @project, @date)
end
end
context 'with a user, project, and date' do
should 'should find the rate for a user on the project before the date' do
assert_equal @rate, Rate.for(@user, @project, @date)
end
should 'should return the most recent rate found' do
assert_equal @rate, Rate.for(@user, @project, @date)
end
should 'should check for a default rate if no rate is found' do
assert @rate.destroy
assert_equal @default_rate, Rate.for(@user, @project, @date)
end
should 'should return nil if no set or default rate is found' do
assert @rate.destroy
assert @default_rate.destroy
assert_equal nil, Rate.for(@user, @project, @date)
end
end
context 'with a user and project' do
should 'should find the rate for a user on the project before today' do
assert_equal @rate, Rate.for(@user, @project)
end
should 'should return the most recent rate found' do
assert_equal @rate, Rate.for(@user, @project)
end
should 'should return nil if no set or default rate is found' do
assert @rate.destroy
assert @default_rate.destroy
assert_equal nil, Rate.for(@user, @project)
end
end
context 'with a user' do
should 'should find the rate without a project for a user on the project before today' do
assert_equal @default_rate, Rate.for(@user)
end
should 'should return the most recent rate found' do
assert_equal @default_rate, Rate.for(@user)
end
should 'should return nil if no set or default rate is found' do
assert @rate.destroy
assert @default_rate.destroy
assert_equal nil, Rate.for(@user)
end
end
should 'with an invalid user should raise an InvalidParameterException' do
object = Object.new
assert_raises Rate::InvalidParameterException do
Rate.for(object)
end
end
should 'with an invalid project should raise an InvalidParameterException' do
object = Object.new
assert_raises Rate::InvalidParameterException do
Rate.for(@user, object)
end
end
should 'with an invalid object for date should raise an InvalidParameterException' do
object = Object.new
assert_raises Rate::InvalidParameterException do
Rate.for(@user, @project, object)
end
end
should 'with an invalid date string should raise an InvalidParameterException' do
assert_raises Rate::InvalidParameterException do
Rate.for(@user, @project, '2000-13-40')
end
end
end
context "#update_all_time_entries_with_missing_cost" do
setup do
@user = User.generate!
@project = Project.generate!
@date = Date.today.to_s
@rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0)
@time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!})
@time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 20.0, :activity => TimeEntryActivity.generate!})
end
should "update the caches of all Time Entries" do
TimeEntry.update_all('cost = null')
# Check that cost is NULL in the database, which skips the caching
assert_equal 2, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i
Rate.update_all_time_entries_with_missing_cost
assert_equal 0, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i
end
should "timestamp a successful run" do
assert_equal nil, Setting.plugin_redmine_rate['last_caching_run']
Rate.update_all_time_entries_with_missing_cost
assert Setting.plugin_redmine_rate['last_caching_run'], "Last run not timestamped"
assert Time.parse(Setting.plugin_redmine_rate['last_caching_run']), "Last run timestamp not parseable"
end
end
context "#update_all_time_entries_to_refresh_cache" do
setup do
@user = User.generate!
@project = Project.generate!
@date = Date.today.to_s
@rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0)
@time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!})
@time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 20.0, :activity => TimeEntryActivity.generate!})
end
should "update the caches of all Time Entries" do
assert_equal 0, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i
Rate.update_all_time_entries_to_refresh_cache
assert_equal 0, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i
end
should "timestamp a successful run" do
assert_equal nil, Setting.plugin_redmine_rate['last_cache_clearing_run']
Rate.update_all_time_entries_to_refresh_cache
assert Setting.plugin_redmine_rate['last_cache_clearing_run'], "Last run not timestamped"
assert Time.parse(Setting.plugin_redmine_rate['last_cache_clearing_run']), "Last run timestamp not parseable"
end
end
end