Feature/remove timelog (#8557)

* rename costs, introduce budgets

* move files from costs to budgets

* rename cost_object to budget

* remove unused code

* move hook - should be turned into standard code in the long run

* move type attributes change over to budgets

* move patch to work_package proper

* move budget menu item up

* combine reporting, time and cost module

* remove rails based time_entries & reports code

* rename cost object filter

* adapt menu spec expectations

* use cost project module name in administration

* include timeline labels in migration

* properly place budget linking method

* fix permitted params

* remove outdated routing spec

* adapt budget request specs

* ensure order of descendent updates

* remove outdated specs

* fix checking for reporting to be enabled

* fix displaying spent units

* fix time entries activity event url

* reenable current rate tab

* fix path on budget page

* allow bulk editing of budgets only in one project scenario

* fix sanitizing reference in controller

* include module required for format_date

* fix reference to correct units from work package spent units

* linting

* remove outdated spec

* remove outdated views and permission references

* remove acts_as_event from time_entries

There is no atom link for time entries

* remove acts_as_event from projects

There are no atom links for projects

* introduce budget filter for cost reports

* remove actions added to removed controller

* move time entries to the costs module

* factor in view_own permission when calculating time entry visibility

* linting

* move mounting of time entries

* include budgets into api v3 documentation
pull/8572/head
ulferts 4 years ago committed by GitHub
parent 81bd0e34cd
commit 6826f90ee2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      Gemfile.lock
  2. 3
      Gemfile.modules
  3. 2
      app/contracts/model_contract.rb
  4. 9
      app/contracts/work_packages/base_contract.rb
  5. 207
      app/controllers/time_entries/reports_controller.rb
  6. 232
      app/controllers/timelog_controller.rb
  7. 279
      app/helpers/timelog_helper.rb
  8. 26
      app/models/permitted_params.rb
  9. 5
      app/models/project.rb
  10. 3
      app/models/user.rb
  11. 9
      app/models/work_package.rb
  12. 9
      app/models/work_package/journalized.rb
  13. 141
      app/models/work_packages/costs.rb
  14. 2
      app/views/activities/index.html.erb
  15. 2
      app/views/search/index.html.erb
  16. 2
      app/views/settings/plugin.html.erb
  17. 48
      app/views/time_entries/reports/_report_criteria.html.erb
  18. 140
      app/views/time_entries/reports/_reports_tab.html.erb
  19. 45
      app/views/time_entries/reports/show.html.erb
  20. 65
      app/views/timelog/_date_range.html.erb
  21. 40
      app/views/timelog/_time_entry_tab.html.erb
  22. 67
      app/views/timelog/edit.html.erb
  23. 47
      app/views/timelog/index.html.erb
  24. 2
      app/views/users/show.html.erb
  25. 12
      app/views/work_packages/bulk/edit.html.erb
  26. 6
      app/views/work_packages/moves/new.html.erb
  27. 5
      config/initializers/activity.rb
  28. 33
      config/initializers/menus.rb
  29. 25
      config/initializers/permissions.rb
  30. 14
      config/locales/en.yml
  31. 18
      config/routes.rb
  32. 2
      config/settings.yml
  33. 90
      db/migrate/migration_utils/module_renamer.rb
  34. 10
      db/migrate/migration_utils/setting_renamer.rb
  35. 147
      docs/api/apiv3/endpoints/budgets.apib
  36. 4
      docs/api/apiv3/endpoints/time_entries.apib
  37. 1
      docs/api/apiv3/endpoints/work-packages.apib
  38. 1
      docs/api/apiv3/index.apib
  39. 1
      frontend/src/app/modules/a11y/keyboard-shortcut-service.ts
  40. 72
      frontend/src/app/modules/augmenting/dynamic-scripts/timelog_date_range.js
  41. 6
      frontend/src/app/modules/common/path-helper/path-helper.service.ts
  42. 3
      frontend/src/app/modules/fields/display/field-types/wp-spent-time-display-field.module.ts
  43. 2
      frontend/src/global_styles/content/_simple_filters.lsg
  44. 2
      frontend/src/global_styles/content/modules/_costs.sass
  45. 1
      lib/api/v3/root.rb
  46. 1
      lib/api/v3/work_packages/schema/specific_work_package_schema.rb
  47. 16
      lib/api/v3/work_packages/schema/work_package_schema_representer.rb
  48. 25
      lib/api/v3/work_packages/work_package_representer.rb
  49. 4
      lib/plugins/acts_as_event/lib/acts_as_event.rb
  50. 23
      lib/redmine/plugin.rb
  51. 35
      modules/backlogs/lib/open_project/backlogs/hooks.rb
  52. 5
      modules/backlogs/lib/open_project/backlogs/patches/update_service_patch.rb
  53. 4
      modules/backlogs/spec/services/work_packages/update_service_version_inheritance_spec.rb
  54. 3
      modules/budgets/Gemfile
  55. 264
      modules/budgets/app/controllers/budgets_controller.rb
  56. 60
      modules/budgets/app/helpers/budgets_helper.rb
  57. 16
      modules/budgets/app/models/activities/budget_activity_provider.rb
  58. 141
      modules/budgets/app/models/budget.rb
  59. 6
      modules/budgets/app/models/journal/budget_journal.rb
  60. 32
      modules/budgets/app/models/labor_budget_item.rb
  61. 16
      modules/budgets/app/models/material_budget_item.rb
  62. 23
      modules/budgets/app/models/queries/work_packages/filter/budget_filter.rb
  63. 0
      modules/budgets/app/views/budgets/_costs.html.erb
  64. 10
      modules/budgets/app/views/budgets/_edit.html.erb
  65. 8
      modules/budgets/app/views/budgets/_form.html.erb
  66. 36
      modules/budgets/app/views/budgets/_list.html.erb
  67. 34
      modules/budgets/app/views/budgets/_show.html.erb
  68. 4
      modules/budgets/app/views/budgets/edit.html.erb
  69. 16
      modules/budgets/app/views/budgets/index.html.erb
  70. 16
      modules/budgets/app/views/budgets/items/_labor_budget_item.html.erb
  71. 16
      modules/budgets/app/views/budgets/items/_material_budget_item.html.erb
  72. 16
      modules/budgets/app/views/budgets/new.html.erb
  73. 50
      modules/budgets/app/views/budgets/show.html.erb
  74. 16
      modules/budgets/app/views/budgets/subform/_labor_budget_subform.html.erb
  75. 12
      modules/budgets/app/views/budgets/subform/_material_budget_subform.html.erb
  76. 10
      modules/budgets/budgets.gemspec
  77. 89
      modules/budgets/config/locales/en.yml
  78. 7
      modules/budgets/config/locales/js-en.yml
  79. 19
      modules/budgets/config/routes.rb
  80. 28
      modules/budgets/db/migrate/20200807083950_keep_enabled_module.rb
  81. 150
      modules/budgets/db/migrate/20200810152654_rename_cost_object_to_budget.rb
  82. 122
      modules/budgets/frontend/module/augment/cost-budget-subform.augment.service.ts
  83. 67
      modules/budgets/frontend/module/augment/cost-subform.augment.service.ts
  84. 101
      modules/budgets/frontend/module/augment/planned-costs-form.ts
  85. 0
      modules/budgets/frontend/module/hal/resources/budget-resource.ts
  86. 70
      modules/budgets/frontend/module/main.ts
  87. 0
      modules/budgets/lib/api/v3/attachments/attachments_by_budget_api.rb
  88. 1
      modules/budgets/lib/api/v3/budgets/budget_collection_representer.rb
  89. 3
      modules/budgets/lib/api/v3/budgets/budget_representer.rb
  90. 5
      modules/budgets/lib/api/v3/budgets/budgets_api.rb
  91. 5
      modules/budgets/lib/api/v3/budgets/budgets_by_project_api.rb
  92. 3
      modules/budgets/lib/api/v3/queries/schemas/budget_filter_dependency_representer.rb
  93. 4
      modules/budgets/lib/budgets.rb
  94. 67
      modules/budgets/lib/budgets/engine.rb
  95. 53
      modules/budgets/lib/budgets/hooks/work_package_hook.rb
  96. 12
      modules/budgets/spec/factories/budget_factory.rb
  97. 2
      modules/budgets/spec/factories/labor_budget_item_factory.rb
  98. 2
      modules/budgets/spec/factories/material_budget_item_factory.rb
  99. 10
      modules/budgets/spec/features/budgets/add_budget_spec.rb
  100. 6
      modules/budgets/spec/features/budgets/attachment_upload_spec.rb
  101. Some files were not shown because too many files have changed in this diff Show More

@ -82,10 +82,15 @@ PATH
specs:
openproject-boards (1.0.0)
PATH
remote: modules/budgets
specs:
budgets (1.0.0)
PATH
remote: modules/costs
specs:
openproject-costs (1.0.0)
costs (1.0.0)
PATH
remote: modules/dashboards
@ -173,7 +178,7 @@ PATH
remote: modules/reporting
specs:
openproject-reporting (1.0.0)
openproject-costs
costs
reporting_engine
PATH
@ -990,6 +995,7 @@ DEPENDENCIES
bootsnap (~> 1.4.5)
brakeman (~> 4.8.0)
browser (~> 2.6.1)
budgets!
capybara (~> 3.32.0)
capybara-screenshot (~> 1.0.17)
carrierwave (~> 1.3.1)
@ -998,6 +1004,7 @@ DEPENDENCIES
cells-rails (~> 0.0.9)
commonmarker (~> 0.21.0)
compare-xml (~> 0.66)
costs!
cucumber (~> 3.1.0)
cucumber-rails (~> 1.8.0)
daemons
@ -1051,7 +1058,6 @@ DEPENDENCIES
openproject-backlogs!
openproject-bim!
openproject-boards!
openproject-costs!
openproject-documents!
openproject-github_integration!
openproject-global_roles!

@ -27,7 +27,7 @@ group :opf_plugins do
gem 'openproject-documents', path: 'modules/documents'
gem 'openproject-xls_export', path: 'modules/xls_export'
gem 'reporting_engine', path: 'modules/reporting_engine'
gem 'openproject-costs', path: 'modules/costs'
gem 'costs', path: 'modules/costs'
gem 'openproject-reporting', path: 'modules/reporting'
gem 'openproject-meeting', path: 'modules/meeting'
gem 'openproject-pdf_export', path: 'modules/pdf_export'
@ -45,6 +45,7 @@ group :opf_plugins do
gem 'dashboards', path: 'modules/dashboards'
gem 'openproject-boards', path: 'modules/boards'
gem 'overviews', path: 'modules/overviews'
gem 'budgets', path: 'modules/budgets'
gem 'openproject-bim', path: 'modules/bim'
end

@ -54,6 +54,8 @@ class ModelContract < Reform::Contract
end
def attribute_alias(db, outside)
raise "Cannot define the alias to #{db} to be the same: #{outside}" if db == outside
attribute_aliases[db] = outside
end

@ -59,7 +59,8 @@ module WorkPackages
}
attribute :estimated_hours
attribute :derived_estimated_hours, writeable: false
attribute :derived_estimated_hours,
writeable: false
attribute :parent_id,
permission: :manage_subtasks
@ -99,6 +100,8 @@ module WorkPackages
model.leaf? || model.schedule_manually?
}
attribute :budget
validates :due_date,
date: { after_or_equal_to: :start_date,
message: :greater_than_or_equal_to_start_date,
@ -183,6 +186,10 @@ module WorkPackages
model.try(:assignable_versions) if model.project
end
def assignable_budgets
model.project&.budgets
end
private
attr_reader :can

@ -1,207 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class TimeEntries::ReportsController < ApplicationController
helper_method :gon
menu_item :issues
before_action :find_optional_project
before_action :load_available_criterias
include SortHelper
include TimelogHelper
include CustomFieldsHelper
menu_item :time_entries
def show
# Set tab param to recognize correct selected tab
params[:tab] = params[:tab] || 'report'
@criterias = params[:criterias] || []
@criterias = @criterias.select { |criteria| @available_criterias.has_key? criteria }
@criterias.uniq!
@criterias = @criterias[0, 3]
@columns = (params[:columns] && %w(year month week day).include?(params[:columns])) ? params[:columns] : 'month'
retrieve_date_range
unless @criterias.empty?
sql_select = @criterias.map { |criteria| @available_criterias[criteria][:sql] + ' AS ' + criteria }.join(', ')
sql_group_by = @criterias.map { |criteria| @available_criterias[criteria][:sql] }.join(', ')
sql = "SELECT #{sql_select}, tyear, tmonth, tweek, spent_on, SUM(hours) AS hours"
sql << " FROM #{TimeEntry.table_name}"
sql << time_report_joins
sql << ' WHERE'
sql << ' (%s) AND' % context_sql_condition
sql << " (spent_on BETWEEN '%s' AND '%s')" % [ActiveRecord::Base.connection.quoted_date(@from), ActiveRecord::Base.connection.quoted_date(@to)]
sql << " GROUP BY #{sql_group_by}, tyear, tmonth, tweek, spent_on"
@hours = ActiveRecord::Base.connection.select_all(sql)
@hours.each do |row|
case @columns
when 'year'
row['year'] = row['tyear']
when 'month'
row['month'] = "#{row['tyear']}-#{row['tmonth']}"
when 'week'
row['week'] = "#{row['tyear']}-#{row['tweek']}"
when 'day'
row['day'] = "#{row['spent_on']}"
end
end
@total_hours = @hours.inject(0) { |s, k| s = s + k['hours'].to_f }
@periods = []
# Date#at_beginning_of_ not supported in Rails 1.2.x
date_from = @from.to_time
# 100 columns max
while date_from <= @to.to_time && @periods.length < 100
case @columns
when 'year'
@periods << "#{date_from.year}"
date_from = (date_from + 1.year).at_beginning_of_year
when 'month'
@periods << "#{date_from.year}-#{date_from.month}"
date_from = (date_from + 1.month).at_beginning_of_month
when 'week'
@periods << "#{date_from.year}-#{date_from.to_date.cweek}"
date_from = (date_from + 7.day).at_beginning_of_week
when 'day'
@periods << "#{date_from.to_date}"
date_from = date_from + 1.day
end
end
end
respond_to do |format|
format.html do render layout: !request.xhr? end
format.csv do
render csv: report_to_csv(@criterias, @periods, @hours), filename: 'timelog.csv'
end
end
end
private
def load_available_criterias
@available_criterias = { 'project' => { sql: "#{TimeEntry.table_name}.project_id",
klass: Project,
label: Project.model_name.human },
'version' => { sql: "#{WorkPackage.table_name}.version_id",
klass: Version,
label: Version.model_name.human },
'category' => { sql: "#{WorkPackage.table_name}.category_id",
klass: Category,
label: Category.model_name.human },
'member' => { sql: "#{TimeEntry.table_name}.user_id",
klass: User,
label: Member.model_name.human },
'type' => { sql: "#{WorkPackage.table_name}.type_id",
klass: ::Type,
label: ::Type.model_name.human },
'activity' => { sql: "#{TimeEntry.table_name}.activity_id",
klass: TimeEntryActivity,
label: :label_activity },
'work_package' => { sql: "#{TimeEntry.table_name}.work_package_id",
klass: WorkPackage,
label: WorkPackage.model_name.human }
}
# Add list and boolean custom fields as available criterias
custom_fields = (@project.nil? ? WorkPackageCustomField.for_all : @project.all_work_package_custom_fields)
if @project
custom_fields.select { |cf| %w(list bool).include? cf.field_format }.each do |cf|
@available_criterias["cf_#{cf.id}"] = { sql: "(SELECT c.value FROM #{CustomValue.table_name} c
WHERE c.custom_field_id = #{cf.id}
AND c.customized_type = 'WorkPackage'
AND c.customized_id = #{WorkPackage.table_name}.id)",
format: cf,
label: cf.name }
end
end
# Add list and boolean time entry custom fields
TimeEntryCustomField.all.select { |cf| %w(list bool).include? cf.field_format }.each do |cf|
@available_criterias["cf_#{cf.id}"] = { sql: "(SELECT c.value FROM #{CustomValue.table_name} c
WHERE c.custom_field_id = #{cf.id}
AND c.customized_type = 'TimeEntry'
AND c.customized_id = #{TimeEntry.table_name}.id)",
format: cf,
label: cf.name }
end
# Add list and boolean time entry activity custom fields
TimeEntryActivityCustomField.all.select { |cf| %w(list bool).include? cf.field_format }.each do |cf|
@available_criterias["cf_#{cf.id}"] = { sql: "(SELECT c.value FROM #{CustomValue.table_name} c
WHERE c.custom_field_id = #{cf.id}
AND c.customized_type = 'Enumeration'
AND c.customized_id = #{TimeEntry.table_name}.activity_id)",
format: cf,
label: cf.name }
end
call_hook(:controller_timelog_available_criterias, available_criterias: @available_criterias, project: @project)
@available_criterias
end
def time_report_joins
sql = ''
sql << " LEFT JOIN #{WorkPackage.table_name} ON #{TimeEntry.table_name}.work_package_id = #{WorkPackage.table_name}.id"
sql << " LEFT JOIN #{Project.table_name} ON #{TimeEntry.table_name}.project_id = #{Project.table_name}.id"
# TODO: rename hook
call_hook(:controller_timelog_time_report_joins, sql: sql)
sql
end
def default_breadcrumb
I18n.t(:label_spent_time)
end
def context_sql_condition
if @project.nil?
project_context_sql_condition
elsif @issue.nil?
@project.project_condition(Setting.display_subprojects_work_packages?).to_sql
else
WorkPackage.self_and_descendants_of_condition(@issue)
end
end
def project_context_sql_condition
time_entry_table = TimeEntry.arel_table
allowed_project_ids = Project.allowed_to(User.current, :view_time_entries).select(:id).arel
time_entry_table[:project_id].in(allowed_project_ids).to_sql
end
end

@ -1,232 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class TimelogController < ApplicationController
helper_method :gon
before_action :find_work_package, only: %i[new create]
before_action :find_project, only: %i[new create]
before_action :find_time_entry, only: %i[show edit update destroy]
before_action :authorize, except: [:index]
before_action :find_optional_project, only: [:index]
include SortHelper
include TimelogHelper
include CustomFieldsHelper
include PaginationHelper
include Layout
menu_item :time_entries
def index
# Set tab param to recognize correct selected tab
params[:tab] = params[:tab] || 'details'
sort_init 'spent_on', 'desc'
sort_update 'spent_on' => 'spent_on',
'user' => 'user_id',
'activity' => 'activity_id',
'project' => "#{Project.table_name}.name",
'work_package' => 'work_package_id',
'comments' => 'comments',
'hours' => 'hours'
cond = ARCondition.new
if @issue
cond << WorkPackage.self_and_descendants_of_condition(@issue)
elsif @project
cond << @project.project_condition(Setting.display_subprojects_work_packages?).to_sql
end
retrieve_date_range allow_nil: true
if @from && @to
cond << ['spent_on BETWEEN ? AND ?', @from, @to]
end
respond_to do |format|
format.html do
render layout: layout_non_or_no_menu
end
format.csv do
# Export all entries
@entries = TimeEntry
.visible
.includes(:project,
:activity,
:user,
work_package: %i[type assigned_to priority])
.references(:projects)
.where(cond.conditions)
.distinct(false)
.order(sort_clause)
render csv: entries_to_csv(@entries), filename: 'timelog.csv'
end
end
end
def new
@time_entry = new_time_entry(@project, @issue, permitted_params.time_entry.to_h)
call_hook(:controller_timelog_edit_before_save, params: params, time_entry: @time_entry)
render action: 'edit'
end
def create
combined_params = permitted_params
.time_entry
.to_h
.reverse_merge(project: @project,
work_package_id: @issue)
call = TimeEntries::CreateService
.new(user: current_user)
.call(combined_params)
@time_entry = call.result
respond_for_saving call
end
def edit
@time_entry.attributes = permitted_params.time_entry
call_hook(:controller_timelog_edit_before_save, params: params, time_entry: @time_entry)
end
def update
service = TimeEntries::UpdateService
.new(user: current_user,
model: @time_entry)
call = service.call(attributes: permitted_params.time_entry)
respond_for_saving call
end
def destroy
if @time_entry.destroy && @time_entry.destroyed?
respond_to do |format|
format.html do
flash[:notice] = l(:notice_successful_delete)
redirect_back fallback_location: { action: 'index', project_id: @time_entry.project }
end
format.json do
render json: { text: l(:notice_successful_delete) }
end
end
else
respond_to do |format|
format.html do
flash[:error] = l(:notice_unable_delete_time_entry)
redirect_back fallback_location: { action: 'index', project_id: @time_entry.project }
end
format.json do
render json: { isError: true, text: l(:notice_unable_delete_time_entry) }
end
end
end
end
private
def find_time_entry
@time_entry = TimeEntry.find(params[:id])
unless @time_entry.editable_by?(User.current)
render_403
return false
end
@project = @time_entry.project
rescue ActiveRecord::RecordNotFound
render_404
end
def find_project
@project = Project.find(project_id_from_params) if @project.nil?
rescue ActiveRecord::RecordNotFound
render_404
end
def new_time_entry(project, work_package, attributes)
time_entry = TimeEntry.new(project: project,
work_package: work_package,
user: User.current,
spent_on: User.current.today)
time_entry.attributes = attributes
time_entry
end
def respond_for_saving(call)
@errors = call.errors
if call.success?
respond_to do |format|
format.html do
flash[:notice] = l(:notice_successful_update)
redirect_back_or_default action: 'index', project_id: @time_entry.project
end
end
else
respond_to do |format|
format.html do
render action: 'edit'
end
end
end
end
def project_id_from_params
if params.has_key?(:project_id)
params[:project_id]
elsif params.has_key?(:time_entry) && permitted_params.time_entry.has_key?(:project_id)
permitted_params.time_entry[:project_id]
end
end
def find_work_package
@issue = work_package_from_params
@project = @issue.project unless @issue.nil?
end
def work_package_from_params
if params.has_key?(:work_package_id)
work_package_id = params[:work_package_id]
elsif params.has_key?(:time_entry) && permitted_params.time_entry.has_key?(:work_package_id)
work_package_id = permitted_params.time_entry[:work_package_id]
end
WorkPackage.find_by id: work_package_id
end
def default_breadcrumb
I18n.t(:label_spent_time)
end
end

@ -1,279 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module TimelogHelper
include ApplicationHelper
def time_entry_tabs
[
{
name: 'details',
partial: 'timelog/time_entry_tab',
path: polymorphic_time_entries_path(@issue || @project),
label: :label_details
},
{
name: 'report',
partial: 'time_entries/reports/reports_tab',
path: polymorphic_time_entries_report_path(@issue || @project),
label: :label_report
}
]
end
# Returns a collection of activities for a select field. time_entry
# is optional and will be used to check if the selected TimeEntryActivity
# is active.
def activity_collection_for_select_options(time_entry = nil, project = nil)
project ||= @project
activities =
if project.nil?
TimeEntryActivity.all.active
else
TimeEntryActivity::Scopes::ActiveInProject.fetch(project)
end
activities.map do |a|
if a.is_default? && !time_entry&.activity
[a.name, a.id, selected: true]
else
[a.name, a.id]
end
end
end
def select_hours(data, criteria, value)
if value.to_s.empty?
data.select { |row| row[criteria].blank? }
else
data.select { |row| row[criteria].to_s == value.to_s }
end
end
def sum_hours(data)
sum = 0
data.each do |row|
sum += row['hours'].to_f
end
sum
end
def options_for_period_select(value)
options_for_select([[l(:label_all_time), 'all'],
[l(:label_today), 'today'],
[l(:label_yesterday), 'yesterday'],
[l(:label_this_week), 'current_week'],
[l(:label_last_week), 'last_week'],
[l(:label_last_n_days, 7), '7_days'],
[l(:label_this_month), 'current_month'],
[l(:label_last_month), 'last_month'],
[l(:label_last_n_days, 30), '30_days'],
[l(:label_this_year), 'current_year']],
value)
end
def entries_to_csv(entries)
decimal_separator = l(:general_csv_decimal_separator)
custom_fields = TimeEntryCustomField.all
export = CSV.generate(col_sep: l(:general_csv_separator)) { |csv|
# csv header fields
headers = [TimeEntry.human_attribute_name(:spent_on),
TimeEntry.human_attribute_name(:user),
TimeEntry.human_attribute_name(:activity),
TimeEntry.human_attribute_name(:project),
TimeEntry.human_attribute_name(:issue),
TimeEntry.human_attribute_name(:type),
TimeEntry.human_attribute_name(:subject),
TimeEntry.human_attribute_name(:hours),
TimeEntry.human_attribute_name(:comments)
]
# Export custom fields
headers += custom_fields.map(&:name)
csv << WorkPackage::Exporter::CSV.encode_csv_columns(headers)
# csv lines
entries.each do |entry|
fields = [format_date(entry.spent_on),
entry.user,
entry.activity,
entry.project,
(entry.work_package ? entry.work_package.id : nil),
(entry.work_package ? entry.work_package.type : nil),
(entry.work_package ? entry.work_package.subject : nil),
entry.hours.to_s.gsub('.', decimal_separator),
entry.comments
]
fields += custom_fields.map { |f| show_value(entry.custom_value_for(f)) }
csv << WorkPackage::Exporter::CSV.encode_csv_columns(fields)
end
}
export
end
def format_criteria_value(criteria, value)
if value.blank?
l(:label_none)
elsif k = @available_criterias[criteria][:klass]
obj = k.find_by(id: value.to_i)
if obj.is_a?(WorkPackage)
obj.visible? ? h("#{obj.type} ##{obj.id}: #{obj.subject}") : h("##{obj.id}")
else
obj
end
else
format_value(value, @available_criterias[criteria][:format])
end
end
def report_to_csv(criterias, periods, hours)
export = CSV.generate(col_sep: l(:general_csv_separator)) { |csv|
# Column headers
headers = criterias.map { |criteria|
label = @available_criterias[criteria][:label]
label.is_a?(Symbol) ? l(label) : label
}
headers += periods
headers << l(:label_total)
csv << headers.map { |c| to_utf8_for_timelogs(c) }
# Content
report_criteria_to_csv(csv, criterias, periods, hours)
# Total row
row = [l(:label_total)] + [''] * (criterias.size - 1)
total = 0
periods.each do |period|
sum = sum_hours(select_hours(hours, @columns, period.to_s))
total += sum
row << (sum > 0 ? '%.2f' % sum : '')
end
row << '%.2f' % total
csv << row
}
export
end
def report_criteria_to_csv(csv, criterias, periods, hours, level = 0)
hours.map { |h| h[criterias[level]].to_s }.uniq.each do |value|
hours_for_value = select_hours(hours, criterias[level], value)
next if hours_for_value.empty?
row = [''] * level
row << to_utf8_for_timelogs(format_criteria_value(criterias[level], value))
row += [''] * (criterias.length - level - 1)
total = 0
periods.each do |period|
sum = sum_hours(select_hours(hours_for_value, @columns, period.to_s))
total += sum
row << (sum > 0 ? '%.2f' % sum : '')
end
row << '%.2f' % total
csv << row
if criterias.length > level + 1
report_criteria_to_csv(csv, criterias, periods, hours_for_value, level + 1)
end
end
end
def to_utf8_for_timelogs(s)
s.to_s.encode(l(:general_csv_encoding), 'UTF-8'); rescue; s.to_s end
def polymorphic_time_entries_path(object)
polymorphic_path([object, :time_entries])
end
def polymorphic_new_time_entry_path(object)
polymorphic_path([:new, object, :time_entry,])
end
def polymorphic_time_entries_report_path(object)
polymorphic_path([object, :time_entries, :report])
end
# Retrieves the date range based on predefined ranges or specific from/to param dates
def retrieve_date_range(allow_nil: false)
@free_period = false
@from = nil
@to = nil
if params[:period_type] == '1' || (params[:period_type].nil? && !params[:period].nil?)
case params[:period].to_s
when 'today'
@from = @to = Date.today
when 'yesterday'
@from = @to = Date.today - 1
when 'current_week'
@from = Date.today - (Date.today.cwday - 1) % 7
@to = @from + 6
when 'last_week'
@from = Date.today - 7 - (Date.today.cwday - 1) % 7
@to = @from + 6
when '7_days'
@from = Date.today - 7
@to = Date.today
when 'current_month'
@from = Date.civil(Date.today.year, Date.today.month, 1)
@to = (@from >> 1) - 1
when 'last_month'
@from = Date.civil(Date.today.year, Date.today.month, 1) << 1
@to = (@from >> 1) - 1
when '30_days'
@from = Date.today - 30
@to = Date.today
when 'current_year'
@from = Date.civil(Date.today.year, 1, 1)
@to = Date.civil(Date.today.year, 12, 31)
end
elsif params[:period_type] == '2' || (params[:period_type].nil? && (!params[:from].nil? || !params[:to].nil?))
begin; @from = params[:from].to_s.to_date unless params[:from].blank?; rescue; end
begin; @to = params[:to].to_s.to_date unless params[:to].blank?; rescue; end
@free_period = true
# default
end
@from, @to = @to, @from if @from && @to && @from > @to
unless allow_nil
@from ||= (TimeEntry.earliest_date_for_project(@project) || Date.today)
@to ||= (TimeEntry.latest_date_for_project(@project) || Date.today)
end
end
def find_optional_project
if !params[:issue_id].blank?
@issue = WorkPackage.find(params[:issue_id])
@project = @issue.project
elsif !params[:work_package_id].blank?
@issue = WorkPackage.find(params[:work_package_id])
@project = @issue.project
elsif !params[:project_id].blank?
@project = Project.find(params[:project_id])
end
deny_access unless User.current.allowed_to?(:view_time_entries, @project, global: true)
end
end

@ -235,14 +235,6 @@ class PermittedParams
params.require(:type).permit(*self.class.permitted_attributes[:move_to])
end
def timelog
params.permit(:period,
:period_type,
:from,
:to,
criterias: [])
end
def search
params.permit(*self.class.permitted_attributes[:search])
end
@ -293,14 +285,6 @@ class PermittedParams
whitelist.merge(custom_field_values(:project))
end
def time_entry
permitted_params = params.fetch(:time_entry, {}).permit(
:hours, :comments, :work_package_id, :activity_id, :spent_on
)
permitted_params.merge(custom_field_values(:time_entry, required: false))
end
def news
params.require(:news).permit(:title, :summary, :description)
end
@ -523,6 +507,7 @@ class PermittedParams
:due_date,
:estimated_hours,
:version_id,
:budget_id,
:parent_id,
:priority_id,
:responsible_id,
@ -539,15 +524,6 @@ class PermittedParams
{ watcher_user_ids: [] }
end
end,
Proc.new do |args|
# avoid costly allowed_to? if the param is not there at all
if args[:params]['work_package'] &&
args[:params]['work_package'].has_key?('time_entry') &&
args[:current_user].allowed_to?(:log_time, args[:project])
{ time_entry: %i[hours activity_id comments] }
end
end,
# attributes unique to :new_work_package
:journal_notes,
:lock_version],

@ -138,6 +138,7 @@ class Project < ApplicationRecord
join_table: "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
association_foreign_key: 'custom_field_id'
has_one :status, class_name: 'Projects::Status', dependent: :destroy
has_many :budgets, dependent: :destroy
acts_as_nested_set order_column: :name, dependent: :destroy
@ -146,10 +147,6 @@ class Project < ApplicationRecord
date_column: "#{table_name}.created_at",
project_key: 'id',
permission: nil
acts_as_event title: Proc.new { |o| "#{Project.model_name.human}: #{o.name}" },
url: Proc.new { |o| { controller: 'overviews/overviews', action: 'show', project_id: o } },
author: nil,
datetime: :created_at
validates :name,
presence: true,

@ -220,12 +220,13 @@ class User < Principal
def self.try_to_login(login, password, session = nil)
# Make sure no one can sign in with an empty password
return nil if password.to_s.empty?
user = find_by_login(login)
user = if user
try_authentication_for_existing_user(user, password, session)
else
try_authentication_and_create_user(login, password)
end
end
unless prevent_brute_force_attack(user, login).nil?
user.log_successful_login if user && !user.new_record?
return user

@ -41,6 +41,7 @@ class WorkPackage < ApplicationRecord
include WorkPackage::Hooks
include WorkPackages::DerivedDates
include WorkPackages::SpentTime
include WorkPackages::Costs
include ::Scopes::Scoped
include OpenProject::Journal::AttachmentHelper
@ -549,14 +550,6 @@ class WorkPackage < ApplicationRecord
.select("#{table_name}.*, COALESCE(max_depth.depth, 0)")
end
def self.self_and_descendants_of_condition(work_package)
relation_subquery = Relation
.with_type_columns_not(hierarchy: nil)
.select(:to_id)
.where(from_id: work_package.id)
"#{table_name}.id IN (#{relation_subquery.to_sql}) OR #{table_name}.id = #{work_package.id}"
end
# Overrides Redmine::Acts::Customizable::ClassMethods#available_custom_fields
def self.available_custom_fields(work_package)
WorkPackage::AvailableCustomFields.for(work_package.project, work_package.type)

@ -88,6 +88,14 @@ module WorkPackage::Journalized
name: JournalizedProcs.event_name,
url: JournalizedProcs.event_url
register_journal_formatter(:cost_association) do |value, journable, field|
association = journable.class.reflect_on_association(field.to_sym)
if association
record = association.class_name.constantize.find_by_id(value.to_i)
record&.subject
end
end
register_on_journal_formatter(:id, 'parent_id')
register_on_journal_formatter(:fraction, 'estimated_hours')
register_on_journal_formatter(:fraction, 'derived_estimated_hours')
@ -96,6 +104,7 @@ module WorkPackage::Journalized
register_on_journal_formatter(:schedule_manually, 'schedule_manually')
register_on_journal_formatter(:attachment, /attachments_?\d+/)
register_on_journal_formatter(:custom_field, /custom_fields_\d+/)
register_on_journal_formatter(:cost_association, 'budget_id')
# Joined
register_on_journal_formatter :named_association, :parent_id, :project_id,

@ -0,0 +1,141 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module WorkPackages::Costs
extend ActiveSupport::Concern
included do
belongs_to :budget, inverse_of: :work_packages
has_many :cost_entries, dependent: :delete_all
# disabled for now, implements part of ticket blocking
validate :validate_budget
after_update :move_cost_entries
associated_to_ask_before_destruction CostEntry,
->(work_packages) { CostEntry.on_work_packages(work_packages).count.positive? },
method(:cleanup_cost_entries_before_destruction_of)
def costs_enabled?
project&.costs_enabled?
end
def validate_budget
if budget_id_changed?
unless budget_id.blank? || project.budget_ids.include?(budget_id)
errors.add :budget, :inclusion
end
end
end
def material_costs
if respond_to?(:cost_entries_sum) # column has been eager loaded into result set
cost_entries_sum.to_f
else
::WorkPackage::MaterialCosts.new(user: User.current).costs_of work_packages: self_and_descendants
end
end
def labor_costs
if respond_to?(:time_entries_sum) # column has been eager loaded into result set
time_entries_sum.to_f
else
::WorkPackage::LaborCosts.new(user: User.current).costs_of work_packages: self_and_descendants
end
end
def overall_costs
labor_costs + material_costs
end
# Wraps the association to get the Cost Object subject. Needed for the
# Query and filtering
def budget_subject
budget&.subject
end
def move_cost_entries
return unless saved_change_to_project_id?
CostEntry
.where(work_package_id: id)
.update_all(project_id: project_id)
end
end
class_methods do
protected
def cleanup_cost_entries_before_destruction_of(work_packages, user, to_do = { action: 'destroy' })
work_packages = Array(work_packages)
return false unless to_do.present?
case to_do[:action]
when 'destroy'
true
# nothing to do
when 'nullify'
work_packages.each do |wp|
wp.errors.add(:base, :nullify_is_not_valid_for_cost_entries)
end
false
when 'reassign'
reassign_cost_entries_before_destruction(work_packages, user, to_do[:reassign_to_id])
else
false
end
end
def reassign_cost_entries_before_destruction(work_packages, user, ids)
reassign_to = ::WorkPackage
.joins(:project)
.merge(Project.allowed_to(user, :edit_cost_entries))
.find_by_id(ids)
if reassign_to.nil?
work_packages.each do |wp|
wp.errors.add(:base, :is_not_a_valid_target_for_cost_entries, id: ids)
end
false
else
condition = "work_package_id = #{reassign_to.id}, project_id = #{reassign_to.project_id}"
::WorkPackage.update_cost_entries(work_packages.map(&:id), condition)
end
end
def update_cost_entries(work_packages, action)
CostEntry.where(work_package_id: work_packages).update_all(action)
end
end
end

@ -41,7 +41,7 @@ See docs/COPYRIGHT.rdoc for more details.
<li class="<%= e.event_type %> <%= User.current.logged? && e.respond_to?(:event_author) && User.current == e.event_author ? 'me' : nil %>">
<div class="title">
<% event_type = e.event_type.start_with?('meeting') ? 'meetings' : e.event_type %>
<% event_type = e.event_type == 'cost_object' ? 'budget' : event_type %>
<% event_type = e.event_type == 'budget' ? 'budget' : event_type %>
<%= icon_wrapper("icon-context icon-#{event_type}", e.event_name) %>
<span class="time"><%= format_time(e.event_datetime.to_time, false) %></span>
<% if (@project.nil? || @project != e.project) && e.project %>

@ -51,7 +51,7 @@ See docs/COPYRIGHT.rdoc for more details.
<dt class="<%= e.event_type %>">
<% event_type = e.event_type == 'meeting' ? 'meetings' : e.event_type %>
<% event_type = e.event_type == 'cost_object' ? 'budget' : event_type %>
<% event_type = e.event_type == 'budget' ? 'budget' : event_type %>
<% event_type = e.event_type == 'reply' ? 'forums' : event_type %>
<%= icon_wrapper("icon-context icon-#{event_type}", e.event_name) %>

@ -30,6 +30,6 @@ See docs/COPYRIGHT.rdoc for more details.
<div id="settings">
<%= styled_form_tag({action: 'plugin'}) do %>
<%= render partial: @partial, locals: {settings: @settings}%>
<%= styled_submit_tag l(:button_apply), class: '-highlight' %>
<%= styled_submit_tag t(:button_apply), class: '-highlight' %>
<% end %>
</div>

@ -1,48 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2017 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<% @hours.collect {|h| h[criterias[level]].to_s}.uniq.each do |value| %>
<% hours_for_value = select_hours(hours, criterias[level], value) -%>
<% next if hours_for_value.empty? -%>
<tr class="<%= 'last-level' unless criterias.length > level+1 %>">
<%= ('<td></td>' * level).html_safe %>
<td><%= h(format_criteria_value(criterias[level], value)) %></td>
<%= ('<td></td>' * (criterias.length - level - 1)).html_safe -%>
<% total = 0 -%>
<% @periods.each do |period| -%>
<% sum = sum_hours(select_hours(hours_for_value, @columns, period.to_s)); total += sum -%>
<td class="hours"><%= html_hours("%.2f" % sum) if sum > 0 %></td>
<% end -%>
<td class="hours"><%= html_hours("%.2f" % total) if total > 0 %></td>
</tr>
<% if criterias.length > level+1 -%>
<%= render(partial: 'report_criteria', locals: { criterias: criterias, hours: hours_for_value, level: (level + 1) }) %>
<% end -%>
<% end %>

@ -1,140 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2017 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= styled_form_tag(polymorphic_time_entries_report_path(@issue || @project), method: :get, id: 'query_form', class: '-vertical') do %>
<% @criterias.each do |criteria| %>
<%= hidden_field_tag 'criterias[]', criteria, id: nil %>
<% end %>
<div class="timelog-report-selection small-6">
<div class="grid-block">
<div class="form--field">
<%= styled_label_tag :columns, "#{t(:label_details)}:" %>
<%= styled_select_tag 'columns', options_for_select([[l(:label_year), 'year'],
[l(:label_month), 'month'],
[l(:label_week), 'week'],
[l(:label_day_plural).titleize, 'day']], @columns),
container_class: '-slim' %>
</div>
<div class="form--field">
<%= styled_label_tag :criterias, "#{t(:button_add)}:" %>
<% available_criterias = [[]] + (@available_criterias.keys - @criterias).collect{ |k|
[l_or_humanize(@available_criterias[k][:label]), k]
}
%>
<%= styled_select_tag('criterias[]', options_for_select(available_criterias),
id: "criterias",
disabled: (@criterias.length >= 3),
container_class: '-slim') %>
</div>
</div>
<div class="grid-block">
<%= submit_tag t(:button_apply), class: 'button -highlight' %>
<%= link_to t(:button_clear), {project_id: @project, issue_id: @issue, period_type: params[:period_type], period: params[:period], from: @from, to: @to, columns: @columns}, class: 'button' %>
</div>
</div>
<% end %>
<% unless @criterias.empty? %>
<div class="total-hours">
<p><%= t(:label_total) %>: <%= html_hours(l_hours(@total_hours)) %></p>
</div>
<% unless @hours.empty? %>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table" id="time-report">
<colgroup>
<% @criterias.each do |criteria| %>
<col highlight-col>
<% end %>
<% @periods.each do |period| %>
<col highlight-col>
<% end %>
<col highlight-col>
</colgroup>
<thead>
<tr>
<% @criterias.each do |criteria| %>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= l_or_humanize(@available_criterias[criteria][:label]) %>
</span>
</div>
</div>
</th>
<% end %>
<% @periods.each do |period| %>
<th class="period">
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= period %>
</span>
</div>
</div>
</th>
<% end %>
<th class="total">
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= t(:label_total) %>
</span>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<%= render partial: 'report_criteria', locals: {criterias: @criterias, hours: @hours, level: 0} %>
<tr class="total">
<td><%= t(:label_total) %></td>
<%= ('<td></td>' * (@criterias.size - 1)).html_safe %>
<% total = 0 -%>
<% @periods.each do |period| -%>
<% sum = sum_hours(select_hours(@hours, @columns, period.to_s)); total += sum -%>
<td class="hours"><%= html_hours("%.2f" % sum) if sum > 0 %></td>
<% end -%>
<td class="hours"><%= html_hours("%.2f" % total) if total > 0 %></td>
</tr>
</tbody>
</table>
</div>
</div>
<%= other_formats_links do |f| %>
<%= f.link_to 'CSV', url: permitted_params.timelog.to_h %>
<% end %>
<% end %>
<% end %>

@ -1,45 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2017 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= toolbar title: t(:label_spent_time) do %>
<% if User.current.allowed_to?({controller: 'timelog', action: :new}, @project) %>
<li class="toolbar-item">
<%= link_to polymorphic_new_time_entry_path(@issue || @project), class: 'button' do %>
<%= op_icon('button--icon icon-time') %>
<span class="button--text"><%= t(:button_log_time) %></span>
<% end %>
</li>
<% end %>
<% end %>
<%= render partial: 'timelog/date_range' %>
<%= render_tabs time_entry_tabs %>
<% html_title t(:label_spent_time), t(:label_report) %>

@ -1,65 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2017 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<% content_for :header_tags do %>
<meta name="required_script" content="timelog_date_range" />
<% end %>
<fieldset id="date-range" class="simple-filters--container">
<legend><%= l(:label_date_range) %></legend>
<ul class="simple-filters--filters">
<li class="simple-filters--filter -with-radio-buttons">
<%= styled_label_tag "period_type_list", l(:description_date_range_list), class: "hidden-for-sighted simple-filters--filter-name" %>
<%= styled_radio_button_tag 'period_type', '1',
!@free_period,
id: "period_type_list"%>
<div class="simple-filters--filter-value">
<%= styled_select_tag 'period',
options_for_period_select(params[:period]),
class: "-narrow" %>
</div>
</li>
<li class="simple-filters--filter -with-radio-buttons">
<%= styled_label_tag "period_type_interval", l(:description_date_range_interval), class: "hidden-for-sighted simple-filters--filter-name" %>
<%= styled_radio_button_tag 'period_type', '2', @free_period, id: "period_type_interval" %>
<%= styled_label_tag("from", l(:label_date_from), class: 'simple-filters--filter-name') %>
<div class="simple-filters--filter-value">
<%= styled_text_field_tag('from', @from, class: '-augmented-datepicker', size: 10) %>
</div>
<%= styled_label_tag("to", l(:label_date_to), class: 'simple-filters--filter-name') %>
<div class="simple-filters--filter-value">
<%= styled_text_field_tag('to', @to, class: '-augmented-datepicker', size: 10) %>
</div>
</li>
<li class="simple-filters--controls">
<%= styled_button_tag l(:button_apply), class: 'button -highlight -small' %>
<%= link_to l(:button_clear), polymorphic_time_entries_path(@issue || @project), class: 'button -small' %>
</li>
</ul>
</fieldset>

@ -1,40 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2017 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<div class="notification-box -warning">
<a title="close" class="notification-box--close icon-context icon-close"></a>
<div class="notification-box--content">
<%= t('deprecations.time_entries') %>
</div>
</div>
<div>
<%= other_formats_links do |f| %>
<%= f.link_to 'CSV', url: permitted_params.timelog.to_h %>
<% end %>
</div>

@ -1,67 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2017 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= toolbar title: t(:label_spent_time) %>
<%= labelled_tabular_form_for [@time_entry.project, @time_entry], as: :time_entry do |f| %>
<%= error_messages_for_contract @time_entry, @errors %>
<%= back_url_hidden_field_tag %>
<div class="form--field">
<%= f.text_field :work_package_id, size: 6, container_class: '-xslim' %>
<div class="form--field-instructions">
<%= h("#{(@time_entry.work_package.type.nil?) ? '' : @time_entry.work_package.type.name} ##{@time_entry.work_package.id}: #{@time_entry.work_package.subject}") if @time_entry.work_package%>
</div>
</div>
<div class="form--field -required">
<%= f.text_field :spent_on, size: 10, required: true, container_class: '-xslim', class: '-augmented-datepicker' %>
</div>
<div class="form--field -required">
<%= f.text_field :hours, size: 6, required: true, container_class: '-xslim', class: '-number' %>
</div>
<div class="form--field">
<%= f.text_field :comments, size: 100, container_class: '-wide' %>
</div>
<div class="form--field -required">
<%= f.select :activity_id,
activity_collection_for_select_options(@time_entry),
prompt: "--- #{t(:actionview_instancetag_blank_option)} ---",
required: true,
container_class: '-slim' %>
</div>
<%= render partial: "customizable/form",
locals: { form: f, all_fields: true, only_required: false } %>
<%= call_hook(:view_timelog_edit_form_bottom, { time_entry: @time_entry, form: f }) %>
<%= f.button t(:button_save), class: 'button -highlight -with-icon icon-checkmark' %>
<% end %>

@ -1,47 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2020 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2017 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= toolbar title: l(:label_spent_time) do %>
<% if User.current.allowed_to?({controller: :timelog, action: :new}, @project) %>
<li class="toolbar-item">
<%= link_to polymorphic_new_time_entry_path(@issue || @project), class: 'button' do %>
<%= op_icon('button--icon icon-time') %>
<span class="button--text"><%= l(:button_log_time) %></span>
<% end %>
</li>
<% end %>
<% end %>
<%= form_tag(polymorphic_path([@issue || @project, :time_entries]), method: :get, id: 'query_form') do %>
<%= render partial: 'date_range' %>
<% end %>
<%= render_tabs time_entry_tabs %>
<% html_title l(:label_spent_time) %>

@ -88,7 +88,7 @@ See docs/COPYRIGHT.rdoc for more details.
<li class="<%= e.event_type %>">
<div class="title">
<% event_type = e.event_type == 'meeting' ? 'meetings' : e.event_type %>
<% event_type = e.event_type == 'cost_object' ? 'budget' : event_type %>
<% event_type = e.event_type == 'budget' ? 'budget' : event_type %>
<%= icon_wrapper("icon-context icon-#{event_type}", e.event_name) %>
<span class="time"><%= format_time(e.event_datetime, false) %></span>
<%= content_tag('span', h(e.project), class: 'project') %>

@ -132,6 +132,18 @@ See docs/COPYRIGHT.rdoc for more details.
</div>
</div>
<% end %>
<% if @project %>
<div class="form--field">
<label class="form--label" for='budget_id'><%= Budget.model_name.human %></label>
<div class="form--field-container">
<%= styled_select_tag('budget_id',
content_tag('option', t(:label_no_change_option), :value => '') +
content_tag('option', t(:label_none), :value => 'none') +
options_from_collection_for_select(@project.budgets.order(Arel.sql('subject ASC')), :id, :subject))
%>
</div>
</div>
<% end %>
<% @custom_fields.each do |custom_field| %>
<div class="form--field">
<%= blank_custom_field_label_tag('work_package', custom_field) %>

@ -113,6 +113,12 @@ See docs/COPYRIGHT.rdoc for more details.
options_from_collection_for_select(@target_project.possible_responsibles, :id, :name)) %>
</div>
</div>
<div class="form--field">
<%= styled_label_tag :budget_id, Budget.model_name.human %>
<%= styled_select_tag('budget_id', (@target_project == @project ? content_tag('option', t(:label_no_change_option), :value => '') : "") +
content_tag('option', t(:label_none), :value => 'none') +
options_from_collection_for_select(@target_project.budgets, :id, :subject)) %>
</div>
</div>
<div class="grid-content medium-6">
<div class="form--field">

@ -37,8 +37,6 @@ OpenProject::Activity.map do |activity|
default: false
activity.register :messages, class_name: 'Activities::MessageActivityProvider',
default: false
activity.register :time_entries, class_name: 'Activities::TimeEntryActivityProvider',
default: false
end
Project.register_latest_project_activity on: 'WorkPackage',
@ -58,6 +56,3 @@ Project.register_latest_project_activity on: 'WikiContent',
Project.register_latest_project_activity on: 'Message',
chain: 'Forum',
attribute: :updated_on
Project.register_latest_project_activity on: 'TimeEntry',
attribute: :updated_on

@ -55,14 +55,6 @@ Redmine::MenuManager.map :top_menu do |menu|
(User.current.logged? || !Setting.login_required?) &&
User.current.allowed_to?(:view_news, nil, global: true)
}
menu.push :time_sheet,
{ controller: '/timelog', project_id: nil, action: 'index' },
context: :modules,
caption: I18n.t('label_time_sheet_menu'),
if: Proc.new {
(User.current.logged? || !Setting.login_required?) &&
User.current.allowed_to?(:view_time_entries, nil, global: true)
}
menu.push :help,
OpenProject::Static::Links.help_link,
last: true,
@ -284,38 +276,38 @@ Redmine::MenuManager.map :admin_menu do |menu|
menu.push :custom_style,
{ controller: '/custom_styles', action: 'show' },
caption: :label_custom_style,
caption: :label_custom_style,
icon: 'icon2 icon-design'
menu.push :colors,
{ controller: '/colors', action: 'index' },
caption: :'timelines.admin_menu.colors',
caption: :'timelines.admin_menu.colors',
icon: 'icon2 icon-status'
menu.push :enterprise,
{ controller: '/enterprises', action: 'show' },
caption: :label_enterprise_edition,
caption: :label_enterprise_edition,
icon: 'icon2 icon-headset',
if: proc { OpenProject::Configuration.ee_manager_visible? }
menu.push :admin_costs,
{ controller: '/settings', action: 'plugin', id: :openproject_costs },
caption: :label_cost_object_plural,
{ controller: '/settings', action: 'plugin', id: :costs },
caption: :project_module_costs,
icon: 'icon2 icon-budget'
menu.push :costs_setting,
{ controller: '/settings', action: 'plugin', id: :openproject_costs },
caption: :label_settings,
{ controller: '/settings', action: 'plugin', id: :costs },
caption: :label_settings,
parent: :admin_costs
menu.push :admin_backlogs,
{ controller: '/settings', action: 'plugin', id: :openproject_backlogs },
caption: :label_backlogs,
caption: :label_backlogs,
icon: 'icon2 icon-backlogs'
menu.push :backlogs_settings,
{ controller: '/settings', action: 'plugin', id: :openproject_backlogs },
caption: :label_settings,
caption: :label_settings,
parent: :admin_backlogs
end
@ -376,13 +368,6 @@ Redmine::MenuManager.map :project_menu do |menu|
# Wiki menu items are added by WikiMenuItemHelper
menu.push :time_entries,
{ controller: '/timelog', action: 'index' },
param: :project_id,
if: -> (project) { User.current.allowed_to?(:view_time_entries, project) },
caption: :label_time_sheet_menu,
icon: 'icon2 icon-cost-reports'
menu.push :members,
{ controller: '/members', action: 'index' },
param: :project_id,

@ -182,31 +182,6 @@ OpenProject::AccessControl.map do |map|
{}
end
map.project_module :time_tracking do |time|
time.permission :view_time_entries,
timelog: %i[index show],
time_entry_reports: [:report]
time.permission :log_time,
{ timelog: %i[new create edit update] },
require: :loggedin
time.permission :edit_time_entries,
{ timelog: %i[new create edit update destroy] },
require: :member
time.permission :view_own_time_entries,
timelog: %i[index report]
time.permission :edit_own_time_entries,
{ timelog: %i[new create edit update destroy] },
require: :loggedin
time.permission :manage_project_activities,
{ 'projects/time_entry_activities': %i[update] },
require: :member
end
map.project_module :news do |news|
news.permission :view_news,
{ news: %i[index show] },

@ -201,9 +201,6 @@ en:
concatenation:
single: 'or'
deprecations:
time_entries: "This time entries view is superseded by the 'Cost reports' module. This view now only supports exporting time entry information to csv. For interactive filtering, please activate the 'Cost reports' module in the project settings."
global_search:
overwritten_tabs:
wiki_pages: "Wiki"
@ -873,7 +870,6 @@ en:
button_generate: "Generate"
button_list: "List"
button_lock: "Lock"
button_log_time: "Log time"
button_login: "Sign in"
button_move: "Move"
button_move_and_follow: "Move and follow"
@ -1061,8 +1057,6 @@ en:
description_compare_to: "Compare to"
description_current_position: "You are here: "
description_date_from: "Enter start date"
description_date_range_interval: "Choose range by selecting start and end date"
description_date_range_list: "Choose range from list"
description_date_to: "Enter end date"
description_enter_number: "Enter number"
description_enter_text: "Enter text"
@ -1363,7 +1357,6 @@ en:
label_date_and_time: "Date and time"
label_date_from: "From"
label_date_from_to: "From %{start} to %{end}"
label_date_range: "Date range"
label_date_to: "To"
label_day_plural: "days"
label_default: "Default"
@ -1510,7 +1503,6 @@ en:
label_modified: "modified"
label_module_plural: "Modules"
label_modules: "Modules"
label_month: "Month"
label_months_from: "months from"
label_more: "More"
label_more_than_ago: "more than days ago"
@ -1681,8 +1673,6 @@ en:
label_this_week: "this week"
label_this_year: "this year"
label_time_entry_plural: "Spent time"
label_time_sheet_menu: "Time sheet"
label_time_tracking: "Time tracking"
label_projects_menu: "Projects"
label_today: "today"
label_top_menu: "Top Menu"
@ -1782,7 +1772,6 @@ en:
one: "1 project"
other: "%{count} projects"
zero: "no projects"
label_year: "Year"
label_yesterday: "yesterday"
auth_source:
@ -1908,7 +1897,6 @@ en:
notice_successful_delete: "Successful deletion."
notice_successful_update: "Successful update."
notice_to_many_principals_to_display: "There are too many results.\nNarrow down the search by typing in the name of the new member (or group)."
notice_unable_delete_time_entry: "Unable to delete time log entry."
notice_user_missing_authentication_method: User has yet to choose a password or another way to sign in.
notice_user_invitation_resent: An invitation has been sent to %{email}.
present_access_key_value: "Your %{key_name} is: %{value}"
@ -2045,8 +2033,6 @@ en:
project_module_work_package_tracking: "Work package tracking"
project_module_news: "News"
project_module_repository: "Repository"
project_module_time_tracking: "Time tracking"
project_module_timelines: "Timelines"
project_module_wiki: "Wiki"
query:

@ -208,11 +208,6 @@ OpenProject::Application.routes.draw do
resources :news, only: %i[index new create]
namespace :time_entries do
resource :report, controller: 'reports', only: [:show]
end
resources :time_entries, controller: 'timelog', except: [:show]
# Match everything to be the ID of the wiki page except the part that
# is reserved for the format. This assumes that we have only two formats:
# .txt and .html
@ -434,12 +429,6 @@ OpenProject::Application.routes.draw do
# move individual wp
resource :move, controller: 'work_packages/moves', only: %i[new create]
# this duplicate mapping is required for the timelog_helper
namespace :time_entries do
resource :report, controller: 'reports'
end
resources :time_entries, controller: 'timelog'
# states managed by client-side routing on work_package#index
get 'details/*state' => 'work_packages#index', on: :collection, as: :details
@ -458,13 +447,6 @@ OpenProject::Application.routes.draw do
end
end
namespace :time_entries do
resource :report, controller: 'reports',
only: [:show]
end
resources :time_entries, controller: 'timelog'
resources :activity, :activities, only: :index, controller: 'activities'
resources :users do

@ -287,7 +287,7 @@ default_projects_modules:
- board_view
- work_package_tracking
- news
- time_tracking
- costs
- wiki
# Role given to a non-admin user who creates a project
new_project_user_role_id:

@ -0,0 +1,90 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module Migration
module MigrationUtils
class ModuleRenamer
class << self
def add_to_enabled(new_module, old_modules)
execute <<~SQL
INSERT INTO
enabled_modules (
project_id,
name
)
SELECT
DISTINCT(project_id),
'#{new_module}'
FROM
enabled_modules
WHERE
name IN (#{comma_separated_strings(old_modules)})
SQL
end
def remove_from_enabled(modules)
execute <<~SQL
DELETE FROM
enabled_modules
WHERE
name IN (#{comma_separated_strings(modules)})
SQL
end
def add_to_default(new_modules, old_modules)
# avoid creating the settings implicitly on new installations
setting = Setting.find_by(name: 'default_projects_modules')
return unless setting
cleaned_setting = setting.value - Array(old_modules)
if setting.value != cleaned_setting
Setting.default_projects_modules = cleaned_setting + Array(new_modules)
end
end
def remove_from_default(name)
add_to_default([], name)
end
private
def execute(string)
ActiveRecord::Base.connection.execute string
end
def comma_separated_strings(array)
Array(array).map { |i| "'#{i}'" }.join(', ')
end
end
end
end
end

@ -27,16 +27,16 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module Migration
module Migration::MigrationUtils
class SettingRenamer
# define all the following methods as class methods
class << self
def rename(source_name, target_name)
ActiveRecord::Base.connection.execute <<-SQL
UPDATE #{settings_table}
SET name = #{quote_value(target_name)}
WHERE name = #{quote_value(source_name)}
SQL
UPDATE #{settings_table}
SET name = #{quote_value(target_name)}
WHERE name = #{quote_value(source_name)}
SQL
end
private

@ -0,0 +1,147 @@
# Group Budgets
*Note: Budgets are currently only implemented as a stub. Further properties of budgets might be added at a future date, however they will require the view budget permission to be displayed.*
## Linked Properties:
| Link | Description | Type | Constraints | Supported operations |
|:---------:|-------------------------------------------- | ------------- | --------------------- | -------------------- |
| self | This budget | Budget | not null | READ |
## Properties
| Property | Description | Type | Constraints | Supported operations | Condition |
| :---------: | ------------------------------------------- | ----------- | ----------- | -------------------- | --------------------------- |
| id | Budget id | Integer | x > 0 | READ | |
| subject | Budget name | String | not empty | READ | |
## Budget [/api/v3/budgets/{id}]
+ Model
+ Body
{
"_type" : "Budget",
"_links" : {
"self" : {
"href" : "/api/v3/budgets/1",
"title" : "Q3 2015"
}
},
"id" : 1,
"subject" : "Q3 2015"
}
## view Budget [GET]
+ Parameters
+ id (required, integer, `1`) ... Budget id
+ Response 200 (application/hal+json)
[Budget][]
+ Response 403 (application/hal+json)
Returned if the client does not have sufficient permissions.
**Required permission:** view work packages **or** view budgets (on the budgets project)
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MissingPermission",
"message": "You are not allowed to see this budget."
}
## Budgets by Project [/api/v3/projects/{id}/budgets]
+ Model
+ Body
{
"_links" : {
"self" : {
"href" : "/api/v3/projects/1/budgets"
}
},
"_type" : "Collection",
"total" : 2,
"count" : 2,
"_embedded" : {
"elements" : [
{
"_type" : "Budget",
"_links" : {
"self" : {
"href" : "/api/v3/budgets/1",
"title" : "Q3 2015"
}
},
"id" : 1,
"subject" : "Q3 2015"
},
{
"_type" : "Budget",
"_links" : {
"self" : {
"href" : "/api/v3/budgets/2",
"title" : "Q4 2015"
}
},
"id" : 2,
"subject" : "Q4 2015"
}
]
}
}
## view Budgets of a Project [GET]
+ Parameters
+ id (required, integer, `1`) ... Project id
+ Response 200 (application/hal+json)
[Budgets by Project][]
+ Response 403 (application/hal+json)
Returned if the client does not have sufficient permissions to see the budgets of the given
project.
**Required permission:** view work packages **or** view budgets
*Note that you will only receive this error, if you are at least allowed to see the corresponding project.*
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:MissingPermission",
"message": "You are not allowed to see the budgets of this project."
}
+ Response 404 (application/hal+json)
Returned if either:
* the project does not exist
* the client does not have sufficient permissions to see the project
* the costs module is not enabled on the given project
**Required permission:** view project
*Note: A client without sufficient permissions shall not be able to test for the existence of a project.
That's why a 404 is returned here, even if a 403 might be more appropriate.*
+ Body
{
"_type": "Error",
"errorIdentifier": "urn:openproject-org:api:v3:errors:NotFound",
"message": "The specified project does not exist."
}

@ -339,6 +339,8 @@ Lists time entries. The time entries returned depend on the filters provided and
Creates a new time entry applying the attributes provided in the body. Please note that while there is a fixed set of attributes, custom fields can extend a time entries' attributes and are accepted by the endpoint.
+ Parameters
+ Request Create time entry
+ Body
@ -425,6 +427,8 @@ Creates a new time entry applying the attributes provided in the body. Please no
Updates the given time entry by applying the attributes provided in the body. Please note that while there is a fixed set of attributes, custom fields can extend a time entries' attributes and are accepted by the endpoint.
+ Parameters
+ Request Update time entry
+ Body

@ -27,6 +27,7 @@
| author | The person that created the work package | User | not null | READ | |
| assignee | The person that is intended to work on the work package | User | | READ / WRITE | |
| availableWatchers | All users that can be added to the work package as watchers. | User | | READ | **Permission** add work package watchers |
| budget | The budget this work package is associated to | Budget | | READ / WRITE | **Permission** view cost objects |
| category | The category of the work package | Category | | READ / WRITE | |
| children | Array of all visible children of the work package | Collection | not null | READ | **Permission** view work packages |
| parent | Parent work package | WorkPackage | Needs to be visible (to the current user) | READ / WRITE | |

@ -5,6 +5,7 @@
<!-- include(endpoints/activities.apib) -->
<!-- include(endpoints/attachments.apib) -->
<!-- include(endpoints/budgets.apib) -->
<!-- include(endpoints/categories.apib) -->
<!-- include(endpoints/configuration.apib) -->
<!-- include(endpoints/custom-actions.apib) -->

@ -62,7 +62,6 @@ export class KeyboardShortcutService {
'g a': this.projectScoped('projectActivityPath'),
'g c': this.projectScoped('projectCalendarPath'),
'g n': this.projectScoped('projectNewsPath'),
'g t': this.projectScoped('projectTimelinesPath'),
'n w p': this.projectScoped('projectWorkPackageNewPath'),
'g e': this.accessKey('edit'),

@ -1,72 +0,0 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2017 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
(function($) {
$(function() {
/*
* See /app/views/timelog/_date_range.html.erb
*/
if ($('#date-range').length < 1) {
return;
}
var intervalInputs = $('#to, #from'),
// select
period = $('#period'),
// radio buttons
periodOptionList = $('#period_type_list'),
periodOptionInterval = $('#period_type_interval');
var disableInputFields = function(radioButton) {
return function () {
if (radioButton == periodOptionList) {
jQuery('#period').attr("tabindex", -1);
jQuery('#from').removeAttr("tabindex");
jQuery('#to').removeAttr("tabindex");
}
else {
jQuery('#from').attr("tabindex", -1);
jQuery('#to').attr("tabindex", -1);
jQuery('#period').removeAttr("tabindex");
}
};
};
jQuery(document).ready(function() {
if (periodOptionInterval.is(':checked')) {
jQuery('#period').attr("tabindex", -1);
}
else {
jQuery('#from').attr("tabindex", -1);
jQuery('#to').attr("tabindex", -1);
}
});
periodOptionList.on('change', disableInputFields(periodOptionInterval));
periodOptionInterval.on('change', disableInputFields(periodOptionList));
});
}(jQuery));

@ -175,12 +175,8 @@ export class PathHelperService {
return this.projectPath(projectId) + '/news';
}
public projectTimelinesPath(projectId:string) {
return this.projectPath(projectId) + '/timelines';
}
public projectTimeEntriesPath(projectIdentifier:string) {
return this.projectPath(projectIdentifier) + '/time_entries';
return this.projectPath(projectIdentifier) + '/cost_reports';
}
public projectWikiPath(projectId:string) {

@ -63,8 +63,9 @@ export class WorkPackageSpentTimeDisplayField extends DurationDisplayField {
.id(this.resource.project)
.get()
.subscribe((project:ProjectResource) => {
// Link to the cost report having the work package filter preselected. No grouping.
const href = URI(this.PathHelper.projectTimeEntriesPath(project.identifier))
.search({ work_package_id: wpID })
.search(`fields[]=WorkPackageId&operators[WorkPackageId]=%3D&values[WorkPackageId]=${wpID}&set_filter=1`)
.toString();
link.href = href;

@ -83,7 +83,7 @@ By default, simple filters can have multiple fields per row (as many as the give
</li>
<li class="simple-filters--filter -with-radio-buttons">
<span class="form--radio-button-container">
<input type="radio" name="period_type" id="period_type_list" value="1" class="form--radio-button" checked="checked">
<input type="radio" name="period_type" value="1" class="form--radio-button" checked="checked">
</span>
<div class="simple-filters--filter-value">
<span class="form--select-container">

@ -26,7 +26,7 @@ table.list.members
.form--text-field.-tiny
min-width: 60px
.cost_object.details .attributes-key-value--value p
.budget.details .attributes-key-value--value p
margin-bottom: 0
.progress-bar .inner-progress.done

@ -62,7 +62,6 @@ module API
mount ::API::V3::Roles::RolesAPI
mount ::API::V3::Statuses::StatusesAPI
mount ::API::V3::StringObjects::StringObjectsAPI
mount ::API::V3::TimeEntries::TimeEntriesAPI
mount ::API::V3::Types::TypesAPI
mount ::API::V3::Users::UsersAPI
mount ::API::V3::UserPreferences::UserPreferencesAPI

@ -53,6 +53,7 @@ module API
:assignable_categories,
:assignable_priorities,
:assignable_versions,
:assignable_budgets,
to: :contract
def no_caching?

@ -160,7 +160,7 @@ module API
schema :spent_time,
type: 'Duration',
required: false,
show_if: ->(*) { represented.project&.module_enabled?('time_tracking') }
show_if: ->(*) { represented.project&.module_enabled?('costs') }
schema :percentage_done,
type: 'Integer',
@ -270,6 +270,20 @@ module API
required: true,
has_default: true
schema_with_allowed_collection :budget,
type: 'Budget',
required: false,
value_representer: ::API::V3::Budgets::BudgetRepresenter,
link_factory: ->(budget) {
{
href: api_v3_paths.budget(budget.id),
title: budget.subject
}
},
show_if: ->(*) {
represented.project&.module_enabled?(:budgets)
}
def attribute_groups
(represented.type&.attribute_groups || []).map do |group|
if group.is_a?(Type::QueryGroup)

@ -85,8 +85,7 @@ module API
next if represented.new_record?
{
href: new_work_package_time_entry_path(represented),
type: 'text/html',
href: api_v3_paths.time_entries,
title: "Log time on #{represented.subject}"
}
end
@ -286,9 +285,10 @@ module API
cache_if: -> { view_time_entries_allowed? } do
next if represented.new_record?
filters = [{ work_package_id: { operator: "=", values: [represented.id.to_s] } }]
{
href: work_package_time_entries_path(represented.id),
type: 'text/html',
href: api_v3_paths.path_for(:time_entries, filters: filters),
title: 'Time entries'
}
end
@ -483,6 +483,13 @@ module API
represented.parent = new_parent
end
associated_resource :budget,
as: :budget,
v3_path: :budget,
link_title_attribute: :subject,
representer: ::API::V3::Budgets::BudgetRepresenter,
skip_render: ->(*) { !view_budgets_allowed? }
resources :customActions,
uncacheable_link: true,
link: ->(*) {
@ -568,7 +575,8 @@ module API
self.to_eager_load = %i[parent
type
watchers
attachments]
attachments
budget]
# The dynamic class generation introduced because of the custom fields interferes with
# the class naming as well as prevents calls to super
@ -586,7 +594,12 @@ module API
end
def view_time_entries_allowed?
current_user_allowed_to(:view_time_entries, context: represented.project)
current_user_allowed_to(:view_time_entries, context: represented.project) ||
current_user_allowed_to(:view_own_time_entries, context: represented.project)
end
def view_budgets_allowed?
current_user_allowed_to(:view_budgets, context: represented.project)
end
def load_complete_model(model)

@ -26,6 +26,10 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
# This now only seems to be used when rendering atom responses.
# Search as well as activities do not rely on it.
# Thus, whenever an atom link is removed for a resource, acts_as_event within that model can also be removed.
module Redmine
module Acts
module Event

@ -92,7 +92,7 @@ module Redmine #:nodoc:
end
end
end
def_field :name, :description, :url, :author, :author_url, :version, :settings, :bundled
def_field :description, :url, :author, :author_url, :version, :settings, :bundled
attr_reader :id
# Plugin constructor
@ -100,8 +100,6 @@ module Redmine #:nodoc:
id = id.to_sym
p = new(id)
p.instance_eval(&block)
# Set a default name if it was not provided during registration
p.name(id.to_s.humanize) if p.name.nil?
registered_plugins[id] = p
@ -127,12 +125,26 @@ module Redmine #:nodoc:
if RedminePluginLocator.instance.has_plugin? e.plugin_id
# The required plugin is going to be loaded later, defer loading this plugin
(deferred_plugins[e.plugin_id] ||= []) << [id, block]
return p
p
else
raise
end
end
def name(*args)
name = args.empty? ? instance_variable_get("@name") : instance_variable_set("@name", *args)
case name
when Symbol
::I18n.t(name)
when NilClass
# Default name if it was not provided during registration
id.to_s.humanize
else
name
end
end
# returns an array of all dependencies we know of for plugin id
# (might not be complete at all times!)
def self.dependencies_for(id)
@ -250,9 +262,6 @@ module Redmine #:nodoc:
hide_menu_item(menu_name, item)
end
# N.B.: I could not find any usages of :delete_menu_item in my locally available plugins
deprecate delete_menu_item: 'Use :hide_menu_item instead'
# Allows to hide an existing +item+ in a menu.
#
# +hide_if+ parameter can be a lambda accepting a project, the item will only be hidden if

@ -27,41 +27,6 @@
#++
module OpenProject::Backlogs::Hooks
class Hook < Redmine::Hook::Listener
include ActionView::Helpers::TagHelper
include ActionView::Context
include WorkPackagesHelper
def work_packages_show_attributes(context = {})
work_package = context[:work_package]
attributes = context[:attributes]
return unless work_package.backlogs_enabled?
return if context[:from] == 'OpenProject::Backlogs::WorkPackageView::FieldsParagraph'
attributes << work_package_show_story_points_attribute(work_package)
attributes << work_package_show_remaining_hours_attribute(work_package)
attributes
end
private
def work_package_show_story_points_attribute(work_package)
return nil unless work_package.is_story?
work_package_show_table_row(:story_points, :"story-points") do
work_package.story_points ? work_package.story_points.to_s : empty_element_tag
end
end
def work_package_show_remaining_hours_attribute(work_package)
work_package_show_table_row(:remaining_hours) do
work_package.remaining_hours ? l_hours(work_package.remaining_hours) : empty_element_tag
end
end
end
class LayoutHook < Redmine::Hook::ViewListener
include RbCommonHelper

@ -59,11 +59,10 @@ module OpenProject::Backlogs::Patches::UpdateServicePatch
attributes = { version_id: work_package.version_id }
descendant_tasks.each do |task|
# Ensure the parent is already moved to new version so that validation errors are avoided.
task.parent = ([work_package] + all_descendants).detect { |d| d.id == task.parent_id }
result.add_dependent!(set_attributes(attributes, task))
end
end
end
end

@ -238,7 +238,9 @@ describe WorkPackages::UpdateService, "version inheritance", type: :model do
parent.reload
instance.call(version: version2)
call = instance.call(version: version2)
expect(call).to be_success
# Because of performance, these assertions are all in one it statement
expect(child.reload.version).to eql version2

@ -0,0 +1,3 @@
source 'https://rubygems.org'
gemspec

@ -0,0 +1,264 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class BudgetsController < ApplicationController
before_action :find_budget, only: %i[show edit update copy]
before_action :find_budgets, only: :destroy
before_action :find_project, only: %i[new create update_material_budget_item update_labor_budget_item]
before_action :find_optional_project, only: :index
before_action :authorize_global, only: :index
before_action :authorize, except: [
# unrestricted actions
:index,
:update_material_budget_item,
:update_labor_budget_item
]
helper :sort
include SortHelper
helper :projects
include ProjectsHelper
helper :attachments
include AttachmentsHelper
helper :costlog
include CostlogHelper
helper :budgets
include BudgetsHelper
include PaginationHelper
def index
sort_init 'id', 'desc'
sort_update default_budget_sort
@budgets = visible_sorted_budgets
respond_to do |format|
format.html do
render action: 'index', layout: !request.xhr?
end
format.csv { send_data(budgets_to_csv(@budgets), type: 'text/csv; header=present', filename: 'export.csv') }
end
end
def show
@edit_allowed = User.current.allowed_to?(:edit_budgets, @project)
respond_to do |format|
format.html { render action: 'show', layout: !request.xhr? }
end
end
def new
@budget ||= Budget.new
@budget.project_id = @project.id
@budget.fixed_date ||= Date.today
render layout: !request.xhr?
end
def copy
source = Budget.find(params[:id].to_i)
@budget = Budget.new
if source
@budget.copy_from(source)
end
@budget.fixed_date ||= Date.today
render action: :new, layout: !request.xhr?
end
def create
@budget = Budget.new
@budget.project_id = @project.id
# fixed_date must be set before material_budget_items and labor_budget_items
@budget.fixed_date = if params[:budget] && params[:budget][:fixed_date]
params[:budget].delete(:fixed_date)
else
Date.today
end
@budget.attributes = permitted_params.budget
@budget.attach_files(permitted_params.attachments.to_h)
if @budget.save
flash[:notice] = t(:notice_successful_create)
redirect_to(params[:continue] ? { action: 'new' } : { action: 'show', id: @budget })
else
render action: 'new', layout: !request.xhr?
end
end
def edit
@budget.attributes = permitted_params.budget if params[:budget]
end
def update
# TODO: This was simply copied over from edit in order to have
# something as a starting point for separating the two
# Please go ahead and start removing code where necessary
@budget.attributes = permitted_params.budget if params[:budget]
if params[:budget][:existing_material_budget_item_attributes].nil?
@budget.existing_material_budget_item_attributes = ({})
end
if params[:budget][:existing_labor_budget_item_attributes].nil?
@budget.existing_labor_budget_item_attributes = ({})
end
@budget.attach_files(permitted_params.attachments.to_h)
if @budget.save
flash[:notice] = t(:notice_successful_update)
redirect_to(params[:back_to] || { action: 'show', id: @budget })
else
render action: 'edit'
end
rescue ActiveRecord::StaleObjectError
# Optimistic locking exception
flash.now[:error] = t(:notice_locking_conflict)
end
def destroy
@budgets.each(&:destroy)
flash[:notice] = t(:notice_successful_delete)
redirect_to action: 'index', project_id: @project
end
def update_material_budget_item
@element_id = params[:element_id]
cost_type = CostType.where(id: params[:cost_type_id]).first
if cost_type && params[:units].present?
volume = Rate.parse_number_string_to_number(params[:units])
@costs = volume * cost_type.rate_at(params[:fixed_date]).rate rescue 0.0
@unit = volume == 1.0 ? cost_type.unit : cost_type.unit_plural
else
@costs = 0.0
@unit = cost_type.try(:unit_plural) || ''
end
respond_to do |format|
format.json do
render json: render_item_as_json(@element_id, @costs, @unit, @project, :view_cost_rates)
end
end
end
def update_labor_budget_item
@element_id = params[:element_id]
user = User.where(id: params[:user_id]).first
if user && params[:hours]
hours = params[:hours].to_s.to_hours
@costs = hours * user.rate_at(params[:fixed_date], @project).rate rescue 0.0
else
@costs = 0.0
end
respond_to do |format|
format.json do
render json: render_item_as_json(@element_id, @costs, @unit, @project, :view_hourly_rates)
end
end
end
private
def find_budget
# This function comes directly from issues_controller.rb (Redmine 0.8.4)
@budget = Budget.includes(:project, :author).find_by(id: params[:id])
@project = @budget.project if @budget
rescue ActiveRecord::RecordNotFound
render_404
end
def find_budgets
# This function comes directly from issues_controller.rb (Redmine 0.8.4)
@budgets = Budget.where(id: params[:id] || params[:ids])
raise ActiveRecord::RecordNotFound if @budgets.empty?
projects = @budgets.map(&:project).compact.uniq
if projects.size == 1
@project = projects.first
else
# TODO: let users bulk edit/move/destroy budgets from different projects
render_error 'Can not bulk edit/move/destroy cost objects from different projects' and return false
end
rescue ActiveRecord::RecordNotFound
render_404
end
def find_project
@project = Project.find(params[:project_id])
rescue ActiveRecord::RecordNotFound
render_404
end
def find_optional_project
@project = Project.find(params[:project_id]) unless params[:project_id].blank?
rescue ActiveRecord::RecordNotFound
render_404
end
def render_item_as_json(element_id, costs, unit, project, permission)
response = {
"#{element_id}_unit_name" => ActionController::Base.helpers.sanitize(unit),
"#{element_id}_currency" => Setting.plugin_costs['costs_currency']
}
if current_user.allowed_to?(permission, project)
response["#{element_id}_costs"] = number_to_currency(costs)
response["#{element_id}_cost_value"] = costs
end
response
end
def default_budget_sort
{
'id' => "#{Budget.table_name}.id",
'subject' => "#{Budget.table_name}.subject",
'fixed_date' => "#{Budget.table_name}.fixed_date"
}
end
def visible_sorted_budgets
Budget
.visible(current_user)
.order(sort_clause)
.includes(:author)
.where(project_id: @project.id)
.page(page_param)
.per_page(per_page_param)
end
end

@ -28,46 +28,48 @@
require 'csv'
module CostObjectsHelper
include ApplicationHelper
module BudgetsHelper
include ActionView::Helpers::NumberHelper
include Redmine::I18n
# Check if the current user is allowed to manage the budget. Based on Role
# permissions.
def allowed_management?
User.current.allowed_to?(:edit_cost_objects, @project)
User.current.allowed_to?(:edit_budgets, @project)
end
def cost_objects_to_csv(cost_objects)
def budgets_to_csv(budgets)
CSV.generate(col_sep: t(:general_csv_separator)) do |csv|
# csv header fields
headers = ['#',
Project.model_name.human,
CostObject.human_attribute_name(:subject),
CostObject.human_attribute_name(:author),
CostObject.human_attribute_name(:fixed_date),
VariableCostObject.human_attribute_name(:material_budget),
VariableCostObject.human_attribute_name(:labor_budget),
CostObject.human_attribute_name(:spent),
CostObject.human_attribute_name(:created_on),
CostObject.human_attribute_name(:updated_on),
CostObject.human_attribute_name(:description)
]
headers = [
'#',
Project.model_name.human,
Budget.human_attribute_name(:subject),
Budget.human_attribute_name(:author),
Budget.human_attribute_name(:fixed_date),
Budget.human_attribute_name(:material_budget),
Budget.human_attribute_name(:labor_budget),
Budget.human_attribute_name(:spent),
Budget.human_attribute_name(:created_at),
Budget.human_attribute_name(:updated_at),
Budget.human_attribute_name(:description)
]
csv << headers.map { |c| begin; c.to_s.encode('UTF-8'); rescue; c.to_s; end }
# csv lines
cost_objects.each do |cost_object|
fields = [cost_object.id,
cost_object.project.name,
cost_object.subject,
cost_object.author.name,
format_date(cost_object.fixed_date),
cost_object.kind == 'VariableCostObject' ? number_to_currency(cost_object.material_budget) : '',
cost_object.kind == 'VariableCostObject' ? number_to_currency(cost_object.labor_budget) : '',
cost_object.kind == 'VariableCostObject' ? number_to_currency(cost_object.spent) : '',
format_time(cost_object.created_on),
format_time(cost_object.updated_on),
cost_object.description
]
budgets.each do |budget|
fields = [
budget.id,
budget.project.name,
budget.subject,
budget.author.name,
format_date(budget.fixed_date),
number_to_currency(budget.material_budget),
number_to_currency(budget.labor_budget),
number_to_currency(budget.spent),
format_time(budget.created_at),
format_time(budget.updated_at),
budget.description
]
csv << fields.map { |c| begin; c.to_s.encode('UTF-8'); rescue; c.to_s; end }
end
end

@ -26,31 +26,31 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Activities::CostObjectActivityProvider < Activities::BaseActivityProvider
activity_provider_for type: 'cost_objects',
permission: :view_cost_objects
class Activities::BudgetActivityProvider < Activities::BaseActivityProvider
activity_provider_for type: 'budgets',
permission: :view_budgets
def event_query_projection
[
activity_journal_projection_statement(:subject, 'cost_object_subject'),
activity_journal_projection_statement(:subject, 'budget_subject'),
activity_journal_projection_statement(:project_id, 'project_id')
]
end
def event_type(_event)
'cost_object'
'budget'
end
def event_title(event)
"#{I18n.t(:label_cost_object)} ##{event['journable_id']}: #{event['cost_object_subject']}"
"#{I18n.t(:label_budget)} ##{event['journable_id']}: #{event['budget_subject']}"
end
def event_path(event)
url_helpers.cost_object_path(url_helper_parameter(event))
url_helpers.budget_path(url_helper_parameter(event))
end
def event_url(event)
url_helpers.cost_object_url(url_helper_parameter(event))
url_helpers.budget_url(url_helper_parameter(event))
end
private

@ -26,15 +26,16 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class VariableCostObject < CostObject
class Budget < ApplicationRecord
belongs_to :author, class_name: 'User', foreign_key: 'author_id'
belongs_to :project
has_many :work_packages, dependent: :nullify
has_many :material_budget_items, -> {
includes(:cost_type).order(Arel.sql('material_budget_items.id ASC'))
}, foreign_key: 'cost_object_id',
dependent: :destroy
}, dependent: :destroy
has_many :labor_budget_items, -> {
includes(:user).order(Arel.sql('labor_budget_items.id ASC'))
}, foreign_key: 'cost_object_id',
dependent: :destroy
}, dependent: :destroy
validates_associated :material_budget_items
validates_associated :labor_budget_items
@ -42,22 +43,81 @@ class VariableCostObject < CostObject
after_update :save_material_budget_items
after_update :save_labor_budget_items
# override acts_as_journalized method
def activity_type
self.class.superclass.plural_name
has_many :cost_entries, through: :work_packages
has_many :time_entries, through: :work_packages
include ActiveModel::ForbiddenAttributesProtection
acts_as_attachable
acts_as_journalized
acts_as_event type: 'cost-objects',
title: Proc.new { |o| "#{I18n.t(:label_budget)} ##{o.id}: #{o.subject}" },
url: Proc.new { |o| { controller: 'budgets', action: 'show', id: o.id } }
validates_presence_of :subject, :project, :author, :fixed_date
validates_length_of :subject, maximum: 255
validates_length_of :subject, minimum: 1
User.before_destroy do |user|
Budget.replace_author_with_deleted_user user
end
def self.visible(user)
includes(:project)
.references(:projects)
.merge(Project.allowed_to(user, :view_budgets))
end
def initialize(attributes = nil)
super
self.author = User.current if new_record?
end
def copy_from(arg)
cost_object = (arg.is_a?(VariableCostObject) ? arg : self.class.find(arg))
attrs = cost_object.attributes.dup
budget = (arg.is_a?(Budget) ? arg : self.class.find(arg))
attrs = budget.attributes.dup
super(attrs)
self.labor_budget_items = cost_object.labor_budget_items.map(&:dup)
self.material_budget_items = cost_object.material_budget_items.map(&:dup)
self.labor_budget_items = budget.labor_budget_items.map(&:dup)
self.material_budget_items = budget.material_budget_items.map(&:dup)
end
def budget
material_budget + labor_budget
end
# Label of the current cost_object type for display in GUI.
def type_label
I18n.t(:label_variable_cost_object)
I18n.t(:label_budget)
end
# Amount of the budget spent. Expressed as as a percentage whole number
def budget_ratio
return 0.0 if budget.nil? || budget == 0.0
((spent / budget) * 100).round
end
def css_classes
'budget'
end
def self.replace_author_with_deleted_user(user)
substitute = DeletedUser.first
where(author_id: user.id).update_all(author_id: substitute.id)
end
def to_s
subject
end
def name
subject
end
# override acts_as_journalized method
def activity_type
self.class.plural_name
end
def material_budget
@ -74,35 +134,35 @@ class VariableCostObject < CostObject
def spent_material
@spent_material ||= begin
if cost_entries.blank?
BigDecimal('0.0000')
else
cost_entries.visible_costs(User.current, project).sum("CASE
if cost_entries.blank?
BigDecimal('0.0000')
else
cost_entries.visible_costs(User.current, project).sum("CASE
WHEN #{CostEntry.table_name}.overridden_costs IS NULL THEN
#{CostEntry.table_name}.costs
ELSE
#{CostEntry.table_name}.overridden_costs END").to_d
end
end
end
end
end
def spent_labor
@spent_labor ||= begin
if time_entries.blank?
BigDecimal('0.0000')
else
time_entries.visible_costs(User.current, project).sum("CASE
if time_entries.blank?
BigDecimal('0.0000')
else
time_entries.visible_costs(User.current, project).sum("CASE
WHEN #{TimeEntry.table_name}.overridden_costs IS NULL THEN
#{TimeEntry.table_name}.costs
ELSE
#{TimeEntry.table_name}.overridden_costs END").to_d
end
end
end
end
end
def new_material_budget_item_attributes=(material_budget_item_attributes)
material_budget_item_attributes.each do |_index, attributes|
material_budget_items.build(attributes) if attributes[:units].to_i > 0
material_budget_items.build(attributes) if attributes[:units].to_i.positive?
end
end
@ -110,10 +170,9 @@ class VariableCostObject < CostObject
material_budget_items.reject(&:new_record?).each do |material_budget_item|
attributes = material_budget_item_attributes[material_budget_item.id.to_s]
if User.current.allowed_to? :edit_cost_objects, material_budget_item.cost_object.project
if attributes && attributes[:units].to_i > 0
attributes[:budget] = Rate.parse_number_string(attributes[:budget])
material_budget_item.attributes = attributes
if User.current.allowed_to? :edit_budgets, material_budget_item.budget.project
if attributes && attributes[:units].to_i.positive?
material_budget_item.attributes = attributes.merge(amount: Rate.parse_number_string(attributes[:amount]))
else
material_budget_items.delete(material_budget_item)
end
@ -129,12 +188,10 @@ class VariableCostObject < CostObject
def new_labor_budget_item_attributes=(labor_budget_item_attributes)
labor_budget_item_attributes.each do |_index, attributes|
if attributes[:hours].to_i > 0 &&
attributes[:user_id].to_i > 0 &&
project.possible_assignees.map(&:id).include?(attributes[:user_id].to_i)
if valid_labor_budget_attributes?(attributes)
item = labor_budget_items.build(attributes)
item.cost_object = self # to please the labor_budget_item validation
item.budget = self # to please the labor_budget_item validation
end
end
end
@ -142,10 +199,9 @@ class VariableCostObject < CostObject
def existing_labor_budget_item_attributes=(labor_budget_item_attributes)
labor_budget_items.reject(&:new_record?).each do |labor_budget_item|
attributes = labor_budget_item_attributes[labor_budget_item.id.to_s]
if User.current.allowed_to? :edit_cost_objects, labor_budget_item.cost_object.project
if attributes && attributes[:hours].to_i > 0 && attributes[:user_id].to_i > 0 && project.possible_assignees.map(&:id).include?(attributes[:user_id].to_i)
attributes[:budget] = Rate.parse_number_string(attributes[:budget])
labor_budget_item.attributes = attributes
if User.current.allowed_to? :edit_budgets, labor_budget_item.budget.project
if valid_labor_budget_attributes?(attributes)
labor_budget_item.attributes = attributes.merge(amount: Rate.parse_number_string(attributes[:amount]))
else
labor_budget_items.delete(labor_budget_item)
end
@ -158,4 +214,11 @@ class VariableCostObject < CostObject
labor_budget_item.save(validate: false)
end
end
def valid_labor_budget_attributes?(attributes)
attributes &&
attributes[:hours].to_i.positive? &&
attributes[:user_id].to_i.positive? &&
project.possible_assignees.map(&:id).include?(attributes[:user_id].to_i)
end
end

@ -26,8 +26,6 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject
module Costs
require 'open_project/costs/engine'
end
class Journal::BudgetJournal < Journal::BaseJournal
self.table_name = 'budget_journals'
end

@ -27,22 +27,22 @@
#++
class LaborBudgetItem < ApplicationRecord
belongs_to :cost_object
belongs_to :budget
belongs_to :user
belongs_to :principal, foreign_key: 'user_id'
include ::OpenProject::Costs::DeletedUserFallback
include ::Costs::DeletedUserFallback
validates_length_of :comments, maximum: 255, allow_nil: true
validates_presence_of :user
validates_presence_of :cost_object
validates_presence_of :budget
validates_numericality_of :hours, allow_nil: false
include ActiveModel::ForbiddenAttributesProtection
# user_id correctness is ensured in VariableCostObject#*_labor_budget_item_attributes=
# user_id correctness is ensured in Budget#*_labor_budget_item_attributes=
def self.visible(user, project)
table = self.arel_table
table = arel_table
view_allowed = Project.allowed_to(user, :view_hourly_rates).select(:id)
view_own_allowed = Project.allowed_to(user, :view_own_hourly_rate).select(:id)
@ -53,29 +53,29 @@ class LaborBudgetItem < ApplicationRecord
.in(view_own_allowed.arel)
.and(table[:user_id].eq(user.id)))
scope = includes([{ cost_object: :project }, :user])
scope = includes([{ budget: :project }, :user])
.references(:projects)
where(view_or_view_own)
.where(view_or_view_own)
if project
scope = scope.where(cost_object: { projects_id: project.id })
scope.where(budget: { projects_id: project.id })
end
end
scope :visible_costs, lambda{|*args|
scope :visible_costs, lambda { |*args|
visible((args.first || User.current), args[1])
}
def costs
budget || calculated_costs
amount || calculated_costs
end
def overridden_budget?
budget.present?
def overridden_costs?
amount.present?
end
def calculated_costs(fixed_date = cost_object.fixed_date, project_id = cost_object.project_id)
if user_id && hours && rate = HourlyRate.at_date_for_user_in_project(fixed_date, user_id, project_id)
def calculated_costs(fixed_date = budget.fixed_date, project_id = budget.project_id)
if user_id && hours && (rate = HourlyRate.at_date_for_user_in_project(fixed_date, user_id, project_id))
rate.rate * hours
else
0.0
@ -83,7 +83,7 @@ class LaborBudgetItem < ApplicationRecord
end
def costs_visible_by?(usr)
usr.allowed_to?(:view_hourly_rates, cost_object.project) ||
(usr.id == user_id && usr.allowed_to?(:view_own_hourly_rate, cost_object.project))
usr.allowed_to?(:view_hourly_rates, budget.project) ||
(usr.id == user_id && usr.allowed_to?(:view_own_hourly_rate, budget.project))
end
end

@ -27,7 +27,7 @@
#++
class MaterialBudgetItem < ApplicationRecord
belongs_to :cost_object
belongs_to :budget
belongs_to :cost_type
validates_length_of :comments, maximum: 255, allow_nil: true
@ -36,7 +36,7 @@ class MaterialBudgetItem < ApplicationRecord
include ActiveModel::ForbiddenAttributesProtection
def self.visible(user)
includes(cost_object: :project)
includes(budget: :project)
.references(:projects)
.merge(Project.allowed_to(user, :view_cost_rates))
end
@ -45,21 +45,21 @@ class MaterialBudgetItem < ApplicationRecord
scope = visible(args.first || User.current)
if args[1]
scope = scope.where(cost_object: { projects_id: args[1].id })
scope = scope.where(budget: { projects_id: args[1].id })
end
scope
}
def costs
budget || calculated_costs
amount || calculated_costs
end
def overridden_budget?
budget.present?
def overridden_costs?
amount.present?
end
def calculated_costs(fixed_date = cost_object.fixed_date)
def calculated_costs(fixed_date = budget.fixed_date)
if units && cost_type && rate = cost_type.rate_at(fixed_date)
rate.rate * units
else
@ -68,6 +68,6 @@ class MaterialBudgetItem < ApplicationRecord
end
def costs_visible_by?(usr)
usr.allowed_to?(:view_cost_rates, cost_object.project)
usr.allowed_to?(:view_cost_rates, budget.project)
end
end

@ -26,20 +26,19 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject::Costs
class WorkPackageFilter < ::Queries::WorkPackages::Filter::WorkPackageFilter
module Queries::WorkPackages::Filter
class BudgetFilter < ::Queries::WorkPackages::Filter::WorkPackageFilter
def allowed_values
cost_objects
budgets
.pluck(:subject, :id)
end
def available?
project &&
project.module_enabled?(:costs_module)
project&.module_enabled?(:budgets)
end
def self.key
:cost_object_id
:budget_id
end
def order
@ -51,7 +50,7 @@ module OpenProject::Costs
end
def dependency_class
'::API::V3::Queries::Schemas::CostObjectFilterDependencyRepresenter'
'::API::V3::Queries::Schemas::BudgetFilterDependencyRepresenter'
end
def ar_object_filter?
@ -59,21 +58,21 @@ module OpenProject::Costs
end
def value_objects
available_cost_objects = cost_objects.index_by(&:id)
available_budgets = budgets.index_by(&:id)
values
.map { |cost_object_id| available_cost_objects[cost_object_id.to_i] }
.map { |budget_id| available_budgets[budget_id.to_i] }
.compact
end
def human_name
WorkPackage.human_attribute_name(:cost_object)
WorkPackage.human_attribute_name(:budget)
end
private
def cost_objects
CostObject
def budgets
Budget
.where(project_id: project)
.order(Arel.sql('subject ASC'))
end

@ -27,13 +27,13 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= labelled_tabular_form_for @cost_object,
:as => :cost_object,
:url => cost_object_path(@cost_object),
<%= labelled_tabular_form_for @budget,
:as => :budget,
:url => budget_path(@budget),
:html => {:multipart => true,
:id => 'cost_object_form',
:id => 'budget_form',
:class => 'form'} do |f| %>
<%= error_messages_for 'cost_object' %>
<%= error_messages_for 'budget' %>
<%= render :partial => 'form', :locals => {:f => f} %>
<div class="generic-table--action-buttons">
<%= styled_button_tag t(:button_submit), class: '-with-icon icon-checkmark -highlight', id: 'budget-table--submit-button'%>

@ -27,8 +27,6 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= f.hidden_field :kind %>
<% resource = budget_attachment_representer(f.object) %>
<div class="form--field -required">
@ -44,9 +42,7 @@ See docs/COPYRIGHT.rdoc for more details.
<%= f.text_field :fixed_date, container_class: '-xslim', class: '-augmented-datepicker' %>
</div>
<% if @cost_object.kind == "VariableCostObject" -%>
<%= render partial: 'cost_objects/subform/material_budget_subform' %>
<%= render partial: 'cost_objects/subform/labor_budget_subform' %>
<%- end %>
<%= render partial: 'budgets/subform/material_budget_subform' %>
<%= render partial: 'budgets/subform/labor_budget_subform' %>
<div style="clear: both;"> </div>

@ -43,12 +43,12 @@ See docs/COPYRIGHT.rdoc for more details.
<thead>
<tr>
<%= sort_header_tag("id", :caption => '#', :default_order => 'desc') %>
<%= sort_header_tag("subject", :caption => CostObject.human_attribute_name(:subject)) %>
<%= sort_header_tag("subject", :caption => Budget.human_attribute_name(:subject)) %>
<th class="currency">
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= CostObject.human_attribute_name(:budget) %>
<%= Budget.human_attribute_name(:budget) %>
</span>
</div>
</div>
@ -57,7 +57,7 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= CostObject.human_attribute_name(:spent) %>
<%= Budget.human_attribute_name(:spent) %>
</span>
</div>
</div>
@ -66,7 +66,7 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= CostObject.human_attribute_name(:available) %>
<%= Budget.human_attribute_name(:available) %>
</span>
</div>
</div>
@ -75,7 +75,7 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= CostObject.human_attribute_name(:budget_ratio) %>
<%= Budget.human_attribute_name(:budget_ratio) %>
</span>
</div>
</div>
@ -84,23 +84,23 @@ See docs/COPYRIGHT.rdoc for more details.
</thead>
<tbody>
<% total_budget = BigDecimal("0"); labor_budget = BigDecimal("0"); material_budget = BigDecimal("0"); spent = BigDecimal("0") %>
<% cost_objects.each do |cost_object| %>
<tr id="cost_object-<%= cost_object.id %>" class="<%= cost_object.css_classes %>">
<td><%= link_to cost_object.id, cost_object_path(cost_object.id) %></td>
<%= content_tag(:td, link_to(h(cost_object.subject), cost_object_path(cost_object.id)), :class => 'subject') %>
<%= content_tag(:td, number_to_currency(cost_object.budget, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, number_to_currency(cost_object.spent, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, number_to_currency(cost_object.budget - cost_object.spent, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, extended_progress_bar(cost_object.budget_ratio, :legend => "#{cost_object.budget_ratio}")) %>
<% budgets.each do |budget| %>
<tr id="budget-<%= budget.id %>" class="<%= budget.css_classes %>">
<td><%= link_to budget.id, budget_path(budget.id) %></td>
<%= content_tag(:td, link_to(h(budget.subject), budget_path(budget.id)), :class => 'subject') %>
<%= content_tag(:td, number_to_currency(budget.budget, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, number_to_currency(budget.spent, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, number_to_currency(budget.budget - budget.spent, :precision => 0), :class => 'currency') %>
<%= content_tag(:td, extended_progress_bar(budget.budget_ratio, :legend => "#{budget.budget_ratio}")) %>
<%-
total_budget += cost_object.budget
labor_budget += cost_object.labor_budget
material_budget += cost_object.material_budget
spent += cost_object.spent
total_budget += budget.budget
labor_budget += budget.labor_budget
material_budget += budget.material_budget
spent += budget.spent
-%>
</tr>
<% end %>
<% if cost_objects.length > 0 %>
<% if budgets.length > 0 %>
<tr>
<td />
<td />

@ -33,7 +33,7 @@ See docs/COPYRIGHT.rdoc for more details.
</legend>
<div class="grid-block">
<div class="grid-content medium-6">
<h4><%= VariableCostObject.human_attribute_name(:material_budget) %></h4>
<h4><%= Budget.human_attribute_name(:material_budget) %></h4>
<div>
<div class="generic-table--container -with-footer">
<div class="generic-table--results-container">
@ -85,7 +85,7 @@ See docs/COPYRIGHT.rdoc for more details.
</tr>
</thead>
<tbody>
<% @cost_object.material_budget_items.each do |material_budget_item| %>
<% @budget.material_budget_items.each do |material_budget_item| %>
<tr>
<td class="units">
<%= localized_float(material_budget_item.units) %>
@ -102,7 +102,7 @@ See docs/COPYRIGHT.rdoc for more details.
<td colspan="3"></td>
<td class="currency">
<div class="generic-table--footer-outer">
<strong><%= number_to_currency(@cost_object.material_budget) %></strong>
<strong><%= number_to_currency(@budget.material_budget) %></strong>
</div>
</td>
</tr>
@ -168,7 +168,7 @@ See docs/COPYRIGHT.rdoc for more details.
</tr>
</thead>
<tbody>
<% @cost_object.cost_entries.visible(User.current, @project).includes(:cost_type).group_by(&:work_package).each do |work_package, cost_entries|
<% @budget.cost_entries.visible(User.current).includes(:cost_type).group_by(&:work_package).each do |work_package, cost_entries|
entries = cost_entries.inject(Hash.new) do |results, entry|
result = results[entry.cost_type.id.to_s]
unless result
@ -185,12 +185,12 @@ See docs/COPYRIGHT.rdoc for more details.
<td class="subject"><%= link_to_work_package work_package %></td>
<td>
<%= link_to localized_float(c.units),
{
controller: "/cost_reports",
action: "index",
cost_type_id: c.cost_type,
project_id: work_package.project_id
} %>
cost_reports_path(work_package.project_id,
'fields[]': 'WorkPackageId',
'operators[WorkPackageId]': '=',
'values[WorkPackageId]': work_package.id,
unit: c.cost_type_id,
set_filter: 1) %>
</td>
<td><%= c.cost_type %></td>
<td class="currency"><%= c.costs_visible_by?(User.current) ? number_to_currency(c.real_costs) : "" %></td>
@ -204,7 +204,7 @@ See docs/COPYRIGHT.rdoc for more details.
<td colspan="3"></td>
<td class="currency">
<div class="generic-table--footer-outer">
<strong><%= number_to_currency(@cost_object.spent_material) %></strong>
<strong><%= number_to_currency(@budget.spent_material) %></strong>
</div>
</td>
</tr>
@ -226,7 +226,7 @@ See docs/COPYRIGHT.rdoc for more details.
</legend>
<div class="grid-block">
<div class="grid-content medium-6">
<h4><%= VariableCostObject.human_attribute_name(:labor_budget)%></h4>
<h4><%= Budget.human_attribute_name(:labor_budget)%></h4>
<div>
<div class="generic-table--container -with-footer">
<div class="generic-table--results-container">
@ -278,7 +278,7 @@ See docs/COPYRIGHT.rdoc for more details.
</tr>
</thead>
<tbody>
<% @cost_object.labor_budget_items.each do |labor_budget_item| %>
<% @budget.labor_budget_items.each do |labor_budget_item| %>
<tr>
<td class="hours"><%= l_hours(labor_budget_item.hours) %></td>
<td><%=h labor_budget_item.principal.name %></td>
@ -299,7 +299,7 @@ See docs/COPYRIGHT.rdoc for more details.
<td colspan="3"></td>
<td class="currency">
<div class="generic-table--footer-outer">
<strong><%= number_to_currency(@cost_object.labor_budget) %></strong>
<strong><%= number_to_currency(@budget.labor_budget) %></strong>
</div>
</td>
</tr>
@ -365,7 +365,7 @@ See docs/COPYRIGHT.rdoc for more details.
</tr>
</thead>
<tbody>
<% @cost_object.time_entries.visible(User.current, @project).group_by(&:work_package).each do |work_package, time_entries|
<% @budget.time_entries.visible(User.current).group_by(&:work_package).each do |work_package, time_entries|
entries = time_entries.inject(Hash.new) do |results, entry|
result = results[entry.user.id.to_s]
unless result
@ -383,7 +383,7 @@ See docs/COPYRIGHT.rdoc for more details.
%>
<tr>
<td class="subject"><%= link_to_work_package work_package %></td>
<td class="hours"><%= link_to l_hours(t.hours), {:controller => "/timelog", :action => "index", :work_package_id => work_package} %></td>
<td class="hours"><%= link_to l_hours(t.hours), cost_reports_path(work_package.project_id, 'fields[]': 'WorkPackageId', 'operators[WorkPackageId]': '=', 'values[WorkPackageId]': work_package.id, set_filter: 1) %></td>
<td><%=h t.user.name %></td>
<td class="currency"><%= number_to_currency(t.real_costs) %></td>
</tr>
@ -395,7 +395,7 @@ See docs/COPYRIGHT.rdoc for more details.
<td colspan="3"></td>
<td class="currency">
<div class="generic-table--footer-outer">
<strong><%= number_to_currency(@cost_object.spent_labor) %></strong>
<strong><%= number_to_currency(@budget.spent_labor) %></strong>
</div>
</td>
</tr>

@ -27,7 +27,7 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title "#{t(:label_edit)} #{t(:label_cost_object_id, id: @cost_object.id)}: #{@cost_object.subject}" %>
<%= toolbar title: t(:label_cost_object_id, id: @cost_object.id) %>
<% html_title "#{t(:label_edit)} #{t(:label_budget_id, id: @budget.id)}: #{@budget.subject}" %>
<%= toolbar title: t(:label_budget_id, id: @budget.id) %>
<%= render :partial => 'edit' %>

@ -27,23 +27,23 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title(t(:label_cost_object_plural)) %>
<%= toolbar title: t(:label_cost_object_plural) do %>
<% if authorize_for(:cost_objects, :new) %>
<% html_title(t(:label_budget_plural)) %>
<%= toolbar title: t(:label_budget_plural) do %>
<% if authorize_for(:budgets, :new) %>
<li class="toolbar-item">
<a href="<%= new_projects_cost_object_path(@project) %>" aria-label="<%= I18n.t(:button_add_cost_object) %>" id="add-budget-button" title="<%= I18n.t(:button_add_cost_object) %>" class="button -alt-highlight">
<a href="<%= new_projects_budget_path(@project) %>" aria-label="<%= I18n.t(:button_add_budget) %>" id="add-budget-button" title="<%= I18n.t(:button_add_budget) %>" class="button -alt-highlight">
<%= op_icon('button--icon icon-add') %>
<span class="button--text"><%= t(:label_cost_object) %></span>
<span class="button--text"><%= t(:label_budget) %></span>
</a>
</li>
<% end %>
<% end %>
<% if @cost_objects.empty? %>
<% if @budgets.empty? %>
<%= no_results_box %>
<% else %>
<%= render :partial => 'list', :locals => {:cost_objects => @cost_objects} %>
<%= pagination_links_full @cost_objects %>
<%= render :partial => 'list', :locals => {:budgets => @budgets} %>
<%= pagination_links_full @budgets %>
<% end %>
<p class="other-formats">

@ -32,9 +32,9 @@ See docs/COPYRIGHT.rdoc for more details.
index ||= "INDEX"
new_or_existing = labor_budget_item.new_record? ? 'new' : 'existing'
id_or_index = labor_budget_item.new_record? ? index : labor_budget_item.id
prefix = "cost_object[#{new_or_existing}_labor_budget_item_attributes][]"
id_prefix = "cost_object_#{new_or_existing}_labor_budget_item_attributes_#{id_or_index}"
name_prefix = "cost_object[#{new_or_existing}_labor_budget_item_attributes][#{id_or_index}]"
prefix = "budget[#{new_or_existing}_labor_budget_item_attributes][]"
id_prefix = "budget_#{new_or_existing}_labor_budget_item_attributes_#{id_or_index}"
name_prefix = "budget[#{new_or_existing}_labor_budget_item_attributes][#{id_or_index}]"
classes ||= ""
classes += " budget-row-template" if templated
@ -77,14 +77,14 @@ See docs/COPYRIGHT.rdoc for more details.
<% if User.current.allowed_to?(:view_cost_rates, @project)%>
<td class="currency budget-table--fields">
<%# Keep current budget as hidden field because otherwise they will be overridden %>
<% if templated == false && !labor_budget_item.new_record? && labor_budget_item.overridden_budget? %>
<%= cost_form.hidden_field :budget, value: unitless_currency_number(labor_budget_item.budget) %>
<% if templated == false && !labor_budget_item.new_record? && labor_budget_item.overridden_costs? %>
<%= cost_form.hidden_field :amount, value: unitless_currency_number(labor_budget_item.amount) %>
<% end %>
<% cost_value = labor_budget_item.budget || labor_budget_item.calculated_costs(@cost_object.fixed_date, @cost_object.project_id) %>
<%= cost_form.hidden_field :currency, index: id_or_index, value: Setting.plugin_openproject_costs['costs_currency'] %>
<% cost_value = labor_budget_item.amount || labor_budget_item.calculated_costs(@budget.fixed_date, @budget.project_id) %>
<%= cost_form.hidden_field :currency, index: id_or_index, value: Setting.plugin_costs['costs_currency'] %>
<cost-unit-subform obj-id="<%= id_prefix %>"
obj-name="<%= "#{name_prefix}[budget]" %>">
obj-name="<%= "#{name_prefix}[amount]" %>">
<a id="<%= "#{id_prefix}_costs" %>" class="costs--edit-planned-costs-btn icon-context icon-edit" title="<%= t(:help_click_to_edit) %>">
<% if labor_budget_item.costs_visible_by?(User.current) %>
<%= cost_form.hidden_field :cost_value, index: id_or_index, value: unitless_currency_number(cost_value) %>

@ -32,9 +32,9 @@ See docs/COPYRIGHT.rdoc for more details.
index ||= "INDEX"
new_or_existing = material_budget_item.new_record? ? 'new' : 'existing'
id_or_index = material_budget_item.new_record? ? index : material_budget_item.id
prefix = "cost_object[#{new_or_existing}_material_budget_item_attributes][]"
id_prefix = "cost_object_#{new_or_existing}_material_budget_item_attributes_#{id_or_index}"
name_prefix = "cost_object[#{new_or_existing}_material_budget_item_attributes][#{id_or_index}]"
prefix = "budget[#{new_or_existing}_material_budget_item_attributes][]"
id_prefix = "budget_#{new_or_existing}_material_budget_item_attributes_#{id_or_index}"
name_prefix = "budget[#{new_or_existing}_material_budget_item_attributes][#{id_or_index}]"
classes ||= ""
classes += " budget-row-template" if templated
@ -79,16 +79,16 @@ See docs/COPYRIGHT.rdoc for more details.
<% if User.current.allowed_to? :view_cost_rates, @project %>
<td class="currency budget-table--fields">
<%# Keep current budget as hidden field because otherwise they will be overridden %>
<% if templated == false && !material_budget_item.new_record? && material_budget_item.overridden_budget? %>
<%= cost_form.hidden_field :budget, value: unitless_currency_number(material_budget_item.budget) %>
<% if templated == false && !material_budget_item.new_record? && material_budget_item.overridden_costs? %>
<%= cost_form.hidden_field :amount, value: unitless_currency_number(material_budget_item.amount) %>
<% end %>
<% cost_value = material_budget_item.budget || material_budget_item.calculated_costs(@cost_object.fixed_date) %>
<%= cost_form.hidden_field :currency, index: id_or_index, value: Setting.plugin_openproject_costs['costs_currency'] %>
<% cost_value = material_budget_item.amount || material_budget_item.calculated_costs(@budget.fixed_date) %>
<%= cost_form.hidden_field :currency, index: id_or_index, value: Setting.plugin_costs['costs_currency'] %>
<%= cost_form.hidden_field :cost_value, index: id_or_index, value: unitless_currency_number(cost_value) %>
<cost-unit-subform obj-id="<%= id_prefix %>"
obj-name="<%= "#{name_prefix}[budget]" %>">
obj-name="<%= "#{name_prefix}[amount]" %>">
<a id="<%= id_prefix %>_costs" class="costs--edit-planned-costs-btn icon-context icon-edit" role="button" title="<%= t(:help_click_to_edit) %>">
<%= number_to_currency(cost_value) %>
</a>

@ -27,14 +27,14 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title(t(:label_cost_object_new)) %>
<%= toolbar title: t(:label_cost_object_new) %>
<%= labelled_tabular_form_for @cost_object,
:url => projects_cost_objects_path(@project),
:as => :cost_object,
:html => {:multipart => true, :id => 'cost_object_form'} do |f| %>
<%= error_messages_for 'cost_object' %>
<% html_title(t(:label_budget_new)) %>
<%= toolbar title: t(:label_budget_new) %>
<%= labelled_tabular_form_for @budget,
:url => projects_budgets_path(@project),
:as => :budget,
:html => {:multipart => true, :id => 'budget_form'} do |f| %>
<%= error_messages_for 'budget' %>
<%= render :partial => 'form', :locals => {:f => f} %>
<%= styled_button_tag t(:button_create), class: '-with-icon icon-checkmark' %>
<%= styled_button_tag t(:button_create_and_continue), :name => 'continue',

@ -27,27 +27,27 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title "#{t(:label_cost_object_id, id: @cost_object.id)}: #{@cost_object.subject}" %>
<%= toolbar title: t(:label_cost_object_id, id: @cost_object.id) do %>
<% if authorize_for(:cost_objects, :edit) %>
<% html_title "#{t(:label_budget_id, id: @budget.id)}: #{@budget.subject}" %>
<%= toolbar title: t(:label_budget_id, id: @budget.id) do %>
<% if authorize_for(:budgets, :edit) %>
<li class="toolbar-item">
<%= link_to({ controller: 'cost_objects', action: 'edit', id: @cost_object }, class: 'button', accesskey: accesskey(:edit)) do %>
<%= link_to({ controller: 'budgets', action: 'edit', id: @budget }, class: 'button', accesskey: accesskey(:edit)) do %>
<%= op_icon('button--icon icon-edit') %>
<span class="button--text"><%= l(:button_update) %></span>
<% end %>
</li>
<% end %>
<% if authorize_for(:cost_objects, :copy) %>
<% if authorize_for(:budgets, :copy) %>
<li class="toolbar-item hidden-for-mobile">
<%= link_to({ controller: 'cost_objects', action: 'copy', id: @cost_object }, class: 'button') do %>
<%= link_to({ controller: 'budgets', action: 'copy', id: @budget }, class: 'button') do %>
<%= op_icon('button--icon icon-copy') %>
<span class="button--text"><%= l(:button_copy) %></span>
<% end %>
</li>
<% end %>
<% if authorize_for(:cost_objects, :copy) %>
<% if authorize_for(:budgets, :copy) %>
<li class="toolbar-item">
<%= link_to({ controller: 'cost_objects', action: 'destroy', id: @cost_object }, class: 'button', method: :delete, data: { confirm: t(:text_are_you_sure)}) do %>
<%= link_to({ controller: 'budgets', action: 'destroy', id: @budget }, class: 'button', method: :delete, data: { confirm: t(:text_are_you_sure)}) do %>
<%= op_icon('button--icon icon-delete') %>
<span class="button--text"><%= t(:button_delete) %></span>
<% end %>
@ -55,43 +55,43 @@ See docs/COPYRIGHT.rdoc for more details.
<% end %>
<% end %>
<div class="<%= @cost_object.css_classes %> details">
<h3><%=h @cost_object.subject %></h3>
<div class="<%= @budget.css_classes %> details">
<h3><%=h @budget.subject %></h3>
<p class="author">
<%= authoring @cost_object.created_on, @cost_object.author %>.
<%= t(:label_updated_time, value: distance_of_time_in_words(Time.now, @cost_object.updated_on)) + '.' if @cost_object.created_on != @cost_object.updated_on %>
<%= authoring @budget.created_at, @budget.author %>.
<%= t(:label_updated_time, value: distance_of_time_in_words(Time.now, @budget.updated_at)) + '.' if @budget.created_at != @budget.updated_at %>
</p>
<div class="attributes-group">
<div class="attributes-key-value">
<div class="attributes-key-value--key"><%= CostObject.human_attribute_name(:type) %></div>
<div class="attributes-key-value--key"><%= Budget.human_attribute_name(:type) %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @cost_object.type_label %></span>
<span><%= @budget.type_label %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= CostObject.human_attribute_name(:fixed_date) %></div>
<div class="attributes-key-value--key"><%= Budget.human_attribute_name(:fixed_date) %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= format_date(@cost_object.fixed_date) %></span>
<span><%= format_date(@budget.fixed_date) %></span>
</div>
</div>
<div class="attributes-key-value--key"><%= CostObject.human_attribute_name(:budget_ratio) %></div>
<div class="attributes-key-value--key"><%= Budget.human_attribute_name(:budget_ratio) %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span>
<%= extended_progress_bar(@cost_object.budget_ratio,
<%= extended_progress_bar(@budget.budget_ratio,
:width => '80px',
:legend => @cost_object.budget_ratio) %>
:legend => @budget.budget_ratio) %>
</span>
</div>
</div>
<% unless format_text(@cost_object, :description, :attachments => @cost_object.attachments).empty? %>
<div class="attributes-key-value--key"><%= CostObject.human_attribute_name(:description) %></div>
<% unless format_text(@budget, :description, :attachments => @budget.attachments).empty? %>
<div class="attributes-key-value--key"><%= Budget.human_attribute_name(:description) %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span>
<%= format_text @cost_object, :description, :attachments => @cost_object.attachments %>
<%= format_text @budget, :description, :attachments => @budget.attachments %>
</span>
</div>
</div>
@ -99,15 +99,15 @@ See docs/COPYRIGHT.rdoc for more details.
</div>
</div>
<% resource = budget_attachment_representer(@cost_object) %>
<% resource = budget_attachment_representer(@budget) %>
<%= list_attachments(resource) %>
<%= render :partial => "show_variable_cost_object" %>
<%= render :partial => "show" %>
</div>
<div style="clear: both;"></div>
<% if authorize_for('cost_objects', 'edit') %>
<% if authorize_for('budgets', 'edit') %>
<div id="update" style="display:none;">
<h3><%= t(:button_update) %></h3>
<%= render :partial => 'edit' %>

@ -27,15 +27,15 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<%# we have to assign the cost_object here as following methods depend on the item having an object -%>
<% template_object = @cost_object.labor_budget_items.build.tap do |i|
i.cost_object = @cost_object
<%# we have to assign the budget here as following methods depend on the item having an object -%>
<% template_object = @budget.labor_budget_items.build.tap do |i|
i.budget = @budget
end -%>
<costs-budget-subform item-count="<%= @cost_object.labor_budget_items.length %>"
<costs-budget-subform item-count="<%= @budget.labor_budget_items.length %>"
update-url="<%= url_for(action: :update_labor_budget_item, project_id: @project.id) %>">
<fieldset id="labor_budget_items_fieldset" class="form--fieldset -collapsible">
<legend class="form--fieldset-legend"><%= VariableCostObject.human_attribute_name(:labor_budget) %></legend>
<legend class="form--fieldset-legend"><%= Budget.human_attribute_name(:labor_budget) %></legend>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table" id="labor_budget_items">
@ -92,9 +92,9 @@ end -%>
</tr>
</thead>
<tbody id="labor_budget_items_body" class="budget-item-container">
<%= render partial: "cost_objects/items/labor_budget_item", object: template_object, locals: { templated: true } %>
<%- @cost_object.labor_budget_items.each_with_index do |labor_budget_item, index| -%>
<%= render partial: 'cost_objects/items/labor_budget_item', object: labor_budget_item, locals: {index: index} %>
<%= render partial: "budgets/items/labor_budget_item", object: template_object, locals: { templated: true } %>
<%- @budget.labor_budget_items.each_with_index do |labor_budget_item, index| -%>
<%= render partial: 'budgets/items/labor_budget_item', object: labor_budget_item, locals: {index: index} %>
<%- end -%>
</tbody>
</table>

@ -29,12 +29,12 @@ See docs/COPYRIGHT.rdoc for more details.
<% if CostType.exists? %>
<%# Build a template object for the frontend to add new rows %>
<% template_object = @cost_object.material_budget_items.build(cost_type: CostType.default) %>
<% template_object = @budget.material_budget_items.build(cost_type: CostType.default) %>
<costs-budget-subform item-count="<%= @cost_object.material_budget_items.length %>"
<costs-budget-subform item-count="<%= @budget.material_budget_items.length %>"
update-url="<%= url_for(action: :update_material_budget_item, project_id: @project.id) %>">
<fieldset id="material_budget_items_fieldset" class="form--fieldset -collapsible">
<legend class="form--fieldset-legend"><%= VariableCostObject.human_attribute_name(:material_budget) %></legend>
<legend class="form--fieldset-legend"><%= Budget.human_attribute_name(:material_budget) %></legend>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table" id="material_budget_items">
@ -100,9 +100,9 @@ See docs/COPYRIGHT.rdoc for more details.
</tr>
</thead>
<tbody id="material_budget_items_body" class="budget-item-container">
<%= render partial: "cost_objects/items/material_budget_item", object: template_object, locals: { templated: true } %>
<%- @cost_object.material_budget_items.each_with_index do |material_budget_item, index| -%>
<%= render partial: 'cost_objects/items/material_budget_item', object: material_budget_item, locals: {index: index} %>
<%= render partial: "budgets/items/material_budget_item", object: template_object, locals: { templated: true } %>
<%- @budget.material_budget_items.each_with_index do |material_budget_item, index| -%>
<%= render partial: 'budgets/items/material_budget_item', object: material_budget_item, locals: {index: index} %>
<%- end -%>
</tbody>
</table>

@ -0,0 +1,10 @@
# encoding: UTF-8
Gem::Specification.new do |s|
s.name = "budgets"
s.version = '1.0.0'
s.authors = ["OpenProject"]
s.summary = "OpenProject Budgets."
s.files = Dir["{app,config,db,lib}/**/*"]
end

@ -0,0 +1,89 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
---
en:
activerecord:
attributes:
budget:
author: "Author"
available: "Available"
budget: "Planned"
budget_ratio: "Spent (ratio)"
created_on: "Created on"
description: "Description"
fixed_date: "Fixed date"
spent: "Spent"
status: "Status"
subject: "Subject"
type: "Cost type"
updated_on: "Updated on"
work_package:
budget_subject: "Budget title"
budget:
labor_budget: "Planned labor costs"
material_budget: "Planned unit costs"
models:
budget: "Budget"
material_budget_item: "Unit"
attributes:
budget: "Planned costs"
budget: "Budget"
button_add_budget_item: "Add planned costs"
button_add_budget: "Add budget"
button_add_cost_type: "Add cost type"
button_cancel_edit_budget: "Cancel editing budget"
button_cancel_edit_costs: "Cancel editing costs"
caption_labor: "Labor"
caption_labor_costs: "Actual labor costs"
caption_material_costs: "Actual unit costs"
budgets_title: "Budgets"
events:
budget: "Budget edited"
help_click_to_edit: "Click here to edit."
help_currency_format: "Format of displayed currency values. %n is replaced with the currency value, %u ist replaced with the currency unit."
help_override_rate: "Enter a value here to override the default rate."
label_budget: "Budget"
label_budget_new: "New budget"
label_budget_plural: "Budgets"
label_cost_type_specific: "Budget #%{id}: %{name}"
label_deliverable: "Budget"
label_view_all_budgets: "View all budgets"
label_yes: "Yes"
notice_budget_conflict: "WorkPackages must be of the same project."
notice_no_budgets_available: "No budgets available."
permission_edit_budgets: "Edit budgets"
permission_view_budgets: "View budgets"
project_module_budgets: "Budgets"

@ -26,5 +26,8 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject::Costs::Hooks
end
en:
js:
work_packages:
properties:
costObject: "Budget"

@ -1,3 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -26,14 +28,15 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
OpenProject::Application.routes.draw do
scope 'projects/:project_id', as: 'projects' do
resources :budgets, only: %i[new create index] do
match :update_labor_budget_item, on: :collection, via: %i[get post]
match :update_material_budget_item, on: :collection, via: %i[get post]
end
end
describe CostlogController, type: :routing do
describe 'routing' do
it {
expect(get('/work_packages/5/cost_entries')).to route_to(controller: 'work_package_costlog',
action: 'index',
work_package_id: '5')
}
resources :budgets, only: %i[show update destroy edit] do
get :copy, on: :member
end
end

@ -1,3 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -26,26 +28,22 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
require_dependency 'api/v3/work_packages/schema/specific_work_package_schema'
require Rails.root.to_s + '/db/migrate/migration_utils/module_renamer'
module OpenProject::Costs::Patches::SpecificWorkPackageSchemaPatch
def self.included(base)
base.class_eval do
prepend InstanceMethods
extend ClassMethods
end
class KeepEnabledModule < ActiveRecord::Migration[6.0]
def up
module_renamer.add_to_enabled('budgets', %w[costs_module])
module_renamer.add_to_default('budgets', %w[costs_module])
end
module ClassMethods
def down
module_renamer.remove_from_enabled('budgets')
module_renamer.remove_from_default('budgets')
end
module InstanceMethods
def assignable_values(property, _context)
if property == :cost_object
return project.try(:cost_objects)
end
private
super
end
def module_renamer
Migration::MigrationUtils::ModuleRenamer
end
end

@ -0,0 +1,150 @@
class RenameCostObjectToBudget < ActiveRecord::Migration[6.0]
def up
remove_column :cost_objects, :type
rename_table :cost_objects, :budgets
execute <<~SQL
UPDATE types
SET attribute_groups = REGEXP_REPLACE(attribute_groups, ' cost_object', ' budget')
WHERE attribute_groups LIKE '%cost_object%'
SQL
execute <<~SQL
UPDATE role_permissions
SET permission = 'view_budgets'
WHERE permission = 'view_cost_objects'
SQL
execute <<~SQL
UPDATE role_permissions
SET permission = 'edit_budgets'
WHERE permission = 'edit_cost_objects'
SQL
execute <<~SQL
UPDATE journals
SET activity_type = 'budgets'
WHERE activity_type = 'cost_objects'
SQL
execute <<~SQL
UPDATE attachments
SET container_type = 'Budget'
WHERE container_type = 'CostObject'
SQL
rename_in_queries('cost_object', 'budget')
rename_in_cost_queries('CostObject', 'Budget')
rename_column :budgets, :created_on, :created_at
rename_column :budgets, :updated_on, :updated_at
rename_table :cost_object_journals, :budget_journals
remove_column :budget_journals, :created_on
rename_column :work_packages, :cost_object_id, :budget_id
rename_column :work_package_journals, :cost_object_id, :budget_id
rename_column :labor_budget_items, :cost_object_id, :budget_id
rename_column :labor_budget_items, :budget, :amount
rename_column :material_budget_items, :cost_object_id, :budget_id
rename_column :material_budget_items, :budget, :amount
end
def down
rename_column :material_budget_items, :amount, :budget
rename_column :material_budget_items, :budget_id, :cost_object_id
rename_column :labor_budget_items, :amount, :budget
rename_column :labor_budget_items, :budget_id, :cost_object_id
rename_column :work_packages, :budget_id, :cost_object_id
rename_column :work_package_journals, :budget_id, :cost_object_id
add_column :budget_journals, :created_on, :timestamp
rename_table :budget_journals, :cost_object_journals
rename_column :budgets, :created_at, :created_on
rename_column :budgets, :updated_at, :updated_on
rename_in_queries('budget', 'cost_object')
rename_in_cost_queries('Budget', 'CostObject')
execute <<~SQL
UPDATE attachments
SET container_type = 'CostObject'
WHERE container_type = 'Budget'
SQL
execute <<~SQL
UPDATE journals
SET activity_type = 'cost_objects'
WHERE activity_type = 'budgets'
SQL
execute <<~SQL
UPDATE role_permissions
SET permission = 'view_cost_objects'
WHERE permission = 'view_budgets'
SQL
execute <<~SQL
UPDATE role_permissions
SET permission = 'edit_cost_objects'
WHERE permission = 'edit_budgets'
SQL
execute <<~SQL
UPDATE types
SET attribute_groups = REGEXP_REPLACE(attribute_groups, ' budget', ' cost_object')
WHERE attribute_groups LIKE '%budget%'
SQL
add_column :budgets, :type, :string
execute <<~SQL
UPDATE budgets SET type = 'VariableCostObject'
SQL
change_column :budgets, :type, :string, null: false
rename_table :budgets, :cost_objects
end
def rename_in_queries(old, new)
execute <<~SQL
UPDATE queries
SET filters = REGEXP_REPLACE(filters, '#{old}_id:', '#{new}_id:')
WHERE filters LIKE '%#{old}_id%'
SQL
execute <<~SQL
UPDATE queries
SET sort_criteria = REGEXP_REPLACE(sort_criteria, '#{old}', '#{new}')
WHERE sort_criteria LIKE '%#{old}%'
SQL
execute <<~SQL
UPDATE queries
SET column_names = REGEXP_REPLACE(column_names, '#{old}', '#{new}')
WHERE column_names LIKE '%#{old}%'
SQL
execute <<~SQL
UPDATE queries
SET group_by = REGEXP_REPLACE(group_by, '#{old}', '#{new}')
WHERE group_by LIKE '%#{old}%'
SQL
execute <<~SQL
UPDATE queries
SET timeline_labels = REGEXP_REPLACE(timeline_labels, '#{old.camelize(:lower)}', '#{new.camelize(:lower)}')
WHERE timeline_labels LIKE '%#{old.camelize(:lower)}%'
SQL
end
def rename_in_cost_queries(old, new)
execute <<~SQL
UPDATE cost_queries
SET serialized = REGEXP_REPLACE(serialized, '#{old}', '#{new}')
WHERE serialized LIKE '%#{old}%'
SQL
end
end

@ -0,0 +1,122 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
// ++
import {Injectable} from "@angular/core";
import {HttpClient} from '@angular/common/http';
import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service";
@Injectable()
export class CostBudgetSubformAugmentService {
constructor(private halNotification:HalResourceNotificationService,
private http:HttpClient) {
}
listen() {
jQuery('costs-budget-subform').each((i, match) => {
let el = jQuery(match);
const container = el.find('.budget-item-container');
const templateEl = el.find('.budget-row-template');
templateEl.detach();
const template = templateEl[0].outerHTML;
let rowIndex = parseInt(el.attr('item-count') as string);
// Refresh row on changes
el.on('change', '.budget-item-value', (evt) => {
let row = jQuery(evt.target).closest('.cost_entry');
this.refreshRow(el, row.attr('id') as string);
});
el.on('click', '.delete-budget-item', (evt) => {
evt.preventDefault();
jQuery(evt.target).closest('.cost_entry').remove();
return false;
});
// Add new row handler
el.find('.budget-add-row').click((evt) => {
evt.preventDefault();
let row = jQuery(template.replace(/INDEX/g, rowIndex.toString()));
row.show();
row.removeClass('budget-row-template');
container.append(row);
rowIndex += 1;
return false;
});
});
}
/**
* Refreshes the given row after updating values
*/
public refreshRow(el:JQuery, row_identifier:string) {
let row = el.find('#' + row_identifier);
let request = this.buildRefreshRequest(row, row_identifier);
this.http
.post(
el.attr('update-url')!,
request,
{
headers: { 'Accept': 'application/json' },
withCredentials: true
})
.subscribe(
(data:any) => {
_.each(data, (val:string, selector:string) => {
let element = document.getElementById(selector) as HTMLElement|HTMLInputElement|undefined;
if (element instanceof HTMLInputElement) {
element.value = val;
} else if (element) {
element.textContent = val;
}
});
},
(error:any) => this.halNotification.handleRawError(error)
);
}
/**
* Returns the params for the update request
*/
private buildRefreshRequest(row:JQuery, row_identifier:string) {
let request:any = {
element_id: row_identifier,
fixed_date: jQuery('#budget_fixed_date').val()
};
// Augment common values with specific values for this type
row.find('.budget-item-value').each((_i:number, el:any) => {
let field = jQuery(el);
request[field.data('requestKey')] = field.val() || '0';
});
return request;
}
}

@ -0,0 +1,67 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
// ++
import {Injectable} from "@angular/core";
@Injectable()
export class CostSubformAugmentService {
constructor() {
jQuery('costs-subform').each((i, match) => {
let el = jQuery(match);
const container = el.find('.subform-container');
const templateEl = el.find('.subform-row-template');
templateEl.detach();
const template = templateEl[0].outerHTML;
let rowIndex = parseInt(el.attr('item-count')!);
el.on('click', '.delete-row-button,.delete-budget-item', (evt:any) => {
jQuery(evt.target).closest('.subform-row').remove();
return false;
});
// Add new row handler
el.find('.add-row-button,.wp-inline-create--add-link').click((evt:any) => {
evt.preventDefault();
let row = jQuery(template.replace(/INDEX/g, rowIndex.toString()));
row.show();
row.removeClass('subform-row-template');
container.append(row);
rowIndex += 1;
container.find('.subform-row:last-child input:first').focus();
return false;
});
});
}
}

@ -0,0 +1,101 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
// ++
export class PlannedCostsFormAugment {
public obj:JQuery;
public objId:string;
public objName:string;
static listen() {
jQuery(document).on('click', '.costs--edit-planned-costs-btn', (evt) => {
const form = jQuery(evt.target as any).closest('cost-unit-subform') as JQuery;
new PlannedCostsFormAugment(form);
});
}
constructor(public $element:JQuery) {
this.objId = this.$element.attr('obj-id')!;
this.objName = this.$element.attr('obj-name')!;
this.obj = jQuery(`#${this.objId}_costs`) as any;
this.makeEditable();
}
public makeEditable() {
this.edit_and_focus();
}
private edit_and_focus() {
this.edit();
jQuery('#' + this.objId + '_costs_edit').trigger('focus');
jQuery('#' + this.objId + '_costs_edit').trigger('select');
}
private getCurrency() {
return jQuery('#' + this.objId + '_currency').val();
}
private getValue() {
return jQuery('#' + this.objId + '_cost_value').val();
}
private edit() {
this.obj.hide();
let id = this.obj[0].id;
let currency = this.getCurrency();
let value = this.getValue();
let name = this.objName;
let template = `
<section class="form--section" id="${id}_section">
<div class="form--field">
<div class="form--field-container">
<div id="${id}_cancel" class="form--field-affix -transparent icon icon-close"></div>
<div id="${id}_editor" class="form--text-field-container">
<input id="${id}_edit" class="form--text-field" name="${name}" value="${value}" class="currency" type="text" />
</div>
<div class="form--field-affix" id="${id}_affix">${currency}</div>
</div>
</div>
</section>
`;
jQuery(template).insertAfter(this.obj);
let that = this;
jQuery('#' + id + '_cancel').on('click', function () {
jQuery('#' + id + '_section').remove();
that.obj.show();
return false;
});
}
}

@ -0,0 +1,70 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
import {Injector, NgModule} from '@angular/core';
import {OpenProjectPluginContext} from 'core-app/modules/plugins/plugin-context';
import {BudgetResource} from './hal/resources/budget-resource';
import {multiInput} from 'reactivestates';
import {CostSubformAugmentService} from "./augment/cost-subform.augment.service";
import {PlannedCostsFormAugment} from "core-app/modules/plugins/linked/budgets/augment/planned-costs-form";
import {CostBudgetSubformAugmentService} from "core-app/modules/plugins/linked/budgets/augment/cost-budget-subform.augment.service";
export function initializeCostsPlugin(injector:Injector) {
window.OpenProject.getPluginContext().then((pluginContext:OpenProjectPluginContext) => {
pluginContext.services.editField.extendFieldType('select', ['Budget']);
let displayFieldService = pluginContext.services.displayField;
displayFieldService.extendFieldType('resource', ['Budget']);
let halResourceService = pluginContext.services.halResource;
halResourceService.registerResource('Budget', {cls: BudgetResource});
let states = pluginContext.services.states;
states.add('budgets', multiInput<BudgetResource>());
// Augment previous cost-subforms
new CostSubformAugmentService();
PlannedCostsFormAugment.listen();
const budgetSubform = injector.get(CostBudgetSubformAugmentService);
budgetSubform.listen();
});
}
@NgModule({
providers: [
CostBudgetSubformAugmentService,
],
})
export class PluginModule {
constructor(injector:Injector) {
initializeCostsPlugin(injector);
}
}

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH

@ -39,8 +39,9 @@ module API
link :staticPath do
next if represented.new_record?
{
href: cost_object_path(represented.id)
href: budget_path(represented.id)
}
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -34,9 +35,9 @@ module API
resources :budgets do
route_param :id, type: Integer, desc: 'Budget ID' do
after_validation do
@budget = CostObject.find(params[:id])
@budget = Budget.find(params[:id])
authorize_any([:view_work_packages, :view_budgets], projects: @budget.project)
authorize_any(%i[view_work_packages view_budgets], projects: @budget.project)
end
get do

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
@ -33,8 +34,8 @@ module API
class BudgetsByProjectAPI < ::API::OpenProjectAPI
resources :budgets do
after_validation do
authorize_any([:view_work_packages, :view_budgets], projects: @project)
@budgets = @project.cost_objects
authorize_any(%i[view_work_packages view_budgets], projects: @project)
@budgets = @project.budgets
end
get do

@ -30,9 +30,8 @@ module API
module V3
module Queries
module Schemas
class CostObjectFilterDependencyRepresenter <
class BudgetFilterDependencyRepresenter <
FilterDependencyRepresenter
def href_callback
api_v3_paths.budgets_by_project filter.project.id
end

@ -0,0 +1,4 @@
require "budgets/engine"
module Budgets
end

@ -0,0 +1,67 @@
module Budgets
class Engine < ::Rails::Engine
include OpenProject::Plugins::ActsAsOpEngine
register 'budgets',
author_url: 'https://www.openproject.com',
bundled: true,
name: 'Budgets' do
project_module :budgets do
permission :view_budgets, { budgets: %i[index show] }
permission :edit_budgets, { budgets: %i[index show edit update destroy new create copy] }
end
menu :project_menu,
:budgets,
{ controller: '/budgets', action: 'index' },
param: :project_id,
if: ->(project) { project.module_enabled?(:budgets) },
after: :costs,
caption: :budgets_title,
icon: 'icon2 icon-budget'
end
activity_provider :budgets, class_name: 'Activities::BudgetActivityProvider', default: false
add_api_path :budget do |id|
"#{root}/budgets/#{id}"
end
add_api_path :budgets_by_project do |project_id|
"#{project(project_id)}/budgets"
end
add_api_path :attachments_by_budget do |id|
"#{budget(id)}/attachments"
end
add_api_endpoint 'API::V3::Root' do
mount ::API::V3::Budgets::BudgetsAPI
end
add_api_endpoint 'API::V3::Projects::ProjectsAPI', :id do
mount ::API::V3::Budgets::BudgetsByProjectAPI
end
initializer 'budgets.register_latest_project_activity' do
Project.register_latest_project_activity on: 'Budget',
attribute: :updated_at
end
initializer 'budgets.register_hooks' do
# TODO: avoid hooks as this is part of the core now
require 'budgets/hooks/work_package_hook'
end
config.to_prepare do
# Add to the budget to the costs group
::Type.add_default_mapping(:costs, :budget)
::Type.add_constraint :budget, ->(_type, project: nil) {
project.nil? || project.module_enabled?(:budgets)
}
Queries::Register.filter Query, Queries::WorkPackages::Filter::BudgetFilter
end
end
end

@ -26,16 +26,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
# Hooks to attach to the Redmine WorkPackages.
class OpenProject::Costs::Hooks::WorkPackageHook < Redmine::Hook::ViewListener
# Renders the Cost Object subject and basic costs information
# render_on :view_work_packages_show_details_bottom, :partial => 'hooks/costs/view_work_packages_show_details_bottom'
# Renders a select tag with all the Cost Objects for the bulk edit page
render_on :view_work_packages_bulk_edit_details_bottom, partial: 'hooks/costs/view_work_packages_bulk_edit_details_bottom'
render_on :view_work_packages_move_bottom, partial: 'hooks/costs/view_work_packages_move_bottom'
class Budgets::Hooks::WorkPackageHook < Redmine::Hook::ViewListener
# Updates the cost object after a move
#
# Context:
@ -46,16 +37,16 @@ class OpenProject::Costs::Hooks::WorkPackageHook < Redmine::Hook::ViewListener
def controller_work_packages_move_before_save(context = {})
# FIXME: In case of copy==true, this will break stuff if the original work_package is saved
cost_object_id = context[:params] && context[:params][:cost_object_id]
case cost_object_id
budget_id = context[:params] && context[:params][:budget_id]
case budget_id
when '' # a.k.a "(No change)"
# cost objects HAVE to be changed if move is performed across project boundaries
# as the are project specific
context[:work_package].cost_object_id = nil unless (context[:work_package].project == context[:target_project])
context[:work_package].budget_id = nil unless context[:work_package].project == context[:target_project]
when 'none'
context[:work_package].cost_object_id = nil
context[:work_package].budget_id = nil
else
context[:work_package].cost_object_id = cost_object_id
context[:work_package].budget_id = budget_id
end
end
@ -68,37 +59,15 @@ class OpenProject::Costs::Hooks::WorkPackageHook < Redmine::Hook::ViewListener
def controller_work_packages_bulk_edit_before_save(context = {})
case true
when context[:params][:cost_object_id].blank?
when context[:params][:budget_id].blank?
# Do nothing
when context[:params][:cost_object_id] == 'none'
# Unassign cost_object
context[:work_package].cost_object = nil
when context[:params][:budget_id] == 'none'
# Unassign budget
context[:work_package].budget = nil
else
context[:work_package].cost_object = CostObject.find(context[:params][:cost_object_id])
context[:work_package].budget = Budget.find(context[:params][:budget_id])
end
''
end
# Cost Object changes for the journal use the Cost Object subject
# instead of the id
#
# Context:
# * :detail => Detail about the journal change
#
def helper_work_packages_show_detail_after_setting(context = {})
# FIXME: Overwritting the caller is bad juju
if (context[:detail].prop_key == 'cost_object_id')
if context[:detail].value.to_i.to_s == context[:detail].value.to_s
d = CostObject.find_by_id(context[:detail].value)
context[:detail].value = d.subject unless d.nil? || d.subject.nil?
end
if context[:detail].old_value.to_i.to_s == context[:detail].old_value.to_s
d = CostObject.find_by_id(context[:detail].old_value)
context[:detail].old_value = d.subject unless d.nil? || d.subject.nil?
end
end
''
end
end

@ -27,13 +27,13 @@
#++
FactoryBot.define do
factory :cost_object do
subject { 'Some Cost Object' }
description { 'Some costs' }
kind { 'VariableCostObject' }
factory :budget do
sequence(:subject) { |n| "Budget No. #{n}" }
sequence(:description) { |n| "I am Budget No. #{n}" }
project
association :author, factory: :user
fixed_date { Date.today }
created_on { 3.days.ago }
updated_on { 3.days.ago }
created_at { 3.days.ago }
updated_at { 3.days.ago }
end
end

@ -29,7 +29,7 @@
FactoryBot.define do
factory :labor_budget_item do
association :user, factory: :user
association :cost_object, factory: :variable_cost_object
association :budget, factory: :budget
hours { 0.0 }
end
end

@ -29,7 +29,7 @@
FactoryBot.define do
factory :material_budget_item do
association :cost_type, factory: :cost_type
association :cost_object, factory: :variable_cost_object
association :budget, factory: :budget
units { 0.0 }
end
end

@ -37,7 +37,7 @@ describe 'adding a new budget', type: :feature, js: true do
end
it 'shows link to create a new budget' do
visit projects_cost_objects_path(project)
visit projects_budgets_path(project)
click_on("Add budget")
@ -57,16 +57,16 @@ describe 'adding a new budget', type: :feature, js: true do
it 'can switch between them' do
visit projects_cost_objects_path(project)
visit projects_budgets_path(project)
click_on("Add budget")
expect(page).to have_content "New budget"
fill_in 'Subject', with: 'My subject'
fill_in 'cost_object_new_material_budget_item_attributes_0_units', with: 15
fill_in 'budget_new_material_budget_item_attributes_0_units', with: 15
# change cost type
select 'Foobar', from: 'cost_object_new_material_budget_item_attributes_0_cost_type_id'
select 'Foobar', from: 'budget_new_material_budget_item_attributes_0_cost_type_id'
click_on "Create"
@ -79,7 +79,7 @@ describe 'adding a new budget', type: :feature, js: true do
end
it 'create the budget' do
visit new_projects_cost_object_path(project)
visit new_projects_budget_path(project)
fill_in("Subject", with: 'My subject')

@ -35,8 +35,8 @@ describe 'Upload attachment to budget', js: true do
let(:user) do
FactoryBot.create :user,
member_in_project: project,
member_with_permissions: %i[view_cost_objects
edit_cost_objects]
member_with_permissions: %i[view_budgets
edit_budgets]
end
let(:project) { FactoryBot.create(:project) }
let(:attachments) { ::Components::Attachments.new }
@ -48,7 +48,7 @@ describe 'Upload attachment to budget', js: true do
end
it 'can upload an image to new and existing budgets via drag & drop' do
visit projects_cost_objects_path(project)
visit projects_budgets_path(project)
within '.toolbar-items' do
click_on "Budget"

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save