Add journals to Jobs to track changes
Redmine uses journals to track changes to Issues. Whilst theoretically a polymorphic association, there are a bunch of places where it is assumed this is an issue, so we have to account for that and start patching things. Refs #3320
This commit is contained in:
parent
c2e16d729d
commit
60e1f72a45
|
@ -7,6 +7,7 @@ class JobsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@journals = @job.journals
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
@ -18,6 +19,7 @@ class JobsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
@job.init_journal(User.current)
|
||||||
if @job.update(remove_empty_time_budgets(job_params))
|
if @job.update(remove_empty_time_budgets(job_params))
|
||||||
redirect_to project_job_path(@job.project, @job)
|
redirect_to project_job_path(@job.project, @job)
|
||||||
else
|
else
|
||||||
|
@ -55,6 +57,7 @@ class JobsController < ApplicationController
|
||||||
:name,
|
:name,
|
||||||
:description,
|
:description,
|
||||||
:category_id,
|
:category_id,
|
||||||
|
:notes,
|
||||||
time_budgets_attributes: [:id, :category_id, :hours, :job_id, :_destroy]
|
time_budgets_attributes: [:id, :category_id, :hours, :job_id, :_destroy]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,4 +12,200 @@ module JobsHelper
|
||||||
(#{l_hours_short(budget.total_time_logged)}/#{l_hours_short(budget.hours)})",
|
(#{l_hours_short(budget.total_time_logged)}/#{l_hours_short(budget.hours)})",
|
||||||
class: "progress")
|
class: "progress")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def job_history_tabs
|
||||||
|
tabs = []
|
||||||
|
|
||||||
|
if @journals.present?
|
||||||
|
has_notes = @journals.any? { |journal| journal.notes.present? }
|
||||||
|
tabs <<
|
||||||
|
{
|
||||||
|
name: "history",
|
||||||
|
label: "History",
|
||||||
|
onclick: 'showIssueHistory("history", this.href)',
|
||||||
|
partial: "jobs/tabs/history",
|
||||||
|
locals: {
|
||||||
|
job: @job,
|
||||||
|
journals: @journals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_notes
|
||||||
|
tabs << {
|
||||||
|
name: "notes",
|
||||||
|
label: "Notes",
|
||||||
|
onclick: 'showIssueHistory("notes", this.href)',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tabs
|
||||||
|
end
|
||||||
|
|
||||||
|
def job_history_default_tab
|
||||||
|
return params[:tab] if params[:tab].present?
|
||||||
|
|
||||||
|
"history"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Below methods copied directly from issues_helper with few adjustments
|
||||||
|
|
||||||
|
|
||||||
|
# Returns the textual representation of a journal details
|
||||||
|
# as an array of strings
|
||||||
|
def details_to_strings(details, no_html=false, options={})
|
||||||
|
options[:only_path] = !(options[:only_path] == false)
|
||||||
|
strings = []
|
||||||
|
values_by_field = {}
|
||||||
|
details.each do |detail|
|
||||||
|
if detail.property == 'cf'
|
||||||
|
field = detail.custom_field
|
||||||
|
if field && field.multiple?
|
||||||
|
values_by_field[field] ||= {:added => [], :deleted => []}
|
||||||
|
if detail.old_value
|
||||||
|
values_by_field[field][:deleted] << detail.old_value
|
||||||
|
end
|
||||||
|
if detail.value
|
||||||
|
values_by_field[field][:added] << detail.value
|
||||||
|
end
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
strings << show_detail(detail, no_html, options)
|
||||||
|
end
|
||||||
|
if values_by_field.present?
|
||||||
|
values_by_field.each do |field, changes|
|
||||||
|
if changes[:added].any?
|
||||||
|
detail = MultipleValuesDetail.new('cf', field.id.to_s, field)
|
||||||
|
detail.value = changes[:added]
|
||||||
|
strings << show_detail(detail, no_html, options)
|
||||||
|
end
|
||||||
|
if changes[:deleted].any?
|
||||||
|
detail = MultipleValuesDetail.new('cf', field.id.to_s, field)
|
||||||
|
detail.old_value = changes[:deleted]
|
||||||
|
strings << show_detail(detail, no_html, options)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
strings
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_detail(detail, no_html=false, options={})
|
||||||
|
multiple = false
|
||||||
|
show_diff = false
|
||||||
|
no_details = false
|
||||||
|
|
||||||
|
case detail.property
|
||||||
|
when 'attr'
|
||||||
|
field = detail.prop_key.to_s.gsub(/\_id$/, "")
|
||||||
|
label = l(("field_" + field).to_sym)
|
||||||
|
case detail.prop_key
|
||||||
|
when 'due_date', 'start_date'
|
||||||
|
value = format_date(detail.value.to_date) if detail.value
|
||||||
|
old_value = format_date(detail.old_value.to_date) if detail.old_value
|
||||||
|
when 'project_id', 'category_id'
|
||||||
|
value = find_name_by_reflection(field, detail.value)
|
||||||
|
old_value = find_name_by_reflection(field, detail.old_value)
|
||||||
|
when 'description'
|
||||||
|
show_diff = true
|
||||||
|
end
|
||||||
|
when 'relation'
|
||||||
|
if detail.value && !detail.old_value
|
||||||
|
rel_issue = Issue.visible.find_by_id(detail.value)
|
||||||
|
value =
|
||||||
|
if rel_issue.nil?
|
||||||
|
"#{l(:label_issue)} ##{detail.value}"
|
||||||
|
else
|
||||||
|
(no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
|
||||||
|
end
|
||||||
|
elsif detail.old_value && !detail.value
|
||||||
|
rel_issue = Issue.visible.find_by_id(detail.old_value)
|
||||||
|
old_value =
|
||||||
|
if rel_issue.nil?
|
||||||
|
"#{l(:label_issue)} ##{detail.old_value}"
|
||||||
|
else
|
||||||
|
(no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
relation_type = IssueRelation::TYPES[detail.prop_key]
|
||||||
|
label = l(relation_type[:name]) if relation_type
|
||||||
|
end
|
||||||
|
call_hook(:helper_issues_show_detail_after_setting,
|
||||||
|
{:detail => detail, :label => label, :value => value, :old_value => old_value})
|
||||||
|
|
||||||
|
label ||= detail.prop_key
|
||||||
|
value ||= detail.value
|
||||||
|
old_value ||= detail.old_value
|
||||||
|
|
||||||
|
unless no_html
|
||||||
|
label = content_tag('strong', label)
|
||||||
|
old_value = content_tag("i", h(old_value)) if detail.old_value
|
||||||
|
if detail.old_value && detail.value.blank? && detail.property != 'relation'
|
||||||
|
old_value = content_tag("del", old_value)
|
||||||
|
end
|
||||||
|
if detail.property == 'attachment' && value.present? &&
|
||||||
|
atta = detail.journal.journalized.attachments.detect {|a| a.id == detail.prop_key.to_i}
|
||||||
|
# Link to the attachment if it has not been removed
|
||||||
|
value = link_to_attachment(atta, only_path: options[:only_path])
|
||||||
|
if options[:only_path] != false
|
||||||
|
value += ' '
|
||||||
|
value += link_to_attachment atta, class: 'icon-only icon-download', title: l(:button_download), download: true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
value = content_tag("i", h(value)) if value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if no_details
|
||||||
|
s = l(:text_journal_changed_no_detail, :label => label).html_safe
|
||||||
|
elsif show_diff
|
||||||
|
s = l(:text_journal_changed_no_detail, :label => label)
|
||||||
|
unless no_html
|
||||||
|
diff_link =
|
||||||
|
link_to(
|
||||||
|
l(:label_diff),
|
||||||
|
diff_journal_url(detail.journal_id, :detail_id => detail.id,
|
||||||
|
:only_path => options[:only_path]),
|
||||||
|
:title => l(:label_view_diff))
|
||||||
|
s << " (#{diff_link})"
|
||||||
|
end
|
||||||
|
s.html_safe
|
||||||
|
elsif detail.value.present?
|
||||||
|
case detail.property
|
||||||
|
when 'attr', 'cf'
|
||||||
|
if detail.old_value.present?
|
||||||
|
l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
|
||||||
|
elsif multiple
|
||||||
|
l(:text_journal_added, :label => label, :value => value).html_safe
|
||||||
|
else
|
||||||
|
l(:text_journal_set_to, :label => label, :value => value).html_safe
|
||||||
|
end
|
||||||
|
when 'attachment', 'relation'
|
||||||
|
l(:text_journal_added, :label => label, :value => value).html_safe
|
||||||
|
end
|
||||||
|
else
|
||||||
|
l(:text_journal_deleted, :label => label, :old => old_value).html_safe
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the name of an associated record stored in the field attribute
|
||||||
|
# For project, return the associated record only if is visible for the current User
|
||||||
|
def find_name_by_reflection(field, id)
|
||||||
|
return nil if id.blank?
|
||||||
|
|
||||||
|
@detail_value_name_by_reflection ||= Hash.new do |hash, key|
|
||||||
|
association = Job.reflect_on_association(key.first.to_sym)
|
||||||
|
name = nil
|
||||||
|
if association
|
||||||
|
record = association.klass.find_by_id(key.last)
|
||||||
|
if (record && !record.is_a?(Project)) || (record.is_a?(Project) && record.visible?)
|
||||||
|
name = record.name.force_encoding('UTF-8')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
hash[key] = name
|
||||||
|
end
|
||||||
|
@detail_value_name_by_reflection[[field, id]]
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_notes(issue, journal, options={})
|
||||||
|
content_tag('div', textilizable(journal, :notes), :id => "journal-#{journal.id}-notes", :class => "wiki")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,13 +12,21 @@ class Job < ActiveRecord::Base
|
||||||
|
|
||||||
has_many :time_entries, dependent: :restrict_with_error
|
has_many :time_entries, dependent: :restrict_with_error
|
||||||
has_many :time_budgets, dependent: :destroy
|
has_many :time_budgets, dependent: :destroy
|
||||||
|
has_many :journals, as: :journalized, dependent: :destroy, inverse_of: :journalized
|
||||||
|
delegate :notes, :notes=, to: :current_journal, allow_nil: true
|
||||||
accepts_nested_attributes_for :time_budgets, allow_destroy: true
|
accepts_nested_attributes_for :time_budgets, allow_destroy: true
|
||||||
|
|
||||||
|
acts_as_customizable
|
||||||
|
acts_as_watchable
|
||||||
|
acts_as_mentionable attributes: [ "description" ]
|
||||||
|
|
||||||
scope :project_or_parent, ->(project) { where(project_id: [project&.id, project&.parent&.id]) }
|
scope :project_or_parent, ->(project) { where(project_id: [project&.id, project&.parent&.id]) }
|
||||||
scope :active, -> { where(starts_on: ..Date.today, ends_on: Date.today..) }
|
scope :active, -> { where(starts_on: ..Date.today, ends_on: Date.today..) }
|
||||||
|
|
||||||
safe_attributes 'name', 'description'
|
safe_attributes 'name', 'description'
|
||||||
|
|
||||||
|
after_save :create_journal
|
||||||
|
|
||||||
def with_all_time_budgets
|
def with_all_time_budgets
|
||||||
time_budgets.build(job_id: id, category_id: nil) unless time_budgets.where(category_id: nil).exists?
|
time_budgets.build(job_id: id, category_id: nil) unless time_budgets.where(category_id: nil).exists?
|
||||||
TimeBudgetCategory.where.not(id: time_budgets.pluck(:category_id)).each do |category|
|
TimeBudgetCategory.where.not(id: time_budgets.pluck(:category_id)).each do |category|
|
||||||
|
@ -54,6 +62,44 @@ class Job < ActiveRecord::Base
|
||||||
ActionController::Base.helpers.link_to name, ActionController::Base.helpers.project_job_path(project, self)
|
ActionController::Base.helpers.link_to name, ActionController::Base.helpers.project_job_path(project, self)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def init_journal(user, notes = "")
|
||||||
|
@current_journal = Journal.new(journalized: self, user: user, notes: notes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_journal
|
||||||
|
@current_journal
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_journal
|
||||||
|
current_journal.save if current_journal
|
||||||
|
end
|
||||||
|
|
||||||
|
def journalized_attribute_names
|
||||||
|
Job.column_names - %w(id created_at updated_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def notified_users
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def notes_addable?(user = User.current)
|
||||||
|
#user_tracker_permission?(user, :add_job_notes)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# tracker and subject are used to make Job quack like an issue so the diff view
|
||||||
|
# built into Redmine will work
|
||||||
|
def tracker
|
||||||
|
OpenStruct.new(
|
||||||
|
name: name,
|
||||||
|
to_s: "Job",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def subject
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
def self.fields_for_order_statement
|
def self.fields_for_order_statement
|
||||||
"jobs.name"
|
"jobs.name"
|
||||||
end
|
end
|
||||||
|
|
|
@ -35,8 +35,17 @@
|
||||||
<%= ff.hidden_field :_destroy, value: false %>
|
<%= ff.hidden_field :_destroy, value: false %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<%= wikitoolbar_for 'job_description' %>
|
<%= wikitoolbar_for 'job_description' %>
|
||||||
|
|
||||||
|
|
||||||
|
<% unless @job.new_record? %>
|
||||||
|
<fieldset id="add_notes">
|
||||||
|
<legend>Notes</legend>
|
||||||
|
<%= f.text_area :notes, cols: 60, rows: 15, class: "wiki-edit",
|
||||||
|
data: { auto_complete: true }, id: "job_notes" %>
|
||||||
|
<%= wikitoolbar_for 'job_notes' %>
|
||||||
|
</fieldset>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<%= f.submit %>
|
<%= f.submit %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -43,3 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="history">
|
||||||
|
<%= render_tabs job_history_tabs, job_history_default_tab %>
|
||||||
|
</div>
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<%
|
||||||
|
job = tab[:locals][:job]
|
||||||
|
journals = tab[:locals][:journals]
|
||||||
|
%>
|
||||||
|
|
||||||
|
<% reply_links = job.notes_addable? %>
|
||||||
|
<% journals.each_with_index do |journal, indice| %>
|
||||||
|
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
|
||||||
|
<div id="note-<%= indice %>" class="note">
|
||||||
|
<div class="contextual">
|
||||||
|
<span class="journal-actions">
|
||||||
|
<!-- TODO -->
|
||||||
|
</span>
|
||||||
|
<a href="#note-<%= journal.indice %>" class="journal-link"><%= indice %></a>
|
||||||
|
</div>
|
||||||
|
<h4 class="note-header">
|
||||||
|
<%= avatar(journal.user) %>
|
||||||
|
<%= authoring journal.created_on, journal.user, label: :label_updated_time_by %>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<% if journal.details.any? %>
|
||||||
|
<ul class="details">
|
||||||
|
<% details_to_strings(journal.visible_details).each do |string| %>
|
||||||
|
<li><%= string %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render_notes(job, journal) unless journal.notes.blank? %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
|
@ -9,3 +9,8 @@ en:
|
||||||
enumeration_time_budget_category: Time budget categories
|
enumeration_time_budget_category: Time budget categories
|
||||||
enumeration_job_category: Job categories
|
enumeration_job_category: Job categories
|
||||||
|
|
||||||
|
History: History
|
||||||
|
Notes: Notes
|
||||||
|
|
||||||
|
field_starts_on: Starts on
|
||||||
|
field_ends_on: Ends on
|
||||||
|
|
2
init.rb
2
init.rb
|
@ -12,10 +12,10 @@ Redmine::Plugin.register :jobs do
|
||||||
User.safe_attributes 'time_budget_category_id'
|
User.safe_attributes 'time_budget_category_id'
|
||||||
|
|
||||||
Rails.application.config.before_initialize do
|
Rails.application.config.before_initialize do
|
||||||
Rails.logger.info "Patch Jobs"
|
|
||||||
TimeEntryQuery.send(:include, TimeEntryQueryPatch)
|
TimeEntryQuery.send(:include, TimeEntryQueryPatch)
|
||||||
TimeEntry.send(:include, TimeEntryPatch)
|
TimeEntry.send(:include, TimeEntryPatch)
|
||||||
Project.send(:include, ProjectPatch)
|
Project.send(:include, ProjectPatch)
|
||||||
|
JournalsController.send(:include, JournalsControllerPatch)
|
||||||
Redmine::Helpers::TimeReport.send(:include, TimeReportHelperPatch)
|
Redmine::Helpers::TimeReport.send(:include, TimeReportHelperPatch)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_dependency 'journals_controller'
|
||||||
|
|
||||||
|
module JournalsControllerPatch
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
include InstanceMethods
|
||||||
|
|
||||||
|
alias_method :original_diff, :diff
|
||||||
|
alias_method :diff, :new_diff
|
||||||
|
alias_method :original_find_journal, :find_journal
|
||||||
|
alias_method :find_journal, :new_find_journal
|
||||||
|
end
|
||||||
|
|
||||||
|
module InstanceMethods
|
||||||
|
def new_diff
|
||||||
|
@journalized = @journal.journalized
|
||||||
|
if params[:detail_id].present?
|
||||||
|
@detail = @journal.details.find_by_id(params[:detail_id])
|
||||||
|
else
|
||||||
|
@detail = @journal.details.detect {|d| d.property == 'attr' && d.prop_key == 'description'}
|
||||||
|
end
|
||||||
|
unless @journalized && @detail
|
||||||
|
render_404
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if @detail.property == 'cf'
|
||||||
|
unless @detail.custom_field && @detail.custom_field.visible_by?(@journalized.project, User.current)
|
||||||
|
raise ::Unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@diff = Redmine::Helpers::Diff.new(@detail.value, @detail.old_value)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The find_journal method in the controller assumes journalized is an Issue, so we need
|
||||||
|
# to be a bit silly about it.
|
||||||
|
def new_find_journal
|
||||||
|
@journal = Journal.find(params[:id])
|
||||||
|
if @journal.journalized.is_a?(Issue)
|
||||||
|
original_find_journal and return
|
||||||
|
end
|
||||||
|
|
||||||
|
@project = @journal.journalized.project
|
||||||
|
@issue = @journal.journalized
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render_404
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue