Merge branch 'stable' into unstable
This commit is contained in:
commit
ddeb1a2a0f
3
Gemfile
3
Gemfile
|
@ -1,10 +1,11 @@
|
|||
source :rubygems
|
||||
|
||||
gem "rails", "2.3.11"
|
||||
gem "rails", "2.3.12"
|
||||
|
||||
gem "coderay", "~> 0.9.7"
|
||||
gem "i18n", "~> 0.4.2"
|
||||
gem "rubytree", "~> 0.5.2", :require => 'tree'
|
||||
gem "rdoc", ">= 2.4.2"
|
||||
|
||||
group :test do
|
||||
gem 'shoulda', '~> 2.10.3'
|
||||
|
|
2
Rakefile
2
Rakefile
|
@ -5,6 +5,6 @@ require(File.join(File.dirname(__FILE__), 'config', 'boot'))
|
|||
|
||||
require 'rake'
|
||||
require 'rake/testtask'
|
||||
require 'rake/rdoctask'
|
||||
require 'rdoc/task'
|
||||
|
||||
require 'tasks/rails'
|
||||
|
|
|
@ -27,9 +27,7 @@ class IssuesController < ApplicationController
|
|||
|
||||
rescue_from Query::StatementInvalid, :with => :query_statement_invalid
|
||||
|
||||
helper :journals
|
||||
include JournalsHelper
|
||||
helper :projects
|
||||
include ProjectsHelper
|
||||
include CustomFieldsHelper
|
||||
include IssueRelationsHelper
|
||||
|
|
|
@ -23,6 +23,7 @@ class Changeset < ActiveRecord::Base
|
|||
:event_description => :long_comments,
|
||||
:event_datetime => :committed_on,
|
||||
:event_url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.identifier}},
|
||||
:event_author => Proc.new {|o| o.author},
|
||||
:activity_timestamp => "#{table_name}.committed_on",
|
||||
:activity_find_options => {:include => [:user, {:repository => :project}]}
|
||||
acts_as_searchable :columns => 'comments',
|
||||
|
|
|
@ -280,6 +280,13 @@ class Issue < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
# Bug #501: browsers might swap the line endings causing a Journal.
|
||||
if attrs.has_key?('description') && attrs['description'].present?
|
||||
if attrs['description'].gsub(/\r\n?/,"\n") == self.description
|
||||
attrs.delete('description')
|
||||
end
|
||||
end
|
||||
|
||||
self.attributes = attrs
|
||||
end
|
||||
|
||||
|
@ -529,6 +536,20 @@ class Issue < ActiveRecord::Base
|
|||
"#{tracker} ##{id}: #{subject}"
|
||||
end
|
||||
|
||||
# The number of "items" this issue spans in it's nested set
|
||||
#
|
||||
# A parent issue would span all of it's children + 1 left + 1 right (3)
|
||||
#
|
||||
# | parent |
|
||||
# || child ||
|
||||
#
|
||||
# A child would span only itself (1)
|
||||
#
|
||||
# |child|
|
||||
def nested_set_span
|
||||
rgt - lft
|
||||
end
|
||||
|
||||
# Returns a string of css classes that apply to the issue
|
||||
def css_classes
|
||||
s = "issue status-#{status.position} priority-#{priority.position}"
|
||||
|
|
|
@ -23,7 +23,7 @@ class Journal < ActiveRecord::Base
|
|||
|
||||
# Make sure each journaled model instance only has unique version ids
|
||||
validates_uniqueness_of :version, :scope => [:journaled_id, :type]
|
||||
belongs_to :journaled
|
||||
belongs_to :journaled, :touch => true
|
||||
belongs_to :user
|
||||
|
||||
# ActiveRecord::Base#changes is an existing method, so before serializing the +changes+ column,
|
||||
|
|
|
@ -15,7 +15,7 @@ class JournalObserver < ActiveRecord::Observer
|
|||
attr_accessor :send_notification
|
||||
|
||||
def after_create(journal)
|
||||
if journal.type == "IssueJournal" and journal.version > 1 and self.send_notification
|
||||
if journal.type == "IssueJournal" and !journal.initial? and send_notification
|
||||
after_create_issue_journal(journal)
|
||||
end
|
||||
clear_notification
|
||||
|
|
|
@ -165,7 +165,7 @@ class MailHandler < ActionMailer::Base
|
|||
end
|
||||
|
||||
# Reply will be added to the issue
|
||||
def receive_journal_reply(journal_id)
|
||||
def receive_issue_journal_reply(journal_id)
|
||||
journal = Journal.find_by_id(journal_id)
|
||||
if journal and journal.journaled.is_a? Issue
|
||||
receive_issue_reply(journal.journaled_id)
|
||||
|
|
|
@ -96,7 +96,7 @@ class Setting < ActiveRecord::Base
|
|||
|
||||
# Returns the value of the setting named name
|
||||
def self.[](name)
|
||||
Marshal.load(Rails.cache.fetch("chiliproject/setting/#{name}") {Marshal.dump(find_or_default(name).value)}).freeze
|
||||
Marshal.load(Rails.cache.fetch("chiliproject/setting/#{name}") {Marshal.dump(find_or_default(name).value)})
|
||||
end
|
||||
|
||||
def self.[]=(name, v)
|
||||
|
@ -104,7 +104,7 @@ class Setting < ActiveRecord::Base
|
|||
setting.value = (v ? v : "")
|
||||
Rails.cache.delete "chiliproject/setting/#{name}"
|
||||
setting.save
|
||||
setting.value.freeze
|
||||
setting.value
|
||||
end
|
||||
|
||||
# Defines getter and setter for each setting
|
||||
|
|
|
@ -25,7 +25,7 @@ class WikiContent < ActiveRecord::Base
|
|||
|
||||
acts_as_journalized :event_type => 'wiki-page',
|
||||
:event_title => Proc.new {|o| "#{l(:label_wiki_edit)}: #{o.page.title} (##{o.version})"},
|
||||
:event_url => Proc.new {|o| {:controller => 'wiki', :id => o.page.wiki.project_id, :page => o.page.title, :version => o.version}},
|
||||
:event_url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :id => o.page.title, :project_id => o.page.wiki.project, :version => o.version}},
|
||||
:activity_type => 'wiki_edits',
|
||||
:activity_permission => :view_wiki_edits,
|
||||
:activity_find_options => { :include => { :page => { :wiki => :project } } }
|
||||
|
|
|
@ -15,10 +15,10 @@
|
|||
<tbody>
|
||||
<% for source in @auth_sources %>
|
||||
<tr class="<%= cycle("odd", "even") %>">
|
||||
<td><%= link_to source.name, :action => 'edit', :id => source%></td>
|
||||
<td align="center"><%= source.auth_method_name %></td>
|
||||
<td align="center"><%= source.host %></td>
|
||||
<td align="center"><%= source.users.count %></td>
|
||||
<td><%= link_to(h(source.name), :action => 'edit', :id => source)%></td>
|
||||
<td align="center"><%= h source.auth_method_name %></td>
|
||||
<td align="center"><%= h source.host %></td>
|
||||
<td align="center"><%= h source.users.count %></td>
|
||||
<td class="buttons">
|
||||
<%= link_to l(:button_test), :action => 'test_connection', :id => source %>
|
||||
<%= link_to l(:button_delete), { :action => 'destroy', :id => source },
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<% if diff.diff_type == 'sbs' -%>
|
||||
<table class="filecontent">
|
||||
<thead>
|
||||
<tr><th colspan="4" class="filename"><%=to_utf8 table_file.file_name %></th></tr>
|
||||
<tr><th colspan="4" class="filename"><%=to_utf8_for_attachments table_file.file_name %></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% table_file.each_line do |spacing, line| -%>
|
||||
|
@ -17,11 +17,11 @@
|
|||
<tr>
|
||||
<th class="line-num"><%= line.nb_line_left %></th>
|
||||
<td class="line-code <%= line.type_diff_left %>">
|
||||
<pre><%=to_utf8 line.html_line_left %></pre>
|
||||
<pre><%=to_utf8_for_attachments line.html_line_left %></pre>
|
||||
</td>
|
||||
<th class="line-num"><%= line.nb_line_right %></th>
|
||||
<td class="line-code <%= line.type_diff_right %>">
|
||||
<pre><%=to_utf8 line.html_line_right %></pre>
|
||||
<pre><%=to_utf8_for_attachments line.html_line_right %></pre>
|
||||
</td>
|
||||
</tr>
|
||||
<% end -%>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<%= error_messages_for 'document' %>
|
||||
<div class="box">
|
||||
<!--[form:document]-->
|
||||
<p><label for="document_category_id"><%=l(:field_category)%></label>
|
||||
<p><label for="document_category_id"><%=l(:field_category)%> <span class="required">*</span></label>
|
||||
<%= select('document', 'category_id', DocumentCategory.all.collect {|c| [c.name, c.id]}) %></p>
|
||||
|
||||
<p><label for="document_title"><%=l(:field_title)%> <span class="required">*</span></label>
|
||||
|
|
|
@ -19,12 +19,13 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom" do
|
|||
end
|
||||
xml.content "type" => "html" do
|
||||
xml.text! '<ul>'
|
||||
change.details.each do |detail|
|
||||
xml.text! '<li>' + change.render_detail(detail, false) + '</li>'
|
||||
change.changes.each do |detail|
|
||||
change_content = change.render_detail(detail, false)
|
||||
xml.text!(content_tag(:li, change_content)) if change_content.present?
|
||||
end
|
||||
xml.text! '</ul>'
|
||||
xml.text! textilizable(change, :notes, :only_path => false) unless change.notes.blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<h2><%= avatar @user, :size => "50" %> <%=h @user.name %></h2>
|
||||
|
||||
<div class="splitcontentleft">
|
||||
<%= call_hook :view_account_left_top, :user => @user %>
|
||||
<ul>
|
||||
<% unless @user.pref.hide_mail %>
|
||||
<li><%=l(:field_mail)%>: <%= mail_to(h(escape_javascript(@user.mail)), nil, :encode => 'javascript') %></li>
|
||||
|
@ -20,6 +21,8 @@
|
|||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= call_hook :view_account_left_middle, :user => @user %>
|
||||
|
||||
<% unless @memberships.empty? %>
|
||||
<h3><%=l(:label_project_plural)%></h3>
|
||||
<ul>
|
||||
|
|
|
@ -49,6 +49,7 @@
|
|||
<%= f.link_to 'Atom', :url => {:controller => 'activities', :action => 'index', :id => @project, :show_wiki_edits => 1, :key => User.current.rss_key} %>
|
||||
<%= f.link_to 'HTML', :url => {:id => @page.title, :version => @content.version} %>
|
||||
<%= f.link_to 'TXT', :url => {:id => @page.title, :version => @content.version} %>
|
||||
<%= call_hook(:view_wiki_show_other_formats, {:link_builder => f, :url_params => {:id => @page.title, :version => @content.version}}) %>
|
||||
<% end if User.current.allowed_to?(:export_wiki_pages, @project) %>
|
||||
|
||||
<% content_for :header_tags do %>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
# ENV['RAILS_ENV'] ||= 'production'
|
||||
|
||||
# Specifies gem version of Rails to use when vendor/rails is not present
|
||||
RAILS_GEM_VERSION = '2.3.11' unless defined? RAILS_GEM_VERSION
|
||||
RAILS_GEM_VERSION = '2.3.12' unless defined? RAILS_GEM_VERSION
|
||||
|
||||
# Bootstrap the Rails environment, frameworks, and default configuration
|
||||
require File.join(File.dirname(__FILE__), 'boot')
|
||||
|
|
|
@ -931,8 +931,8 @@ en:
|
|||
text_powered_by: "Powered by %{link}"
|
||||
text_warn_on_leaving_unsaved: "The current page contains unsaved text that will be lost if you leave this page."
|
||||
text_default_encoding: "Default: UTF-8"
|
||||
text_mercurial_repo_example: "local repository (e.g. /hgrepo, c:\hgrepo)"
|
||||
text_git_repo_example: "a bare and local repository (e.g. /gitrepo, c:\gitrepo)"
|
||||
text_mercurial_repo_example: "local repository (e.g. /hgrepo, c:\\hgrepo)"
|
||||
text_git_repo_example: "a bare and local repository (e.g. /gitrepo, c:\\gitrepo)"
|
||||
|
||||
default_role_manager: Manager
|
||||
default_role_developer: Developer
|
||||
|
|
|
@ -968,18 +968,18 @@ pt-BR:
|
|||
label_my_queries: Minhas consultas personalizadas
|
||||
text_journal_changed_no_detail: "%{label} atualizado(a)"
|
||||
label_news_comment_added: Notícia recebeu um comentário
|
||||
button_expand_all: Expand all
|
||||
button_collapse_all: Collapse all
|
||||
label_additional_workflow_transitions_for_assignee: Additional transitions allowed when the user is the assignee
|
||||
label_additional_workflow_transitions_for_author: Additional transitions allowed when the user is the author
|
||||
field_effective_date: Due date
|
||||
button_expand_all: Expandir tudo
|
||||
button_collapse_all: Recolher tudo
|
||||
label_additional_workflow_transitions_for_assignee: Transições adicionais permitidas quando o usuário é o responsável pela tarefa
|
||||
label_additional_workflow_transitions_for_author: Transições adicionais permitidas quando o usuário é o autor
|
||||
field_effective_date: Data prevista
|
||||
label_cvs_path: CVSROOT
|
||||
text_powered_by: Powered by %{link}
|
||||
text_default_encoding: "Default: UTF-8"
|
||||
text_git_repo_example: a bare and local repository (e.g. /gitrepo, c:\gitrepo)
|
||||
label_notify_member_plural: Email issue updates
|
||||
label_path_encoding: Path encoding
|
||||
text_mercurial_repo_example: local repository (e.g. /hgrepo, c:\hgrepo)
|
||||
text_powered_by: Tecnologia empregada por %{link}
|
||||
text_default_encoding: "Padrão: UTF-8"
|
||||
text_git_repo_example: "um repositório local do tipo bare (ex.: /gitrepo, c:\\gitrepo)"
|
||||
label_notify_member_plural: Enviar atualizações da tarefa por e-mail
|
||||
label_path_encoding: Codificação do caminho
|
||||
text_mercurial_repo_example: "repositório local (ex.: /hgrepo, c:\\hgrepo)"
|
||||
label_cvs_module: Módulo
|
||||
label_filesystem_path: Diretório raiz
|
||||
label_darcs_path: Diretório raiz
|
||||
|
|
|
@ -1,122 +0,0 @@
|
|||
#-- copyright
|
||||
# ChiliProject is a project management system.
|
||||
#
|
||||
# Copyright (C) 2010-2011 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.
|
||||
#
|
||||
# See doc/COPYRIGHT.rdoc for more details.
|
||||
#++
|
||||
|
||||
class GeneralizeJournals < ActiveRecord::Migration
|
||||
def self.up
|
||||
# This is provided here for migrating up after the JournalDetails has been removed
|
||||
unless Object.const_defined?("JournalDetails")
|
||||
Object.const_set("JournalDetails", Class.new(ActiveRecord::Base))
|
||||
end
|
||||
|
||||
change_table :journals do |t|
|
||||
t.rename :journalized_id, :journaled_id
|
||||
t.rename :created_on, :created_at
|
||||
|
||||
t.integer :version, :default => 0, :null => false
|
||||
t.string :activity_type
|
||||
t.text :changes
|
||||
t.string :type
|
||||
|
||||
t.index :journaled_id
|
||||
t.index :activity_type
|
||||
t.index :created_at
|
||||
t.index :type
|
||||
end
|
||||
|
||||
Journal.all.group_by(&:journaled_id).each_pair do |id, journals|
|
||||
journals.sort_by(&:created_at).each_with_index do |j, idx|
|
||||
j.update_attribute(:type, "#{j.journalized_type}Journal")
|
||||
j.update_attribute(:version, idx + 1)
|
||||
# FIXME: Find some way to choose the right activity here
|
||||
j.update_attribute(:activity_type, j.journalized_type.constantize.activity_provider_options.keys.first)
|
||||
end
|
||||
end
|
||||
|
||||
change_table :journals do |t|
|
||||
t.remove :journalized_type
|
||||
end
|
||||
|
||||
JournalDetails.all.each do |detail|
|
||||
journal = Journal.find(detail.journal_id)
|
||||
changes = journal.changes || {}
|
||||
if detail.property == 'attr' # Standard attributes
|
||||
changes[detail.prop_key.to_s] = [detail.old_value, detail.value]
|
||||
elsif detail.property == 'cf' # Custom fields
|
||||
changes["custom_values_" + detail.prop_key.to_s] = [detail.old_value, detail.value]
|
||||
elsif detail.property == 'attachment' # Attachment
|
||||
changes["attachments_" + detail.prop_key.to_s] = [detail.old_value, detail.value]
|
||||
end
|
||||
journal.update_attribute(:changes, changes.to_yaml)
|
||||
end
|
||||
|
||||
# Create creation journals for all activity providers
|
||||
providers = Redmine::Activity.providers.collect {|k, v| v.collect(&:constantize) }.flatten.compact.uniq
|
||||
providers.each do |p|
|
||||
next unless p.table_exists? # Objects not in the DB yet need creation journal entries
|
||||
p.find(:all).each do |o|
|
||||
unless o.last_journal
|
||||
o.send(:update_journal)
|
||||
created_at = nil
|
||||
[:created_at, :created_on, :updated_at, :updated_on].each do |m|
|
||||
if o.respond_to? m
|
||||
created_at = o.send(m)
|
||||
break
|
||||
end
|
||||
end
|
||||
p "Updating #{o}"
|
||||
o.last_journal.update_attribute(:created_at, created_at) if created_at and o.last_journal
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# drop_table :journal_details
|
||||
end
|
||||
|
||||
def self.down
|
||||
# create_table "journal_details", :force => true do |t|
|
||||
# t.integer "journal_id", :default => 0, :null => false
|
||||
# t.string "property", :limit => 30, :default => "", :null => false
|
||||
# t.string "prop_key", :limit => 30, :default => "", :null => false
|
||||
# t.string "old_value"
|
||||
# t.string "value"
|
||||
# end
|
||||
|
||||
change_table "journals" do |t|
|
||||
t.rename :journaled_id, :journalized_id
|
||||
t.rename :created_at, :created_on
|
||||
|
||||
t.string :journalized_type, :limit => 30, :default => "", :null => false
|
||||
end
|
||||
|
||||
custom_field_names = CustomField.all.group_by(&:type)[IssueCustomField].collect(&:name)
|
||||
Journal.all.each do |j|
|
||||
# Can't used j.journalized.class.name because the model changes make it nil
|
||||
j.update_attribute(:journalized_type, j.type.to_s.sub("Journal","")) if j.type.present?
|
||||
end
|
||||
|
||||
change_table "journals" do |t|
|
||||
t.remove_index :journaled_id
|
||||
t.remove_index :activity_type
|
||||
t.remove_index :created_at
|
||||
t.remove_index :type
|
||||
|
||||
t.remove :type
|
||||
t.remove :version
|
||||
t.remove :activity_type
|
||||
t.remove :changes
|
||||
end
|
||||
|
||||
# add_index "journal_details", ["journal_id"], :name => "journal_details_journal_id"
|
||||
# add_index "journals", ["journalized_id", "journalized_type"], :name => "journals_journalized_id"
|
||||
end
|
||||
end
|
|
@ -0,0 +1,55 @@
|
|||
#-- copyright
|
||||
# ChiliProject is a project management system.
|
||||
#
|
||||
# Copyright (C) 2010-2011 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.
|
||||
#
|
||||
# See doc/COPYRIGHT.rdoc for more details.
|
||||
#++
|
||||
|
||||
class PrepareJournalsForActsAsJournalized < ActiveRecord::Migration
|
||||
def self.up
|
||||
# This is provided here for migrating up after the JournalDetails has been removed
|
||||
unless Object.const_defined?("JournalDetails")
|
||||
Object.const_set("JournalDetails", Class.new(ActiveRecord::Base))
|
||||
end
|
||||
|
||||
change_table :journals do |t|
|
||||
t.rename :journalized_id, :journaled_id
|
||||
t.rename :created_on, :created_at
|
||||
|
||||
t.integer :version, :default => 0, :null => false
|
||||
t.string :activity_type
|
||||
t.text :changes
|
||||
t.string :type
|
||||
|
||||
t.index :journaled_id
|
||||
t.index :activity_type
|
||||
t.index :created_at
|
||||
t.index :type
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def self.down
|
||||
change_table "journals" do |t|
|
||||
t.rename :journaled_id, :journalized_id
|
||||
t.rename :created_at, :created_on
|
||||
|
||||
t.remove_index :journaled_id
|
||||
t.remove_index :activity_type
|
||||
t.remove_index :created_at
|
||||
t.remove_index :type
|
||||
|
||||
t.remove :type
|
||||
t.remove :version
|
||||
t.remove :activity_type
|
||||
t.remove :changes
|
||||
end
|
||||
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
Redmine::Activity.providers.values.flatten.uniq.collect(&:underscore).each {|klass| require_dependency klass }
|
||||
|
||||
class UpdateJournalsForActsAsJournalized < ActiveRecord::Migration
|
||||
def self.up
|
||||
# This is provided here for migrating up after the JournalDetails has been removed
|
||||
unless Object.const_defined?("JournalDetails")
|
||||
Object.const_set("JournalDetails", Class.new(ActiveRecord::Base))
|
||||
end
|
||||
|
||||
say_with_time("Updating existing Journals...") do
|
||||
Journal.all.group_by(&:journaled_id).each_pair do |id, journals|
|
||||
journals.sort_by(&:created_at).each_with_index do |j, idx|
|
||||
# Recast the basic Journal into it's STI journalized class so callbacks work (#467)
|
||||
klass_name = "#{j.journalized_type}Journal"
|
||||
j = j.becomes(klass_name.constantize)
|
||||
j.type = klass_name
|
||||
j.version = idx + 2 # initial journal should be 1
|
||||
j.activity_type = j.journalized_type.constantize.activity_provider_options.keys.first
|
||||
begin
|
||||
j.save(false)
|
||||
rescue ActiveRecord::RecordInvalid => ex
|
||||
puts "Error saving: #{j.class.to_s}##{j.id} - #{ex.message}"
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
change_table :journals do |t|
|
||||
t.remove :journalized_type
|
||||
end
|
||||
end
|
||||
|
||||
def self.down
|
||||
change_table "journals" do |t|
|
||||
t.string :journalized_type, :limit => 30, :default => "", :null => false
|
||||
end
|
||||
|
||||
custom_field_names = CustomField.all.group_by(&:type)[IssueCustomField].collect(&:name)
|
||||
Journal.all.each do |j|
|
||||
# Can't used j.journalized.class.name because the model changes make it nil
|
||||
j.update_attribute(:journalized_type, j.type.to_s.sub("Journal","")) if j.type.present?
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
class BuildInitialJournalsForActsAsJournalized < ActiveRecord::Migration
|
||||
def self.up
|
||||
# This is provided here for migrating up after the JournalDetails has been removed
|
||||
unless Object.const_defined?("JournalDetails")
|
||||
Object.const_set("JournalDetails", Class.new(ActiveRecord::Base))
|
||||
end
|
||||
|
||||
# Reset class and subclasses, otherwise they will try to save using older attributes
|
||||
Journal.reset_column_information
|
||||
Journal.send(:subclasses).each do |klass|
|
||||
klass.reset_column_information if klass.respond_to?(:reset_column_information)
|
||||
end
|
||||
|
||||
providers = Redmine::Activity.providers.collect {|k, v| v.collect(&:constantize) }.flatten.compact.uniq
|
||||
providers.each do |p|
|
||||
next unless p.table_exists? # Objects not in the DB yet need creation journal entries
|
||||
|
||||
say_with_time("Building initial journals for #{p.class_name}") do
|
||||
|
||||
activity_type = p.activity_provider_options.keys.first
|
||||
|
||||
p.find(:all).each do |o|
|
||||
# Create initial journals
|
||||
new_journal = o.journals.build
|
||||
# Mock up a list of changes for the creation journal based on Class defaults
|
||||
new_attributes = o.class.new.attributes.except(o.class.primary_key,
|
||||
o.class.inheritance_column,
|
||||
:updated_on,
|
||||
:updated_at,
|
||||
:lock_version,
|
||||
:lft,
|
||||
:rgt)
|
||||
creation_changes = {}
|
||||
new_attributes.each do |name, default_value|
|
||||
# Set changes based on the initial value to current. Can't get creation value without
|
||||
# rebuiling the object history
|
||||
creation_changes[name] = [default_value, o.send(name)] # [initial_value, creation_value]
|
||||
end
|
||||
new_journal.changes = creation_changes
|
||||
new_journal.version = 1
|
||||
new_journal.activity_type = activity_type
|
||||
|
||||
if o.respond_to?(:author)
|
||||
new_journal.user = o.author
|
||||
elsif o.respond_to?(:user)
|
||||
new_journal.user = o.user
|
||||
end
|
||||
# Using rescue and save! here because either the Journal or the
|
||||
# touched record could fail. This will catch either error and continue
|
||||
begin
|
||||
new_journal.save!
|
||||
|
||||
new_journal.reload
|
||||
|
||||
# Backdate journal
|
||||
if o.respond_to?(:created_at)
|
||||
new_journal.update_attribute(:created_at, o.created_at)
|
||||
elsif o.respond_to?(:created_on)
|
||||
new_journal.update_attribute(:created_at, o.created_on)
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => ex
|
||||
if new_journal.errors.count == 1 && new_journal.errors.first[0] == "version"
|
||||
# Skip, only error was from creating the initial journal for a record that already had one.
|
||||
else
|
||||
puts "ERROR: errors creating the initial journal for #{o.class.to_s}##{o.id.to_s}:"
|
||||
puts " #{ex.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def self.down
|
||||
# No-op
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,35 @@
|
|||
class AddChangesFromJournalDetailsForActsAsJournalized < ActiveRecord::Migration
|
||||
def self.up
|
||||
# This is provided here for migrating up after the JournalDetails has been removed
|
||||
unless Object.const_defined?("JournalDetails")
|
||||
Object.const_set("JournalDetails", Class.new(ActiveRecord::Base))
|
||||
end
|
||||
|
||||
say_with_time("Adding changes from JournalDetails") do
|
||||
JournalDetails.all.each do |detail|
|
||||
journal = Journal.find(detail.journal_id)
|
||||
changes = journal.changes || {}
|
||||
if detail.property == 'attr' # Standard attributes
|
||||
changes[detail.prop_key.to_s] = [detail.old_value, detail.value]
|
||||
elsif detail.property == 'cf' # Custom fields
|
||||
changes["custom_values_" + detail.prop_key.to_s] = [detail.old_value, detail.value]
|
||||
elsif detail.property == 'attachment' # Attachment
|
||||
changes["attachments_" + detail.prop_key.to_s] = [detail.old_value, detail.value]
|
||||
end
|
||||
begin
|
||||
journal.update_attribute(:changes, changes.to_yaml)
|
||||
rescue ActiveRecord::RecordInvalid => ex
|
||||
puts "Error saving: #{journal.class.to_s}##{journal.id} - #{ex.message}"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def self.down
|
||||
# No-op
|
||||
end
|
||||
|
||||
end
|
|
@ -20,7 +20,7 @@ class MergeWikiVersionsWithJournals < ActiveRecord::Migration
|
|||
|
||||
WikiContent::Version.find_by_sql("SELECT * FROM wiki_content_versions").each do |wv|
|
||||
journal = WikiContentJournal.create!(:journaled_id => wv.wiki_content_id, :user_id => wv.author_id,
|
||||
:notes => wv.comments, :activity_type => "wiki_edits")
|
||||
:notes => wv.comments, :created_at => wv.updated_on, :activity_type => "wiki_edits")
|
||||
changes = {}
|
||||
changes["compression"] = wv.compression
|
||||
changes["data"] = wv.data
|
||||
|
|
|
@ -7,9 +7,11 @@
|
|||
* Bug #343: Review Gantt and Calender links from 07cf681
|
||||
* Bug #345: Entering large numbers for 'Estimated Time' fails with 'Invalid big Decimal Value'
|
||||
* Bug #346: I18n YAML files not parsable with psych yaml library
|
||||
* Bug #383: Fix broken tests in unstable
|
||||
* Bug #383: Fix broken tests in unstable caused by conflicting to_utf8 method names
|
||||
* Bug #389: Context menu doesn't work in Opera
|
||||
* Bug #390: mysql2 incompatibility in WikiPage model
|
||||
* Bug #397: FIXME in generalize_journals migration
|
||||
* Bug #398: Remove helper calls from IssuesController
|
||||
* Bug #400: Review and fix the Activity event types
|
||||
* Bug #401: Move JournalsHelpers from aaj to the core
|
||||
* Bug #403: [AAJ] Attachment has it's files and documents activity provider removed but only documents added
|
||||
|
@ -27,17 +29,42 @@
|
|||
* Bug #419: Issue list context menu not working in IE9
|
||||
* Bug #422: cvs test are not working
|
||||
* Bug #423: Remove explicit render from WikiController#show
|
||||
* Bug #437: Encoding error on Ruby 1.9 in pdf exports
|
||||
* Bug #441: Creating a Journal does not update the journaled record's updated_at/on attribute
|
||||
* Bug #442: Issue atom feed shows "issue creation" journal, didn't before
|
||||
* Bug #443: IssuesControllerTest.test_show_atom test failure on 1.9.2
|
||||
* Bug #444: ChangesetTest and RepositoryGitTest test failures on 1.9.2
|
||||
* Bug #445: Track initial attributes in a Journal when created
|
||||
* Bug #453: Update to Rails 2.3.12 to fix some bugs
|
||||
* Bug #466: SVN: Apache initialization error
|
||||
* Bug #467: uninitialized constant Journal::Journaled
|
||||
* Bug #468: Lost WIKI history timestamps during 2.0.0rc1 upgrade.
|
||||
* Bug #469: Wong URL for WIKI activity entries in 2.0.0rc2
|
||||
* Bug #474: Changesets are displaying the wrong user and commit date in the Activity
|
||||
* Bug #475: News, docs, changesets and time activities were not migrated to 2.0.0rc2
|
||||
* Bug #477: Getting rid of "rake/rdoctask is deprecated." warning
|
||||
* Bug #479: Generalize Journals migrations does too much
|
||||
* Bug #480: Issue Journal replies get ignored
|
||||
* Bug #493: uninitialized constant TimeEntryJournal
|
||||
* Bug #501: Updating a ticket that was created by email forces a "change" of description
|
||||
* Bug #503: 2.0.0RC3 - YAML Parser fails in ruby 1.9
|
||||
* Feature #112: Provide a library function to detect the database type used
|
||||
* Feature #123: Review and Merge acts_as_journalized
|
||||
* Feature #196: Upgrade to Rails 2.3-latest
|
||||
* Feature #197: Rake task to manage copyright inside of source files
|
||||
* Feature #216: Remove the rubygems hack from boot.rb
|
||||
* Feature #217: Remove the hack to require a specific i18n version in boot.rb
|
||||
* Feature #269: Refactor lib/redmine/menu_manager.rb to increase extensibility
|
||||
* Feature #279: Optional start date on Versions
|
||||
* Feature #288: Review latest Redmine commits
|
||||
* Feature #289: Switch to helper :all
|
||||
* Feature #290: Add bundler
|
||||
* Feature #310: Option to skip mail notifications on issue updates
|
||||
* Feature #350: Setting model should use Rails.cache instead of class variable
|
||||
* Feature #416: Refactor watcher_tag and watcher_link to use css selectors for the replace action
|
||||
* Feature #436: Clean up trailing whitespace and tabs
|
||||
* Feature #462: pt-BR translation update
|
||||
* Feature #473: pt-BR translation fix
|
||||
* Task #123: Review and Merge acts_as_journalized
|
||||
* Task #197: Rake task to manage copyright inside of source files
|
||||
* Task #288: Review latest Redmine commits
|
||||
|
@ -59,6 +86,11 @@
|
|||
** Patch #7598: Extensible MailHandler
|
||||
** Patch #7795: Internal server error at journals#index with custom fields
|
||||
|
||||
== 2011-06-27 v1.5.0
|
||||
|
||||
* Bug #490: XSS in app/views/auth_sources/index.html.erb
|
||||
* Feature #488: Hook for additional formats on Wiki#show page
|
||||
|
||||
== 2011-05-27 v1.4.0
|
||||
|
||||
* Bug #81: Replace favicon
|
||||
|
|
|
@ -480,6 +480,7 @@ sub is_member {
|
|||
sub get_project_identifier {
|
||||
my $r = shift;
|
||||
|
||||
my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
|
||||
my $location = $r->location;
|
||||
my ($identifier) = $r->uri =~ m{$location/*([^/]+)};
|
||||
$identifier =~ s/\.git$// if (defined $cfg->{RedmineGitSmartHttp} and $cfg->{RedmineGitSmartHttp});
|
||||
|
|
|
@ -32,7 +32,7 @@ module Redmine
|
|||
#
|
||||
# 2.0.0debian-2
|
||||
def self.special
|
||||
'RC1'
|
||||
''
|
||||
end
|
||||
|
||||
def self.revision
|
||||
|
|
|
@ -18,7 +18,7 @@ Rake::Task["doc/app/index.html"].clear
|
|||
|
||||
namespace :doc do
|
||||
desc "Generate documentation for the application. Set custom template with TEMPLATE=/path/to/rdoc/template.rb or title with TITLE=\"Custom Title\""
|
||||
Rake::RDocTask.new("app") { |rdoc|
|
||||
RDoc::Task.new("app") { |rdoc|
|
||||
rdoc.rdoc_dir = 'doc/app'
|
||||
rdoc.template = ENV['template'] if ENV['template']
|
||||
rdoc.title = ENV['title'] || "ChiliProject"
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
#-- copyright
|
||||
# ChiliProject is a project management system.
|
||||
#
|
||||
# Copyright (C) 2010-2011 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.
|
||||
#
|
||||
# See doc/COPYRIGHT.rdoc for more details.
|
||||
#++
|
||||
|
||||
require 'fileutils'
|
||||
|
||||
desc "Package up a ChiliProject release from git. example: `rake release[1.1.0]`"
|
||||
task :release, [:version] do |task, args|
|
||||
version = args[:version]
|
||||
abort "Missing version in the form of 1.0.0" unless version.present?
|
||||
|
||||
dir = Pathname.new(ENV['HOME']) + 'dev' + 'chiliproject' + 'packages'
|
||||
FileUtils.mkdir_p dir
|
||||
|
||||
commands = [
|
||||
"cd #{dir}",
|
||||
"git clone git://github.com/chiliproject/chiliproject.git chiliproject-#{version}",
|
||||
"cd chiliproject-#{version}/",
|
||||
"git checkout v#{version}",
|
||||
"rm -vRf #{dir}/chiliproject-#{version}/.git",
|
||||
"cd #{dir}",
|
||||
"tar -zcvf chiliproject-#{version}.tar.gz chiliproject-#{version}",
|
||||
"zip -r -9 chiliproject-#{version}.zip chiliproject-#{version}",
|
||||
"md5sum chiliproject-#{version}.tar.gz chiliproject-#{version}.zip > chiliproject-#{version}.md5sum",
|
||||
"echo 'Release ready'"
|
||||
].join(' && ')
|
||||
system(commands)
|
||||
end
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2005-2008 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
|
||||
// (c) 2005-2008 Ivan Krstic (http://blogs.law.harvard.edu/ivan)
|
||||
// (c) 2005-2008 Jon Tirsen (http://www.tirsen.com)
|
||||
// script.aculo.us controls.js v1.9.0, Thu Dec 23 16:54:48 -0500 2010
|
||||
|
||||
// Copyright (c) 2005-2010 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
|
||||
// (c) 2005-2010 Ivan Krstic (http://blogs.law.harvard.edu/ivan)
|
||||
// (c) 2005-2010 Jon Tirsen (http://www.tirsen.com)
|
||||
// Contributors:
|
||||
// Richard Livsey
|
||||
// Rahul Bhargava
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
// Copyright (c) 2005-2008 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
|
||||
// (c) 2005-2008 Sammi Williams (http://www.oriontransfer.co.nz, sammi@oriontransfer.co.nz)
|
||||
// script.aculo.us dragdrop.js v1.9.0, Thu Dec 23 16:54:48 -0500 2010
|
||||
|
||||
// Copyright (c) 2005-2010 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
|
||||
//
|
||||
// script.aculo.us is freely distributable under the terms of an MIT-style license.
|
||||
// For details, see the script.aculo.us web site: http://script.aculo.us/
|
||||
|
@ -311,7 +312,7 @@ var Draggable = Class.create({
|
|||
tag_name=='TEXTAREA')) return;
|
||||
|
||||
var pointer = [Event.pointerX(event), Event.pointerY(event)];
|
||||
var pos = Position.cumulativeOffset(this.element);
|
||||
var pos = this.element.cumulativeOffset();
|
||||
this.offset = [0,1].map( function(i) { return (pointer[i] - pos[i]) });
|
||||
|
||||
Draggables.activate(this);
|
||||
|
@ -373,7 +374,7 @@ var Draggable = Class.create({
|
|||
if (this.options.scroll == window) {
|
||||
with(this._getWindowScroll(this.options.scroll)) { p = [ left, top, left+width, top+height ]; }
|
||||
} else {
|
||||
p = Position.page(this.options.scroll);
|
||||
p = Position.page(this.options.scroll).toArray();
|
||||
p[0] += this.options.scroll.scrollLeft + Position.deltaX;
|
||||
p[1] += this.options.scroll.scrollTop + Position.deltaY;
|
||||
p.push(p[0]+this.options.scroll.offsetWidth);
|
||||
|
@ -454,7 +455,7 @@ var Draggable = Class.create({
|
|||
},
|
||||
|
||||
draw: function(point) {
|
||||
var pos = Position.cumulativeOffset(this.element);
|
||||
var pos = this.element.cumulativeOffset();
|
||||
if(this.options.ghosting) {
|
||||
var r = Position.realOffset(this.element);
|
||||
pos[0] += r[0] - Position.deltaX; pos[1] += r[1] - Position.deltaY;
|
||||
|
@ -730,7 +731,7 @@ var Sortable = {
|
|||
}
|
||||
|
||||
// keep reference
|
||||
this.sortables[element.id] = options;
|
||||
this.sortables[element.identify()] = options;
|
||||
|
||||
// for onupdate
|
||||
Draggables.addObserver(new SortableObserver(element, options.onUpdate));
|
||||
|
@ -825,7 +826,7 @@ var Sortable = {
|
|||
hide().addClassName('dropmarker').setStyle({position:'absolute'});
|
||||
document.getElementsByTagName("body").item(0).appendChild(Sortable._marker);
|
||||
}
|
||||
var offsets = Position.cumulativeOffset(dropon);
|
||||
var offsets = dropon.cumulativeOffset();
|
||||
Sortable._marker.setStyle({left: offsets[0]+'px', top: offsets[1] + 'px'});
|
||||
|
||||
if(position=='after')
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
// Copyright (c) 2005-2008 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
|
||||
// script.aculo.us effects.js v1.9.0, Thu Dec 23 16:54:48 -0500 2010
|
||||
|
||||
// Copyright (c) 2005-2010 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
|
||||
// Contributors:
|
||||
// Justin Palmer (http://encytemedia.com/)
|
||||
// Mark Pilgrim (http://diveintomark.org/)
|
||||
|
@ -145,14 +147,13 @@ var Effect = {
|
|||
'blind': ['BlindDown','BlindUp'],
|
||||
'appear': ['Appear','Fade']
|
||||
},
|
||||
toggle: function(element, effect) {
|
||||
toggle: function(element, effect, options) {
|
||||
element = $(element);
|
||||
effect = (effect || 'appear').toLowerCase();
|
||||
var options = Object.extend({
|
||||
effect = (effect || 'appear').toLowerCase();
|
||||
|
||||
return Effect[ Effect.PAIRS[ effect ][ element.visible() ? 1 : 0 ] ](element, Object.extend({
|
||||
queue: { position:'end', scope:(element.id || 'global'), limit: 1 }
|
||||
}, arguments[2] || { });
|
||||
Effect[element.visible() ?
|
||||
Effect.PAIRS[effect][1] : Effect.PAIRS[effect][0]](element, options);
|
||||
}, options || {}));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -228,12 +229,6 @@ Effect.Queue = Effect.Queues.get('global');
|
|||
Effect.Base = Class.create({
|
||||
position: null,
|
||||
start: function(options) {
|
||||
function codeForEvent(options,eventName){
|
||||
return (
|
||||
(options[eventName+'Internal'] ? 'this.options.'+eventName+'Internal(this);' : '') +
|
||||
(options[eventName] ? 'this.options.'+eventName+'(this);' : '')
|
||||
);
|
||||
}
|
||||
if (options && options.transition === false) options.transition = Effect.Transitions.linear;
|
||||
this.options = Object.extend(Object.extend({ },Effect.DefaultOptions), options || { });
|
||||
this.currentFrame = 0;
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,74 @@
|
|||
Return-Path: <jsmith@somenet.foo>
|
||||
Received: from osiris ([127.0.0.1])
|
||||
by OSIRIS
|
||||
with hMailServer ; Sat, 21 Jun 2008 18:41:39 +0200
|
||||
Message-ID: <006a01c8d3bd$ad9baec0$0a00a8c0@osiris>
|
||||
In-Reply-To: <chiliproject.issue_journal-3.20110617112550@example.net>
|
||||
From: "John Smith" <jsmith@somenet.foo>
|
||||
To: <redmine@somenet.foo>
|
||||
References: <485d0ad366c88_d7014663a025f@osiris.tmail>
|
||||
Subject: Re: Add ingredients categories
|
||||
Date: Sat, 21 Jun 2008 18:41:39 +0200
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="----=_NextPart_000_0067_01C8D3CE.711F9CC0"
|
||||
X-Priority: 3
|
||||
X-MSMail-Priority: Normal
|
||||
X-Mailer: Microsoft Outlook Express 6.00.2900.2869
|
||||
X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
|
||||
------=_NextPart_000_0067_01C8D3CE.711F9CC0
|
||||
Content-Type: text/plain;
|
||||
charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
This is reply
|
||||
------=_NextPart_000_0067_01C8D3CE.711F9CC0
|
||||
Content-Type: text/html;
|
||||
charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
=EF=BB=BF<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
|
||||
<HTML><HEAD>
|
||||
<META http-equiv=3DContent-Type content=3D"text/html; charset=3Dutf-8">
|
||||
<STYLE>BODY {
|
||||
FONT-SIZE: 0.8em; COLOR: #484848; FONT-FAMILY: Verdana, sans-serif
|
||||
}
|
||||
BODY H1 {
|
||||
FONT-SIZE: 1.2em; MARGIN: 0px; FONT-FAMILY: "Trebuchet MS", Verdana, =
|
||||
sans-serif
|
||||
}
|
||||
A {
|
||||
COLOR: #2a5685
|
||||
}
|
||||
A:link {
|
||||
COLOR: #2a5685
|
||||
}
|
||||
A:visited {
|
||||
COLOR: #2a5685
|
||||
}
|
||||
A:hover {
|
||||
COLOR: #c61a1a
|
||||
}
|
||||
A:active {
|
||||
COLOR: #c61a1a
|
||||
}
|
||||
HR {
|
||||
BORDER-RIGHT: 0px; BORDER-TOP: 0px; BACKGROUND: #ccc; BORDER-LEFT: 0px; =
|
||||
WIDTH: 100%; BORDER-BOTTOM: 0px; HEIGHT: 1px
|
||||
}
|
||||
.footer {
|
||||
FONT-SIZE: 0.8em; FONT-STYLE: italic
|
||||
}
|
||||
</STYLE>
|
||||
|
||||
<META content=3D"MSHTML 6.00.2900.2883" name=3DGENERATOR></HEAD>
|
||||
<BODY bgColor=3D#ffffff>
|
||||
<DIV><SPAN class=3Dfooter><FONT face=3DArial color=3D#000000 =
|
||||
size=3D2>This is=20
|
||||
reply</FONT></DIV></SPAN></BODY></HTML>
|
||||
|
||||
------=_NextPart_000_0067_01C8D3CE.711F9CC0--
|
||||
|
|
@ -30,8 +30,10 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
issue1.reload
|
||||
issue2.reload
|
||||
|
||||
assert_equal [issue1.id, nil, 1, 2], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
|
||||
assert_equal [issue2.id, nil, 1, 2], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
|
||||
assert_equal issue1.id, issue1.root_id
|
||||
assert issue1.leaf?
|
||||
assert_equal issue2.id, issue2.root_id
|
||||
assert issue2.leaf?
|
||||
end
|
||||
|
||||
def test_create_child_issue
|
||||
|
@ -40,8 +42,8 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
parent.reload
|
||||
child.reload
|
||||
|
||||
assert_equal [parent.id, nil, 1, 4], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
|
||||
assert_equal [parent.id, parent.id, 2, 3], [child.root_id, child.parent_id, child.lft, child.rgt]
|
||||
assert_equal [parent.id, nil, 3], [parent.root_id, parent.parent_id, parent.rgt - parent.lft]
|
||||
assert_equal [parent.id, parent.id, 1], [child.root_id, child.parent_id, child.rgt - child.lft]
|
||||
end
|
||||
|
||||
def test_creating_a_child_in_different_project_should_not_validate
|
||||
|
@ -62,9 +64,9 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
parent1.reload
|
||||
parent2.reload
|
||||
|
||||
assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [parent1.id, 4, 5], [parent2.root_id, parent2.lft, parent2.rgt]
|
||||
assert_equal [parent1.id, 2, 3], [child.root_id, child.lft, child.rgt]
|
||||
assert_equal [parent1.id, 5], [parent1.root_id, parent1.nested_set_span]
|
||||
assert_equal [parent1.id, 1], [parent2.root_id, parent2.nested_set_span]
|
||||
assert_equal [parent1.id, 1], [child.root_id, child.nested_set_span]
|
||||
end
|
||||
|
||||
def test_move_a_child_to_root
|
||||
|
@ -78,9 +80,9 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
parent1.reload
|
||||
parent2.reload
|
||||
|
||||
assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
|
||||
assert_equal [child.id, 1, 2], [child.root_id, child.lft, child.rgt]
|
||||
assert_equal [parent1.id, 1], [parent1.root_id, parent1.nested_set_span]
|
||||
assert_equal [parent2.id, 1], [parent2.root_id, parent2.nested_set_span]
|
||||
assert_equal [child.id, 1], [child.root_id, child.nested_set_span]
|
||||
end
|
||||
|
||||
def test_move_a_child_to_another_issue
|
||||
|
@ -94,9 +96,9 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
parent1.reload
|
||||
parent2.reload
|
||||
|
||||
assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [parent2.id, 1, 4], [parent2.root_id, parent2.lft, parent2.rgt]
|
||||
assert_equal [parent2.id, 2, 3], [child.root_id, child.lft, child.rgt]
|
||||
assert_equal [parent1.id, 1], [parent1.root_id, parent1.nested_set_span]
|
||||
assert_equal [parent2.id, 3], [parent2.root_id, parent2.nested_set_span]
|
||||
assert_equal [parent2.id, 1], [child.root_id, child.nested_set_span]
|
||||
end
|
||||
|
||||
def test_move_a_child_with_descendants_to_another_issue
|
||||
|
@ -110,10 +112,10 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
child.reload
|
||||
grandchild.reload
|
||||
|
||||
assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
|
||||
assert_equal [parent1.id, 2, 5], [child.root_id, child.lft, child.rgt]
|
||||
assert_equal [parent1.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
|
||||
assert_equal [parent1.id, 5], [parent1.root_id, parent1.nested_set_span]
|
||||
assert_equal [parent2.id, 1], [parent2.root_id, parent2.nested_set_span]
|
||||
assert_equal [parent1.id, 3], [child.root_id, child.nested_set_span]
|
||||
assert_equal [parent1.id, 1], [grandchild.root_id, grandchild.nested_set_span]
|
||||
|
||||
child.reload.parent_issue_id = parent2.id
|
||||
child.save!
|
||||
|
@ -122,10 +124,10 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
parent1.reload
|
||||
parent2.reload
|
||||
|
||||
assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [parent2.id, 1, 6], [parent2.root_id, parent2.lft, parent2.rgt]
|
||||
assert_equal [parent2.id, 2, 5], [child.root_id, child.lft, child.rgt]
|
||||
assert_equal [parent2.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
|
||||
assert_equal [parent1.id, 1], [parent1.root_id, parent1.nested_set_span]
|
||||
assert_equal [parent2.id, 5], [parent2.root_id, parent2.nested_set_span]
|
||||
assert_equal [parent2.id, 3], [child.root_id, child.nested_set_span]
|
||||
assert_equal [parent2.id, 1], [grandchild.root_id, grandchild.nested_set_span]
|
||||
end
|
||||
|
||||
def test_move_a_child_with_descendants_to_another_project
|
||||
|
@ -138,9 +140,9 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
grandchild.reload
|
||||
parent1.reload
|
||||
|
||||
assert_equal [1, parent1.id, 1, 2], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [2, child.id, 1, 4], [child.project_id, child.root_id, child.lft, child.rgt]
|
||||
assert_equal [2, child.id, 2, 3], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
|
||||
assert_equal [1, parent1.id, 1], [parent1.project_id, parent1.root_id, parent1.nested_set_span]
|
||||
assert_equal [2, child.id, 3], [child.project_id, child.root_id, child.nested_set_span]
|
||||
assert_equal [2, child.id, 1], [grandchild.project_id, grandchild.root_id, grandchild.nested_set_span]
|
||||
end
|
||||
|
||||
def test_invalid_move_to_another_project
|
||||
|
@ -150,7 +152,7 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
Project.find(2).tracker_ids = [1]
|
||||
|
||||
parent1.reload
|
||||
assert_equal [1, parent1.id, 1, 6], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [1, parent1.id, 5], [parent1.project_id, parent1.root_id, parent1.nested_set_span]
|
||||
|
||||
# child can not be moved to Project 2 because its child is on a disabled tracker
|
||||
assert_equal false, Issue.find(child.id).move_to_project(Project.find(2))
|
||||
|
@ -159,9 +161,9 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
parent1.reload
|
||||
|
||||
# no change
|
||||
assert_equal [1, parent1.id, 1, 6], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
|
||||
assert_equal [1, parent1.id, 2, 5], [child.project_id, child.root_id, child.lft, child.rgt]
|
||||
assert_equal [1, parent1.id, 3, 4], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
|
||||
assert_equal [1, parent1.id, 5], [parent1.project_id, parent1.root_id, parent1.nested_set_span]
|
||||
assert_equal [1, parent1.id, 3], [child.project_id, child.root_id, child.nested_set_span]
|
||||
assert_equal [1, parent1.id, 1], [grandchild.project_id, grandchild.root_id, grandchild.nested_set_span]
|
||||
end
|
||||
|
||||
def test_moving_an_issue_to_a_descendant_should_not_validate
|
||||
|
@ -212,8 +214,8 @@ class IssueNestedSetTest < ActiveSupport::TestCase
|
|||
issue4.reload
|
||||
assert !Issue.exists?(issue2.id)
|
||||
assert !Issue.exists?(issue3.id)
|
||||
assert_equal [issue1.id, 1, 4], [issue1.root_id, issue1.lft, issue1.rgt]
|
||||
assert_equal [issue1.id, 2, 3], [issue4.root_id, issue4.lft, issue4.rgt]
|
||||
assert_equal [issue1.id, 3], [issue1.root_id, issue1.nested_set_span]
|
||||
assert_equal [issue1.id, 1], [issue4.root_id, issue4.nested_set_span]
|
||||
end
|
||||
|
||||
def test_destroy_parent_issue_updated_during_children_destroy
|
||||
|
|
|
@ -372,6 +372,7 @@ class IssueTest < ActiveSupport::TestCase
|
|||
def test_move_to_another_project_should_clear_fixed_version_when_not_shared
|
||||
issue = Issue.find(1)
|
||||
issue.update_attribute(:fixed_version_id, 1)
|
||||
issue.reload
|
||||
assert issue.move_to_project(Project.find(2))
|
||||
issue.reload
|
||||
assert_equal 2, issue.project_id
|
||||
|
@ -382,6 +383,7 @@ class IssueTest < ActiveSupport::TestCase
|
|||
def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
|
||||
issue = Issue.find(1)
|
||||
issue.update_attribute(:fixed_version_id, 4)
|
||||
issue.reload
|
||||
assert issue.move_to_project(Project.find(5))
|
||||
issue.reload
|
||||
assert_equal 5, issue.project_id
|
||||
|
@ -392,6 +394,7 @@ class IssueTest < ActiveSupport::TestCase
|
|||
def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
|
||||
issue = Issue.find(1)
|
||||
issue.update_attribute(:fixed_version_id, 1)
|
||||
issue.reload
|
||||
assert issue.move_to_project(Project.find(5))
|
||||
issue.reload
|
||||
assert_equal 5, issue.project_id
|
||||
|
@ -402,6 +405,7 @@ class IssueTest < ActiveSupport::TestCase
|
|||
def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
|
||||
issue = Issue.find(1)
|
||||
issue.update_attribute(:fixed_version_id, 7)
|
||||
issue.reload
|
||||
assert issue.move_to_project(Project.find(2))
|
||||
issue.reload
|
||||
assert_equal 2, issue.project_id
|
||||
|
@ -871,4 +875,22 @@ class IssueTest < ActiveSupport::TestCase
|
|||
assert issue.save
|
||||
assert_equal 0, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
test 'changing the line endings in a description will not be recorded as a Journal' do
|
||||
User.current = User.find(1)
|
||||
issue = Issue.find(1)
|
||||
issue.update_attribute(:description, "Description with newlines\n\nembedded")
|
||||
issue.reload
|
||||
assert issue.description.include?("\n")
|
||||
|
||||
assert_no_difference("Journal.count") do
|
||||
issue.safe_attributes= {
|
||||
'description' => "Description with newlines\r\n\r\nembedded"
|
||||
}
|
||||
assert issue.save
|
||||
end
|
||||
|
||||
assert_equal "Description with newlines\n\nembedded", issue.reload.description
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -13,103 +13,97 @@
|
|||
require File.expand_path('../../test_helper', __FILE__)
|
||||
|
||||
class JournalObserverTest < ActiveSupport::TestCase
|
||||
fixtures :issues, :issue_statuses, :journals
|
||||
|
||||
def setup
|
||||
@user = User.generate!(:mail_notification => 'all')
|
||||
@project = Project.generate!
|
||||
User.add_to_project(@user, @project, Role.generate!(:permissions => [:view_issues, :edit_issues]))
|
||||
@issue = Issue.generate_for_project!(@project)
|
||||
ActionMailer::Base.deliveries.clear
|
||||
@journal = Journal.find 1
|
||||
if (i = Issue.find(:first)).journals.empty?
|
||||
i.init_journal(User.current, 'Creation') # Make sure the initial journal is created
|
||||
i.save
|
||||
end
|
||||
|
||||
context "#after_create for 'issue_updated'" do
|
||||
should "should send a notification when configured as a notification" do
|
||||
Setting.notified_events = ['issue_updated']
|
||||
assert_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user)
|
||||
@issue.subject = "A change to the issue"
|
||||
assert @issue.save
|
||||
end
|
||||
end
|
||||
|
||||
should "not send a notification with not configured" do
|
||||
Setting.notified_events = []
|
||||
assert_no_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user)
|
||||
@issue.subject = "A change to the issue"
|
||||
assert @issue.save
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
context "#after_create for 'issue_note_added'" do
|
||||
should "should send a notification when configured as a notification" do
|
||||
Setting.notified_events = ['issue_note_added']
|
||||
assert_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user, 'This update has a note')
|
||||
assert @issue.save
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
should "not send a notification with not configured" do
|
||||
Setting.notified_events = []
|
||||
assert_no_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user, 'This update has a note')
|
||||
assert @issue.save
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
# context: issue_updated notified_events
|
||||
def test_create_should_send_email_notification_with_issue_updated
|
||||
Setting.notified_events = ['issue_updated']
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user)
|
||||
context "#after_create for 'issue_status_updated'" do
|
||||
should "should send a notification when configured as a notification" do
|
||||
Setting.notified_events = ['issue_status_updated']
|
||||
assert_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user)
|
||||
@issue.status = IssueStatus.generate!
|
||||
assert @issue.save
|
||||
|
||||
assert issue.send(:create_journal)
|
||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
should "not send a notification with not configured" do
|
||||
Setting.notified_events = []
|
||||
assert_no_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user)
|
||||
@issue.status = IssueStatus.generate!
|
||||
assert @issue.save
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_create_should_not_send_email_notification_without_issue_updated
|
||||
Setting.notified_events = []
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user)
|
||||
context "#after_create for 'issue_priority_updated'" do
|
||||
should "should send a notification when configured as a notification" do
|
||||
Setting.notified_events = ['issue_priority_updated']
|
||||
assert_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user)
|
||||
@issue.priority = IssuePriority.generate!
|
||||
assert @issue.save
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
assert issue.save
|
||||
assert_equal 0, ActionMailer::Base.deliveries.size
|
||||
should "not send a notification with not configured" do
|
||||
Setting.notified_events = []
|
||||
assert_no_difference('ActionMailer::Base.deliveries.size') do
|
||||
@issue.init_journal(@user)
|
||||
@issue.priority = IssuePriority.generate!
|
||||
assert @issue.save
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
# context: issue_note_added notified_events
|
||||
def test_create_should_send_email_notification_with_issue_note_added
|
||||
Setting.notified_events = ['issue_note_added']
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user, 'This update has a note')
|
||||
|
||||
assert issue.save
|
||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
def test_create_should_not_send_email_notification_without_issue_note_added
|
||||
Setting.notified_events = []
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user, 'This update has a note')
|
||||
|
||||
assert issue.save
|
||||
assert_equal 0, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
# context: issue_status_updated notified_events
|
||||
def test_create_should_send_email_notification_with_issue_status_updated
|
||||
Setting.notified_events = ['issue_status_updated']
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user)
|
||||
issue.status = IssueStatus.last
|
||||
|
||||
assert issue.save
|
||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
def test_create_should_not_send_email_notification_without_issue_status_updated
|
||||
Setting.notified_events = []
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user)
|
||||
issue.status = IssueStatus.last
|
||||
|
||||
assert issue.save
|
||||
assert_equal 0, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
# context: issue_priority_updated notified_events
|
||||
def test_create_should_send_email_notification_with_issue_priority_updated
|
||||
Setting.notified_events = ['issue_priority_updated']
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user)
|
||||
issue.priority = IssuePriority.last
|
||||
|
||||
assert issue.save
|
||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
def test_create_should_not_send_email_notification_without_issue_priority_updated
|
||||
Setting.notified_events = []
|
||||
issue = Issue.find(:first)
|
||||
user = User.find(:first)
|
||||
issue.init_journal(user)
|
||||
issue.priority = IssuePriority.last
|
||||
|
||||
assert issue.save
|
||||
assert_equal 0, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -36,12 +36,13 @@ class JournalTest < ActiveSupport::TestCase
|
|||
ActionMailer::Base.deliveries.clear
|
||||
issue = Issue.find(:first)
|
||||
if issue.journals.empty?
|
||||
issue.init_journal(User.current, "This journal represents the creational journal version 1")
|
||||
issue.init_journal(User.current, "This journal represents the creationa of journal version 1")
|
||||
issue.save
|
||||
end
|
||||
user = User.find(:first)
|
||||
|
||||
assert_equal 0, ActionMailer::Base.deliveries.size
|
||||
issue.reload
|
||||
issue.update_attribute(:subject, "New subject to trigger automatic journal entry")
|
||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
@ -58,4 +59,39 @@ class JournalTest < ActiveSupport::TestCase
|
|||
end
|
||||
assert_equal 0, ActionMailer::Base.deliveries.size
|
||||
end
|
||||
|
||||
test "creating the initial journal should track the changes from creation" do
|
||||
@project = Project.generate!
|
||||
issue = Issue.new do |i|
|
||||
i.project = @project
|
||||
i.subject = "Test initial journal"
|
||||
i.tracker = @project.trackers.first
|
||||
i.author = User.generate!
|
||||
i.description = "Some content"
|
||||
end
|
||||
|
||||
assert_difference("Journal.count") do
|
||||
assert issue.save
|
||||
end
|
||||
|
||||
journal = issue.reload.journals.first
|
||||
assert_equal ["","Test initial journal"], journal.changes["subject"]
|
||||
assert_equal [0, @project.id], journal.changes["project_id"]
|
||||
assert_equal [nil, "Some content"], journal.changes["description"]
|
||||
end
|
||||
|
||||
test "creating a journal should update the updated_on value of the parent record (touch)" do
|
||||
@user = User.generate!
|
||||
@project = Project.generate!
|
||||
@issue = Issue.generate_for_project!(@project).reload
|
||||
start = @issue.updated_on
|
||||
sleep(1) # TODO: massive hack to make sure the timestamps are different. switch to timecop later
|
||||
|
||||
assert_difference("Journal.count") do
|
||||
@issue.init_journal(@user, "A note")
|
||||
@issue.save
|
||||
end
|
||||
|
||||
assert_not_equal start, @issue.reload.updated_on
|
||||
end
|
||||
end
|
||||
|
|
|
@ -60,7 +60,8 @@ class MailHandlerTest < ActiveSupport::TestCase
|
|||
assert_equal Version.find_by_name('alpha'), issue.fixed_version
|
||||
assert_equal 2.5, issue.estimated_hours
|
||||
assert_equal 30, issue.done_ratio
|
||||
assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
|
||||
assert_equal issue.id, issue.root_id
|
||||
assert issue.leaf?
|
||||
# keywords should be removed from the email body
|
||||
assert !issue.description.match(/^Project:/i)
|
||||
assert !issue.description.match(/^Status:/i)
|
||||
|
@ -208,7 +209,8 @@ class MailHandlerTest < ActiveSupport::TestCase
|
|||
assert issue.is_a?(Issue)
|
||||
assert issue.author.anonymous?
|
||||
assert !issue.project.is_public?
|
||||
assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
|
||||
assert_equal issue.id, issue.root_id
|
||||
assert issue.leaf?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -299,6 +301,15 @@ class MailHandlerTest < ActiveSupport::TestCase
|
|||
assert_equal 'Feature request', journal.issue.tracker.name
|
||||
end
|
||||
|
||||
test "reply to issue update (Journal) by message_id" do
|
||||
journal = submit_email('ticket_reply_by_message_id.eml')
|
||||
assert journal.is_a?(IssueJournal), "Email was a #{journal.class}"
|
||||
assert_equal User.find_by_login('jsmith'), journal.user
|
||||
assert_equal Issue.find(2), journal.journaled
|
||||
assert_match /This is reply/, journal.notes
|
||||
assert_equal 'Feature request', journal.issue.tracker.name
|
||||
end
|
||||
|
||||
def test_add_issue_note_with_attribute_changes
|
||||
# This email contains: 'Status: Resolved'
|
||||
journal = submit_email('ticket_reply_with_status.eml')
|
||||
|
|
|
@ -135,8 +135,8 @@ module Redmine
|
|||
def journalized_activity_hash(options)
|
||||
options.tap do |h|
|
||||
h[:type] ||= plural_name
|
||||
h[:timestamp] = "#{journal_class.table_name}.created_at"
|
||||
h[:author_key] = "#{journal_class.table_name}.user_id"
|
||||
h[:timestamp] ||= "#{journal_class.table_name}.created_at"
|
||||
h[:author_key] ||= "#{journal_class.table_name}.user_id"
|
||||
|
||||
h[:find_options] ||= {} # in case it is nil
|
||||
h[:find_options] = {}.tap do |opts|
|
||||
|
@ -173,10 +173,11 @@ module Redmine
|
|||
end
|
||||
end
|
||||
options[:type] ||= self.name.underscore.dasherize # Make sure the name of the journalized model and not the name of the journal is used for events
|
||||
{ :description => :notes, :author => :user }.reverse_merge options
|
||||
options[:author] ||= :user
|
||||
{ :description => :notes }.reverse_merge options
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -45,7 +45,7 @@ module Redmine::Acts::Journalized
|
|||
base.class_eval do
|
||||
include InstanceMethods
|
||||
|
||||
after_update :merge_journal_changes
|
||||
after_save :merge_journal_changes
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -107,4 +107,4 @@ module Redmine::Acts::Journalized
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue