├── VERSION ├── Gemfile ├── routes.rb ├── rails └── init.rb ├── autotest └── discover.rb ├── .gitignore ├── app ├── views │ ├── rates │ │ ├── edit.html.erb │ │ ├── new.html.erb │ │ ├── index.html.erb │ │ ├── create_error.js.rjs │ │ ├── create.js.rjs │ │ ├── show.html.erb │ │ ├── _form.html.erb │ │ └── _list.html.erb │ ├── rate_caches │ │ └── index.html.erb │ └── users │ │ ├── _rates.html.erb │ │ └── _membership_rate.html.erb ├── controllers │ ├── rate_caches_controller.rb │ └── rates_controller.rb ├── helpers │ └── rate_helper.rb └── models │ └── rate.rb ├── assets └── images │ └── database_refresh.png ├── config ├── routes.rb └── locales │ ├── ru.yml │ ├── en.yml │ ├── de.yml │ └── fr.yml ├── CREDITS.txt ├── db └── migrate │ ├── 004_add_cost_to_time_entries.rb │ ├── 003_add_rate_id_to_time_entries.rb │ ├── 002_add_indexes_to_rates.rb │ └── 001_create_rates.rb ├── lang ├── en.yml ├── fr.yml └── de.yml ├── lib ├── redmine_rate │ └── hooks │ │ ├── plugin_timesheet_views_timesheet_group_header_hook.rb │ │ ├── view_layouts_base_html_head_hook.rb │ │ ├── plugin_timesheet_view_timesheets_report_header_tags_hook.rb │ │ ├── timesheet_hook_helper.rb │ │ ├── plugin_timesheet_views_timesheet_time_entry_hook.rb │ │ ├── plugin_timesheet_views_timesheet_time_entry_sum_hook.rb │ │ └── plugin_timesheet_views_timesheets_time_entry_row_class_hook.rb ├── tasks │ ├── cache.rake │ └── data.rake ├── rate_conversion.rb ├── rate_memberships_hook.rb ├── rate_users_helper_patch.rb ├── rate_time_entry_patch.rb └── rate_project_hook.rb ├── test ├── integration │ ├── routing_test.rb │ └── admin_panel_test.rb ├── unit │ ├── lib │ │ ├── redmine_rate │ │ │ └── hooks │ │ │ │ ├── plugin_timesheet_views_timesheet_group_header_hook_test.rb │ │ │ │ ├── plugin_timesheet_view_timesheets_report_header_tags_hook_test.rb │ │ │ │ ├── plugin_timesheet_views_timesheet_time_entry_hook_test.rb │ │ │ │ ├── plugin_timesheet_views_timesheet_time_entry_sum_hook_test.rb │ │ │ │ └── plugin_timesheet_views_timesheets_time_entry_row_class_hook_test.rb │ │ ├── rate_users_helper_patch_test.rb │ │ └── rate_time_entry_patch_test.rb │ ├── rate_for_test.rb │ └── rate_test.rb ├── test_helper.rb └── functional │ └── rates_controller_test.rb ├── COPYRIGHT.txt ├── Rakefile ├── init.rb ├── README.rdoc ├── redmine_rate.gemspec └── GPL.txt /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.1 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | gem 'lockfile' 2 | -------------------------------------------------------------------------------- /routes.rb: -------------------------------------------------------------------------------- 1 | resources :rates 2 | -------------------------------------------------------------------------------- /rails/init.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + "/../init" 2 | -------------------------------------------------------------------------------- /autotest/discover.rb: -------------------------------------------------------------------------------- 1 | Autotest.add_discovery do 2 | "rails" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.tar.gz 2 | *.zip 3 | .DS_Store 4 | coverage 5 | doc 6 | pkg 7 | rdoc 8 | -------------------------------------------------------------------------------- /app/views/rates/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Editing rate

2 | 3 | <%= render :partial => 'rates/form' %> 4 | -------------------------------------------------------------------------------- /app/views/rates/new.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:rate_label_new_rate) %>

2 | 3 | <%= render :partial => 'rates/form' %> 4 | -------------------------------------------------------------------------------- /assets/images/database_refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edavis10/redmine_rate/HEAD/assets/images/database_refresh.png -------------------------------------------------------------------------------- /app/views/rates/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

<%= l(:rate_label_rate_history) %>

3 | 4 | <%= render :partial => 'list' %> 5 |
6 | -------------------------------------------------------------------------------- /app/views/rates/create_error.js.rjs: -------------------------------------------------------------------------------- 1 | content = render_flash_messages 2 | page.select("div.flash").each do |value, index| 3 | page.hide value 4 | end 5 | page.insert_html :top, 'content', content 6 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | ActionController::Routing::Routes.draw do |map| 2 | map.resources :rates 3 | map.connect 'rate_caches', :conditions => {:method => :put}, :controller => 'rate_caches', :action => 'update' 4 | end 5 | -------------------------------------------------------------------------------- /CREDITS.txt: -------------------------------------------------------------------------------- 1 | Thanks go to the following people for patches and contributions: 2 | 3 | * Eric Davis of Little Stream Software - Project Maintainer 4 | * Peter Chester of Shane and Peter, Inc - Project sponsorship 5 | * Shane Pearlman of Shane and Peter, Inc - Project sponsorship 6 | -------------------------------------------------------------------------------- /app/views/rates/create.js.rjs: -------------------------------------------------------------------------------- 1 | element = 'rate_' + @rate.project_id.to_s + '_' + @rate.user_id.to_s 2 | page.replace_html element, "#{link_to number_to_currency(@rate.amount), { :controller => 'users', :action => 'edit', :id => @rate.user, :tab => 'rates'} }" 3 | -------------------------------------------------------------------------------- /db/migrate/004_add_cost_to_time_entries.rb: -------------------------------------------------------------------------------- 1 | class AddCostToTimeEntries < ActiveRecord::Migration 2 | def self.up 3 | add_column :time_entries, :cost, :decimal, :precision => 15, :scale => 2 4 | end 5 | 6 | def self.down 7 | remove_column :time_entries, :cost 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lang/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here 2 | rate_label_rates: Rates 3 | rate_label_rate: Rate 4 | rate_label_rate_history: Rate History 5 | rate_label_new_rate: New Rate 6 | rate_label_currency: $ 7 | rate_error_user_not_found: User not found 8 | rate_label_set_rate: Set Rate 9 | rate_label_default: Default Rate 10 | -------------------------------------------------------------------------------- /lang/fr.yml: -------------------------------------------------------------------------------- 1 | rate_label_rates: Tarifs 2 | rate_label_rate: Tarif 3 | rate_label_rate_history: Historique du tarif 4 | rate_label_new_rate: Nouveau tarif 5 | rate_label_currency: € 6 | rate_error_user_not_found: Utilisateur introuvable 7 | rate_label_set_rate: Parametrage du tarif 8 | rate_label_default: Tarif par défaut 9 | -------------------------------------------------------------------------------- /lang/de.yml: -------------------------------------------------------------------------------- 1 | # English strings go here 2 | rate_label_rates: Betraege 3 | rate_label_rate: Betrag 4 | rate_label_rate_history: Betragsverlauf 5 | rate_label_new_rate: Neuer Betrag 6 | rate_label_currency: EUR 7 | rate_error_user_not_found: Benutzer nicht gefunden 8 | rate_label_set_rate: Betrag setzen 9 | rate_label_default: Standard Betrag 10 | -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | rate_label_rates: Платежи 3 | rate_label_rate: Платеж 4 | rate_label_rate_history: История платежей 5 | rate_label_new_rate: Новый платежe 6 | rate_label_currency: $ 7 | rate_error_user_not_found: Пользователь не найден 8 | rate_label_set_rate: Установить платеж 9 | rate_label_default: Платеж по умолчанию 10 | -------------------------------------------------------------------------------- /db/migrate/003_add_rate_id_to_time_entries.rb: -------------------------------------------------------------------------------- 1 | class AddRateIdToTimeEntries < ActiveRecord::Migration 2 | def self.up 3 | add_column :time_entries, :rate_id, :integer 4 | add_index :time_entries, :rate_id 5 | end 6 | 7 | def self.down 8 | remove_index :time_entries, :rate_id 9 | remove_column :time_entries, :rate_id 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineRate 2 | module Hooks 3 | class PluginTimesheetViewsTimesheetGroupHeaderHook < Redmine::Hook::ViewListener 4 | def plugin_timesheet_views_timesheet_group_header(context={}) 5 | return content_tag(:th, l(:rate_cost), :width => '8%') 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/002_add_indexes_to_rates.rb: -------------------------------------------------------------------------------- 1 | class AddIndexesToRates < ActiveRecord::Migration 2 | def self.up 3 | add_index :rates, :user_id 4 | add_index :rates, :project_id 5 | add_index :rates, :date_in_effect 6 | end 7 | 8 | def self.down 9 | remove_index :rates, :user_id 10 | remove_index :rates, :project_id 11 | remove_index :rates, :date_in_effect 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/001_create_rates.rb: -------------------------------------------------------------------------------- 1 | class CreateRates < ActiveRecord::Migration 2 | def self.up 3 | create_table :rates do |t| 4 | t.column :amount, :decimal, :precision => 15, :scale => 2 5 | t.column :user_id, :integer 6 | t.column :project_id, :integer 7 | t.column :date_in_effect, :date 8 | end 9 | end 10 | 11 | def self.down 12 | drop_table :rates 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/rates/show.html.erb: -------------------------------------------------------------------------------- 1 |

2 | Amount: 3 | <%=h @rate.amount %> 4 |

5 | 6 |

7 | User: 8 | <%=h @rate.user_id %> 9 |

10 | 11 |

12 | Project: 13 | <%=h @rate.project_id %> 14 |

15 | 16 |

17 | Date in effect: 18 | <%=h @rate.date_in_effect %> 19 |

20 | 21 | 22 | <%= link_to 'Edit', edit_rate_path(@rate) %> | 23 | <%= link_to 'Back', rates_path %> 24 | -------------------------------------------------------------------------------- /lib/redmine_rate/hooks/view_layouts_base_html_head_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineRate 2 | module Hooks 3 | class ViewLayoutsBaseHtmlHeadHook < Redmine::Hook::ViewListener 4 | def view_layouts_base_html_head(context={}) 5 | return content_tag(:style, "#admin-menu a.rate-caches { background-image: url('#{image_path('database_refresh.png', :plugin => 'redmine_rate')}'); }", :type => 'text/css') 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/tasks/cache.rake: -------------------------------------------------------------------------------- 1 | namespace :rate_plugin do 2 | namespace :cache do 3 | desc "Update Time Entry cost caches for Time Entries without a cost" 4 | task :update_cost_cache => :environment do 5 | Rate.update_all_time_entries_with_missing_cost 6 | end 7 | 8 | desc "Clear and update all Time Entry cost caches" 9 | task :refresh_cost_cache => :environment do 10 | Rate.update_all_time_entries_to_refresh_cache 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineRate 2 | module Hooks 3 | class PluginTimesheetViewTimesheetsReportHeaderTagsHook < Redmine::Hook::ViewListener 4 | def plugin_timesheet_view_timesheets_report_header_tags(context={}) 5 | return content_tag(:style, 6 | 'tr.missing-rate td.cost { color: red; }', 7 | :type => 'text/css') 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/redmine_rate/hooks/timesheet_hook_helper.rb: -------------------------------------------------------------------------------- 1 | module TimesheetHookHelper 2 | # Returns the cost of a time entry, checking user permissions 3 | def cost_item(time_entry) 4 | if User.current.logged? && (User.current.allowed_to?(:view_rate, time_entry.project) || User.current.admin?) 5 | return time_entry.cost 6 | else 7 | return nil 8 | end 9 | end 10 | 11 | def td_cell(html) 12 | return content_tag(:td, html, :align => 'right', :class => 'cost') 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineRate 2 | module Hooks 3 | class PluginTimesheetViewsTimesheetTimeEntryHook < Redmine::Hook::ViewListener 4 | include TimesheetHookHelper 5 | 6 | def plugin_timesheet_views_timesheet_time_entry(context={}) 7 | cost = cost_item(context[:time_entry]) 8 | if cost 9 | td_cell(number_to_currency(cost)) 10 | else 11 | td_cell(' ') 12 | end 13 | 14 | end 15 | 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rate_conversion.rb: -------------------------------------------------------------------------------- 1 | class RateConversion 2 | RoundTo = 10 3 | 4 | MemberRateDataFile = "#{RAILS_ROOT}/tmp/budget_member_rate_data.yml" 5 | DeliverableDataFile = "#{RAILS_ROOT}/tmp/budget_deliverable_data.yml" 6 | VendorInvoiceDataFile = "#{RAILS_ROOT}/tmp/billing_vendor_invoice_data.yml" 7 | 8 | 9 | def self.compare_values(pre, post, message) 10 | pre = pre.to_f.round(RoundTo) 11 | post = post.to_f.round(RoundTo) 12 | 13 | puts "ERROR: #{message} (pre: #{pre}, post: #{post})" unless pre == post 14 | return pre == post 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineRate 2 | module Hooks 3 | class PluginTimesheetViewsTimesheetTimeEntrySumHook < Redmine::Hook::ViewListener 4 | include TimesheetHookHelper 5 | 6 | def plugin_timesheet_views_timesheet_time_entry_sum(context={}) 7 | time_entries = context[:time_entries] 8 | costs = time_entries.collect {|time_entry| cost_item(time_entry)}.compact.sum 9 | if costs >= 0 10 | return td_cell(number_to_currency(costs)) 11 | else 12 | return td_cell(' ') 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineRate 2 | module Hooks 3 | class PluginTimesheetViewsTimesheetsTimeEntryRowClassHook < Redmine::Hook::ViewListener 4 | include TimesheetHookHelper 5 | 6 | def plugin_timesheet_views_timesheets_time_entry_row_class(context={}) 7 | time_entry = context[:time_entry] 8 | return "" unless time_entry 9 | 10 | cost = cost_item(time_entry) 11 | return "" unless cost # Permissions 12 | 13 | if cost && cost <= 0 14 | return "missing-rate" 15 | else 16 | return "" 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | rate_label_rates: Rates 3 | rate_label_rate: Rate 4 | rate_label_rate_history: Rate History 5 | rate_label_new_rate: New Rate 6 | rate_label_currency: $ 7 | rate_error_user_not_found: User not found 8 | rate_label_set_rate: Set Rate 9 | rate_label_default: Default Rate 10 | rate_cost: Cost 11 | text_rate_caches_panel: "Rate Caches" 12 | text_no_cache_run: "no cache run found" 13 | text_last_caching_run: "Last caching run at: " 14 | text_last_cache_clearing_run: "Last cache clearing run at: " 15 | text_load_missing_caches: "Load Missing Caches" 16 | text_clear_and_load_all_caches: "Clear and Load All Caches" 17 | text_caches_loaded_successfully: "Caches loaded successfully" 18 | 19 | -------------------------------------------------------------------------------- /lib/rate_memberships_hook.rb: -------------------------------------------------------------------------------- 1 | class RateMembershipsHook < Redmine::Hook::ViewListener 2 | def view_users_memberships_table_header(context={}) 3 | return content_tag(:th, l(:rate_label_rate) + ' ' + l(:rate_label_currency)) 4 | end 5 | 6 | def view_users_memberships_table_row(context={}) 7 | return context[:controller].send(:render_to_string, { 8 | :partial => 'users/membership_rate', 9 | :locals => { 10 | :membership => context[:membership], 11 | :user => context[:user] 12 | }}) 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/rate_caches/index.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:text_rate_caches_panel) %>

