├── 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 | | <%= l(:label_date) %> |
5 | <%= l(:label_project) %> |
6 | <%= l(:rate_label_rate) %> |
7 | |
8 |
9 |
10 |
11 | |
12 | <%= f.text_field "date_in_effect", :size => 10 %><%= calendar_for('rate_date_in_effect') %>
13 | |
14 |
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 | |
21 |
22 | <%= l(:rate_label_currency) %> <%= f.text_field "amount", :size => 10 %>
23 | |
24 |
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 | |
33 |
34 |
35 |
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 | | <%= l(:rate_label_rate) %> |
17 | |
18 |
19 |
20 | <% @rates.each do |rate| %>
21 |
22 | | <%= h format_date(rate.date_in_effect) %> |
23 |
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 | |
30 | <%= h rate.amount %> |
31 |
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 | |
39 |
40 |
41 | <% end; reset_cycle %>
42 |
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 |
--------------------------------------------------------------------------------
|