2 | 3 |
4 |

5 | <%= l(:text_last_caching_run) %><%= h(@last_caching_run) %> 6 |

7 | 8 |

9 | <%= button_to(l(:text_load_missing_caches), {:controller => 'rate_caches', :action => 'update', :cache => 'missing'}, :method => :put) %> 10 |

11 |
12 | 13 |
14 |

15 | <%= l(:text_last_cache_clearing_run) %><%= h(@last_cache_clearing_run) %> 16 |

17 | 18 |

19 | <%= button_to(l(:text_clear_and_load_all_caches), {:controller => 'rate_caches', :action => 'update', :cache => 'reload'}, :method => :put) %> 20 |

21 |
22 | -------------------------------------------------------------------------------- /app/views/users/_rates.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:rate_label_new_rate) %>

2 | 3 | <% @rate = Rate.new(:user => @user ) %> 4 | <% @back_url = url_for(:controller => 'users', :action => 'edit', :id => @user, :tab => 'rates') %> 5 | <%= render :partial => 'rates/form' %> 6 | 7 |
8 |

<%= l(:rate_label_rate_history) %>

9 | <%# TODO: Refactor out of the view once there is a hook in the controller (Post 0.8.0). %> 10 | <%# Can't expect everyone to upgrade at the moment %> 11 | <% sort_init "#{Rate.table_name}.date_in_effect", "desc" %> 12 | <% sort_update RatesController::ValidSortOptions %> 13 | 14 | <% @rates = Rate.history_for_user(@user, "#{Rate.table_name}.date_in_effect desc") %> 15 | 16 | <%= render :partial => 'rates/list' %> 17 |
18 | -------------------------------------------------------------------------------- /config/locales/de.yml: -------------------------------------------------------------------------------- 1 | de: 2 | rate_label_rates: Betraege 3 | rate_label_rate: Betrag 4 | rate_label_rate_history: Betragsverlauf 5 | rate_label_new_rate: Neuer Betrag 6 | rate_label_currency: EUR 7 | rate_error_user_not_found: Benutzer nicht gefunden 8 | rate_label_set_rate: Betrag setzen 9 | rate_label_default: Standard Betrag 10 | rate_cost: Kosten 11 | text_rate_caches_panel: "Betrags Cache" 12 | text_no_cache_run: "kein Cache gefunden" 13 | text_last_caching_run: "Zuletzt Cache erstellt: " 14 | text_last_cache_clearing_run: "Last cache clearing run at: " 15 | text_load_missing_caches: "Load Missing Caches" 16 | text_clear_and_load_all_caches: "Clear and Load All Caches" 17 | text_caches_loaded_successfully: "Caches loaded successfully" 18 | 19 | -------------------------------------------------------------------------------- /test/integration/routing_test.rb: -------------------------------------------------------------------------------- 1 | require "#{File.dirname(__FILE__)}/../test_helper" 2 | 3 | class RoutingTest < ActionController::IntegrationTest 4 | context "routing rates" do 5 | should_route :get, "/rates", :controller => "rates", :action => "index" 6 | should_route :get, "/rates/new", :controller => "rates", :action => "new" 7 | should_route :get, "/rates/1", :controller => "rates", :action => "show", :id => "1" 8 | should_route :get, "/rates/1/edit", :controller => "rates", :action => "edit", :id => "1" 9 | 10 | should_route :post, "/rates", :controller => "rates", :action => "create" 11 | 12 | should_route :put, "/rates/1", :controller => "rates", :action => "update", :id => "1" 13 | 14 | should_route :delete, "/rates/1", :controller => "rates", :action => "destroy", :id => "1" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | rate_label_rates: Tarifs 3 | rate_label_rate: Tarif 4 | rate_label_rate_history: Historique du tarif 5 | rate_label_new_rate: Nouveau tarif 6 | rate_label_currency: € 7 | rate_error_user_not_found: Utilisateur introuvable 8 | rate_label_set_rate: Parametrage du tarif 9 | rate_label_default: Tarif par défaut 10 | rate_error_user_not_found: Utilisateur non trouvé 11 | rate_label_set_rate: Définir le tarif 12 | rate_cost: Coût 13 | text_rate_caches_panel: "Caches des tarifs" 14 | text_no_cache_run: "pas de cache actif trouvé" 15 | text_last_caching_run: "Dernier cache actif à : " 16 | text_last_cache_clearing_run: "Dernier cache purgé à : " 17 | text_load_missing_caches: "Charger les caches manquants" 18 | text_clear_and_load_all_caches: "Recharger tous les caches" 19 | text_caches_loaded_successfully: "Caches chargés avec succés" 20 | 21 | -------------------------------------------------------------------------------- /test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../../../test_helper' 2 | 3 | class RedmineRate::Hooks::PluginTimesheetViewsTimesheetGroupHeaderTest < ActionController::TestCase 4 | include Redmine::Hook::Helper 5 | 6 | def controller 7 | @controller ||= ApplicationController.new 8 | @controller.response ||= ActionController::TestResponse.new 9 | @controller 10 | end 11 | 12 | def request 13 | @request ||= ActionController::TestRequest.new 14 | end 15 | 16 | def hook(args={}) 17 | call_hook :plugin_timesheet_views_timesheet_group_header, args 18 | end 19 | 20 | context "#plugin_timesheet_views_timesheet_group_header" do 21 | should "render the cost table header" do 22 | @response.body = hook 23 | assert_select "th", :text => "Cost" 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/unit/lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../../../test_helper' 2 | 3 | class RedmineRate::Hooks::PluginTimesheetViewTimesheetsReportHeaderTagsTest < ActionController::TestCase 4 | include Redmine::Hook::Helper 5 | 6 | def controller 7 | @controller ||= ApplicationController.new 8 | @controller.response ||= ActionController::TestResponse.new 9 | @controller 10 | end 11 | 12 | def request 13 | @request ||= ActionController::TestRequest.new 14 | end 15 | 16 | def hook(args={}) 17 | call_hook :plugin_timesheet_view_timesheets_report_header_tags, args 18 | end 19 | 20 | context "#plugin_timesheet_view_timesheets_report_header_tags" do 21 | should "return a css string" do 22 | @response.body = hook 23 | assert_select "style", :text => /missing-rate/ 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /COPYRIGHT.txt: -------------------------------------------------------------------------------- 1 | The Rate plugin provides an API for Redmine that can be used to find the 2 | rate for a Member of a Project at a specific date. 3 | 4 | Copyright (C) 2009 Eric Davis, Little Stream Software 5 | 6 | This program is free software; you can redistribute it and/or 7 | modify it under the terms of the GNU General Public License 8 | as published by the Free Software Foundation; either version 2 9 | of the License, or (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program; if not, write to the Free Software 18 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 | -------------------------------------------------------------------------------- /app/views/users/_membership_rate.html.erb: -------------------------------------------------------------------------------- 1 | 2 | <% rate = Rate.for(user, membership.project) %> 3 | 4 | <% if rate.nil? || rate.default? %> 5 | <% if rate && rate.default? %> 6 | <%= number_to_currency(rate.amount) %> 7 | <% end %> 8 | 9 | <% remote_form_for(:rate, :url => rates_path(:format => 'js')) do |f| %> 10 | 11 | <%= f.text_field :amount %> 12 | <%= f.hidden_field :date_in_effect, :value => Date.today.to_s, :id => "" %> 13 | <%= f.hidden_field :project_id, :value => membership.project.id %> 14 | <%= f.hidden_field :user_id, :value => user.id %> 15 | <%= hidden_field_tag "back_url", url_for(:controller => 'users', :action => 'edit', :id => user, :tab => 'memberships') %> 16 | 17 | <%= submit_tag(l(:rate_label_set_rate), :class => "small") %> 18 | <% end %> 19 | <% else %> 20 | <%= link_to number_to_currency(rate.amount), { :action => 'edit', :id => user, :tab => 'rates'} %> 21 | <% end %> 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the normal Rails helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../../test/test_helper') 3 | 4 | # Ensure that we are using the temporary fixture path 5 | Engines::Testing.set_fixture_path 6 | 7 | require "webrat" 8 | 9 | Webrat.configure do |config| 10 | config.mode = :rails 11 | end 12 | 13 | module IntegrationTestHelper 14 | def login_as(user="existing", password="existing") 15 | visit "/login" 16 | fill_in 'Login', :with => user 17 | fill_in 'Password', :with => password 18 | click_button 'login' 19 | assert_response :success 20 | assert User.current.logged? 21 | end 22 | 23 | def logout 24 | visit '/logout' 25 | assert_response :success 26 | assert !User.current.logged? 27 | end 28 | 29 | def assert_forbidden 30 | assert_response :forbidden 31 | assert_template 'common/error' 32 | end 33 | 34 | def assert_requires_login 35 | assert_response :success 36 | assert_template 'account/login' 37 | end 38 | 39 | end 40 | 41 | class ActionController::IntegrationTest 42 | include IntegrationTestHelper 43 | end 44 | -------------------------------------------------------------------------------- /test/unit/lib/rate_users_helper_patch_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../test_helper' 2 | 3 | class UsersHelperWrapper 4 | include UsersHelper 5 | end 6 | 7 | class RateUsersHelperPatchTest < ActiveSupport::TestCase 8 | should 'should return 3 tabs' do 9 | helper = UsersHelperWrapper.new 10 | assert_equal 3, helper.user_settings_tabs.length 11 | end 12 | 13 | should 'should include a rate tab at the end' do 14 | helper = UsersHelperWrapper.new 15 | rate_tab = helper.user_settings_tabs[-1] 16 | assert_not_nil rate_tab 17 | end 18 | 19 | context 'rate tab' do 20 | setup do 21 | helper = UsersHelperWrapper.new 22 | @rate_tab = helper.user_settings_tabs[-1] 23 | end 24 | 25 | should 'should have the name of "rates"' do 26 | assert_equal 'rates', @rate_tab[:name] 27 | end 28 | 29 | should 'should use the rates partial' do 30 | assert_equal 'users/rates', @rate_tab[:partial] 31 | end 32 | 33 | should 'should use the i18n rates label' do 34 | assert_equal :rate_label_rate_history, @rate_tab[:label] 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/views/rates/_form.html.erb: -------------------------------------------------------------------------------- 1 | <% form_for(@rate) do |f| %> 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 21 | 24 | 33 | 34 | 35 |
<%= l(:label_date) %><%= l(:label_project) %><%= l(:rate_label_rate) %>
12 | <%= f.text_field "date_in_effect", :size => 10 %><%= calendar_for('rate_date_in_effect') %> 13 | 15 | <%= # TODO: move to controller once a hook is in place for the Admin panel 16 | projects = Project.find(:all, :conditions => { :status => Project::STATUS_ACTIVE}) 17 | 18 | select_tag("rate[project_id]", project_options_for_select_with_selected(projects, @rate.project)) 19 | %> 20 | 22 | <%= l(:rate_label_currency) %> <%= f.text_field "amount", :size => 10 %> 23 | 25 | <%= f.hidden_field "user_id" %> 26 | <%= hidden_field_tag "back_url", @back_url %> 27 | <% if @rate.unlocked? %> 28 | <%= submit_tag((@rate.new_record? ? l(:button_add) : l(:button_update)), :class => 'button-small')-%> 29 | <% else %> 30 | <%= image_tag('locked.png') %> 31 | <% end %> 32 |
36 | <% end %> 37 | -------------------------------------------------------------------------------- /app/controllers/rate_caches_controller.rb: -------------------------------------------------------------------------------- 1 | class RateCachesController < ApplicationController 2 | unloadable 3 | 4 | layout 'admin' 5 | 6 | before_filter :require_admin 7 | 8 | def index 9 | @last_caching_run = if Setting.plugin_redmine_rate['last_caching_run'].present? && Setting.plugin_redmine_rate['last_caching_run'].to_date 10 | format_time(Setting.plugin_redmine_rate['last_caching_run']) 11 | else 12 | l(:text_no_cache_run) 13 | end 14 | 15 | @last_cache_clearing_run = if Setting.plugin_redmine_rate['last_cache_clearing_run'].present? && Setting.plugin_redmine_rate['last_cache_clearing_run'].to_date 16 | format_time(Setting.plugin_redmine_rate['last_cache_clearing_run']) 17 | else 18 | l(:text_no_cache_run) 19 | end 20 | 21 | end 22 | 23 | def update 24 | if params[:cache].present? 25 | if params[:cache].match(/missing/) 26 | Rate.update_all_time_entries_with_missing_cost(:force => true) 27 | flash[:notice] = l(:text_caches_loaded_successfully) 28 | elsif params[:cache].match(/reload/) 29 | Rate.update_all_time_entries_to_refresh_cache(:force => true) 30 | flash[:notice] = l(:text_caches_loaded_successfully) 31 | end 32 | end 33 | redirect_to :action => 'index' 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'redmine_plugin_support' 3 | 4 | Dir[File.expand_path(File.dirname(__FILE__)) + "/lib/tasks/**/*.rake"].sort.each { |ext| load ext } 5 | 6 | RedminePluginSupport::Base.setup do |plugin| 7 | plugin.project_name = 'redmine_rate' 8 | plugin.default_task = [:test] 9 | plugin.tasks = [:db, :doc, :release, :clean, :test, :stats, :metrics] 10 | plugin.redmine_root = File.expand_path(File.dirname(__FILE__) + '/../../../') 11 | end 12 | 13 | begin 14 | require 'jeweler' 15 | Jeweler::Tasks.new do |s| 16 | s.name = "redmine_rate" 17 | s.summary = "A Rate plugin for Redmine to store billing rate for user." 18 | s.email = "edavis@littlestreamsoftware.com" 19 | s.homepage = "https://projects.littlestreamsoftware.com/projects/redmine-rate" 20 | 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." 21 | s.authors = ["Eric Davis"] 22 | s.files = FileList[ 23 | "[A-Z]*", 24 | "init.rb", 25 | "rails/init.rb", 26 | "{bin,generators,lib,test,app,assets,config,lang}/**/*", 27 | 'lib/jeweler/templates/.gitignore' 28 | ] 29 | end 30 | Jeweler::GemcutterTasks.new 31 | rescue LoadError 32 | puts "Jeweler, or one of its dependencies, is not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com" 33 | end 34 | -------------------------------------------------------------------------------- /lib/rate_users_helper_patch.rb: -------------------------------------------------------------------------------- 1 | module RateUsersHelperPatch 2 | def self.included(base) # :nodoc: 3 | base.send(:include, InstanceMethods) 4 | base.class_eval do 5 | alias_method_chain :user_settings_tabs, :rate_tab 6 | end 7 | end 8 | 9 | module InstanceMethods 10 | # Adds a rates tab to the user administration page 11 | def user_settings_tabs_with_rate_tab 12 | tabs = user_settings_tabs_without_rate_tab 13 | tabs << { :name => 'rates', :partial => 'users/rates', :label => :rate_label_rate_history} 14 | return tabs 15 | end 16 | 17 | # Similar to +project_options_for_select+ but allows selecting the active value 18 | def project_options_for_select_with_selected(projects, selected = nil) 19 | options = content_tag('option', "--- #{l(:rate_label_default)} ---", :value => '') 20 | projects_by_root = projects.group_by(&:root) 21 | projects_by_root.keys.sort.each do |root| 22 | root_selected = (root == selected) ? 'selected' : nil 23 | 24 | options << content_tag('option', h(root.name), :value => root.id, :disabled => (!projects.include?(root)), :selected => root_selected) 25 | projects_by_root[root].sort.each do |project| 26 | next if project == root 27 | child_selected = (project == selected) ? 'selected' : nil 28 | 29 | options << content_tag('option', '» ' + h(project.name), :value => project.id, :selected => child_selected) 30 | end 31 | end 32 | options 33 | end 34 | 35 | end 36 | end 37 | 38 | -------------------------------------------------------------------------------- /test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../../../test_helper' 2 | 3 | class RedmineRate::Hooks::PluginTimesheetViewsTimesheetTimeEntryTest < ActionController::TestCase 4 | include Redmine::Hook::Helper 5 | 6 | def controller 7 | @controller ||= ApplicationController.new 8 | @controller.response ||= ActionController::TestResponse.new 9 | @controller 10 | end 11 | 12 | def request 13 | @request ||= ActionController::TestRequest.new 14 | end 15 | 16 | def hook(args={}) 17 | call_hook :plugin_timesheet_views_timesheet_time_entry, args 18 | end 19 | 20 | context "#plugin_timesheet_views_timesheet_time_entry" do 21 | context "for users with view rate permission" do 22 | should "render a cost cell showing the cost for the time entry" do 23 | User.current = User.generate!(:admin => true) 24 | rate = Rate.generate!(:amount => 100) 25 | time_entry = TimeEntry.generate!(:hours => 2, :rate => rate) 26 | 27 | @response.body = hook(:time_entry => time_entry) 28 | 29 | assert_select 'td', :text => "$200.00" 30 | 31 | end 32 | end 33 | 34 | context "for users without view rate permission" do 35 | should "render an empty cost cell" do 36 | User.current = nil 37 | rate = Rate.generate!(:amount => 100) 38 | time_entry = TimeEntry.generate!(:hours => 2, :rate => rate) 39 | 40 | @response.body = hook(:time_entry => time_entry) 41 | 42 | assert_select 'td', :text => ' ' 43 | 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../../../test_helper' 2 | 3 | class RedmineRate::Hooks::PluginTimesheetViewsTimesheetTimeEntrySumTest < ActionController::TestCase 4 | include Redmine::Hook::Helper 5 | 6 | def controller 7 | @controller ||= ApplicationController.new 8 | @controller.response ||= ActionController::TestResponse.new 9 | @controller 10 | end 11 | 12 | def request 13 | @request ||= ActionController::TestRequest.new 14 | end 15 | 16 | def hook(args={}) 17 | call_hook :plugin_timesheet_views_timesheet_time_entry_sum, args 18 | end 19 | 20 | context "#plugin_timesheet_views_timesheet_time_entry_sum" do 21 | context "for users with view rate permission" do 22 | should "render a cost cell showing the total cost for the time entries" do 23 | User.current = User.generate!(:admin => true) 24 | rate = Rate.generate!(:amount => 100) 25 | time_entry1 = TimeEntry.generate!(:hours => 2, :rate => rate) 26 | time_entry2 = TimeEntry.generate!(:hours => 10, :rate => rate) 27 | 28 | @response.body = hook(:time_entries => [time_entry1, time_entry2]) 29 | 30 | assert_select 'td', :text => "$1,200.00" 31 | 32 | end 33 | end 34 | 35 | context "for users without view rate permission" do 36 | should "render an empty cost cell" do 37 | User.current = nil 38 | rate = Rate.generate!(:amount => 100) 39 | time_entry = TimeEntry.generate!(:hours => 2, :rate => rate) 40 | 41 | @response.body = hook(:time_entries => [time_entry]) 42 | 43 | assert_select 'td', :text => '$0.00' 44 | 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/views/rates/_list.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= rate_sort_header_tag("date_in_effect", 4 | :caption => l(:label_date), 5 | :default_order => 'desc', 6 | :style => "width: 15%", 7 | :method => :get, 8 | :update => "rate_history", 9 | :user_id => @user.id) %> 10 | <%= rate_sort_header_tag("project_id", 11 | :caption => l(:label_project), 12 | :default_order => 'asc', 13 | :method => :get, 14 | :update => "rate_history", 15 | :user_id => @user.id) %> 16 | 17 | 18 | 19 | 20 | <% @rates.each do |rate| %> 21 | 22 | 23 | 30 | 31 | 39 | 40 | 41 | <% end; reset_cycle %> 42 |
<%= l(:rate_label_rate) %>
<%= h format_date(rate.date_in_effect) %> 24 | <% if rate.project %> 25 | <%= link_to(h(rate.project), :controller => 'projects', :action => 'show', :id => rate.project) %> 26 | <% else %> 27 | <%= l(:rate_label_default) %> 28 | <% end %> 29 | <%= h rate.amount %> 32 | <% if rate.unlocked? %> 33 | <%= link_to image_tag('edit.png'), edit_rate_path(rate, :back_url => @back_url) %> 34 | <%= link_to image_tag('delete.png'), rate_path(rate, :back_url => @back_url), :method => :delete, :confirm => l(:text_are_you_sure) %> 35 | <% else %> 36 | <%= image_tag('locked.png') %> 37 | <% end %> 38 |
43 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | 3 | # Patches to the Redmine core 4 | require 'dispatcher' 5 | 6 | Dispatcher.to_prepare :redmine_rate do 7 | gem 'lockfile' 8 | 9 | require_dependency 'application_controller' 10 | ApplicationController.send(:include, RateHelper) 11 | ApplicationController.send(:helper, :rate) 12 | 13 | require_dependency 'time_entry' 14 | TimeEntry.send(:include, RateTimeEntryPatch) 15 | 16 | require_dependency 'users_helper' 17 | UsersHelper.send(:include, RateUsersHelperPatch) unless UsersHelper.included_modules.include?(RateUsersHelperPatch) 18 | end 19 | 20 | # Hooks 21 | require 'rate_project_hook' 22 | require 'rate_memberships_hook' 23 | 24 | Redmine::Plugin.register :redmine_rate do 25 | name 'Rate' 26 | author 'Eric Davis' 27 | url 'https://projects.littlestreamsoftware.com/projects/redmine-rate' 28 | author_url 'http://www.littlestreamsoftware.com' 29 | 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." 30 | version '0.2.1' 31 | 32 | requires_redmine :version_or_higher => '1.0.0' 33 | 34 | # These settings are set automatically when caching 35 | settings(:default => { 36 | 'last_caching_run' => nil 37 | }) 38 | 39 | permission :view_rate, { } 40 | 41 | menu :admin_menu, :rate_caches, { :controller => 'rate_caches', :action => 'index'}, :caption => :text_rate_caches_panel 42 | end 43 | 44 | require 'redmine_rate/hooks/timesheet_hook_helper' 45 | require 'redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook' 46 | require 'redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook' 47 | require 'redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook' 48 | require 'redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook' 49 | require 'redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook' 50 | require 'redmine_rate/hooks/view_layouts_base_html_head_hook' 51 | -------------------------------------------------------------------------------- /test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../../../test_helper' 2 | 3 | class RedmineRate::Hooks::PluginTimesheetViewsTimesheetsTimeEntryRowClassTest < ActionController::TestCase 4 | include Redmine::Hook::Helper 5 | 6 | def controller 7 | @controller ||= ApplicationController.new 8 | @controller.response ||= ActionController::TestResponse.new 9 | @controller 10 | end 11 | 12 | def request 13 | @request ||= ActionController::TestRequest.new 14 | end 15 | 16 | def hook(args={}) 17 | call_hook :plugin_timesheet_views_timesheets_time_entry_row_class, args 18 | end 19 | 20 | context "#plugin_timesheet_views_timesheets_time_entry_row_class" do 21 | context "for users with view rate permission" do 22 | setup do 23 | User.current = User.generate!(:admin => true) 24 | end 25 | 26 | should "render a missing rate css class if the time entry has no cost" do 27 | time_entry = TimeEntry.generate!(:hours => 2, :rate => nil) 28 | 29 | assert_equal "missing-rate", hook(:time_entry => time_entry) 30 | end 31 | 32 | should "render nothing if the time entry has a cost" do 33 | rate = Rate.generate!(:amount => 100) 34 | time_entry = TimeEntry.generate!(:hours => 2, :rate => rate) 35 | 36 | assert_equal "", hook(:time_entry => time_entry) 37 | end 38 | end 39 | 40 | context "for users without view rate permission" do 41 | setup do 42 | User.current = nil 43 | end 44 | 45 | should "render nothing if the time entry has no cost" do 46 | time_entry = TimeEntry.generate!(:hours => 2, :rate => nil) 47 | 48 | assert_equal "", hook(:time_entry => time_entry) 49 | end 50 | 51 | should "render nothing if the time entry has a cost" do 52 | rate = Rate.generate!(:amount => 100) 53 | time_entry = TimeEntry.generate!(:hours => 2, :rate => rate) 54 | 55 | assert_equal "", hook(:time_entry => time_entry) 56 | end 57 | end 58 | 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/rate_time_entry_patch.rb: -------------------------------------------------------------------------------- 1 | module RateTimeEntryPatch 2 | def self.included(base) # :nodoc: 3 | base.extend(ClassMethods) 4 | 5 | base.send(:include, InstanceMethods) 6 | 7 | # Same as typing in the class 8 | base.class_eval do 9 | unloadable # Send unloadable so it will not be unloaded in development 10 | belongs_to :rate 11 | 12 | before_save :recalculate_cost 13 | 14 | end 15 | 16 | end 17 | 18 | module ClassMethods 19 | # Updated the cached cost of all TimeEntries for user and project 20 | def update_cost_cache(user, project=nil) 21 | c = ARCondition.new 22 | c << ["#{TimeEntry.table_name}.user_id = ?", user] 23 | c << ["#{TimeEntry.table_name}.project_id = ?", project] if project 24 | 25 | TimeEntry.all(:conditions => c.conditions).each do |time_entry| 26 | time_entry.save_cached_cost 27 | end 28 | end 29 | end 30 | 31 | module InstanceMethods 32 | # Returns the current cost of the TimeEntry based on it's rate and hours 33 | # 34 | # Is a read-through cache method 35 | def cost(options={}) 36 | store_to_db = options[:store] || false 37 | 38 | unless read_attribute(:cost) 39 | if self.rate.nil? 40 | amount = Rate.amount_for(self.user, self.project, self.spent_on.to_s) 41 | else 42 | amount = rate.amount 43 | end 44 | 45 | if amount.nil? 46 | write_attribute(:cost, 0.0) 47 | else 48 | if store_to_db 49 | # Write the cost to the database for caching 50 | update_attribute(:cost, amount.to_f * hours.to_f) 51 | else 52 | # Cache to object only 53 | write_attribute(:cost, amount.to_f * hours.to_f) 54 | end 55 | end 56 | end 57 | 58 | read_attribute(:cost) 59 | end 60 | 61 | def clear_cost_cache 62 | write_attribute(:cost, nil) 63 | end 64 | 65 | def save_cached_cost 66 | clear_cost_cache 67 | update_attribute(:cost, cost) 68 | end 69 | 70 | def recalculate_cost 71 | clear_cost_cache 72 | cost(:store => false) 73 | true # for callback 74 | end 75 | 76 | end 77 | end 78 | 79 | 80 | -------------------------------------------------------------------------------- /test/integration/admin_panel_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AdminPanelTest < ActionController::IntegrationTest 4 | include Redmine::I18n 5 | 6 | def setup 7 | @last_caching_run = 4.days.ago.to_s 8 | @last_cache_clearing_run = 7.days.ago.to_s 9 | 10 | Setting.plugin_redmine_rate = { 11 | 'last_caching_run' => @last_caching_run, 12 | 'last_cache_clearing_run' => @last_cache_clearing_run 13 | } 14 | 15 | @user = User.generate!(:admin => true, :password => 'rates', :password_confirmation => 'rates') 16 | 17 | login_as(@user.login, 'rates') 18 | end 19 | 20 | context "Rate Caches admin panel" do 21 | should "be listed in the main Admin section" do 22 | click_link "Administration" 23 | assert_response :success 24 | 25 | assert_select "#admin-menu" do 26 | assert_select "a.rate-caches" 27 | end 28 | 29 | end 30 | 31 | should "show the last run timestamp for the last caching run" do 32 | click_link "Administration" 33 | click_link "Rate Caches" 34 | 35 | assert_select '#caching-run' do 36 | assert_select 'p', :text => /#{format_time(@last_caching_run)}/ 37 | end 38 | 39 | end 40 | 41 | should "show the last run timestamp for the last cache clearing run" do 42 | click_link "Administration" 43 | click_link "Rate Caches" 44 | 45 | assert_select '#cache-clearing-run' do 46 | assert_select 'p', :text => /#{format_time(@last_cache_clearing_run)}/ 47 | end 48 | 49 | end 50 | 51 | should "have a button to force a caching run" do 52 | click_link "Administration" 53 | click_link "Rate Caches" 54 | click_button "Load Missing Caches" 55 | 56 | assert_response :success 57 | 58 | appx_clear_time = Date.today.strftime("%m/%d/%Y") 59 | 60 | assert_select '#caching-run' do 61 | assert_select 'p', :text => /#{appx_clear_time}/ 62 | end 63 | 64 | end 65 | 66 | should "have a button to force a cache clearing run" do 67 | click_link "Administration" 68 | click_link "Rate Caches" 69 | click_button "Clear and Load All Caches" 70 | 71 | assert_response :success 72 | 73 | appx_clear_time = Date.today.strftime("%m/%d/%Y") 74 | 75 | assert_select '#cache-clearing-run' do 76 | assert_select 'p', :text => /#{appx_clear_time}/ 77 | end 78 | 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/unit/lib/rate_time_entry_patch_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../../test_helper' 2 | 3 | class RateTimeEntryPatchTest < ActiveSupport::TestCase 4 | def setup 5 | @user = User.generate! 6 | @project = Project.generate! 7 | @date = Date.today.to_s 8 | @time_entry = TimeEntry.new({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!}) 9 | @rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0) 10 | end 11 | 12 | should 'should return 0.0 if there are no rates for the user' do 13 | @rate.destroy 14 | assert_equal 0.0, @time_entry.cost 15 | end 16 | 17 | context 'should return the product of hours by' do 18 | should 'the results of Rate.amount_for' do 19 | assert_equal((200.0 * @time_entry.hours), @time_entry.cost) 20 | end 21 | 22 | should 'the assigned rate' do 23 | rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 100.0) 24 | @time_entry.rate = rate 25 | assert_equal rate.amount * @time_entry.hours, @time_entry.cost 26 | end 27 | 28 | end 29 | 30 | context "#cost" do 31 | setup do 32 | @time_entry.save! 33 | end 34 | 35 | context "without a cache" do 36 | should "return the calculated cost" do 37 | @time_entry.update_attribute(:cost, nil) 38 | assert_equal 2000.0, @time_entry.cost 39 | end 40 | 41 | should "cache the cost to the field" do 42 | @time_entry.update_attribute(:cost, nil) 43 | @time_entry.cost 44 | 45 | assert_equal 2000.0, @time_entry.read_attribute(:cost) 46 | assert_equal 2000.0, @time_entry.reload.read_attribute(:cost) 47 | end 48 | 49 | end 50 | 51 | context "with a cache" do 52 | setup do 53 | @time_entry.update_attribute(:cost, 2000.0) 54 | @time_entry.reload 55 | end 56 | 57 | should "return the cached cost" do 58 | assert_equal 2000.0, @time_entry.read_attribute(:cost) 59 | assert_equal 2000.0, @time_entry.cost 60 | end 61 | 62 | end 63 | 64 | end 65 | 66 | context "before save" do 67 | should "clear and recalculate the cache" do 68 | assert_equal nil, @time_entry.read_attribute(:cost) 69 | 70 | assert @time_entry.save 71 | 72 | assert_equal 2000.0, @time_entry.read_attribute(:cost) 73 | end 74 | 75 | should "clear and recalculate the cache when the attribute is already set but stale" do 76 | # Set the cost 77 | assert @time_entry.save 78 | assert_equal 2000.0, @time_entry.read_attribute(:cost) 79 | 80 | @time_entry.reload 81 | @time_entry.hours = 20 82 | assert @time_entry.save 83 | 84 | assert_equal 4000.0, @time_entry.read_attribute(:cost) 85 | assert_equal 4000.0, @time_entry.reload.cost 86 | end 87 | 88 | 89 | end 90 | 91 | 92 | end 93 | -------------------------------------------------------------------------------- /test/unit/rate_for_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | # Test cases for the main Rate#for API 4 | class RateForTest < ActiveSupport::TestCase 5 | context 'calculated for' do 6 | setup do 7 | @user = User.generate! 8 | end 9 | 10 | context 'a user with no Rates' do 11 | should 'should return nil' do 12 | assert_nil Rate.for(@user) 13 | end 14 | end 15 | 16 | context 'a user with one default Rate' do 17 | should 'should return the Rate if the Rate is effective today' do 18 | rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.today}) 19 | assert_equal rate, Rate.for(@user) 20 | end 21 | 22 | should 'should return nil if the Rate is not effective yet' do 23 | assert_nil Rate.for(@user) 24 | end 25 | 26 | should 'should return the same default Rate on all projects' do 27 | project = Project.generate! 28 | rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.today}) 29 | assert_equal rate, Rate.for(@user, project) 30 | end 31 | end 32 | 33 | context 'a user with two default Rates' do 34 | should 'should return the newest Rate before the todays date' do 35 | rate = Rate.create!({ :user_id => @user.id, :amount => 100.0, :date_in_effect => Date.yesterday}) 36 | rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today}) 37 | assert_equal rate2, Rate.for(@user) 38 | end 39 | end 40 | 41 | context 'a user with a default Rate and Rate on a project' do 42 | should 'should return the project Rate if its effective today' do 43 | project = Project.generate! 44 | rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.yesterday}) 45 | rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today}) 46 | assert_equal rate, Rate.for(@user, project) 47 | end 48 | 49 | should 'should return the default Rate if the project Rate isnt effective yet but the default Rate is' do 50 | project = Project.generate! 51 | rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.tomorrow}) 52 | rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.today}) 53 | assert_equal rate2, Rate.for(@user, project) 54 | end 55 | 56 | should 'should return nil if neither Rate is effective yet' do 57 | project = Project.generate! 58 | rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.tomorrow}) 59 | rate2 = Rate.create!({ :user_id => @user.id, :amount => 300.0, :date_in_effect => Date.tomorrow}) 60 | assert_nil Rate.for(@user, project) 61 | end 62 | end 63 | 64 | context 'a user with two Rates on a project' do 65 | should 'should return the newest Rate before the todays date' do 66 | project = Project.generate! 67 | rate = Rate.create!({ :user_id => @user.id, :project => project, :amount => 100.0, :date_in_effect => Date.yesterday}) 68 | rate2 = Rate.create!({ :user_id => @user.id, :project => project, :amount => 300.0, :date_in_effect => Date.today}) 69 | assert_equal rate2, Rate.for(@user, project) 70 | end 71 | end 72 | end 73 | 74 | end 75 | -------------------------------------------------------------------------------- /app/helpers/rate_helper.rb: -------------------------------------------------------------------------------- 1 | module RateHelper 2 | # Allows more parameters than the standard sort_header_tag 3 | def rate_sort_header_tag(column, options = {}) 4 | caption = options.delete(:caption) || titleize(ActiveSupport::Inflector::humanize(column)) 5 | default_order = options.delete(:default_order) || 'asc' 6 | options[:title]= l(:label_sort_by, "\"#{caption}\"") unless options[:title] 7 | content_tag('th', 8 | rate_sort_link(column, 9 | caption, 10 | default_order, 11 | { :method => options[:method], :update => options[:update], :user_id => options[:user_id] }), 12 | options) 13 | end 14 | 15 | # Allows more parameters than the standard sort_link and is hard coded to use 16 | # the RatesController and to have an :method and :update options 17 | def rate_sort_link(column, caption, default_order, options = { }) 18 | # 0.8.x compatibility 19 | if SortHelper.const_defined? 'SortCriteria' 20 | rate_sort_link_trunk_version(column, caption, default_order, options) 21 | else 22 | rate_sort_link_08_version(column, caption, default_order, options) 23 | end 24 | end 25 | 26 | private 27 | # Trunk version of sort_link. Was modified in r2571 of Redmine 28 | def rate_sort_link_trunk_version(column, caption, default_order, options = { }) 29 | css, order = nil, default_order 30 | 31 | if column.to_s == @sort_criteria.first_key 32 | if @sort_criteria.first_asc? 33 | css = 'sort asc' 34 | order = 'desc' 35 | else 36 | css = 'sort desc' 37 | order = 'asc' 38 | end 39 | end 40 | caption = column.to_s.humanize unless caption 41 | 42 | sort_options = { :sort => @sort_criteria.add(column.to_s, order).to_param } 43 | # don't reuse params if filters are present 44 | url_options = params.has_key?(:set_filter) ? sort_options : params.merge(sort_options) 45 | 46 | # Add project_id to url_options 47 | url_options = url_options.merge(:project_id => params[:project_id]) if params.has_key?(:project_id) 48 | 49 | ##### Hard code url to the Rates index 50 | url_options[:controller] = 'rates' 51 | url_options[:action] = 'index' 52 | url_options[:user_id] ||= options[:user_id] 53 | ##### 54 | 55 | 56 | link_to_remote(caption, 57 | {:update => options[:update] || "content", :url => url_options, :method => options[:method] || :post}, 58 | {:href => url_for(url_options), 59 | :class => css}) 60 | end 61 | 62 | private 63 | # 0.8.x branch of sort_link. 64 | def rate_sort_link_08_version(column, caption, default_order, options = { }) 65 | key, order = session[@sort_name][:key], session[@sort_name][:order] 66 | if key == column 67 | if order.downcase == 'asc' 68 | icon = 'sort_asc.png' 69 | order = 'desc' 70 | else 71 | icon = 'sort_desc.png' 72 | order = 'asc' 73 | end 74 | else 75 | icon = nil 76 | order = default_order 77 | end 78 | caption = titleize(ActiveSupport::Inflector::humanize(column)) unless caption 79 | 80 | sort_options = { :sort_key => column, :sort_order => order } 81 | # don't reuse params if filters are present 82 | url_options = params.has_key?(:set_filter) ? sort_options : params.merge(sort_options) 83 | 84 | ##### Hard code url to the Rates index 85 | url_options[:controller] = 'rates' 86 | url_options[:action] = 'index' 87 | url_options[:user_id] ||= options[:user_id] 88 | ##### 89 | 90 | link_to_remote(caption, 91 | {:update => options[:update] || "content", :url => url_options, :method => options[:method] || :post}, 92 | {:href => url_for(url_options)}) + 93 | (icon ? nbsp(2) + image_tag(icon) : '') 94 | end 95 | 96 | end 97 | -------------------------------------------------------------------------------- /lib/rate_project_hook.rb: -------------------------------------------------------------------------------- 1 | # Hooks to attach to the Redmine Projects. 2 | class RateProjectHook < Redmine::Hook::ViewListener 3 | 4 | def protect_against_forgery? 5 | false 6 | end 7 | 8 | # Renders an additional table header to the membership setting 9 | # 10 | # Context: 11 | # * :project => Current project 12 | # 13 | def view_projects_settings_members_table_header(context ={ }) 14 | return '' unless (User.current.allowed_to?(:view_rate, context[:project]) || User.current.admin?) 15 | return "#{l(:rate_label_rate)} #{l(:rate_label_currency)}" 16 | end 17 | 18 | # Renders an AJAX from to update the member's billing rate 19 | # 20 | # Context: 21 | # * :project => Current project 22 | # * :member => Current Member record 23 | # 24 | # TODO: Move to a view 25 | def view_projects_settings_members_table_row(context = { }) 26 | member = context[:member] 27 | project = context[:project] 28 | 29 | return '' unless (User.current.allowed_to?(:view_rate, project) || User.current.admin?) 30 | 31 | if Object.const_defined? 'Group' # 0.8.x compatibility 32 | # Groups cannot have a rate 33 | return content_tag(:td,'') if member.principal.is_a? Group 34 | rate = Rate.for(member.principal, project) 35 | else 36 | rate = Rate.for(member.user, project) 37 | end 38 | 39 | content = '' 40 | 41 | if rate.nil? || rate.default? 42 | if rate && rate.default? 43 | content << "#{number_to_currency(rate.amount)} " 44 | end 45 | 46 | if (User.current.admin?) 47 | 48 | url = { 49 | :controller => 'rates', 50 | :action => 'create', 51 | :method => :post, 52 | :protocol => Setting.protocol, 53 | :host => Setting.host_name 54 | } 55 | # Build a form_remote_tag by hand since this isn't in the scope of a controller 56 | # and url_rewriter doesn't like that fact. 57 | form = form_tag(url, :onsubmit => remote_function(:url => url, 58 | :host => Setting.host_name, 59 | :protocol => Setting.protocol, 60 | :form => true, 61 | :method => 'post', 62 | :return => 'false' )+ '; return false;') 63 | 64 | form << text_field(:rate, :amount) 65 | form << hidden_field(:rate,:date_in_effect, :value => Date.today.to_s) 66 | form << hidden_field(:rate, :project_id, :value => project.id) 67 | form << hidden_field(:rate, :user_id, :value => member.user.id) 68 | form << hidden_field_tag("back_url", url_for(:controller => 'projects', :action => 'settings', :id => project, :tab => 'members', :protocol => Setting.protocol, :host => Setting.host_name)) 69 | 70 | form << submit_tag(l(:rate_label_set_rate), :class => "small") 71 | form << "" 72 | 73 | content << form 74 | end 75 | else 76 | if (User.current.admin?) 77 | 78 | content << content_tag(:strong, link_to(number_to_currency(rate.amount), { 79 | :controller => 'users', 80 | :action => 'edit', 81 | :id => member.user, 82 | :tab => 'rates', 83 | :protocol => Setting.protocol, 84 | :host => Setting.host_name 85 | })) 86 | else 87 | content << content_tag(:strong, number_to_currency(rate.amount)) 88 | end 89 | end 90 | return content_tag(:td, content, :align => 'left', :id => "rate_#{project.id}_#{member.user.id}" ) 91 | end 92 | 93 | def model_project_copy_before_save(context = {}) 94 | source = context[:source_project] 95 | destination = context[:destination_project] 96 | 97 | Rate.find(:all, :conditions => {:project_id => source.id}).each do |source_rate| 98 | destination_rate = Rate.new 99 | 100 | destination_rate.attributes = source_rate.attributes.except("project_id") 101 | destination_rate.project = destination 102 | destination_rate.save # Need to save here because there is no relation on project to rate 103 | end 104 | end 105 | end 106 | 107 | -------------------------------------------------------------------------------- /app/controllers/rates_controller.rb: -------------------------------------------------------------------------------- 1 | class RatesController < ApplicationController 2 | unloadable 3 | helper :users 4 | helper :sort 5 | include SortHelper 6 | 7 | before_filter :require_admin 8 | before_filter :require_user_id, :only => [:index, :new] 9 | before_filter :set_back_url 10 | 11 | ValidSortOptions = {'date_in_effect' => "#{Rate.table_name}.date_in_effect", 'project_id' => "#{Project.table_name}.name"} 12 | 13 | # GET /rates?user_id=1 14 | # GET /rates.xml?user_id=1 15 | def index 16 | sort_init "#{Rate.table_name}.date_in_effect", "desc" 17 | sort_update ValidSortOptions 18 | 19 | @rates = Rate.history_for_user(@user, sort_clause) 20 | 21 | respond_to do |format| 22 | format.html { render :action => 'index', :layout => !request.xhr?} 23 | format.xml { render :xml => @rates } 24 | end 25 | end 26 | 27 | # GET /rates/1 28 | # GET /rates/1.xml 29 | def show 30 | @rate = Rate.find(params[:id]) 31 | 32 | respond_to do |format| 33 | format.html # show.html.erb 34 | format.xml { render :xml => @rate } 35 | end 36 | end 37 | 38 | # GET /rates/new?user_id=1 39 | # GET /rates/new.xml?user_id=1 40 | def new 41 | @rate = Rate.new(:user_id => @user.id) 42 | 43 | respond_to do |format| 44 | format.html # new.html.erb 45 | format.xml { render :xml => @rate } 46 | end 47 | end 48 | 49 | # GET /rates/1/edit 50 | def edit 51 | @rate = Rate.find(params[:id]) 52 | end 53 | 54 | # POST /rates 55 | # POST /rates.xml 56 | def create 57 | @rate = Rate.new(params[:rate]) 58 | 59 | respond_to do |format| 60 | if @rate.save 61 | format.html { 62 | flash[:notice] = 'Rate was successfully created.' 63 | redirect_back_or_default(rates_url(:user_id => @rate.user_id)) 64 | } 65 | format.xml { render :xml => @rate, :status => :created, :location => @rate } 66 | format.js { render :action => 'create.js.rjs'} 67 | else 68 | format.html { render :action => "new" } 69 | format.xml { render :xml => @rate.errors, :status => :unprocessable_entity } 70 | format.js { 71 | flash.now[:error] = 'Error creating a new Rate.' 72 | render :action => 'create_error.js.rjs' 73 | } 74 | end 75 | end 76 | end 77 | 78 | # PUT /rates/1 79 | # PUT /rates/1.xml 80 | def update 81 | @rate = Rate.find(params[:id]) 82 | 83 | respond_to do |format| 84 | # Locked rates will fail saving here. 85 | if @rate.update_attributes(params[:rate]) 86 | flash[:notice] = 'Rate was successfully updated.' 87 | format.html { redirect_back_or_default(rates_url(:user_id => @rate.user_id)) } 88 | format.xml { head :ok } 89 | else 90 | if @rate.locked? 91 | flash[:error] = "Rate is locked and cannot be edited" 92 | @rate.reload # Removes attribute changes 93 | end 94 | format.html { render :action => "edit" } 95 | format.xml { render :xml => @rate.errors, :status => :unprocessable_entity } 96 | end 97 | end 98 | end 99 | 100 | # DELETE /rates/1 101 | # DELETE /rates/1.xml 102 | def destroy 103 | @rate = Rate.find(params[:id]) 104 | @rate.destroy 105 | 106 | respond_to do |format| 107 | format.html { 108 | flash[:error] = "Rate is locked and cannot be deleted" if @rate.locked? 109 | redirect_back_or_default(rates_url(:user_id => @rate.user_id)) 110 | } 111 | format.xml { head :ok } 112 | end 113 | end 114 | 115 | private 116 | 117 | def require_user_id 118 | begin 119 | @user = User.find(params[:user_id]) 120 | rescue ActiveRecord::RecordNotFound 121 | respond_to do |format| 122 | flash[:error] = l(:rate_error_user_not_found) 123 | format.html { redirect_to(home_url) } 124 | format.xml { render :xml => "User not found", :status => :not_found } 125 | end 126 | end 127 | end 128 | 129 | def set_back_url 130 | @back_url = params[:back_url] 131 | @back_url 132 | end 133 | 134 | # Override defination from ApplicationController to make sure it follows a 135 | # whitelist 136 | def redirect_back_or_default(default) 137 | whitelist = %r{(rates|/users/edit)} 138 | 139 | back_url = CGI.unescape(params[:back_url].to_s) 140 | if !back_url.blank? 141 | begin 142 | uri = URI.parse(back_url) 143 | if uri.path && uri.path.match(whitelist) 144 | super 145 | return 146 | end 147 | rescue URI::InvalidURIError 148 | # redirect to default 149 | logger.debug("Invalid URI sent to redirect_back_or_default: " + params[:back_url].inspect) 150 | end 151 | end 152 | redirect_to default 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Redmine Rate Plugin 2 | 3 | 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. 4 | 5 | == Features 6 | 7 | * Track rates for a user based on 8 | * Date Rate came into effect and 9 | * the Project 10 | * Store historic rate amounts 11 | * Lock rates to preserve historic calculations 12 | * Rate.for API for other plugins 13 | * Integration with the Billing plugin 14 | * Integration with the Budget plugin 15 | * Integration with the Contracts plugin 16 | 17 | == Getting the plugin 18 | 19 | A copy of the plugin can be downloaded from {Little Stream Software}[https://projects.littlestreamsoftware.com/projects/redmine-rate/files] or from {GitHub}[http://github.com/edavis10/redmine_rate/tree/master] 20 | 21 | 22 | == Installation and Setup 23 | 24 | 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. 25 | 26 | === Option #1: If you have data from a previous version of Budget or Billing 27 | 28 | These installation instructions are very specific because the Rate plugin adjusts data inside the Budget plugin so several data integrity checks are needed. 29 | 30 | 0. Backup up your data! Backup your data! 31 | 1. Install the Lockfile gem 32 | 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+ 33 | 3. Make sure you are running the 0.1.0 version of the Budget plugin and 0.0.1 version of the Billing plugin 34 | 4. Run the pre_install_export to export your current budget and billing data to file +rake rate_plugin:pre_install_export+ 35 | 5. Run the plugin migrations +rake db:migrate_plugins+ in order to get the new tables for Rates 36 | 6. Upgrade the budget plugin to 0.2.0 and the billing plugin to 0.3.0 37 | 7. Rerun the plugin migrations +rake db:migrate_plugins+ in order to update to Budget's 0.2.0 schema 38 | 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+ 39 | 9. If the script reports no errors, proceed. If errors are found, please file a bug report and revert to your backups 40 | 10. Restart your Redmine web servers (e.g. mongrel, thin, mod_rails) 41 | 11. Setup the "View Rate" permission for any Role that should be allowed to see the user rates in a Project 42 | 43 | === Option #2: If you do not have any data from Budget or Billing 44 | 45 | 1. Install the Lockfile gem 46 | 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+ 47 | 3. Run the plugin migrations +rake db:migrate_plugins+ in order to get the new tables for Rates 48 | 4. Restart your Redmine web servers (e.g. mongrel, thin, mod_rails) 49 | 5. Setup the "View Rate" permission for any Role that should be allowed to see the user rates in a Project 50 | 51 | == Usage 52 | 53 | === Enter new rate for a project 54 | 55 | There are two ways to set rates for a Member of a Project. 56 | 57 | 1. Browse to the Project Settings page 58 | 2. Select the Members tab 59 | 3. Enter the rate for the Member and click the set Rate 60 | 61 | Alternatively, Rates can be set in the User Administration panel 62 | 63 | 1. Browse to the Administration panel 64 | 2. Select Users 65 | 3. Select the specific user to add a rate for 66 | 4. Select the Membership tab and enter a rate for each project 67 | 4. Or, select the Rate History and enter a new rate in the form 68 | 69 | === Enter default rate for a user 70 | 71 | A default rate is a user's Rate that doesn't correspond to a specific project. It can be set in the User Administration panel: 72 | 73 | 1. Browse to the Administration panel 74 | 2. Select Users 75 | 3. Select the specific user to add a rate for 76 | 4. Select the Rate History and enter a new rate in the form, keep the Project field set to Default Rate. 77 | 78 | === Lock a Rate 79 | 80 | Currently this feature is only available through the Rate API. A Rate will become locked once a valid TimeEntry is assigned to the Rate. 81 | 82 | === Caching 83 | 84 | 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). 85 | 86 | == License 87 | 88 | This plugin is licensed under the GNU GPL v2. See COPYRIGHT.txt and GPL.txt for details. 89 | 90 | == Project help 91 | 92 | If you need help you can contact the maintainer on the Bug Tracker. The bug tracker is located at https://projects.littlestreamsoftware.com 93 | 94 | -------------------------------------------------------------------------------- /redmine_rate.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{redmine_rate} 8 | s.version = "0.2.1" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["Eric Davis"] 12 | s.date = %q{2011-04-28} 13 | 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.} 14 | s.email = %q{edavis@littlestreamsoftware.com} 15 | s.extra_rdoc_files = [ 16 | "README.rdoc" 17 | ] 18 | s.files = [ 19 | "COPYRIGHT.txt", 20 | "CREDITS.txt", 21 | "GPL.txt", 22 | "README.rdoc", 23 | "Rakefile", 24 | "VERSION", 25 | "app/controllers/rate_caches_controller.rb", 26 | "app/controllers/rates_controller.rb", 27 | "app/models/rate.rb", 28 | "app/views/rate_caches/index.html.erb", 29 | "app/views/rates/_form.html.erb", 30 | "app/views/rates/_list.html.erb", 31 | "app/views/rates/create.js.rjs", 32 | "app/views/rates/create_error.js.rjs", 33 | "app/views/rates/edit.html.erb", 34 | "app/views/rates/index.html.erb", 35 | "app/views/rates/new.html.erb", 36 | "app/views/rates/show.html.erb", 37 | "app/views/users/_membership_rate.html.erb", 38 | "app/views/users/_rates.html.erb", 39 | "assets/images/database_refresh.png", 40 | "config/locales/de.yml", 41 | "config/locales/en.yml", 42 | "config/locales/fr.yml", 43 | "config/locales/ru.yml", 44 | "config/routes.rb", 45 | "init.rb", 46 | "lang/de.yml", 47 | "lang/en.yml", 48 | "lang/fr.yml", 49 | "lib/rate_conversion.rb", 50 | "lib/rate_memberships_hook.rb", 51 | "lib/rate_project_hook.rb", 52 | "lib/rate_sort_helper_patch.rb", 53 | "lib/rate_time_entry_patch.rb", 54 | "lib/rate_users_helper_patch.rb", 55 | "lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook.rb", 56 | "lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook.rb", 57 | "lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook.rb", 58 | "lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook.rb", 59 | "lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook.rb", 60 | "lib/redmine_rate/hooks/timesheet_hook_helper.rb", 61 | "lib/redmine_rate/hooks/view_layouts_base_html_head_hook.rb", 62 | "lib/tasks/cache.rake", 63 | "lib/tasks/data.rake", 64 | "rails/init.rb", 65 | "test/functional/rates_controller_test.rb", 66 | "test/integration/admin_panel_test.rb", 67 | "test/integration/routing_test.rb", 68 | "test/test_helper.rb", 69 | "test/unit/lib/rate_time_entry_patch_test.rb", 70 | "test/unit/lib/rate_users_helper_patch_test.rb", 71 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook_test.rb", 72 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook_test.rb", 73 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook_test.rb", 74 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook_test.rb", 75 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook_test.rb", 76 | "test/unit/rate_for_test.rb", 77 | "test/unit/rate_test.rb" 78 | ] 79 | s.homepage = %q{https://projects.littlestreamsoftware.com/projects/redmine-rate} 80 | s.rdoc_options = ["--charset=UTF-8"] 81 | s.require_paths = ["lib"] 82 | s.rubygems_version = %q{1.3.7} 83 | s.summary = %q{A Rate plugin for Redmine to store billing rate for user.} 84 | s.test_files = [ 85 | "test/test_helper.rb", 86 | "test/integration/routing_test.rb", 87 | "test/integration/admin_panel_test.rb", 88 | "test/unit/rate_for_test.rb", 89 | "test/unit/lib/rate_time_entry_patch_test.rb", 90 | "test/unit/lib/rate_users_helper_patch_test.rb", 91 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheets_time_entry_row_class_hook_test.rb", 92 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_hook_test.rb", 93 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_group_header_hook_test.rb", 94 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_views_timesheet_time_entry_sum_hook_test.rb", 95 | "test/unit/lib/redmine_rate/hooks/plugin_timesheet_view_timesheets_report_header_tags_hook_test.rb", 96 | "test/unit/rate_test.rb", 97 | "test/functional/rates_controller_test.rb" 98 | ] 99 | 100 | if s.respond_to? :specification_version then 101 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 102 | s.specification_version = 3 103 | 104 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 105 | else 106 | end 107 | else 108 | end 109 | end 110 | 111 | -------------------------------------------------------------------------------- /app/models/rate.rb: -------------------------------------------------------------------------------- 1 | require 'lockfile' 2 | 3 | class Rate < ActiveRecord::Base 4 | unloadable 5 | class InvalidParameterException < Exception; end 6 | CACHING_LOCK_FILE_NAME = 'rate_cache' 7 | 8 | belongs_to :project 9 | belongs_to :user 10 | has_many :time_entries 11 | 12 | validates_presence_of :user_id 13 | validates_presence_of :date_in_effect 14 | validates_numericality_of :amount 15 | 16 | before_save :unlocked? 17 | after_save :update_time_entry_cost_cache 18 | before_destroy :unlocked? 19 | after_destroy :update_time_entry_cost_cache 20 | 21 | named_scope :history_for_user, lambda { |user, order| 22 | { 23 | :conditions => { :user_id => user.id }, 24 | :order => order, 25 | :include => :project 26 | } 27 | } 28 | 29 | def locked? 30 | return self.time_entries.length > 0 31 | end 32 | 33 | def unlocked? 34 | return !self.locked? 35 | end 36 | 37 | def default? 38 | return self.project.nil? 39 | end 40 | 41 | def specific? 42 | return !self.default? 43 | end 44 | 45 | def update_time_entry_cost_cache 46 | TimeEntry.update_cost_cache(user, project) 47 | end 48 | 49 | # API to find the Rate for a +user+ on a +project+ at a +date+ 50 | def self.for(user, project = nil, date = Date.today.to_s) 51 | # Check input since it's a "public" API 52 | if Object.const_defined? 'Group' # 0.8.x compatibility 53 | raise Rate::InvalidParameterException.new("user must be a Principal instance") unless user.is_a?(Principal) 54 | else 55 | raise Rate::InvalidParameterException.new("user must be a User instance") unless user.is_a?(User) 56 | end 57 | raise Rate::InvalidParameterException.new("project must be a Project instance") unless project.nil? || project.is_a?(Project) 58 | Rate.check_date_string(date) 59 | 60 | rate = self.for_user_project_and_date(user, project, date) 61 | # Check for a default (non-project) rate 62 | rate = self.default_for_user_and_date(user, date) if rate.nil? && project 63 | rate 64 | end 65 | 66 | # API to find the amount for a +user+ on a +project+ at a +date+ 67 | def self.amount_for(user, project = nil, date = Date.today.to_s) 68 | rate = self.for(user, project, date) 69 | 70 | return nil if rate.nil? 71 | return rate.amount 72 | end 73 | 74 | def self.update_all_time_entries_with_missing_cost(options={}) 75 | with_common_lockfile(options[:force]) do 76 | TimeEntry.all(:conditions => {:cost => nil}).each do |time_entry| 77 | begin 78 | time_entry.save_cached_cost 79 | rescue Rate::InvalidParameterException => ex 80 | puts "Error saving #{time_entry.id}: #{ex.message}" 81 | end 82 | end 83 | end 84 | store_cache_timestamp('last_caching_run', Time.now.utc.to_s) 85 | end 86 | 87 | def self.update_all_time_entries_to_refresh_cache(options={}) 88 | with_common_lockfile(options[:force]) do 89 | TimeEntry.find_each do |time_entry| # batch find 90 | begin 91 | time_entry.save_cached_cost 92 | rescue Rate::InvalidParameterException => ex 93 | puts "Error saving #{time_entry.id}: #{ex.message}" 94 | end 95 | end 96 | end 97 | store_cache_timestamp('last_cache_clearing_run', Time.now.utc.to_s) 98 | end 99 | 100 | private 101 | def self.for_user_project_and_date(user, project, date) 102 | if project.nil? 103 | return Rate.find(:first, 104 | :order => 'date_in_effect DESC', 105 | :conditions => [ 106 | "user_id IN (?) AND date_in_effect <= ? AND project_id IS NULL", 107 | user.id, 108 | date 109 | ]) 110 | 111 | else 112 | return Rate.find(:first, 113 | :order => 'date_in_effect DESC', 114 | :conditions => [ 115 | "user_id IN (?) AND project_id IN (?) AND date_in_effect <= ?", 116 | user.id, 117 | project.id, 118 | date 119 | ]) 120 | end 121 | end 122 | 123 | def self.default_for_user_and_date(user, date) 124 | self.for_user_project_and_date(user, nil, date) 125 | end 126 | 127 | # Checks a date string to make sure it is in format of +YYYY-MM-DD+, throwing 128 | # a Rate::InvalidParameterException otherwise 129 | def self.check_date_string(date) 130 | raise Rate::InvalidParameterException.new("date must be a valid Date string (e.g. YYYY-MM-DD)") unless date.is_a?(String) 131 | 132 | begin 133 | Date.parse(date) 134 | rescue ArgumentError 135 | raise Rate::InvalidParameterException.new("date must be a valid Date string (e.g. YYYY-MM-DD)") 136 | end 137 | end 138 | 139 | def self.store_cache_timestamp(cache_name, timestamp) 140 | Setting.plugin_redmine_rate = Setting.plugin_redmine_rate.merge({cache_name => timestamp}) 141 | end 142 | 143 | def self.with_common_lockfile(force = false, &block) 144 | # Wait 1 second after stealing a forced lock 145 | options = {:retries => 0, :suspend => 1} 146 | options[:max_age] = 1 if force 147 | 148 | Lockfile(lock_file, options) do 149 | block.call 150 | end 151 | end 152 | 153 | if Rails.env.test? 154 | require 'object_daddy' 155 | include ObjectDaddy 156 | 157 | public 158 | generator_for :date_in_effect => Date.today 159 | end 160 | 161 | def self.lock_file 162 | Rails.root + 'tmp' + Rate::CACHING_LOCK_FILE_NAME 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/tasks/data.rake: -------------------------------------------------------------------------------- 1 | namespace :rate_plugin do 2 | desc "Export both the Budget and Billing plugin data to a file" 3 | task :pre_install_export => ['budget:pre_install_export', 'billing:pre_install_export'] 4 | 5 | desc "Check the export against the migrated Rate data" 6 | task :post_install_check => ['budget:post_install_check', 'billing:post_install_check'] 7 | 8 | namespace :budget do 9 | desc "Export the values of the Budget plugin to a file before installing the rate plugin" 10 | task :pre_install_export => :environment do 11 | 12 | unless Redmine::Plugin.registered_plugins[:budget_plugin].version == "0.1.0" 13 | puts "ERROR: This task is only needed when upgrading Budget from version 0.1.0 to version 0.2.0" 14 | return false 15 | end 16 | 17 | rates = '' 18 | # Rate for members 19 | Member.find(:all, :conditions => ['rate IS NOT NULL']).each do |member| 20 | 21 | rates << { 22 | :user_id => member.user_id, 23 | :project_id => member.project_id, 24 | :rate => member.rate 25 | }.to_yaml 26 | 27 | end 28 | 29 | File.open(RateConversion::MemberRateDataFile, 'w') do |file| 30 | file.puts rates 31 | end 32 | 33 | # HourlyDeliverable.spent and FixedDeliverable.spent 34 | deliverables = '' 35 | Deliverable.find(:all).each do |deliverable| 36 | deliverables << { 37 | :id => deliverable.id, 38 | :spent => deliverable.spent 39 | }.to_yaml 40 | end 41 | 42 | File.open(RateConversion::DeliverableDataFile, 'w') do |file| 43 | file.puts deliverables 44 | end 45 | end 46 | 47 | desc "Check the values of the export" 48 | task :post_install_check => :environment do 49 | 50 | unless Redmine::Plugin.registered_plugins[:budget_plugin].version == "0.2.0" 51 | puts "ERROR: Please upgrade the budget_plugin to 0.2.0 now" 52 | return false 53 | end 54 | 55 | counter = 0 56 | # Member Rates 57 | File.open(RateConversion::MemberRateDataFile) do |file| 58 | YAML::load_documents(file) { |member_export| 59 | user_id = member_export[:user_id] 60 | project_id = member_export[:project_id] 61 | rate = Rate.find_by_user_id_and_project_id(user_id, project_id) 62 | 63 | if rate.nil? 64 | puts "ERROR: No Rate found for User: #{user_id}, Project: #{project_id}" 65 | counter += 1 66 | else 67 | counter += 1 unless RateConversion.compare_values(member_export[:rate], rate.amount, "Rate #{rate.id}'s amount is off") 68 | end 69 | } 70 | end 71 | 72 | # Deliverables 73 | File.open(RateConversion::DeliverableDataFile) do |file| 74 | YAML::load_documents(file) { |deliverable_export| 75 | id = deliverable_export[:id] 76 | spent = deliverable_export[:spent] 77 | deliverable = Deliverable.find(id) 78 | 79 | counter += 1 unless RateConversion.compare_values(spent, deliverable.spent, "Deliverable #{id}'s spent is off") 80 | } 81 | end 82 | 83 | if counter > 0 84 | puts "#{counter} errors found." 85 | else 86 | puts "No Budget conversation errors found, congrats." 87 | end 88 | end 89 | end 90 | 91 | namespace :billing do 92 | desc "Export the values of the Billing plugin to a file before installing the rate plugin" 93 | task :pre_install_export => :environment do 94 | 95 | unless Redmine::Plugin.registered_plugins[:redmine_billing].version == "0.0.1" 96 | puts "ERROR: This task is only needed when upgrading Billing from version 0.0.1 to version 0.3.0" 97 | return false 98 | end 99 | 100 | invoices = '' 101 | 102 | FixedVendorInvoice.find(:all).each do |invoice| 103 | invoices << { 104 | :id => invoice.id, 105 | :number => invoice.number, 106 | :amount => invoice.amount, 107 | :project_id => invoice.project_id, 108 | :type => 'FixedVendorInvoice' 109 | }.to_yaml 110 | end 111 | 112 | HourlyVendorInvoice.find(:all).each do |invoice| 113 | invoices << { 114 | :id => invoice.id, 115 | :number => invoice.number, 116 | :amount => invoice.amount_for_user, 117 | :project_id => invoice.project_id, 118 | :type => 'HourlyVendorInvoice' 119 | }.to_yaml 120 | end 121 | 122 | File.open(RateConversion::VendorInvoiceDataFile, 'w') do |file| 123 | file.puts invoices 124 | end 125 | end 126 | 127 | desc "Check the values of the export" 128 | task :post_install_check => :environment do 129 | 130 | unless Redmine::Plugin.registered_plugins[:redmine_billing].version == "0.3.0" 131 | puts "ERROR: Please upgrade the billing_plugin to 0.3.0 now" 132 | return false 133 | end 134 | 135 | counter = 0 136 | 137 | File.open(RateConversion::VendorInvoiceDataFile) do |file| 138 | YAML::load_documents(file) { |invoice_export| 139 | invoice = VendorInvoice.find_by_id(invoice_export[:id]) 140 | 141 | if invoice.nil? 142 | puts "ERROR: No VendorInvoice found with the ID of #{invoice_export[:id]}" 143 | counter += 1 144 | else 145 | if invoice.type.to_s == "FixedVendorInvoice" 146 | counter += 1 unless RateConversion.compare_values(invoice_export[:amount], invoice.amount, "VendorInvoice #{invoice.id}'s amount is off") 147 | else 148 | counter += 1 unless RateConversion.compare_values(invoice_export[:amount], invoice.amount_for_user, "VendorInvoice #{invoice.id}'s amount is off") 149 | end 150 | 151 | end 152 | } 153 | end 154 | 155 | if counter > 0 156 | puts "#{counter} errors found." 157 | else 158 | puts "No Billing conversation errors found, congrats." 159 | end 160 | end 161 | end 162 | 163 | end 164 | -------------------------------------------------------------------------------- /test/functional/rates_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class RatesControllerTest < ActionController::TestCase 4 | 5 | def self.should_be_unauthorized(&block) 6 | should 'should return a forbidden status code' do 7 | instance_eval(&block) 8 | assert_response :forbidden 9 | end 10 | 11 | should 'should display the standard unauthorized page' do 12 | instance_eval(&block) 13 | assert_template 'common/error' 14 | end 15 | 16 | context "with mime type of xml" do 17 | 18 | should "should return an forbidden error" do 19 | @request.env["HTTP_ACCEPT"] = "application/xml" 20 | instance_eval(&block) 21 | assert_response :forbidden 22 | end 23 | end 24 | 25 | end 26 | 27 | def mock_rate(stubs={}) 28 | @project = Project.generate! 29 | stubs = { 30 | :date_in_effect => Date.today, 31 | :project => @project, 32 | :amount => 100.0, 33 | :user => @user 34 | }.merge(stubs) 35 | @mock_rate = Rate.generate(stubs) 36 | end 37 | 38 | def mock_locked_rate(stubs={}) 39 | @mock_rate = mock_rate 40 | @mock_rate.time_entries << TimeEntry.generate! 41 | @mock_rate 42 | end 43 | 44 | context "as regular user" do 45 | setup do 46 | @user = User.generate! 47 | @request.session[:user_id] = @user.id 48 | end 49 | 50 | context "responding to GET index" do 51 | should_be_unauthorized { get :index } 52 | end 53 | 54 | context "responding to GET show" do 55 | should_be_unauthorized { get :show, :id => "37" } 56 | end 57 | 58 | context "responding to GET new" do 59 | should_be_unauthorized { get :new } 60 | end 61 | 62 | context "responding to GET edit" do 63 | should_be_unauthorized { get :edit, :id => "37" } 64 | end 65 | 66 | context "responding to POST create" do 67 | should_be_unauthorized { post :create, :rate => {:these => 'params'} } 68 | end 69 | 70 | context "responding to PUT update" do 71 | should_be_unauthorized { put :update, :id => "37", :rate => {:these => 'params'} } 72 | end 73 | 74 | context "responding to DELETE destroy" do 75 | should_be_unauthorized { delete :destroy, :id => "37" } 76 | end 77 | end 78 | 79 | 80 | context "as an administrator" do 81 | 82 | setup do 83 | @user = User.generate!(:admin => true) 84 | @request.session[:user_id] = @user.id 85 | end 86 | 87 | context "responding to GET index" do 88 | 89 | should "should redirect to the homepage" do 90 | get :index 91 | assert_redirected_to home_url 92 | end 93 | 94 | should "should display an error flash message" do 95 | get :index 96 | assert_match /not found/, flash[:error] 97 | end 98 | 99 | context "with mime type of xml" do 100 | 101 | should "should return a 404 error" do 102 | @request.env["HTTP_ACCEPT"] = "application/xml" 103 | get :index 104 | assert_response :not_found 105 | end 106 | 107 | end 108 | 109 | end 110 | 111 | context "responding to GET index with user" do 112 | setup do 113 | mock_rate 114 | end 115 | 116 | should "should expose all historic rates for the user as @rates" do 117 | get :index, :user_id => @user.id 118 | assert_equal assigns(:rates), [@mock_rate] 119 | end 120 | 121 | context "with mime type of xml" do 122 | 123 | should "should render all rates as xml" do 124 | @request.env["HTTP_ACCEPT"] = "application/xml" 125 | get :index, :user_id => @user.id 126 | 127 | assert_select 'rates' do 128 | assert_select 'rate' do 129 | assert_select 'id', :text => @mock_rate.id 130 | end 131 | end 132 | 133 | end 134 | 135 | end 136 | 137 | end 138 | 139 | context "responding to GET show" do 140 | setup do 141 | mock_rate 142 | end 143 | 144 | should "should expose the @requested rate as @rate" do 145 | get :show, :id => @mock_rate.id 146 | assert_equal assigns(:rate), @mock_rate 147 | end 148 | 149 | context "with mime type of xml" do 150 | 151 | should "should render the requested rate as xml" do 152 | @request.env["HTTP_ACCEPT"] = "application/xml" 153 | get :show, :id => @mock_rate.id 154 | 155 | assert_select 'rate' do 156 | assert_select 'id', :text => @mock_rate.id 157 | assert_select 'amount', :text => /100/ 158 | end 159 | 160 | end 161 | 162 | end 163 | 164 | end 165 | 166 | context "responding to GET new" do 167 | 168 | should "should redirect to the homepage" do 169 | get :new 170 | assert_redirected_to home_url 171 | end 172 | 173 | should "should display an error flash message" do 174 | get :new 175 | assert_match /not found/, flash[:error] 176 | end 177 | 178 | context "with mime type of xml" do 179 | 180 | should "should return a 404 error" do 181 | @request.env["HTTP_ACCEPT"] = "application/xml" 182 | get :new 183 | assert_response :not_found 184 | end 185 | 186 | end 187 | end 188 | 189 | context "responding to GET new with user" do 190 | should 'should be successful' do 191 | get :new, :user_id => @user.id 192 | assert_response :success 193 | end 194 | 195 | should "should expose a new rate as @rate" do 196 | get :new, :user_id => @user.id 197 | assert assigns(:rate) 198 | assert assigns(:rate).new_record? 199 | end 200 | 201 | end 202 | 203 | context "responding to GET edit" do 204 | setup do 205 | mock_rate 206 | end 207 | 208 | should "should expose the requested rate as @rate" do 209 | get :edit, :id => @mock_rate.id 210 | assert_equal assigns(:rate), @mock_rate 211 | end 212 | 213 | context "on a locked rate" do 214 | setup do 215 | mock_locked_rate 216 | end 217 | 218 | should 'should not have a Update button' do 219 | get :edit, :id => @mock_rate.id 220 | assert_select "input[type=submit]", :count => 0 221 | end 222 | 223 | should 'should show the locked icon' do 224 | get :edit, :id => @mock_rate.id 225 | assert_select "img[src*=locked.png]" 226 | end 227 | end 228 | 229 | end 230 | 231 | context "responding to POST create" do 232 | 233 | context "with valid params" do 234 | setup do 235 | @project = Project.generate! 236 | end 237 | 238 | should "should expose a newly created rate as @rate" do 239 | post :create, :rate => {:project_id => @project.id, :amount => '50', :date_in_effect => Date.today.to_s, :user_id => @user.id} 240 | assert assigns(:rate) 241 | end 242 | 243 | should "should redirect to the rate list" do 244 | post :create, :rate => {:project_id => @project.id, :amount => '50', :date_in_effect => Date.today.to_s, :user_id => @user.id} 245 | 246 | assert_redirected_to rates_url(:user_id => @user.id) 247 | end 248 | 249 | should 'should redirect to the back_url if set' do 250 | back_url = '/rates' 251 | post :create, :rate => {:project_id => @project.id, :amount => '50', :date_in_effect => Date.today.to_s, :user_id => @user.id}, :back_url => back_url 252 | 253 | assert_redirected_to back_url 254 | end 255 | 256 | end 257 | 258 | context "with invalid params" do 259 | should "should expose a newly created but unsaved rate as @rate" do 260 | post :create, :rate => {} 261 | assert assigns(:rate).new_record? 262 | end 263 | 264 | should "should re-render the 'new' template" do 265 | post :create, :rate => {} 266 | assert_template 'new' 267 | end 268 | 269 | end 270 | 271 | end 272 | 273 | context "responding to PUT udpate" do 274 | 275 | context "with valid params" do 276 | setup do 277 | mock_rate 278 | end 279 | 280 | should "should update the requested rate" do 281 | put :update, :id => @mock_rate.id, :rate => {:amount => '150'} 282 | 283 | assert_equal 150.0, @mock_rate.reload.amount 284 | end 285 | 286 | should "should expose the requested rate as @rate" do 287 | put :update, :id => @mock_rate.id 288 | 289 | assert_equal assigns(:rate), @mock_rate 290 | end 291 | 292 | should "should redirect to the rate list" do 293 | put :update, :id => "1" 294 | 295 | assert_redirected_to rates_url(:user_id => @user.id) 296 | end 297 | 298 | should 'should redirect to the back_url if set' do 299 | back_url = '/rates' 300 | put :update, :id => "1", :back_url => back_url 301 | 302 | assert_redirected_to back_url 303 | end 304 | 305 | end 306 | 307 | context "with invalid params" do 308 | setup do 309 | mock_rate 310 | end 311 | 312 | should "should not update the requested rate" do 313 | put :update, :id => @mock_rate.id, :rate => {:amount => 'asdf'} 314 | 315 | assert_equal 100.0, @mock_rate.reload.amount 316 | end 317 | 318 | should "should expose the rate as @rate" do 319 | put :update, :id => @mock_rate.id, :rate => {:amount => 'asdf'} 320 | 321 | assert_equal assigns(:rate), @mock_rate 322 | end 323 | 324 | should "should re-render the 'edit' template" do 325 | put :update, :id => @mock_rate.id, :rate => {:amount => 'asdf'} 326 | 327 | assert_template 'edit' 328 | end 329 | 330 | end 331 | 332 | context "on a locked rate" do 333 | setup do 334 | mock_locked_rate 335 | end 336 | 337 | should "should not save the rate" do 338 | put :update, :id => @mock_rate.id, :rate => {:amount => '150'} 339 | 340 | assert_equal 100, @mock_rate.reload.amount 341 | end 342 | 343 | should "should set the locked rate as @rate" do 344 | put :update, :id => @mock_rate.id, :rate => { :amount => 200.0 } 345 | 346 | assert_equal assigns(:rate), @mock_rate 347 | end 348 | 349 | should "should re-render the 'edit' template" do 350 | put :update, :id => @mock_rate.id 351 | 352 | assert_template 'edit' 353 | end 354 | 355 | should "should render an error message" do 356 | put :update, :id => @mock_rate.id 357 | 358 | assert_match /locked/, flash[:error] 359 | end 360 | end 361 | 362 | end 363 | 364 | context "responding to DELETE destroy" do 365 | setup do 366 | mock_rate 367 | end 368 | 369 | should "should destroy the requested rate" do 370 | assert_difference('Rate.count', -1) do 371 | delete :destroy, :id => @mock_rate.id 372 | end 373 | end 374 | 375 | should "should redirect to the user's rates list" do 376 | delete :destroy, :id => @mock_rate.id 377 | assert_redirected_to rates_url(:user_id => @user.id) 378 | end 379 | 380 | should 'should redirect to the back_url if set' do 381 | back_url = '/rates' 382 | delete :destroy, :id => "1", :back_url => back_url 383 | 384 | assert_redirected_to back_url 385 | end 386 | 387 | context "on a locked rate" do 388 | setup do 389 | mock_locked_rate 390 | end 391 | 392 | should "should display an error message" do 393 | delete :destroy, :id => @mock_rate.id 394 | assert_match /locked/, flash[:error] 395 | end 396 | end 397 | 398 | end 399 | 400 | end 401 | end 402 | -------------------------------------------------------------------------------- /test/unit/rate_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class RateTest < ActiveSupport::TestCase 4 | def rate_valid_attributes 5 | { 6 | :user => User.generate!, 7 | :project => Project.generate!, 8 | :date_in_effect => Date.new(Date.today.year, 1, 1), 9 | :amount => 100.50 10 | } 11 | end 12 | 13 | should_belong_to :project 14 | should_belong_to :user 15 | should_have_many :time_entries 16 | 17 | should_validate_presence_of :user_id 18 | should_validate_presence_of :date_in_effect 19 | should_validate_numericality_of :amount 20 | 21 | 22 | context '#locked?' do 23 | should 'should be true if a Time Entry is associated' do 24 | rate = Rate.new 25 | rate.time_entries << TimeEntry.generate! 26 | assert rate.locked? 27 | end 28 | 29 | should 'should be false if no Time Entries are associated' do 30 | rate = Rate.new 31 | assert ! rate.locked? 32 | end 33 | end 34 | 35 | 36 | context '#unlocked?' do 37 | should 'should be false if a Time Entry is associated' do 38 | rate = Rate.new 39 | rate.time_entries << TimeEntry.generate! 40 | assert ! rate.unlocked? 41 | end 42 | 43 | should 'should be true if no Time Entries are associated' do 44 | rate = Rate.new 45 | assert rate.unlocked? 46 | end 47 | 48 | end 49 | 50 | context '#save' do 51 | 52 | should 'should save if a Rate is unlocked' do 53 | rate = Rate.new(rate_valid_attributes) 54 | assert rate.save 55 | end 56 | 57 | should 'should not save if a Rate is locked' do 58 | rate = Rate.new(rate_valid_attributes) 59 | rate.time_entries << TimeEntry.generate! 60 | assert !rate.save 61 | end 62 | end 63 | 64 | 65 | 66 | 67 | context '#destroy' do 68 | 69 | should 'should destroy the Rate if should is unlocked' do 70 | rate = Rate.create(rate_valid_attributes) 71 | assert_difference('Rate.count', -1) do 72 | rate.destroy 73 | end 74 | 75 | end 76 | 77 | should 'should not destroy the Rate if should is locked' do 78 | rate = Rate.create(rate_valid_attributes) 79 | rate.time_entries << TimeEntry.generate! 80 | 81 | assert_difference('Rate.count', 0) do 82 | rate.destroy 83 | end 84 | end 85 | end 86 | 87 | context "after save" do 88 | should "recalculate all of the cached cost of all Time Entries for the user" do 89 | @user = User.generate! 90 | @project = Project.generate! 91 | @date = Date.today.to_s 92 | @past_date = 1.month.ago.strftime('%Y-%m-%d') 93 | @rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0) 94 | @time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!}) 95 | @time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @past_date, :hours => 20.0, :activity => TimeEntryActivity.generate!}) 96 | 97 | 98 | assert_equal 2000.00, @time_entry1.cost 99 | assert_equal 0, @time_entry2.cost 100 | 101 | @old_rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => 2.months.ago.strftime('%Y-%m-%d'), :amount => 10.0) 102 | 103 | assert_equal 2000.00, TimeEntry.find(@time_entry1.id).cost 104 | assert_equal 200.00, TimeEntry.find(@time_entry2.id).cost 105 | end 106 | 107 | end 108 | 109 | context "after destroy" do 110 | should "recalculate all of the cached cost of all Time Entries for the user" do 111 | @user = User.generate! 112 | @project = Project.generate! 113 | @date = Date.today.to_s 114 | @past_date = 1.month.ago.strftime('%Y-%m-%d') 115 | @rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0) 116 | @old_rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => 2.months.ago.strftime('%Y-%m-%d'), :amount => 10.0) 117 | 118 | @time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!}) 119 | @time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @past_date, :hours => 20.0, :activity => TimeEntryActivity.generate!}) 120 | 121 | 122 | assert_equal 2000.00, @time_entry1.cost 123 | assert_equal 2000.00, @time_entry1.read_attribute(:cost) 124 | assert_equal 200.0, @time_entry2.cost 125 | assert_equal 200.0, @time_entry2.read_attribute(:cost) 126 | 127 | @old_rate.destroy 128 | 129 | assert_equal 2000.0, TimeEntry.find(@time_entry1.id).cost 130 | assert_equal 0, TimeEntry.find(@time_entry2.id).cost 131 | end 132 | 133 | end 134 | 135 | context '#for' do 136 | setup do 137 | @user = User.generate! 138 | @project = Project.generate! 139 | @date = '2009-01-01' 140 | @date = Date.new(Date.today.year, 1, 1).to_s 141 | @default_rate = Rate.generate!(:amount => 100.10, :date_in_effect => @date, :project => nil, :user => @user) 142 | @rate = Rate.generate!(:amount => 50.50, :date_in_effect => @date, :project => @project, :user => @user) 143 | end 144 | 145 | context 'parameters' do 146 | should 'should be passed user' do 147 | assert_raises ArgumentError do 148 | Rate.for 149 | end 150 | end 151 | 152 | should 'can be passed an optional project' do 153 | assert_nothing_raised do 154 | Rate.for(@user) 155 | end 156 | 157 | assert_nothing_raised do 158 | Rate.for(@user, @project) 159 | end 160 | end 161 | 162 | should 'can be passed an optional date string' do 163 | assert_nothing_raised do 164 | Rate.for(@user) 165 | end 166 | 167 | assert_nothing_raised do 168 | Rate.for(@user, nil, @date) 169 | end 170 | end 171 | 172 | end 173 | 174 | context 'returns' do 175 | should 'a Rate object when there is a rate' do 176 | assert_equal @rate, Rate.for(@user, @project, @date) 177 | end 178 | 179 | should 'a nil when there is no rate' do 180 | assert @rate.destroy 181 | assert @default_rate.destroy 182 | 183 | assert_equal nil, Rate.for(@user, @project, @date) 184 | end 185 | end 186 | 187 | context 'with a user, project, and date' do 188 | should 'should find the rate for a user on the project before the date' do 189 | assert_equal @rate, Rate.for(@user, @project, @date) 190 | end 191 | 192 | should 'should return the most recent rate found' do 193 | assert_equal @rate, Rate.for(@user, @project, @date) 194 | end 195 | 196 | should 'should check for a default rate if no rate is found' do 197 | assert @rate.destroy 198 | 199 | assert_equal @default_rate, Rate.for(@user, @project, @date) 200 | end 201 | 202 | should 'should return nil if no set or default rate is found' do 203 | assert @rate.destroy 204 | assert @default_rate.destroy 205 | 206 | assert_equal nil, Rate.for(@user, @project, @date) 207 | end 208 | end 209 | 210 | context 'with a user and project' do 211 | should 'should find the rate for a user on the project before today' do 212 | assert_equal @rate, Rate.for(@user, @project) 213 | end 214 | 215 | should 'should return the most recent rate found' do 216 | assert_equal @rate, Rate.for(@user, @project) 217 | end 218 | 219 | should 'should return nil if no set or default rate is found' do 220 | assert @rate.destroy 221 | assert @default_rate.destroy 222 | 223 | assert_equal nil, Rate.for(@user, @project) 224 | end 225 | end 226 | 227 | context 'with a user' do 228 | should 'should find the rate without a project for a user on the project before today' do 229 | assert_equal @default_rate, Rate.for(@user) 230 | end 231 | 232 | should 'should return the most recent rate found' do 233 | assert_equal @default_rate, Rate.for(@user) 234 | end 235 | 236 | should 'should return nil if no set or default rate is found' do 237 | assert @rate.destroy 238 | assert @default_rate.destroy 239 | 240 | assert_equal nil, Rate.for(@user) 241 | end 242 | end 243 | 244 | should 'with an invalid user should raise an InvalidParameterException' do 245 | object = Object.new 246 | assert_raises Rate::InvalidParameterException do 247 | Rate.for(object) 248 | end 249 | end 250 | 251 | should 'with an invalid project should raise an InvalidParameterException' do 252 | object = Object.new 253 | assert_raises Rate::InvalidParameterException do 254 | Rate.for(@user, object) 255 | end 256 | end 257 | 258 | should 'with an invalid object for date should raise an InvalidParameterException' do 259 | object = Object.new 260 | assert_raises Rate::InvalidParameterException do 261 | Rate.for(@user, @project, object) 262 | end 263 | end 264 | 265 | should 'with an invalid date string should raise an InvalidParameterException' do 266 | assert_raises Rate::InvalidParameterException do 267 | Rate.for(@user, @project, '2000-13-40') 268 | end 269 | end 270 | 271 | end 272 | 273 | context "#update_all_time_entries_with_missing_cost" do 274 | setup do 275 | @user = User.generate! 276 | @project = Project.generate! 277 | @date = Date.today.to_s 278 | @rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0) 279 | @time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!}) 280 | @time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 20.0, :activity => TimeEntryActivity.generate!}) 281 | end 282 | 283 | should "update the caches of all Time Entries" do 284 | TimeEntry.update_all('cost = null') 285 | 286 | # Check that cost is NULL in the database, which skips the caching 287 | assert_equal 2, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i 288 | 289 | Rate.update_all_time_entries_with_missing_cost 290 | 291 | assert_equal 0, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i 292 | 293 | end 294 | 295 | should "timestamp a successful run" do 296 | assert_equal nil, Setting.plugin_redmine_rate['last_caching_run'] 297 | 298 | Rate.update_all_time_entries_with_missing_cost 299 | 300 | assert Setting.plugin_redmine_rate['last_caching_run'], "Last run not timestamped" 301 | assert Time.parse(Setting.plugin_redmine_rate['last_caching_run']), "Last run timestamp not parseable" 302 | end 303 | end 304 | 305 | context "#update_all_time_entries_to_refresh_cache" do 306 | setup do 307 | @user = User.generate! 308 | @project = Project.generate! 309 | @date = Date.today.to_s 310 | @rate = Rate.generate!(:user => @user, :project => @project, :date_in_effect => @date, :amount => 200.0) 311 | @time_entry1 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 10.0, :activity => TimeEntryActivity.generate!}) 312 | @time_entry2 = TimeEntry.generate!({:user => @user, :project => @project, :spent_on => @date, :hours => 20.0, :activity => TimeEntryActivity.generate!}) 313 | end 314 | 315 | should "update the caches of all Time Entries" do 316 | assert_equal 0, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i 317 | 318 | Rate.update_all_time_entries_to_refresh_cache 319 | 320 | assert_equal 0, ActiveRecord::Base.connection.select_all('select count(*) as count from time_entries where cost IS NULL').first["count"].to_i 321 | 322 | end 323 | 324 | should "timestamp a successful run" do 325 | assert_equal nil, Setting.plugin_redmine_rate['last_cache_clearing_run'] 326 | 327 | Rate.update_all_time_entries_to_refresh_cache 328 | 329 | assert Setting.plugin_redmine_rate['last_cache_clearing_run'], "Last run not timestamped" 330 | assert Time.parse(Setting.plugin_redmine_rate['last_cache_clearing_run']), "Last run timestamp not parseable" 331 | end 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /GPL.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | --------------------------------------------------------------------------------