Commit dabe5caa authored by Eric Davis's avatar Eric Davis

Merge branch 'ticket/unstable/123-journalized' into unstable

parents bf90848a f79e209d
......@@ -17,8 +17,7 @@ class IssueMovesController < ApplicationController
moved_issues = []
@issues.each do |issue|
issue.reload
issue.init_journal(User.current)
issue.current_journal.notes = @notes if @notes.present?
issue.init_journal(User.current, @notes || "")
call_hook(:controller_issues_move_before_save, { :params => params, :issue => issue, :target_project => @target_project, :copy => !!@copy })
if r = issue.move_to_project(@target_project, new_tracker, {:copy => @copy, :attributes => extract_changed_attributes_for_move(params)})
moved_issues << r
......
......@@ -31,6 +31,9 @@ class IssuesController < ApplicationController
rescue_from Query::StatementInvalid, :with => :query_statement_invalid
helper :journals
include JournalsHelper
helper :projects
include ProjectsHelper
include CustomFieldsHelper
include IssueRelationsHelper
......@@ -92,8 +95,7 @@ class IssuesController < ApplicationController
end
def show
@journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
@journals.each_with_index {|j,i| j.indice = i+1}
@journals = @issue.journals.find(:all, :include => [:user], :order => "#{Journal.table_name}.created_at ASC")
@journals.reverse! if User.current.wants_comments_in_reverse_order?
@changesets = @issue.changesets.visible.all
@changesets.reverse! if User.current.wants_comments_in_reverse_order?
......@@ -144,6 +146,7 @@ class IssuesController < ApplicationController
end
def edit
return render_reply(@journal) if @journal
update_issue_from_params
@journal = @issue.current_journal
......@@ -159,7 +162,7 @@ class IssuesController < ApplicationController
JournalObserver.instance.send_notification = params[:send_notification] == '0' ? false : true
if @issue.save_issue_with_child_records(params, @time_entry)
render_attachment_warning_if_needed(@issue)
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal == @journal
respond_to do |format|
format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
......@@ -167,7 +170,7 @@ class IssuesController < ApplicationController
end
else
render_attachment_warning_if_needed(@issue)
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
flash[:notice] = l(:notice_successful_update) unless @issue.current_journal == @journal
@journal = @issue.current_journal
respond_to do |format|
......@@ -268,6 +271,7 @@ private
@notes = params[:notes] || (params[:issue].present? ? params[:issue][:notes] : nil)
@issue.init_journal(User.current, @notes)
@issue.safe_attributes = params[:issue]
@journal = @issue.current_journal
end
# TODO: Refactor, lots of extra code in here
......
# Redmine - project management software
# Copyright (C) 2006-2011 Jean-Philippe Lang
#
# 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.
class JournalsController < ApplicationController
before_filter :find_journal, :only => [:edit, :diff]
before_filter :find_issue, :only => [:new]
before_filter :find_optional_project, :only => [:index]
before_filter :authorize, :only => [:new, :edit, :diff]
accept_key_auth :index
menu_item :issues
include QueriesHelper
include SortHelper
def index
retrieve_query
sort_init 'id', 'desc'
sort_update(@query.sortable_columns)
if @query.valid?
@journals = @query.journals(:order => "#{Journal.table_name}.created_on DESC",
:limit => 25)
end
@title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
render :layout => false, :content_type => 'application/atom+xml'
rescue ActiveRecord::RecordNotFound
render_404
end
def diff
@issue = @journal.issue
if params[:detail_id].present?
@detail = @journal.details.find_by_id(params[:detail_id])
else
@detail = @journal.details.detect {|d| d.prop_key == 'description'}
end
(render_404; return false) unless @issue && @detail
@diff = Redmine::Helpers::Diff.new(@detail.value, @detail.old_value)
end
def new
journal = Journal.find(params[:journal_id]) if params[:journal_id]
if journal
user = journal.user
text = journal.notes
else
user = @issue.author
text = @issue.description
end
# Replaces pre blocks with [...]
text = text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]')
content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
render(:update) { |page|
page.<< "$('notes').value = \"#{escape_javascript content}\";"
page.show 'update'
page << "Form.Element.focus('notes');"
page << "Element.scrollTo('update');"
page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
}
end
def edit
(render_403; return false) unless @journal.editable_by?(User.current)
if request.post?
@journal.update_attributes(:notes => params[:notes]) if params[:notes]
@journal.destroy if @journal.details.empty? && @journal.notes.blank?
call_hook(:controller_journals_edit_post, { :journal => @journal, :params => params})
respond_to do |format|
format.html { redirect_to :controller => 'issues', :action => 'show', :id => @journal.journalized_id }
format.js { render :action => 'update' }
end
else
respond_to do |format|
format.html {
# TODO: implement non-JS journal update
render :nothing => true
}
format.js
end
end
end
private
def find_journal
@journal = Journal.find(params[:id])
@project = @journal.journalized.project
rescue ActiveRecord::RecordNotFound
render_404
end
# TODO: duplicated in IssuesController
def find_issue
@issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
@project = @issue.project
rescue ActiveRecord::RecordNotFound
render_404
end
end
......@@ -97,7 +97,7 @@ class WikiController < ApplicationController
@content.comments = nil
# To prevent StaleObjectError exception when reverting to a previous version
@content.version = @page.content.version
@content.lock_version = @page.content.lock_version
end
verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
......@@ -119,6 +119,7 @@ class WikiController < ApplicationController
redirect_to :action => 'show', :project_id => @project, :id => @page.title
return
end
params[:content].delete(:version) # The version count is automatically increased
@content.attributes = params[:content]
@content.author = User.current
# if page is new @page.save will also save content, but not if page isn't a new record
......@@ -160,7 +161,7 @@ class WikiController < ApplicationController
@version_pages = Paginator.new self, @version_count, per_page_option, params['p']
# don't load text
@versions = @page.content.versions.find :all,
:select => "id, author_id, comments, updated_on, version",
:select => "id, user_id, notes, created_at, version",
:order => 'version DESC',
:limit => @version_pages.items_per_page + 1,
:offset => @version_pages.current.offset
......
......@@ -19,28 +19,43 @@ require "digest/md5"
class Attachment < ActiveRecord::Base
belongs_to :container, :polymorphic => true
belongs_to :author, :class_name => "User", :foreign_key => "author_id"
# FIXME: Remove these once the Versions, Documents and Projects themselves can provide file events
belongs_to :version, :foreign_key => "container_id"
belongs_to :document, :foreign_key => "container_id"
belongs_to :author, :class_name => "User", :foreign_key => "author_id"
validates_presence_of :container, :filename, :author
validates_length_of :filename, :maximum => 255
validates_length_of :disk_filename, :maximum => 255
acts_as_event :title => :filename,
:url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
acts_as_journalized :event_title => :filename,
:event_url => (Proc.new do |o|
{ :controller => 'attachments', :action => 'download',
:id => o.journaled_id, :filename => o.filename }
end),
:activity_type => 'files',
:activity_permission => :view_files,
:activity_find_options => { :include => { :version => :project } }
acts_as_activity_provider :type => 'files',
:permission => :view_files,
:author_key => :author_id,
:find_options => {:select => "#{Attachment.table_name}.*",
:joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
"LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
acts_as_activity_provider :type => 'documents',
:permission => :view_documents,
:author_key => :author_id,
:find_options => {:select => "#{Attachment.table_name}.*",
:joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
"LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
acts_as_activity :type => 'documents', :permission => :view_documents,
:find_options => { :include => { :document => :project } }
# This method is called on save by the AttachmentJournal in order to
# decide which kind of activity we are dealing with. When that activity
# is retrieved later, we don't need to check the container_type in
# SQL anymore as that will be just the one we have specified here.
def activity_type
case container_type
when "Document"
"documents"
when "Version"
"files"
else
super
end
end
cattr_accessor :storage_path
@@storage_path = Redmine::Configuration['attachments_storage_path'] || "#{RAILS_ROOT}/files"
......
......@@ -23,19 +23,17 @@ class Changeset < ActiveRecord::Base
has_many :changes, :dependent => :delete_all
has_and_belongs_to_many :issues
acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.format_identifier}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))},
:description => :long_comments,
:datetime => :committed_on,
:url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.identifier}}
acts_as_journalized :event_title => Proc.new {|o| "#{l(:label_revision)} #{o.format_identifier}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))},
:event_description => :long_comments,
:event_datetime => :committed_on,
:event_url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.revision}},
:activity_timestamp => "#{table_name}.committed_on",
:activity_find_options => {:include => [:user, {:repository => :project}]}
acts_as_searchable :columns => 'comments',
:include => {:repository => :project},
:project_key => "#{Repository.table_name}.project_id",
:date_column => 'committed_on'
acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
:author_key => :user_id,
:find_options => {:include => [:user, {:repository => :project}]}
validates_presence_of :repository_id, :revision, :committed_on, :commit_date
validates_uniqueness_of :revision, :scope => :repository_id
......@@ -193,7 +191,7 @@ class Changeset < ActiveRecord::Base
# don't change the status is the issue is closed
return if issue.status && issue.status.is_closed?
journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag))
issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag))
issue.status = status
unless Setting.commit_fix_done_ratio.blank?
issue.done_ratio = Setting.commit_fix_done_ratio.to_i
......
......@@ -20,11 +20,13 @@ class Document < ActiveRecord::Base
belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
acts_as_attachable :delete_permission => :manage_documents
acts_as_journalized :event_title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
:event_url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.journaled_id}},
:event_author => (Proc.new do |o|
o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC").try(:author)
end)
acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
:author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
:url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
acts_as_activity_provider :find_options => {:include => :project}
validates_presence_of :project, :title, :category
validates_length_of :title, :maximum => 60
......
......@@ -27,7 +27,6 @@ class Issue < ActiveRecord::Base
belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
has_many :journals, :as => :journalized, :dependent => :destroy
has_many :time_entries, :dependent => :delete_all
has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
......@@ -38,21 +37,25 @@ class Issue < ActiveRecord::Base
acts_as_attachable :after_remove => :attachment_removed
acts_as_customizable
acts_as_watchable
acts_as_journalized :event_title => Proc.new {|o| "#{o.tracker.name} ##{o.journaled_id} (#{o.status}): #{o.subject}"},
:event_type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '')}
register_on_journal_formatter(:id, 'parent_id')
register_on_journal_formatter(:named_association, 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
'priority_id', 'category_id', 'fixed_version_id')
register_on_journal_formatter(:fraction, 'estimated_hours')
register_on_journal_formatter(:decimal, 'done_ratio')
register_on_journal_formatter(:datetime, 'due_date', 'start_date')
register_on_journal_formatter(:plaintext, 'subject', 'description')
acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
:include => [:project, :journals],
# sort by id so that limited eager loading doesn't break with postgresql
:order_column => "#{table_name}.id"
acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
:url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
:type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
:author_key => :author_id
DONE_RATIO_OPTIONS = %w(issue_field issue_status)
attr_reader :current_journal
validates_presence_of :subject, :priority, :project, :tracker, :author, :status
validates_length_of :subject, :maximum => 255
......@@ -83,7 +86,7 @@ class Issue < ActiveRecord::Base
before_create :default_assign
before_save :close_duplicates, :update_done_ratio_from_issue_status
after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes
after_destroy :update_parent_attributes
# Returns a SQL conditions string used to find all issues visible by the specified user
......@@ -346,15 +349,11 @@ class Issue < ActiveRecord::Base
end
end
def init_journal(user, notes = "")
@current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
@issue_before_change = self.clone
@issue_before_change.status = self.status
@custom_values_before_change = {}
self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
# Make sure updated_on is updated when adding a note.
updated_on_will_change!
@current_journal
# Callback on attachment deletion
def attachment_removed(obj)
init_journal(User.current)
create_journal
last_journal.update_attribute(:changes, {"attachments_" + obj.id.to_s => [obj.filename, nil]}.to_yaml)
end
# Return true if the issue is closed, otherwise false
......@@ -556,13 +555,12 @@ class Issue < ActiveRecord::Base
if valid?
attachments = Attachment.attach_files(self, params[:attachments])
attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
# TODO: Rename hook
Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => current_journal})
begin
if save
# TODO: Rename hook
Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => current_journal})
else
raise ActiveRecord::Rollback
end
......@@ -777,22 +775,12 @@ class Issue < ActiveRecord::Base
).each do |issue|
next if issue.project.nil? || issue.fixed_version.nil?
unless issue.project.shared_versions.include?(issue.fixed_version)
issue.init_journal(User.current)
issue.fixed_version = nil
issue.save
end
end
end
# Callback on attachment deletion
def attachment_removed(obj)
journal = init_journal(User.current)
journal.details << JournalDetail.new(:property => 'attachment',
:prop_key => obj.id,
:old_value => obj.filename)
journal.save
end
# Default assignment based on category
def default_assign
if assigned_to.nil? && category && category.assigned_to
......@@ -817,40 +805,14 @@ class Issue < ActiveRecord::Base
duplicate.reload
# Don't re-close it if it's already closed
next if duplicate.closed?
# Same user and notes
if @current_journal
duplicate.init_journal(@current_journal.user, @current_journal.notes)
end
# Implicitely creates a new journal
duplicate.update_attribute :status, self.status
# Same user and notes
duplicate.journals.last.user = current_journal.user
duplicate.journals.last.notes = current_journal.notes
end
end
end
# Saves the changes in a Journal
# Called after_save
def create_journal
if @current_journal
# attributes changes
(Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
@current_journal.details << JournalDetail.new(:property => 'attr',
:prop_key => c,
:old_value => @issue_before_change.send(c),
:value => send(c)) unless send(c)==@issue_before_change.send(c)
}
# custom fields changes
custom_values.each {|c|
next if (@custom_values_before_change[c.custom_field_id]==c.value ||
(@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
@current_journal.details << JournalDetail.new(:property => 'cf',
:prop_key => c.custom_field_id,
:old_value => @custom_values_before_change[c.custom_field_id],
:value => c.value)
}
@current_journal.save
# reset current journal
init_journal @current_journal.user, @current_journal.notes
end
end
# Query generator for selecting groups of issue counts for a project
# based on specific criteria
......@@ -880,4 +842,13 @@ class Issue < ActiveRecord::Base
end
IssueJournal.class_eval do
# Shortcut
def new_status
if details.keys.include? 'status_id'
(newval = details['status_id'].last) ? IssueStatus.find_by_id(newval.to_i) : nil
end
end
end
end
# Redmine - project management software
# Copyright (C) 2006-2011 Jean-Philippe Lang
#
# 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.
class Journal < ActiveRecord::Base
belongs_to :journalized, :polymorphic => true
# added as a quick fix to allow eager loading of the polymorphic association
# since always associated to an issue, for now
belongs_to :issue, :foreign_key => :journalized_id
belongs_to :user
has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
attr_accessor :indice
acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
:description => :notes,
:author => :user,
:type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
:url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
acts_as_activity_provider :type => 'issues',
:permission => :view_issues,
:author_key => :user_id,
:find_options => {:include => [{:issue => :project}, :details, :user],
:conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
" (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
named_scope :visible, lambda {|*args| {
:include => {:issue => :project},
:conditions => Issue.visible_condition(args.first || User.current)
}}
def save(*args)
# Do not save an empty journal
(details.empty? && notes.blank?) ? false : super
end
# Returns the new status if the journal contains a status change, otherwise nil
def new_status
c = details.detect {|detail| detail.prop_key == 'status_id'}
(c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
end
def new_value_for(prop)
c = details.detect {|detail| detail.prop_key == prop}
c ? c.value : nil
end
def editable_by?(usr)
usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
end
def project
journalized.respond_to?(:project) ? journalized.project : nil
end
def attachments
journalized.respond_to?(:attachments) ? journalized.attachments : nil
end
# Returns a string of css classes
def css_classes
s = 'journal'
s << ' has-notes' unless notes.blank?
s << ' has-details' unless details.blank?
s
end
end
......@@ -159,21 +159,20 @@ class MailHandler < ActionMailer::Base
# ignore CLI-supplied defaults for new issues
@@handler_options[:issue].clear
journal = issue.init_journal(user)
issue.safe_attributes = issue_attributes_from_keywords(issue)
issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
journal.notes = cleaned_up_text_body
issue.init_journal(user, cleaned_up_text_body)
add_attachments(issue)
issue.save!
logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
journal
issue.last_journal
end
# Reply will be added to the issue
def receive_journal_reply(journal_id)
journal = Journal.find_by_id(journal_id)
if journal && journal.journalized_type == 'Issue'
receive_issue_reply(journal.journalized_id)
if journal and journal.journaled.is_a? Issue
receive_issue_reply(journal.journaled_id)
end
end
......
......@@ -21,6 +21,7 @@ class Mailer < ActionMailer::Base
layout 'mailer'
helper :application
helper :issues
helper :journals
helper :custom_fields
include ActionController::UrlWriter
......@@ -58,7 +59,7 @@ class Mailer < ActionMailer::Base
# issue_edit(journal) => tmail object
# Mailer.deliver_issue_edit(journal) => sends an email to issue recipients
def issue_edit(journal)
issue = journal.journalized.reload
issue = journal.journaled.reload
redmine_headers 'Project' => issue.project.identifier,
'Issue-Id' => issue.id,
'Issue-Author' => issue.author.login,
......@@ -71,7 +72,7 @@ class Mailer < ActionMailer::Base
# Watchers in cc
cc(issue.watcher_recipients - @recipients)
s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
s << "(#{issue.status.name}) " if journal.details['status_id']
s << issue.subject
subject s
body :issue => issue,
......@@ -188,7 +189,7 @@ class Mailer < ActionMailer::Base
cc((message.root.watcher_recipients + message.board.watcher_recipients).uniq - @recipients)
subject "[#{message.board.project.name} - #{message.board.name} - msg#{message.root.id}] #{message.subject}"
body :message => message,
:message_url => url_for(message.event_url)
:message_url => url_for(message.last_journal.event_url)
render_multipart('message_posted', body)
end
......
......@@ -22,18 +22,24 @@ class Message < ActiveRecord::Base
acts_as_attachable
belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
acts_as_journalized :event_title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
:event_description => :content,
:event_type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
:event_url => (Proc.new do |o|
msg = o.journaled
if msg.parent_id.nil?
{:id => msg.id}
else
{:id => msg.parent_id, :r => msg.id, :anchor => "message-#{msg.id}"}
end.reverse_merge :controller => 'messages', :action => 'show', :board_id => msg.board_id
end),
:activity_find_options => { :include => { :board => :project } }
acts_as_searchable :columns => ['subject', 'content'],
:include => {:board => :project},
:project_key => 'project_id',
:date_column => "#{table_name}.created_on"
acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
:description => :content,
:type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
:url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
{:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
:author_key => :author_id
acts_as_watchable
attr_protected :locked, :sticky
......
......@@ -16,7 +16,10 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
class MessageObserver < ActiveRecord::Observer
def after_create(message)
Mailer.deliver_message_posted(message) if Setting.notified_events.include?('message_posted')
def after_save(message)
if message.last_journal.version == 1
# Only deliver mails for the first journal
Mailer.deliver_message_posted(message) if Setting.notified_events.include?('message_posted')
end
end
end
......@@ -24,10 +24,8 @@ class News < ActiveRecord::Base
validates_length_of :title, :maximum => 60
validates_length_of :summary, :maximum => 255
acts_as_journalized :event_url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.journaled_id} }
acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
acts_as_activity_provider :find_options => {:include => [:project, :author]},
:author_key => :author_id
acts_as_watchable
after_create :add_author_as_watcher
......
......@@ -545,8 +545,8 @@ class Query < ActiveRecord::Base
# Returns the journals
# Valid options are :order, :offset, :limit
def journals(options={})
Journal.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
def issue_journals(options={})
IssueJournal.find :all, :joins => [:user, {:issue => [:project, :author, :tracker, :status]}],
:conditions => statement,
:order => options[:order],
:limit => options[:limit],
......
......@@ -26,14 +26,10 @@ class TimeEntry < ActiveRecord::Base
attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
acts_as_customizable
acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
:url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
:author => :user,
:description => :comments
acts_as_activity_provider :timestamp => "#{table_name}.created_on",
:author_key => :user_id,
:find_options => {:include => :project}
acts_as_journalized :event_title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
:event_url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
:event_author => :user,
:event_description => :comments
validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
validates_numericality_of :hours, :allow_nil => true, :message => :invalid
......
......@@ -18,14 +18,22 @@
require 'zlib'
class WikiContent < ActiveRecord::Base
set_locking_column :version
belongs_to :page, :class_name => 'WikiPage', :foreign_key => 'page_id'
belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
validates_presence_of :text
validates_length_of :comments, :maximum => 255, :allow_nil => true
acts_as_versioned
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}},
:activity_type => 'wiki_edits',
:activity_permission => :view_wiki_edits,
:activity_find_options => { :include => { :page => { :wiki => :project } } }
def activity_type
'wiki_edits'
end
def visible?(user=User.current)
page.visible?(user)
end
......@@ -44,67 +52,71 @@ class WikiContent < ActiveRecord::Base
notified.reject! {|user| !visible?(user)}
notified.collect(&:mail)
end
class Version
belongs_to :page, :class_name => '::WikiPage', :foreign_key => 'page_id'
belongs_to :author, :class_name => '::User', :foreign_key => 'author_id'
attr_protected :data
acts_as_event :title => Proc.new {|o| "#{l(:label_wiki_edit)}: #{o.page.title} (##{o.version})"},
:description => :comments,
:datetime => :updated_on,
:type => 'wiki-page',
:url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.page.wiki.project, :id => o.page.title, :version => o.version}}
# FIXME: Deprecate
def versions
journals
end
def version
unless last_journal
# FIXME: This is code that caters for a case that should never happen in the normal code paths!!
create_journal
last_journal.update_attribute(:created_at, updated_on)
end
last_journal.version
end
# FIXME: This is for backwards compatibility only. Remove once we decide it is not needed anymore
WikiContentJournal.class_eval do
attr_protected :data
after_save :compress_version_text
acts_as_activity_provider :type => 'wiki_edits',
:timestamp => "#{WikiContent.versioned_table_name}.updated_on",
:author_key => "#{WikiContent.versioned_table_name}.author_id",
:permission => :view_wiki_edits,
:find_options => {:select => "#{WikiContent.versioned_table_name}.updated_on, #{WikiContent.versioned_table_name}.comments, " +
"#{WikiContent.versioned_table_name}.#{WikiContent.version_column}, #{WikiPage.table_name}.title, " +
"#{WikiContent.versioned_table_name}.page_id, #{WikiContent.versioned_table_name}.author_id, " +
"#{WikiContent.versioned_table_name}.id",
:joins => "LEFT JOIN #{WikiPage.table_name} ON #{WikiPage.table_name}.id = #{WikiContent.versioned_table_name}.page_id " +
"LEFT JOIN #{Wiki.table_name} ON #{Wiki.table_name}.id = #{WikiPage.table_name}.wiki_id " +
"LEFT JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Wiki.table_name}.project_id"}
# Wiki Content might be large and the data should possibly be compressed
def compress_version_text
self.text = changes["text"].last if changes["text"]
self.text ||= self.journaled.text
end
def text=(plain)
case Setting.wiki_compression
when 'gzip'
begin
self.data = Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION)
self.compression = 'gzip'
rescue
self.data = plain
self.compression = ''
end
when "gzip"
begin
text_hash :text => Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION), :compression => Setting.wiki_compression
rescue
text_hash :text => plain, :compression => ''
end
else
self.data = plain
self.compression = ''
text_hash :text => plain, :compression => ''
end
plain
end
def text_hash(hash)
changes.delete("text")
changes["data"] = hash[:text]
changes["compression"] = hash[:compression]
update_attribute(:changes, changes.to_yaml)
end
def text
@text ||= case compression
@text ||= case changes[:compression]
when 'gzip'
Zlib::Inflate.inflate(data)
else
# uncompressed data
data
end
end
def project
page.project
changes["data"]
end
end
# Returns the previous version or nil
def previous
@previous ||= WikiContent::Version.find(:first,
:order => 'version DESC',
:include => :author,
:conditions => ["wiki_content_id = ? AND version < ?", wiki_content_id, version])
@previous ||= journaled.journals.at(version - 1)
end
# FIXME: Deprecate
def versioned
journaled
end
end
end
<% reply_links = authorize_for('issues', 'edit') -%>
<% for journal in journals %>
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
<h4><div class="journal-link"><%= link_to "##{journal.indice}", :anchor => "note-#{journal.indice}" %></div>
<%= avatar(journal.user, :size => "24") %>
<%= content_tag('a', '', :name => "note-#{journal.indice}")%>
<%= authoring journal.created_on, journal.user, :label => :label_updated_time_by %></h4>
<% if journal.details.any? %>
<ul class="details">
<% for detail in journal.details %>
<li><%= show_detail(detail) %></li>
<% end %>
</ul>
<% end %>
<%= render_notes(issue, journal, :reply_links => reply_links) unless journal.notes.blank? %>
</div>
<%= render_journal issue, journal, :edit_permission => :edit_issue_notes,
:edit_own_permission => :edit_own_issue_notes %>
<%= call_hook(:view_issues_history_journal_bottom, { :journal => journal }) %>
<% end %>
......
......@@ -49,9 +49,9 @@ api.issue do
api.created_on journal.created_on
api.array :details do
journal.details.each do |detail|
api.detail :property => detail.property, :name => detail.prop_key do
api.old_value detail.old_value
api.new_value detail.value
api.detail :property => journal.property(detail), :name => journal.prop_key(detail) do
api.old_value journal.old_value(detail)
api.new_value journal.value(detail)
end
end
end
......
......@@ -7,7 +7,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom" do
xml.updated((@journals.first ? @journals.first.event_datetime : Time.now).xmlschema)
xml.author { xml.name "#{Setting.app_title}" }
@journals.each do |change|
issue = change.issue
issue = change.journaled
xml.entry do
xml.title "#{issue.project.name} - #{issue.tracker.name} ##{issue.id}: #{issue.subject}"
xml.link "rel" => "alternate", "href" => url_for(:controller => 'issues' , :action => 'show', :id => issue, :only_path => false)
......@@ -20,7 +20,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom" do
xml.content "type" => "html" do
xml.text! '<ul>'
change.details.each do |detail|
xml.text! '<li>' + show_detail(detail, false) + '</li>'
xml.text! '<li>' + change.render_detail(detail, false) + '</li>'
end
xml.text! '</ul>'
xml.text! textilizable(change, :notes, :only_path => false) unless change.notes.blank?
......
......@@ -2,7 +2,7 @@
<ul>
<% for detail in @journal.details %>
<li><%= show_detail(detail, true) %></li>
<li><%= @journal.render_detail(detail, true) %></li>
<% end %>
</ul>
......
<%= l(:text_issue_updated, :id => "##{@issue.id}", :author => @journal.user) %>
<% for detail in @journal.details -%>
<%= show_detail(detail, true) %>
<%= @journal.render_detail(detail, true) %>
<% end -%>
<%= @journal.notes if @journal.notes? %>
......
......@@ -26,7 +26,10 @@
<h3><%= l(:label_result_plural) %> (<%= @results_by_type.values.sum %>)</h3>
<dl id="search-results">
<% @results.each do |e| %>
<dt class="<%= e.event_type %>"><%= content_tag('span', h(e.project), :class => 'project') unless @project == e.project %> <%= link_to highlight_tokens(truncate(e.event_title, :length => 255), @tokens), e.event_url %></dt>
<dt class="<%= e.event_type %>">
<%= content_tag('span', h(e.project), :class => 'project') unless @project == e.project %>
<%= link_to highlight_tokens(truncate(e.event_title, :length => 255), @tokens), e.event_url %>
</dt>
<dd><span class="description"><%= highlight_tokens(e.event_description, @tokens) %></span>
<span class="author"><%= format_time(e.event_datetime) %></span></dd>
<% end %>
......
<h2><%=h @page.pretty_title %></h2>
<% form_for :content, @content, :url => {:action => 'update', :id => @page.title}, :html => {:method => :put, :multipart => true, :id => 'wiki_form'} do |f| %>
<%= f.hidden_field :version %>
<%= f.hidden_field :lock_version %>
<%= error_messages_for 'content' %>
<p><%= f.text_area :text, :cols => 100, :rows => 25, :class => 'wiki-edit', :accesskey => accesskey(:edit) %></p>
......
......@@ -21,9 +21,9 @@
<td class="id"><%= link_to ver.version, :action => 'show', :id => @page.title, :project_id => @page.project, :version => ver.version %></td>
<td class="checkbox"><%= radio_button_tag('version', ver.version, (line_num==1), :id => "cb-#{line_num}", :onclick => "$('cbto-#{line_num+1}').checked=true;") if show_diff && (line_num < @versions.size) %></td>
<td class="checkbox"><%= radio_button_tag('version_from', ver.version, (line_num==2), :id => "cbto-#{line_num}") if show_diff && (line_num > 1) %></td>
<td class="updated_on"><%= format_time(ver.updated_on) %></td>
<td class="author"><%= link_to_user ver.author %></td>
<td class="comments"><%=h ver.comments %></td>
<td class="updated_on"><%= format_time(ver.created_at) %></td>
<td class="author"><%= link_to_user ver.user %></td>
<td class="comments"><%=h ver.notes %></td>
<td class="buttons"><%= link_to l(:button_annotate), :action => 'annotate', :id => @page.title, :version => ver.version %></td>
</tr>
<% line_num += 1 %>
......
......@@ -36,7 +36,7 @@ Rails::Initializer.run do |config|
# Activate observers that should always be running
# config.active_record.observers = :cacher, :garbage_collector
config.active_record.observers = :message_observer, :issue_observer, :journal_observer, :news_observer, :document_observer, :wiki_content_observer, :comment_observer
config.active_record.observers = :journal_observer, :message_observer, :issue_observer, :news_observer, :document_observer, :wiki_content_observer, :comment_observer
# Make Active Record use UTC-base instead of local time
# config.active_record.default_timezone = :utc
......
......@@ -2,7 +2,7 @@ class RenameCommentToComments < ActiveRecord::Migration
def self.up
rename_column(:comments, :comment, :comments) if ActiveRecord::Base.connection.columns(Comment.table_name).detect{|c| c.name == "comment"}
rename_column(:wiki_contents, :comment, :comments) if ActiveRecord::Base.connection.columns(WikiContent.table_name).detect{|c| c.name == "comment"}
rename_column(:wiki_content_versions, :comment, :comments) if ActiveRecord::Base.connection.columns(WikiContent.versioned_table_name).detect{|c| c.name == "comment"}
rename_column(:wiki_content_versions, :comment, :comments) if ActiveRecord::Base.connection.columns("wiki_content_versions").detect{|c| c.name == "comment"}
rename_column(:time_entries, :comment, :comments) if ActiveRecord::Base.connection.columns(TimeEntry.table_name).detect{|c| c.name == "comment"}
rename_column(:changesets, :comment, :comments) if ActiveRecord::Base.connection.columns(Changeset.table_name).detect{|c| c.name == "comment"}
end
......
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
class MergeWikiVersionsWithJournals < ActiveRecord::Migration
def self.up
# This is provided here for migrating up after the WikiContent::Version class has been removed
unless WikiContent.const_defined?("Version")
WikiContent.const_set("Version", Class.new(ActiveRecord::Base))
end
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")
changes = {}
changes["compression"] = wv.compression
changes["data"] = wv.data
journal.update_attribute(:changes, changes.to_yaml)
journal.update_attribute(:version, wv.version)
end
# drop_table :wiki_content_versions
change_table :wiki_contents do |t|
t.rename :version, :lock_version
end
end
def self.down
change_table :wiki_contents do |t|
t.rename :lock_version, :version
end
# create_table :wiki_content_versions do |t|
# t.column :wiki_content_id, :integer, :null => false
# t.column :page_id, :integer, :null => false
# t.column :author_id, :integer
# t.column :data, :binary
# t.column :compression, :string, :limit => 6, :default => ""
# t.column :comments, :string, :limit => 255, :default => ""
# t.column :updated_on, :datetime, :null => false
# t.column :version, :integer, :null => false
# end
# add_index :wiki_content_versions, :wiki_content_id, :name => :wiki_content_versions_wcid
#
# WikiContentJournal.all.each do |j|
# WikiContent::Version.create(:wiki_content_id => j.journaled_id, :page_id => j.journaled.page_id,
# :author_id => j.user_id, :data => j.changes["data"], :compression => j.changes["compression"],
# :comments => j.notes, :updated_on => j.created_at, :version => j.version)
# end
WikiContentJournal.destroy_all
end
end
class Meeting < ActiveRecord::Base
belongs_to :project
acts_as_event :title => Proc.new {|o| "#{o.scheduled_on} Meeting"},
:datetime => :scheduled_on,
:author => nil,
:url => Proc.new {|o| {:controller => 'meetings', :action => 'show', :id => o.id}}
acts_as_activity_provider :timestamp => 'scheduled_on',
:find_options => { :include => :project }
acts_as_journalized :event_title => Proc.new {|o| "#{o.scheduled_on} Meeting"},
:event_datetime => :scheduled_on,
:event_author => nil,
:event_url => Proc.new {|o| {:controller => 'meetings', :action => 'show', :id => o.id}}
:activity_timestamp => 'scheduled_on'
end
......@@ -208,12 +208,12 @@ Redmine::MenuManager.map :project_menu do |menu|
end
Redmine::Activity.map do |activity|
activity.register :issues, :class_name => %w(Issue Journal)
activity.register :issues, :class_name => 'Issue'
activity.register :changesets
activity.register :news
activity.register :documents, :class_name => %w(Document Attachment)
activity.register :files, :class_name => 'Attachment'
activity.register :wiki_edits, :class_name => 'WikiContent::Version', :default => false
activity.register :wiki_edits, :class_name => 'WikiContent', :default => false
activity.register :messages, :default => false
activity.register :time_entries, :default => false
end
......
......@@ -38,7 +38,17 @@ module Redmine
return @event_types unless @event_types.nil?
@event_types = Redmine::Activity.available_event_types
@event_types = @event_types.select {|o| @project.self_and_descendants.detect {|p| @user.allowed_to?("view_#{o}".to_sym, p)}} if @project
if @project
@event_types = @event_types.select do |o|
@project.self_and_descendants.detect do |p|
permissions = constantized_providers(o).collect do |p|
p.activity_provider_options[o].try(:[], :permission)
end.compact
return @user.allowed_to?("view_#{o}".to_sym, @project) if permissions.blank?
permissions.all? {|p| @user.allowed_to?(p, @project) } if @project
end
end
end
@event_types
end
......
......@@ -359,13 +359,13 @@ module Redmine
pdf.SetFontStyle('B',9)
pdf.RDMCell(190,5, l(:label_history), "B")
pdf.Ln
for journal in issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
for journal in issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_at ASC")
pdf.SetFontStyle('B',8)
pdf.RDMCell(190,5, format_time(journal.created_on) + " - " + journal.user.name)
pdf.RDMCell(190,5, format_time(journal.created_at) + " - " + journal.user.name)
pdf.Ln
pdf.SetFontStyle('I',8)
for detail in journal.details
pdf.RDMCell(190,5, "- " + show_detail(detail, true))
pdf.RDMCell(190,5, "- " + journal.render_detail(detail, true))
pdf.Ln
end
if journal.notes?
......
......@@ -95,7 +95,7 @@ module Redmine
page = nil
if args.size > 0
page = Wiki.find_page(args.first.to_s, :project => @project)
elsif obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)
elsif obj.is_a?(WikiContent)
page = obj.page
else
raise 'With no argument, this macro can be called from wiki pages only.'
......
---
journal_details_001:
old_value: "1"
property: attr
id: 1
value: "2"
prop_key: status_id
journal_id: 1
journal_details_002:
old_value: "40"
property: attr
id: 2
value: "30"
prop_key: done_ratio
journal_id: 1
journal_details_003:
old_value: nil
property: attr
id: 3
value: "6"
prop_key: fixed_version_id
journal_id: 4
journal_details_004:
old_value: "This word was removed and an other was"
property: attr
id: 4
value: "This word was and an other was added"
prop_key: description
journal_id: 3
journal_details_005:
old_value: Old value
property: cf
id: 5
value: New value
prop_key: 2
journal_id: 3
---
journals_001:
created_on: <%= 2.days.ago.to_date.to_s(:db) %>
notes: "Journal notes"
---
journals_001:
id: 1
journalized_type: Issue
type: "IssueJournal"
activity_type: "issues"
created_at: <%= 3.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 1
journalized_id: 1
journals_002:
created_on: <%= 1.days.ago.to_date.to_s(:db) %>
notes: "Some notes with Redmine links: #2, r2."
notes: "Journal notes"
journaled_id: 1
changes: |
---
status_id:
- 1
- 2
done_ratio:
- 40
- 30
journals_002:
id: 2
journalized_type: Issue
type: "IssueJournal"
activity_type: "issues"
created_at: <%= 1.days.ago.to_date.to_s(:db) %>
version: 2
user_id: 2
journalized_id: 1
journals_003:
created_on: <%= 1.days.ago.to_date.to_s(:db) %>
notes: "A comment with inline image: !picture.jpg!"
notes: "Some notes with Redmine links: #2, r2."
journaled_id: 1
changes: "--- {}"
journals_003:
id: 3
journalized_type: Issue
type: "IssueJournal"
activity_type: "issues"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
journalized_id: 2
journals_004:
created_on: <%= 1.days.ago.to_date.to_s(:db) %>
notes: "A comment with a private version."
notes: "A comment with inline image: !picture.jpg!"
journaled_id: 2
changes: "--- {}"
journals_004:
id: 4
journalized_type: Issue
type: "IssueJournal"
activity_type: "issues"
created_at: <%= 1.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "A comment with a private version."
journaled_id: 6
changes: |
---
fixed_version_id:
-
- 6
journals_005:
id: 5
type: "IssueJournal"
activity_type: "issues"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 0
user_id: 1
notes:
journaled_id: 5
changes: "--- {}"
journals_006:
id: 6
type: "WikiContentJournal"
activity_type: "wiki_edits"
created_at: 2007-03-07 00:08:07 +01:00
version: 1
user_id: 2
notes: Page creation
journaled_id: 1
changes: |
---
compression: ""
data: |-
h1. CookBook documentation
Some [[documentation]] here...
journals_007:
id: 7
type: "WikiContentJournal"
activity_type: "wiki_edits"
created_at: 2007-03-07 00:08:34 +01:00
version: 2
user_id: 1
journalized_id: 6
notes: Small update
journaled_id: 1
changes: |
---
compression: ""
data: |-
h1. CookBook documentation
Some updated [[documentation]] here...
journals_008:
id: 8
type: "WikiContentJournal"
activity_type: "wiki_edits"
created_at: 2007-03-07 00:10:51 +01:00
version: 3
user_id: 1
notes: ""
journaled_id: 1
changes: |
---
compression: ""
data: |-
h1. CookBook documentation
Some updated [[documentation]] here...
journals_009:
id: 9
type: "WikiContentJournal"
activity_type: "wiki_edits"
created_at: 2007-03-08 00:18:07 +01:00
version: 1
user_id: 1
notes:
journaled_id: 2
changes: |
---
data: |-
h1. Another page
This is a link to a ticket: #2
journals_010:
id: 10
type: "MessageJournal"
activity_type: "messages"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 1
notes:
journaled_id: 5
changes: --- {}
journals_011:
id: 11
type: "AttachmentJournal"
activity_type: "files"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "An attachment on a version"
journaled_id: 9
changes: --- {}
journals_012:
id: 12
type: "AttachmentJournal"
activity_type: "files"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "An attachment on a project"
journaled_id: 8
changes: --- {}
journals_013:
id: 13
type: "AttachmentJournal"
activity_type: "files"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "An attachment on an issue"
journaled_id: 7
changes: --- {}
journals_014:
id: 14
type: "AttachmentJournal"
activity_type: "documents"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "An attachment on a document"
journaled_id: 2
changes: --- {}
journals_015:
id: 15
type: "MessageJournal"
activity_type: "messages"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "A message journal"
journaled_id: 1
journals_016:
id: 16
type: "MessageJournal"
activity_type: "messages"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "A message journal"
journaled_id: 2
journals_017:
id: 17
type: "MessageJournal"
activity_type: "messages"
created_at: <%= 2.days.ago.to_date.to_s(:db) %>
version: 1
user_id: 2
notes: "A message journal"
journaled_id: 3
journals_018:
id: 18
type: "IssueJournal"
activity_type: "issues"
created_at: <%= 3.days.ago.to_date.to_s(:db) %>
version: 0
user_id: 2
notes:
journaled_id: 11
changes: "--- {}"
\ No newline at end of file
---
wiki_content_versions_001:
updated_on: 2007-03-07 00:08:07 +01:00
page_id: 1
id: 1
version: 1
author_id: 2
comments: Page creation
wiki_content_id: 1
compression: ""
data: |-
h1. CookBook documentation
Some [[documentation]] here...
wiki_content_versions_002:
updated_on: 2007-03-07 00:08:34 +01:00
page_id: 1
id: 2
version: 2
author_id: 1
comments: Small update
wiki_content_id: 1
compression: ""
data: |-
h1. CookBook documentation
Some updated [[documentation]] here...
wiki_content_versions_003:
updated_on: 2007-03-07 00:10:51 +01:00
page_id: 1
id: 3
version: 3
author_id: 1
comments: ""
wiki_content_id: 1
compression: ""
data: |-
h1. CookBook documentation
Some updated [[documentation]] here...
wiki_content_versions_004:
data: |-
h1. Another page
This is a link to a ticket: #2
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 2
wiki_content_id: 2
id: 4
version: 1
author_id: 1
comments:
......@@ -9,7 +9,7 @@ wiki_contents_001:
updated_on: 2007-03-07 00:10:51 +01:00
page_id: 1
id: 1
version: 3
lock_version: 3
author_id: 1
comments: Gzip compression activated
wiki_contents_002:
......@@ -22,7 +22,7 @@ wiki_contents_002:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 2
id: 2
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_003:
......@@ -33,7 +33,7 @@ wiki_contents_003:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 3
id: 3
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_004:
......@@ -46,7 +46,7 @@ wiki_contents_004:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 4
id: 4
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_005:
......@@ -57,7 +57,7 @@ wiki_contents_005:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 5
id: 5
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_006:
......@@ -68,7 +68,7 @@ wiki_contents_006:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 6
id: 6
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_007:
......@@ -76,7 +76,7 @@ wiki_contents_007:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 7
id: 7
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_008:
......@@ -84,7 +84,7 @@ wiki_contents_008:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 8
id: 8
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_009:
......@@ -92,7 +92,7 @@ wiki_contents_009:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 9
id: 9
version: 1
lock_version: 1
author_id: 1
comments:
wiki_contents_010:
......@@ -100,7 +100,7 @@ wiki_contents_010:
updated_on: 2007-03-08 00:18:07 +01:00
page_id: 10
id: 10
version: 1
lock_version: 1
author_id: 1
comments:
\ No newline at end of file
......@@ -10,10 +10,10 @@ class ActivitiesControllerTest < ActionController::TestCase
assert_not_nil assigns(:events_by_day)
assert_tag :tag => "h3",
:content => /#{2.days.ago.to_date.day}/,
:content => /#{1.day.ago.to_date.day}/,
:sibling => { :tag => "dl",
:child => { :tag => "dt",
:attributes => { :class => /issue-edit/ },
:attributes => { :class => /issue/ },
:child => { :tag => "a",
:content => /(#{IssueStatus.find(2).name})/,
}
......@@ -46,12 +46,12 @@ class ActivitiesControllerTest < ActionController::TestCase
assert_not_nil assigns(:events_by_day)
assert_tag :tag => "h3",
:content => /#{5.day.ago.to_date.day}/,
:content => /#{3.day.ago.to_date.day}/,
:sibling => { :tag => "dl",
:child => { :tag => "dt",
:attributes => { :class => /issue/ },
:child => { :tag => "a",
:content => /#{Issue.find(5).subject}/,
:content => /#{Issue.find(1).subject}/,
}
}
}
......
......@@ -120,10 +120,9 @@ class AttachmentsControllerTest < ActionController::TestCase
# no referrer
assert_redirected_to '/projects/ecookbook'
assert_nil Attachment.find_by_id(1)
j = issue.journals.find(:first, :order => 'created_on DESC')
assert_equal 'attachment', j.details.first.property
assert_equal '1', j.details.first.prop_key
assert_equal 'error281.txt', j.details.first.old_value
j = issue.journals.find(:first, :order => 'created_at DESC')
assert_equal ['attachments_1'], j.details.keys
assert_equal 'error281.txt', j.details['attachments_1'].first
end
def test_destroy_wiki_page_attachment
......
......@@ -43,7 +43,6 @@ class IssuesControllerTest < ActionController::TestCase
:custom_fields_trackers,
:time_entries,
:journals,
:journal_details,
:queries
def setup
......@@ -663,14 +662,14 @@ class IssuesControllerTest < ActionController::TestCase
context "#update" do
should "ignore status change" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:status_id => 3}
end
assert_equal 1, Issue.find(1).status_id
end
should "ignore attributes changes" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:subject => 'changed', :assigned_to_id => 2}
end
issue = Issue.find(1)
......@@ -690,21 +689,21 @@ class IssuesControllerTest < ActionController::TestCase
context "#update" do
should "accept authorized status" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:status_id => 3}
end
assert_equal 3, Issue.find(1).status_id
end
should "ignore unauthorized status" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:status_id => 2}
end
assert_equal 1, Issue.find(1).status_id
end
should "accept authorized attributes changes" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:assigned_to_id => 2}
end
issue = Issue.find(1)
......@@ -712,7 +711,7 @@ class IssuesControllerTest < ActionController::TestCase
end
should "ignore unauthorized attributes changes" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:subject => 'changed'}
end
issue = Issue.find(1)
......@@ -726,21 +725,21 @@ class IssuesControllerTest < ActionController::TestCase
end
should "accept authorized status" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:status_id => 3}
end
assert_equal 3, Issue.find(1).status_id
end
should "ignore unauthorized status" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:status_id => 2}
end
assert_equal 1, Issue.find(1).status_id
end
should "accept authorized attributes changes" do
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
put :update, :id => 1, :notes => 'just trying', :issue => {:subject => 'changed', :assigned_to_id => 2}
end
issue = Issue.find(1)
......@@ -838,15 +837,17 @@ class IssuesControllerTest < ActionController::TestCase
assert_equal '125', issue.custom_value_for(2).value
old_subject = issue.subject
new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
assert_difference('Journal.count') do
assert_difference('JournalDetail.count', 2) do
put :update, :id => 1, :issue => {:subject => new_subject,
:priority_id => '6',
:category_id => '1' # no change
}
end
assert_difference('IssueJournal.count') do
put :update, :id => 1, :issue => {:subject => new_subject,
:priority_id => '6',
:category_id => '1' # no change
}
end
assert issue.current_journal.changes.has_key? "subject"
assert issue.current_journal.changes.has_key? "priority_id"
assert !issue.current_journal.changes.has_key?("category_id")
assert_redirected_to :action => 'show', :id => '1'
issue.reload
assert_equal new_subject, issue.subject
......@@ -862,17 +863,21 @@ class IssuesControllerTest < ActionController::TestCase
def test_put_update_with_custom_field_change
@request.session[:user_id] = 2
issue = Issue.find(1)
ActionMailer::Base.deliveries.clear
assert_equal '125', issue.custom_value_for(2).value
assert_difference('Journal.count') do
assert_difference('JournalDetail.count', 3) do
put :update, :id => 1, :issue => {:subject => 'Custom field change',
:priority_id => '6',
:category_id => '1', # no change
:custom_field_values => { '2' => 'New custom value' }
}
end
assert_difference('IssueJournal.count') do
put :update, :id => 1, :issue => {:subject => 'Custom field change',
:priority_id => '6',
:category_id => '1', # no change
:custom_field_values => { '2' => 'New custom value' }
}
end
assert issue.current_journal.changes.has_key? "subject"
assert issue.current_journal.changes.has_key? "priority_id"
assert !issue.current_journal.changes.has_key?("category_id")
assert issue.current_journal.changes.has_key? "custom_values2"
assert_redirected_to :action => 'show', :id => '1'
issue.reload
assert_equal 'New custom value', issue.custom_value_for(2).value
......@@ -896,7 +901,7 @@ class IssuesControllerTest < ActionController::TestCase
assert_redirected_to :action => 'show', :id => '1'
issue.reload
assert_equal 2, issue.status_id
j = Journal.find(:first, :order => 'id DESC')
j = IssueJournal.find(:first, :order => 'id DESC')
assert_equal 'Assigned to dlopper', j.notes
assert_equal 2, j.details.size
......@@ -913,7 +918,7 @@ class IssuesControllerTest < ActionController::TestCase
:id => 1,
:notes => notes
assert_redirected_to :action => 'show', :id => '1'
j = Journal.find(:first, :order => 'id DESC')
j = IssueJournal.find(:first, :order => 'id DESC')
assert_equal notes, j.notes
assert_equal 0, j.details.size
assert_equal User.anonymous, j.user
......@@ -935,7 +940,7 @@ class IssuesControllerTest < ActionController::TestCase
issue = Issue.find(1)
j = Journal.find(:first, :order => 'id DESC')
j = IssueJournal.find(:first, :order => 'id DESC')
assert_equal '2.5 hours added', j.notes
assert_equal 0, j.details.size
......@@ -947,10 +952,6 @@ class IssuesControllerTest < ActionController::TestCase
def test_put_update_with_attachment_only
set_tmp_attachments_directory
# Delete all fixtured journals, a race condition can occur causing the wrong
# journal to get fetched in the next find.
Journal.delete_all
# anonymous user
put :update,
......@@ -958,10 +959,10 @@ class IssuesControllerTest < ActionController::TestCase
:notes => '',
:attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
assert_redirected_to :action => 'show', :id => '1'
j = Issue.find(1).journals.find(:first, :order => 'id DESC')
j = Issue.find(1).last_journal
assert j.notes.blank?
assert_equal 1, j.details.size
assert_equal 'testfile.txt', j.details.first.value
assert_equal 'testfile.txt', j.value(j.details.first)
assert_equal User.anonymous, j.user
mail = ActionMailer::Base.deliveries.last
......@@ -973,7 +974,7 @@ class IssuesControllerTest < ActionController::TestCase
# Delete all fixtured journals, a race condition can occur causing the wrong
# journal to get fetched in the next find.
Journal.delete_all
IssueJournal.delete_all
# Mock out the unsaved attachment
Attachment.any_instance.stubs(:create).returns(Attachment.new)
......@@ -990,16 +991,16 @@ class IssuesControllerTest < ActionController::TestCase
def test_put_update_with_no_change
issue = Issue.find(1)
issue.journals.clear
ActionMailer::Base.deliveries.clear
put :update,
:id => 1,
:notes => ''
assert_no_difference('IssueJournal.count') do
put :update,
:id => 1,
:notes => ''
end
assert_redirected_to :action => 'show', :id => '1'
issue.reload
assert issue.journals.empty?
# No email should be sent
assert ActionMailer::Base.deliveries.empty?
end
......@@ -1038,7 +1039,7 @@ class IssuesControllerTest < ActionController::TestCase
@request.session[:user_id] = 2
notes = 'Note added by IssuesControllerTest#test_post_edit_with_invalid_spent_time'
assert_no_difference('Journal.count') do
assert_no_difference('IssueJournal.count') do
put :update,
:id => 1,
:notes => notes,
......@@ -1056,7 +1057,7 @@ class IssuesControllerTest < ActionController::TestCase
@request.session[:user_id] = 2
notes = 'Note added by IssuesControllerTest#test_post_edit_with_invalid_spent_time'
assert_no_difference('Journal.count') do
assert_no_difference('IssueJournal.count') do
put :update,
:id => 1,
:notes => notes,
......@@ -1165,7 +1166,7 @@ class IssuesControllerTest < ActionController::TestCase
assert_equal [7, 7], Issue.find_all_by_id([1, 2]).collect {|i| i.priority.id}
issue = Issue.find(1)
journal = issue.journals.find(:first, :order => 'created_on DESC')
journal = issue.journals.find(:first, :order => 'created_at DESC')
assert_equal '125', issue.custom_value_for(2).value
assert_equal 'Bulk editing', journal.notes
assert_equal 1, journal.details.size
......@@ -1203,7 +1204,7 @@ class IssuesControllerTest < ActionController::TestCase
assert_equal [7, 7, 7], Issue.find([1,2,6]).map(&:priority_id)
issue = Issue.find(1)
journal = issue.journals.find(:first, :order => 'created_on DESC')
journal = issue.journals.find(:first, :order => 'created_at DESC')
assert_equal '125', issue.custom_value_for(2).value
assert_equal 'Bulk editing', journal.notes
assert_equal 1, journal.details.size
......@@ -1220,7 +1221,7 @@ class IssuesControllerTest < ActionController::TestCase
:assigned_to_id => '',
:custom_field_values => {'2' => ''}}
assert_response 403
assert_not_equal "Bulk should fail", Journal.last.notes
assert_not_equal "Bulk should fail", IssueJournal.last.notes
end
def test_bullk_update_should_send_a_notification
......@@ -1278,11 +1279,11 @@ class IssuesControllerTest < ActionController::TestCase
assert_response 302
issue = Issue.find(1)
journal = issue.journals.find(:first, :order => 'created_on DESC')
journal = issue.journals.last
assert_equal '777', issue.custom_value_for(2).value
assert_equal 1, journal.details.size
assert_equal '125', journal.details.first.old_value
assert_equal '777', journal.details.first.value
assert_equal '125', journal.old_value(journal.details.first)
assert_equal '777', journal.value(journal.details.first)
end
def test_bulk_update_unassign
......@@ -1392,4 +1393,12 @@ class IssuesControllerTest < ActionController::TestCase
:child => {:tag => 'form',
:child => {:tag => 'input', :attributes => {:name => 'issues', :type => 'hidden', :value => '1'}}}
end
def test_reply_to_note
@request.session[:user_id] = 2
get :edit, :id => 1, :journal_id => 1
assert_response :success
assert_select_rjs :show, "update"
end
end
......@@ -43,7 +43,6 @@ class IssuesControllerTransactionTest < ActionController::TestCase
:custom_fields_trackers,
:time_entries,
:journals,
:journal_details,
:queries
self.use_transactional_fixtures = false
......
......@@ -22,9 +22,8 @@ require 'journals_controller'
class JournalsController; def rescue_action(e) raise e end; end
class JournalsControllerTest < ActionController::TestCase
fixtures :projects, :users, :members, :member_roles, :roles, :issues, :journals, :journal_details, :enabled_modules,
:trackers, :issue_statuses, :enumerations, :custom_fields, :custom_values, :custom_fields_projects
fixtures :projects, :users, :members, :member_roles, :roles, :issues, :journals, :enabled_modules
def setup
@controller = JournalsController.new
@request = ActionController::TestRequest.new
......@@ -32,46 +31,6 @@ class JournalsControllerTest < ActionController::TestCase
User.current = nil
end
def test_index
get :index, :project_id => 1
assert_response :success
assert_not_nil assigns(:journals)
assert_equal 'application/atom+xml', @response.content_type
end
def test_diff
get :diff, :id => 3, :detail_id => 4
assert_response :success
assert_template 'diff'
assert_tag 'span',
:attributes => {:class => 'diff_out'},
:content => /removed/
assert_tag 'span',
:attributes => {:class => 'diff_in'},
:content => /added/
end
def test_reply_to_issue
@request.session[:user_id] = 2
get :new, :id => 6
assert_response :success
assert_select_rjs :show, "update"
end
def test_reply_to_issue_without_permission
@request.session[:user_id] = 7
get :new, :id => 6
assert_response 403
end
def test_reply_to_note
@request.session[:user_id] = 2
get :new, :id => 6, :journal_id => 4
assert_response :success
assert_select_rjs :show, "update"
end
def test_get_edit
@request.session[:user_id] = 1
xhr :get, :edit, :id => 2
......
......@@ -22,7 +22,7 @@ require 'projects_controller'
class ProjectsController; def rescue_action(e) raise e end; end
class ProjectsControllerTest < ActionController::TestCase
fixtures :projects, :versions, :users, :roles, :members, :member_roles, :issues, :journals, :journal_details,
fixtures :projects, :versions, :users, :roles, :members, :member_roles, :issues, :journals,
:trackers, :projects_trackers, :issue_statuses, :enabled_modules, :enumerations, :boards, :messages,
:attachments, :custom_fields, :custom_values, :time_entries
......
......@@ -38,7 +38,7 @@ class SearchControllerTest < ActionController::TestCase
assert assigns(:results).include?(Changeset.find(101))
assert_tag :dt, :attributes => { :class => /issue/ },
:child => { :tag => 'a', :content => /Add ingredients categories/ },
:sibling => { :tag => 'dd', :content => /should be classified by categories/ }
:sibling => { :tag => 'dd', :content => /A comment with inline image: !picture.jpg!/ }
assert assigns(:results_by_type).is_a?(Hash)
assert_equal 5, assigns(:results_by_type)['changesets']
......
......@@ -22,8 +22,8 @@ require 'wiki_controller'
class WikiController; def rescue_action(e) raise e end; end
class WikiControllerTest < ActionController::TestCase
fixtures :projects, :users, :roles, :members, :member_roles, :enabled_modules, :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions, :attachments
fixtures :projects, :users, :roles, :members, :member_roles, :enabled_modules, :wikis, :wiki_pages, :wiki_contents, :journals, :attachments
def setup
@controller = WikiController.new
@request = ActionController::TestRequest.new
......@@ -83,8 +83,7 @@ class WikiControllerTest < ActionController::TestCase
put :update, :project_id => 1,
:id => 'New page',
:content => {:comments => 'Created the page',
:text => "h1. New page\n\nThis is a new page",
:version => 0}
:text => "h1. New page\n\nThis is a new page" }
assert_redirected_to :action => 'show', :project_id => 'ecookbook', :id => 'New_page'
page = Project.find(1).wiki.find_page('New page')
assert !page.new_record?
......@@ -100,7 +99,7 @@ class WikiControllerTest < ActionController::TestCase
:id => 'New page',
:content => {:comments => 'Created the page',
:text => "h1. New page\n\nThis is a new page",
:version => 0},
:lock_version => 0},
:attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
end
end
......@@ -113,13 +112,13 @@ class WikiControllerTest < ActionController::TestCase
@request.session[:user_id] = 2
assert_no_difference 'WikiPage.count' do
assert_no_difference 'WikiContent.count' do
assert_difference 'WikiContent::Version.count' do
assert_difference 'WikiContentJournal.count' do
put :update, :project_id => 1,
:id => 'Another_page',
:content => {
:comments => "my comments",
:text => "edited",
:version => 1
:lock_version => 1
}
end
end
......@@ -136,13 +135,13 @@ class WikiControllerTest < ActionController::TestCase
@request.session[:user_id] = 2
assert_no_difference 'WikiPage.count' do
assert_no_difference 'WikiContent.count' do
assert_no_difference 'WikiContent::Version.count' do
assert_no_difference 'WikiContentJournal.count' do
put :update, :project_id => 1,
:id => 'Another_page',
:content => {
:comments => 'a' * 300, # failure here, comment is too long
:text => 'edited',
:version => 1
:lock_version => 1
}
end
end
......@@ -152,7 +151,7 @@ class WikiControllerTest < ActionController::TestCase
assert_error_tag :descendant => {:content => /Comment is too long/}
assert_tag :tag => 'textarea', :attributes => {:id => 'content_text'}, :content => 'edited'
assert_tag :tag => 'input', :attributes => {:id => 'content_version', :value => '1'}
assert_tag :tag => 'input', :attributes => {:id => 'content_lock_version', :value => '1'}
end
def test_update_stale_page_should_not_raise_an_error
......@@ -164,13 +163,13 @@ class WikiControllerTest < ActionController::TestCase
assert_no_difference 'WikiPage.count' do
assert_no_difference 'WikiContent.count' do
assert_no_difference 'WikiContent::Version.count' do
assert_no_difference 'WikiContentJournal.count' do
put :update, :project_id => 1,
:id => 'Another_page',
:content => {
:comments => 'My comments',
:text => 'Text should not be lost',
:version => 1
:lock_version => 1
}
end
end
......@@ -196,7 +195,7 @@ class WikiControllerTest < ActionController::TestCase
xhr :post, :preview, :project_id => 1, :id => 'CookBook_documentation',
:content => { :comments => '',
:text => 'this is a *previewed text*',
:version => 3 }
:lock_version => 3 }
assert_response :success
assert_template 'common/_preview'
assert_tag :tag => 'strong', :content => /previewed text/
......@@ -207,7 +206,7 @@ class WikiControllerTest < ActionController::TestCase
xhr :post, :preview, :project_id => 1, :id => 'New page',
:content => { :text => 'h1. New page',
:comments => '',
:version => 0 }
:lock_version => 0 }
assert_response :success
assert_template 'common/_preview'
assert_tag :tag => 'h1', :content => /New page/
......
......@@ -39,7 +39,6 @@ class ApiTest::IssuesTest < ActionController::IntegrationTest
:custom_fields_trackers,
:time_entries,
:journals,
:journal_details,
:queries
def setup
......
......@@ -18,7 +18,7 @@
require File.expand_path('../../../test_helper', __FILE__)
class ApiTest::ProjectsTest < ActionController::IntegrationTest
fixtures :projects, :versions, :users, :roles, :members, :member_roles, :issues, :journals, :journal_details,
fixtures :projects, :versions, :users, :roles, :members, :member_roles, :issues, :journals,
:trackers, :projects_trackers, :issue_statuses, :enabled_modules, :enumerations, :boards, :messages,
:attachments, :custom_fields, :custom_values, :time_entries
......
......@@ -166,21 +166,17 @@ class ActiveSupport::TestCase
end
should "use the new value's name" do
@detail = JournalDetail.generate!(:property => 'attr',
:old_value => @old_value.id,
:value => @new_value.id,
:prop_key => prop_key)
assert_match @new_value.name, show_detail(@detail, true)
@detail = IssueJournal.generate(:version => 1, :journaled => Issue.last)
@detail.update_attribute(:changes, {prop_key => [@old_value.id, @new_value.id]}.to_yaml)
assert_match @new_value.class.find(@new_value.id).name, @detail.render_detail(prop_key, true)
end
should "use the old value's name" do
@detail = JournalDetail.generate!(:property => 'attr',
:old_value => @old_value.id,
:value => @new_value.id,
:prop_key => prop_key)
assert_match @old_value.name, show_detail(@detail, true)
@detail = IssueJournal.generate(:version => 1, :journaled => Issue.last)
@detail.update_attribute(:changes, {prop_key => [@old_value.id, @new_value.id]}.to_yaml)
assert_match @old_value.class.find(@old_value.id).name, @detail.render_detail(prop_key, true)
end
end
end
......
......@@ -18,11 +18,16 @@
require File.expand_path('../../test_helper', __FILE__)
class ActivityTest < ActiveSupport::TestCase
fixtures :projects, :versions, :attachments, :users, :roles, :members, :member_roles, :issues, :journals, :journal_details,
fixtures :projects, :versions, :attachments, :users, :roles, :members, :member_roles, :issues, :journals,
:trackers, :projects_trackers, :issue_statuses, :enabled_modules, :enumerations, :boards, :messages
def setup
@project = Project.find(1)
[1,4,5,6].each do |issue_id|
i = Issue.find(issue_id)
i.init_journal(User.current, "A journal to find")
i.save!
end
end
def test_activity_without_subprojects
......@@ -51,7 +56,7 @@ class ActivityTest < ActiveSupport::TestCase
assert events.include?(Issue.find(1))
assert events.include?(Message.find(5))
# Issue of a private project
assert !events.include?(Issue.find(4))
assert !events.include?(Issue.find(6))
end
def test_global_activity_logged_user
......@@ -60,7 +65,7 @@ class ActivityTest < ActiveSupport::TestCase
assert events.include?(Issue.find(1))
# Issue of a private project the user belongs to
assert events.include?(Issue.find(4))
assert events.include?(Issue.find(6))
end
def test_user_activity
......@@ -78,15 +83,18 @@ class ActivityTest < ActiveSupport::TestCase
events = f.events
assert_kind_of Array, events
assert events.include?(Attachment.find_by_container_type_and_container_id('Project', 1))
assert events.include?(Attachment.find_by_container_type_and_container_id('Version', 1))
assert_equal [Attachment], events.collect(&:class).uniq
assert_equal %w(Project Version), events.collect(&:container_type).uniq.sort
assert events.include?(Attachment.find_by_container_type_and_container_id('Project', 1).last_journal)
assert events.include?(Attachment.find_by_container_type_and_container_id('Version', 1).last_journal)
assert_equal [Attachment], events.collect(&:journaled).collect(&:class).uniq
assert_equal %w(Project Version), events.collect(&:journaled).collect(&:container_type).uniq.sort
end
private
def find_events(user, options={})
Redmine::Activity::Fetcher.new(user, options).events(Date.today - 30, Date.today + 1)
events = Redmine::Activity::Fetcher.new(user, options).events(Date.today - 30, Date.today + 1)
# Because events are provided by the journals, but we want to test for
# their targets here, transform that
events.group_by(&:journaled).keys
end
end
......@@ -43,29 +43,34 @@ class IssuesHelperTest < HelperTestCase
def request
@request ||= ActionController::TestRequest.new
end
# This is probably needed in this test only anymore
def show_detail(journal, detail, html = true)
journal.render_detail(detail, html)
end
context "IssuesHelper#show_detail" do
context "with no_html" do
should 'show a changing attribute' do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '40', :value => '100', :prop_key => 'done_ratio')
assert_equal "% Done changed from 40 to 100", show_detail(@detail, true)
@journal = IssueJournal.generate!(:changes => {"done_ratio" => [40, 100]}, :journaled => Issue.last)
assert_equal "% Done changed from 40 to 100", show_detail(@journal, @journal.details.to_a.first, true)
end
should 'show a new attribute' do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => nil, :value => '100', :prop_key => 'done_ratio')
assert_equal "% Done set to 100", show_detail(@detail, true)
@journal = IssueJournal.generate!(:changes => {"done_ratio" => [nil, 100]}, :journaled => Issue.last)
assert_equal "% Done set to 100", show_detail(@journal, @journal.details.to_a.first, true)
end
should 'show a deleted attribute' do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '50', :value => nil, :prop_key => 'done_ratio')
assert_equal "% Done deleted (50)", show_detail(@detail, true)
@journal = IssueJournal.generate!(:changes => {"done_ratio" => [50, nil]}, :journaled => Issue.last)
assert_equal "% Done deleted (50)", show_detail(@journal, @journal.details.to_a.first, true)
end
end
context "with html" do
should 'show a changing attribute with HTML highlights' do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '40', :value => '100', :prop_key => 'done_ratio')
@response.body = show_detail(@detail, false)
@journal = IssueJournal.generate!(:changes => {"done_ratio" => [40, 100]}, :journaled => Issue.last)
@response.body = show_detail(@journal, @journal.details.to_a.first, false)
assert_select 'strong', :text => '% Done'
assert_select 'i', :text => '40'
......@@ -73,16 +78,16 @@ class IssuesHelperTest < HelperTestCase
end
should 'show a new attribute with HTML highlights' do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => nil, :value => '100', :prop_key => 'done_ratio')
@response.body = show_detail(@detail, false)
@journal = IssueJournal.generate!(:changes => {"done_ratio" => [nil, 100]}, :journaled => Issue.last)
@response.body = show_detail(@journal, @journal.details.to_a.first, false)
assert_select 'strong', :text => '% Done'
assert_select 'i', :text => '100'
end
should 'show a deleted attribute with HTML highlights' do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '50', :value => nil, :prop_key => 'done_ratio')
@response.body = show_detail(@detail, false)
@journal = IssueJournal.generate!(:changes => {"done_ratio" => [50, nil]}, :journaled => Issue.last)
@response.body = show_detail(@journal, @journal.details.to_a.first, false)
assert_select 'strong', :text => '% Done'
assert_select 'strike' do
......@@ -93,25 +98,25 @@ class IssuesHelperTest < HelperTestCase
context "with a start_date attribute" do
should "format the current date" do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '2010-01-01', :value => '2010-01-31', :prop_key => 'start_date')
assert_match "01/31/2010", show_detail(@detail, true)
@journal = IssueJournal.generate!(:changes => {"start_date" => ['2010-01-01', '2010-01-31']}, :journaled => Issue.last)
assert_match "01/31/2010", show_detail(@journal, @journal.details.to_a.first, true)
end
should "format the old date" do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '2010-01-01', :value => '2010-01-31', :prop_key => 'start_date')
assert_match "01/01/2010", show_detail(@detail, true)
@journal = IssueJournal.generate!(:changes => {"start_date" => ['2010-01-01', '2010-01-31']}, :journaled => Issue.last)
assert_match "01/01/2010", show_detail(@journal, @journal.details.to_a.first, true)
end
end
context "with a due_date attribute" do
should "format the current date" do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '2010-01-01', :value => '2010-01-31', :prop_key => 'due_date')
assert_match "01/31/2010", show_detail(@detail, true)
@journal = IssueJournal.generate!(:changes => {"due_date" => ['2010-01-01', '2010-01-31']}, :journaled => Issue.last)
assert_match "01/31/2010", show_detail(@journal, @journal.details.to_a.first, true)
end
should "format the old date" do
@detail = JournalDetail.generate!(:property => 'attr', :old_value => '2010-01-01', :value => '2010-01-31', :prop_key => 'due_date')
assert_match "01/01/2010", show_detail(@detail, true)
@journal = IssueJournal.generate!(:changes => {"due_date" => ['2010-01-01', '2010-01-31']}, :journaled => Issue.last)
assert_match "01/01/2010", show_detail(@journal, @journal.details.to_a.first, true)
end
end
......
......@@ -208,10 +208,8 @@ class IssueNestedSetTest < ActiveSupport::TestCase
issue3.save!
assert_difference 'Issue.count', -2 do
assert_difference 'Journal.count', -1 do
assert_difference 'JournalDetail.count', -1 do
Issue.find(issue2.id).destroy
end
assert_difference 'IssueJournal.count', -3 do
Issue.find(issue2.id).destroy
end
end
......@@ -234,18 +232,17 @@ class IssueNestedSetTest < ActiveSupport::TestCase
end
def test_destroy_child_issue_with_children
root = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'root')
child = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => root.id)
leaf = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'leaf', :parent_issue_id => child.id)
root = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'root').reload
child = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => root.id).reload
leaf = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'leaf', :parent_issue_id => child.id).reload
leaf.init_journal(User.find(2))
leaf.subject = 'leaf with journal'
leaf.save!
total_journals_on_children = leaf.reload.journals.count + child.reload.journals.count
assert_difference 'Issue.count', -2 do
assert_difference 'Journal.count', -1 do
assert_difference 'JournalDetail.count', -1 do
Issue.find(child.id).destroy
end
assert_difference 'IssueJournal.count', -total_journals_on_children do
Issue.find(child.id).destroy
end
end
......
......@@ -630,41 +630,40 @@ class IssueTest < ActiveSupport::TestCase
i.init_journal(User.find(2))
i.description = new_description
assert_difference 'Journal.count', 1 do
assert_difference 'JournalDetail.count', 1 do
i.save!
end
assert_difference 'IssueJournal.count', 1 do
i.save!
end
detail = JournalDetail.first(:order => 'id DESC')
assert_equal i, detail.journal.journalized
assert_equal 'attr', detail.property
assert_equal 'description', detail.prop_key
assert_equal old_description, detail.old_value
assert_equal new_description, detail.value
journal = IssueJournal.first(:order => 'id DESC')
assert_equal i, journal.journaled
assert journal.changes.has_key? "description"
assert_equal old_description, journal.old_value("description")
assert_equal new_description, journal.value("description")
end
# TODO: This test has become somewhat obsolete with the new journalized scheme
def test_saving_twice_should_not_duplicate_journal_details
i = Issue.find(:first)
i.init_journal(User.find(2), 'Some notes')
# initial changes
i.subject = 'New subject'
i.done_ratio = i.done_ratio + 10
assert_difference 'Journal.count' do
assert_difference 'IssueJournal.count' do
assert i.save
end
assert i.current_journal.changes.has_key? "subject"
assert i.current_journal.changes.has_key? "done_ratio"
# 1 more change
i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
assert_no_difference 'Journal.count' do
assert_difference 'JournalDetail.count', 1 do
i.save
end
assert_difference 'IssueJournal.count' do
i.save
end
assert i.current_journal.changes.has_key? "priority_id"
# no more change
assert_no_difference 'Journal.count' do
assert_no_difference 'JournalDetail.count' do
i.save
end
assert_no_difference 'IssueJournal.count' do
i.save
end
end
......
......@@ -18,11 +18,15 @@
require File.expand_path('../../test_helper', __FILE__)
class JournalObserverTest < ActiveSupport::TestCase
fixtures :issues, :issue_statuses, :journals, :journal_details
fixtures :issues, :issue_statuses, :journals
def setup
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
end
# context: issue_updated notified_events
......@@ -30,9 +34,9 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = ['issue_updated']
issue = Issue.find(:first)
user = User.find(:first)
journal = issue.init_journal(user, issue)
issue.init_journal(user)
assert journal.save
assert issue.send(:create_journal)
assert_equal 1, ActionMailer::Base.deliveries.size
end
......@@ -40,9 +44,9 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = []
issue = Issue.find(:first)
user = User.find(:first)
journal = issue.init_journal(user, issue)
issue.init_journal(user)
assert journal.save
assert issue.save
assert_equal 0, ActionMailer::Base.deliveries.size
end
......@@ -51,10 +55,9 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = ['issue_note_added']
issue = Issue.find(:first)
user = User.find(:first)
journal = issue.init_journal(user, issue)
journal.notes = 'This update has a note'
issue.init_journal(user, 'This update has a note')
assert journal.save
assert issue.save
assert_equal 1, ActionMailer::Base.deliveries.size
end
......@@ -62,10 +65,9 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = []
issue = Issue.find(:first)
user = User.find(:first)
journal = issue.init_journal(user, issue)
journal.notes = 'This update has a note'
issue.init_journal(user, 'This update has a note')
assert journal.save
assert issue.save
assert_equal 0, ActionMailer::Base.deliveries.size
end
......@@ -74,7 +76,7 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = ['issue_status_updated']
issue = Issue.find(:first)
user = User.find(:first)
issue.init_journal(user, issue)
issue.init_journal(user)
issue.status = IssueStatus.last
assert issue.save
......@@ -85,7 +87,7 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = []
issue = Issue.find(:first)
user = User.find(:first)
issue.init_journal(user, issue)
issue.init_journal(user)
issue.status = IssueStatus.last
assert issue.save
......@@ -97,7 +99,7 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = ['issue_priority_updated']
issue = Issue.find(:first)
user = User.find(:first)
issue.init_journal(user, issue)
issue.init_journal(user)
issue.priority = IssuePriority.last
assert issue.save
......@@ -108,7 +110,7 @@ class JournalObserverTest < ActiveSupport::TestCase
Setting.notified_events = []
issue = Issue.find(:first)
user = User.find(:first)
issue.init_journal(user, issue)
issue.init_journal(user)
issue.priority = IssuePriority.last
assert issue.save
......
......@@ -18,14 +18,14 @@
require File.expand_path('../../test_helper', __FILE__)
class JournalTest < ActiveSupport::TestCase
fixtures :projects, :issues, :issue_statuses, :journals, :journal_details, :users, :members, :member_roles
fixtures :issues, :issue_statuses, :journals
def setup
@journal = Journal.find 1
@journal = IssueJournal.find(1)
end
def test_journalized_is_an_issue
issue = @journal.issue
issue = @journal.journalized
assert_kind_of Issue, issue
assert_equal 1, issue.id
end
......@@ -40,14 +40,18 @@ class JournalTest < ActiveSupport::TestCase
def test_create_should_send_email_notification
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.save
end
user = User.find(:first)
journal = issue.init_journal(user, issue)
assert journal.save
assert_equal 0, ActionMailer::Base.deliveries.size
issue.update_attribute(:subject, "New subject to trigger automatic journal entry")
assert_equal 1, ActionMailer::Base.deliveries.size
end
def test_visible_scope_for_anonymous
should_eventually "test_visible_scope_for_anonymous" do
# Anonymous user should see issues of public projects only
journals = Journal.visible(User.anonymous).all
assert journals.any?
......@@ -57,8 +61,8 @@ class JournalTest < ActiveSupport::TestCase
journals = Journal.visible(User.anonymous).all
assert journals.empty?
end
def test_visible_scope_for_user
should_eventually "test_visible_scope_for_user" do
user = User.find(9)
assert user.projects.empty?
# Non member user should see issues of public projects only
......@@ -78,7 +82,7 @@ class JournalTest < ActiveSupport::TestCase
assert_nil journals.detect {|journal| journal.issue.project_id != 1}
end
def test_visible_scope_for_admin
should_eventually "test_visible_scope_for_admin" do
user = User.find(1)
user.members.each(&:destroy)
assert user.projects.empty?
......@@ -92,10 +96,12 @@ class JournalTest < ActiveSupport::TestCase
ActionMailer::Base.deliveries.clear
issue = Issue.find(:first)
user = User.find(:first)
journal = issue.init_journal(user, issue)
journal = issue.init_journal(user, "A note")
JournalObserver.instance.send_notification = false
assert journal.save
assert_difference("Journal.count") do
assert issue.save
end
assert_equal 0, ActionMailer::Base.deliveries.size
end
end
......@@ -300,7 +300,7 @@ class MailHandlerTest < ActiveSupport::TestCase
journal = submit_email('ticket_reply.eml')
assert journal.is_a?(Journal)
assert_equal User.find_by_login('jsmith'), journal.user
assert_equal Issue.find(2), journal.journalized
assert_equal Issue.find(2), journal.journaled
assert_match /This is reply/, journal.notes
assert_equal 'Feature request', journal.issue.tracker.name
end
......@@ -309,9 +309,9 @@ class MailHandlerTest < ActiveSupport::TestCase
# This email contains: 'Status: Resolved'
journal = submit_email('ticket_reply_with_status.eml')
assert journal.is_a?(Journal)
issue = Issue.find(journal.issue.id)
issue = Issue.find(journal.journaled.id)
assert_equal User.find_by_login('jsmith'), journal.user
assert_equal Issue.find(2), journal.journalized
assert_equal Issue.find(2), journal.journaled
assert_match /This is reply/, journal.notes
assert_equal 'Feature request', journal.issue.tracker.name
assert_equal IssueStatus.find_by_name("Resolved"), issue.status
......
......@@ -172,7 +172,7 @@ class MailerTest < ActiveSupport::TestCase
mail = ActionMailer::Base.deliveries.last
assert_not_nil mail
assert_equal Mailer.message_id_for(journal), mail.message_id
assert_equal Mailer.message_id_for(journal.issue), mail.references.first.to_s
assert_equal Mailer.message_id_for(journal.journaled), mail.references.first.to_s
end
def test_message_posted_message_id
......
......@@ -192,8 +192,7 @@ class ProjectTest < ActiveSupport::TestCase
assert_equal 0, Member.count, "Members were not deleted: #{Member.all.inspect}"
assert_equal 0, MemberRole.count
assert_equal 0, Issue.count
assert_equal 0, Journal.count
assert_equal 0, JournalDetail.count
assert_equal 0, IssueJournal.count
assert_equal 0, Attachment.count
assert_equal 0, EnabledModule.count
assert_equal 0, IssueCategory.count
......@@ -212,7 +211,7 @@ class ProjectTest < ActiveSupport::TestCase
assert_equal 0, Wiki.count
assert_equal 0, WikiPage.count
assert_equal 0, WikiContent.count
assert_equal 0, WikiContent::Version.count
assert_equal 0, WikiContentJournal.count
assert_equal 0, Project.connection.select_all("SELECT * FROM projects_trackers").size
assert_equal 0, Project.connection.select_all("SELECT * FROM custom_fields_projects").size
assert_equal 0, CustomValue.count(:conditions => {:customized_type => ['Project', 'Issue', 'TimeEntry', 'Version']})
......
......@@ -249,7 +249,7 @@ class RepositoryGitTest < ActiveSupport::TestCase
end
def test_activities
c = Changeset.new(:repository => @repository,
c = Changeset.create(:repository => @repository,
:committed_on => Time.now,
:revision => 'abc7234cb2750b63f47bff735edc50a1c0a433c2',
:scmid => 'abc7234cb2750b63f47bff735edc50a1c0a433c2',
......
......@@ -228,7 +228,7 @@ class RepositoryMercurialTest < ActiveSupport::TestCase
end
def test_activities
c = Changeset.new(:repository => @repository,
c = Changeset.create(:repository => @repository,
:committed_on => Time.now,
:revision => '123',
:scmid => 'abc400bb8672',
......
......@@ -128,14 +128,14 @@ class RepositorySubversionTest < ActiveSupport::TestCase
end
def test_activities
c = Changeset.new(:repository => @repository, :committed_on => Time.now,
c = Changeset.create(:repository => @repository, :committed_on => Time.now,
:revision => '1', :comments => 'test')
assert c.event_title.include?('1:')
assert_equal '1', c.event_url[:rev]
end
def test_activities_nine_digit
c = Changeset.new(:repository => @repository, :committed_on => Time.now,
c = Changeset.create(:repository => @repository, :committed_on => Time.now,
:revision => '123456789', :comments => 'test')
assert c.event_title.include?('123456789:')
assert_equal '123456789', c.event_url[:rev]
......
......@@ -97,7 +97,7 @@ class RepositoryTest < ActiveSupport::TestCase
assert_equal [101], fixed_issue.changeset_ids
# issue change
journal = fixed_issue.journals.find(:first, :order => 'created_on desc')
journal = fixed_issue.journals.last
assert_equal User.find_by_login('dlopper'), journal.user
assert_equal 'Applied in changeset r2.', journal.notes
......
......@@ -27,7 +27,6 @@ class SearchTest < ActiveSupport::TestCase
:issues,
:trackers,
:journals,
:journal_details,
:repositories,
:changesets
......
......@@ -18,7 +18,7 @@
require File.expand_path('../../test_helper', __FILE__)
class WikiContentTest < ActiveSupport::TestCase
fixtures :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions, :users
fixtures :wikis, :wiki_pages, :wiki_contents, :journals, :users
def setup
@wiki = Wiki.find(1)
......@@ -72,9 +72,9 @@ class WikiContentTest < ActiveSupport::TestCase
end
def test_fetch_history
assert !@page.content.versions.empty?
@page.content.versions.each do |version|
assert_kind_of String, version.text
assert !@page.content.journals.empty?
@page.content.journals.each do |journal|
assert_kind_of String, journal.text
end
end
......
......@@ -18,7 +18,7 @@
require File.expand_path('../../test_helper', __FILE__)
class WikiPageTest < ActiveSupport::TestCase
fixtures :projects, :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions
fixtures :projects, :wikis, :wiki_pages, :wiki_contents, :journals
def setup
@wiki = Wiki.find(1)
......@@ -101,11 +101,14 @@ class WikiPageTest < ActiveSupport::TestCase
def test_destroy
page = WikiPage.find(1)
content_ids = WikiContent.find_all_by_page_id(1).collect(&:id)
page.destroy
assert_nil WikiPage.find_by_id(1)
# make sure that page content and its history are deleted
assert WikiContent.find_all_by_page_id(1).empty?
assert WikiContent.versioned_class.find_all_by_page_id(1).empty?
content_ids.each do |wiki_content_id|
assert WikiContent.journal_class.find_all_by_journaled_id(wiki_content_id).empty?
end
end
def test_destroy_should_not_nullify_children
......
......@@ -20,8 +20,8 @@
require File.expand_path('../../test_helper', __FILE__)
class WikiTest < ActiveSupport::TestCase
fixtures :projects, :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions
fixtures :projects, :wikis, :wiki_pages, :wiki_contents, :journals
def test_create
wiki = Wiki.new(:project => Project.find(2))
assert !wiki.save
......
......@@ -68,12 +68,12 @@ module Redmine
scope_options[:conditions] = cond.conditions
if options[:limit]
# id and creation time should be in same order in most cases
scope_options[:order] = "#{table_name}.id DESC"
scope_options[:order] = "#{journal_class.table_name}.id DESC"
scope_options[:limit] = options[:limit]
end
with_scope(:find => scope_options) do
find(:all, provider_options[:find_options].dup)
journal_class.with_scope(:find => scope_options) do
journal_class.find(:all, provider_options[:find_options].dup)
end
end
end
......
## MAC OS
.DS_Store
## TEXTMATE
*.tmproj
tmtags
## EMACS
*~
\#*
.\#*
## VIM
*.swp
## PROJECT::GENERAL
coverage
rdoc
pkg
## PROJECT::SPECIFIC
*.db
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
"Acts_as_journalized" is a Redmine core plugin derived from the vestal_versions
Ruby on Rails plugin. The parts are under different copyright and license conditions
noted below.
The overall license terms applying to "Acts_as_journalized" as in
this distribution are 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.
For the individual files, the following copyrights and licenses apply:
app/controllers/**
app/views/**
app/helpers/**
app/models/journal_observer.rb
Copyright (C) 2006-2008 Jean-Philippe Lang
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.
lib/acts_as_journalized.rb
lib/journal_formatter.rb
lib/redmine/acts/journalized/permissions.rb
lib/redmine/acts/journalized/save_hooks.rb
lib/redmine/acts/journalized/format_hooks.rb
lib/redmine/acts/journalized/deprecated.rb
Copyright (c) 2010 Finn GmbH
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.
All remaining files are:
Copyright (c) 2009 Steve Richert
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
acts as journalized
A redmine core plugin for unification of journals, events and activities in redmine
67a8c4bee0a06420f1ba64eb9906a15d63bf5ac5 https://github.com/edavis10/acts_as_journalized
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2006-2008 Jean-Philippe Lang
#
# 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 journal 2
# of the License, or (at your option) any later journal.
#
# 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.
class JournalsController < ApplicationController
unloadable
before_filter :find_journal
def edit
if request.post?
@journal.update_attribute(:notes, params[:notes]) if params[:notes]
@journal.destroy if @journal.details.empty? && @journal.notes.blank?
call_hook(:controller_journals_edit_post, { :journal => @journal, :params => params})
respond_to do |format|
format.html { redirect_to :controller => @journal.journaled.class.name.pluralize.downcase,
:action => 'show', :id => @journal.journaled_id }
format.js { render :action => 'update' }
end
end
end
private
def find_journal
@journal = Journal.find(params[:id])
(render_403; return false) unless @journal.editable_by?(User.current)
@project = @journal.project
rescue ActiveRecord::RecordNotFound
render_404
end
end
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2006-2008 Jean-Philippe Lang
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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 journal 2
# of the License, or (at your option) any later journal.
#
# 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.
module JournalsHelper
unloadable
include ApplicationHelper
include ActionView::Helpers::TagHelper
def self.included(base)
base.class_eval do
if respond_to? :before_filter
before_filter :find_optional_journal, :only => [:edit]
end
end
end
def render_journal(model, journal, options = {})
return "" if journal.initial?
journal_content = render_journal_details(journal, :label_updated_time_by)
journal_content += render_notes(model, journal, options) unless journal.notes.blank?
content_tag "div", journal_content, { :id => "change-#{journal.id}", :class => journal.css_classes }
end
# This renders a journal entry wiht a header and details
def render_journal_details(journal, header_label = :label_updated_time_by)
header = <<-HTML
<h4>
<div style="float:right;">#{link_to "##{journal.anchor}", :anchor => "note-#{journal.anchor}"}</div>
#{avatar(journal.user, :size => "24")}
#{content_tag('a', '', :name => "note-#{journal.anchor}")}
#{authoring journal.created_at, journal.user, :label => header_label}
</h4>
HTML
if journal.details.any?
details = content_tag "ul", :class => "details" do
journal.details.collect do |detail|
if d = journal.render_detail(detail)
content_tag("li", d)
end
end.compact
end
end
content_tag("div", "#{header}#{details}", :id => "change-#{journal.id}", :class => "journal")
end
def render_notes(model, journal, options={})
controller = model.class.name.downcase.pluralize
action = 'edit'
reply_links = authorize_for(controller, action)
if User.current.logged?
editable = User.current.allowed_to?(options[:edit_permission], journal.project) if options[:edit_permission]
if journal.user == User.current && options[:edit_own_permission]
editable ||= User.current.allowed_to?(options[:edit_own_permission], journal.project)
end
end
unless journal.notes.blank?
links = returning [] do |l|
if reply_links
l << link_to_remote(image_tag('comment.png'), :title => l(:button_quote),
:url => {:controller => controller, :action => action, :id => model, :journal_id => journal})
end
if editable
l << link_to_in_place_notes_editor(image_tag('edit.png'), "journal-#{journal.id}-notes",
{ :controller => 'journals', :action => 'edit', :id => journal },
:title => l(:button_edit))
end
end
end
content = ''
content << content_tag('div', links.join(' '), :class => 'contextual') unless links.empty?
content << textilizable(journal, :notes)
css_classes = "wiki"
css_classes << " editable" if editable
content_tag('div', content, :id => "journal-#{journal.id}-notes", :class => css_classes)
end
def link_to_in_place_notes_editor(text, field_id, url, options={})
onclick = "new Ajax.Request('#{url_for(url)}', {asynchronous:true, evalScripts:true, method:'get'}); return false;"
link_to text, '#', options.merge(:onclick => onclick)
end
# This may conveniently be used by controllers to find journals referred to in the current request
def find_optional_journal
@journal = Journal.find_by_id(params[:journal_id])
end
def render_reply(journal)
user = journal.user
text = journal.notes
# Replaces pre blocks with [...]
text = text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]')
content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
render(:update) do |page|
page << "$('notes').value = \"#{escape_javascript content}\";"
page.show 'update'
page << "Form.Element.focus('notes');"
page << "Element.scrollTo('update');"
page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
end
end
end
\ No newline at end of file
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (c) 2009 Steve Richert
# Copyright (c) 2010 Finn GmbH, http://finn.de
#
# 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 journal 2
# of the License, or (at your option) any later journal.
#
# 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.
require_dependency 'journal_formatter'
# The ActiveRecord model representing journals.
class Journal < ActiveRecord::Base
unloadable
include Comparable
include JournalFormatter
include JournalDeprecated
# Make sure each journaled model instance only has unique version ids
validates_uniqueness_of :version, :scope => [:journaled_id, :type]
belongs_to :journaled
belongs_to :user
# ActiveRecord::Base#changes is an existing method, so before serializing the +changes+ column,
# the existing +changes+ method is undefined. The overridden +changes+ method pertained to
# dirty attributes, but will not affect the partial updates functionality as that's based on
# an underlying +changed_attributes+ method, not +changes+ itself.
# undef_method :changes
serialize :changes, Hash
# In conjunction with the included Comparable module, allows comparison of journal records
# based on their corresponding version numbers, creation timestamps and IDs.
def <=>(other)
[version, created_at, id].map(&:to_i) <=> [other.version, other.created_at, other.id].map(&:to_i)
end
# Returns whether the version has a version number of 1. Useful when deciding whether to ignore
# the version during reversion, as initial versions have no serialized changes attached. Helps
# maintain backwards compatibility.
def initial?
version < 2
end
# The anchor number for html output
def anchor
version - 1
end
# Possible shortcut to the associated project
def project
if journaled.respond_to?(:project)
journaled.project
elsif journaled.is_a? Project
journaled
else
nil
end
end
def editable_by?(user)
journaled.journal_editable_by?(user)
end
def details
attributes["changes"] || {}
end
alias_method :changes, :details
def new_value_for(prop)
details[prop.to_s].last if details.keys.include? prop.to_s
end
def old_value_for(prop)
details[prop.to_s].first if details.keys.include? prop.to_s
end
# Returns a string of css classes
def css_classes
s = 'journal'
s << ' has-notes' unless notes.blank?
s << ' has-details' unless details.empty?
s
end
# This is here to allow people to disregard the difference between working with a
# Journal and the object it is attached to.
# The lookup is as follows:
## => Call super if the method corresponds to one of our attributes (will end up in AR::Base)
## => Try the journaled object with the same method and arguments
## => On error, call super
def method_missing(method, *args, &block)
return super if attributes[method.to_s]
journaled.send(method, *args, &block)
rescue NoMethodError => e
e.name == method ? super : raise(e)
end
end
......@@ -17,18 +17,24 @@
class JournalObserver < ActiveRecord::Observer
attr_accessor :send_notification
def after_create(journal)
if journal.type == "IssueJournal" and journal.version > 1 and self.send_notification
after_create_issue_journal(journal)
end
clear_notification
end
def after_create_issue_journal(journal)
if Setting.notified_events.include?('issue_updated') ||
(Setting.notified_events.include?('issue_note_added') && journal.notes.present?) ||
(Setting.notified_events.include?('issue_status_updated') && journal.new_status.present?) ||
(Setting.notified_events.include?('issue_priority_updated') && journal.new_value_for('priority_id').present?)
Mailer.deliver_issue_edit(journal) if self.send_notification
Mailer.deliver_issue_edit(journal)
end
clear_notification
end
# Wrap send_notification so it defaults to true, when it's nil
# Wrap send_notification so it defaults to true, when it's nil
def send_notification
return true if @send_notification.nil?
return @send_notification
......@@ -40,4 +46,5 @@ class JournalObserver < ActiveRecord::Observer
def clear_notification
@send_notification = true
end
end
<% form_remote_tag(:url => {}, :html => { :id => "journal-#{@journal.id}-form" }) do %>
<%= text_area_tag :notes, @journal.notes,
:id => "journal_#{@journal.id}_notes",
:class => 'wiki-edit',
:rows => (@journal.notes.blank? ? 10 : [[10, @journal.notes.length / 50].max, 100].min) %>
<%= text_area_tag :notes, @journal.notes, :class => 'wiki-edit',
:rows => (@journal.notes.blank? ? 10 : [[10, @journal.notes.length / 50].max, 100].min) %>
<%= call_hook(:view_journals_notes_form_after_notes, { :journal => @journal}) %>
<p><%= submit_tag l(:button_save) %>
<%= link_to_remote l(:label_preview),
{ :url => preview_issue_path(:project_id => @project, :id => @journal.issue),
:method => 'post',
:update => "journal_#{@journal.id}_preview",
:with => "Form.serialize('journal-#{@journal.id}-form')",
:complete => "Element.scrollTo('journal_#{@journal.id}_preview')"
}, :accesskey => accesskey(:preview) %>
|
<%= link_to l(:button_cancel), '#', :onclick => "Element.remove('journal-#{@journal.id}-form'); " +
"Element.show('journal-#{@journal.id}-notes'); return false;" %></p>
<div id="journal_<%= @journal.id %>_preview" class="wiki"></div>
<% end %>
<%= wikitoolbar_for "journal_#{@journal.id}_notes" %>
......@@ -2,7 +2,8 @@ if @journal.frozen?
# journal was destroyed
page.remove "change-#{@journal.id}"
else
page.replace "journal-#{@journal.id}-notes", render_notes(@journal.issue, @journal, :reply_links => authorize_for('issues', 'edit'))
page.replace "journal-#{@journal.id}-notes", render_notes(@journal.journaled, @journal,
:edit_permission => :edit_issue_notes, :edit_own_permission => :edit_own_issue_notes)
page.show "journal-#{@journal.id}-notes"
page.remove "journal-#{@journal.id}-form"
end
......
$LOAD_PATH.unshift File.expand_path("../lib/", __FILE__)
require "acts_as_journalized"
ActiveRecord::Base.send(:include, Redmine::Acts::Journalized)
require 'dispatcher'
Dispatcher.to_prepare do
# Model
require_dependency "journal"
# this is for compatibility with current trunk
# once the plugin is part of the core, this will not be needed
# patches should then be ported onto the core
# require_dependency File.dirname(__FILE__) + '/lib/acts_as_journalized/journal_patch'
# require_dependency File.dirname(__FILE__) + '/lib/acts_as_journalized/journal_observer_patch'
# require_dependency File.dirname(__FILE__) + '/lib/acts_as_journalized/activity_fetcher_patch'
end
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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 journal 2
# of the License, or (at your option) any later journal.
#
# 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.
Dir[File.expand_path("../redmine/acts/journalized/*.rb", __FILE__)].each{|f| require f }
require_dependency 'lib/ar_condition'
module Redmine
module Acts
module Journalized
def self.included(base)
base.extend ClassMethods
base.extend Versioned
end
module ClassMethods
def plural_name
self.name.underscore.pluralize
end
# A model might provide as many activity_types as it wishes.
# Activities are just different search options for the event a model provides
def acts_as_activity(options = {})
activity_hash = journalized_activity_hash(options)
type = activity_hash[:type]
acts_as_activity_provider activity_hash
unless Redmine::Activity.providers[type].include? self.name
Redmine::Activity.register type.to_sym, :class_name => self.name
end
end
# This call will add an activity and, if neccessary, start the journaling and
# add an event callback on the model.
# Versioning and acting as an Event may only be applied once.
# To apply more than on activity, use acts_as_activity
def acts_as_journalized(options = {}, &block)
activity_hash, event_hash, journal_hash = split_option_hashes(options)
acts_as_activity(activity_hash)
return if journaled?
include Options
include Changes
include Creation
include Users
include Reversion
include Reset
include Reload
include Permissions
include SaveHooks
include FormatHooks
# FIXME: When the transition to the new API is complete, remove me
include Deprecated
journal_class.acts_as_event journalized_event_hash(event_hash)
(journal_hash[:except] ||= []) << self.primary_key << inheritance_column <<
:updated_on << :updated_at << :lock_version << :lft << :rgt
prepare_journaled_options(journal_hash)
has_many :journals, journal_hash.merge({:class_name => journal_class.name,
:foreign_key => "journaled_id"}), &block
end
def journal_class
journal_class_name = "#{name.gsub("::", "_")}Journal"
if Object.const_defined?(journal_class_name)
Object.const_get(journal_class_name)
else
Object.const_set(journal_class_name, Class.new(Journal)).tap do |c|
# Run after the inherited hook to associate with the parent record.
# This eager loads the associated project (for permissions) if possible
if project_assoc = reflect_on_association(:project).try(:name)
include_option = ", :include => :#{project_assoc.to_s}"
end
c.class_eval("belongs_to :journaled, :class_name => '#{name}' #{include_option}")
c.class_eval("belongs_to :#{name.gsub("::", "_").underscore},
:foreign_key => 'journaled_id' #{include_option}")
end
end
end
private
# Splits an option has into three hashes:
## => [{ options prefixed with "activity_" }, { options prefixed with "event_" }, { other options }]
def split_option_hashes(options)
activity_hash = {}
event_hash = {}
journal_hash = {}
options.each_pair do |k, v|
case
when k.to_s =~ /^activity_(.+)$/
activity_hash[$1.to_sym] = v
when k.to_s =~ /^event_(.+)$/
event_hash[$1.to_sym] = v
else
journal_hash[k.to_sym] = v
end
end
[activity_hash, event_hash, journal_hash]
end
# Merges the passed activity_hash with the options we require for
# acts_as_journalized to work, as follows:
# # type is the supplied or the pluralized class name
# # timestamp is supplied or the journal's created_at
# # author_key will always be the journal's author
# #
# # find_options are merged as follows:
# # # select statement is enriched with the journal fields
# # # journal association is added to the includes
# # # if a project is associated with the model, this is added to the includes
# # # the find conditions are extended to only choose journals which have the proper activity_type
# => a valid activity hash
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[:find_options] ||= {} # in case it is nil
h[:find_options] = {}.tap do |opts|
cond = ARCondition.new
cond.add(["#{journal_class.table_name}.activity_type = ?", h[:type]])
cond.add(h[:find_options][:conditions]) if h[:find_options][:conditions]
opts[:conditions] = cond.conditions
include_opts = []
include_opts << :project if reflect_on_association(:project)
if h[:find_options][:include]
include_opts += case h[:find_options][:include]
when Array then h[:find_options][:include]
else [h[:find_options][:include]]
end
end
include_opts.uniq!
opts[:include] = [:journaled => include_opts]
#opts[:joins] = h[:find_options][:joins] if h[:find_options][:joins]
end
end
end
# Merges the event hashes defaults with the options provided by the user
# The defaults take their details from the journal
def journalized_event_hash(options)
unless options.has_key? :url
options[:url] = Proc.new do |journal|
{ :controller => plural_name,
:action => 'show',
:id => journal.journaled_id,
:anchor => ("note-#{journal.anchor}" unless journal.initial?) }
end
end
{ :description => :notes, :author => :user }.reverse_merge options
end
end
end
end
end
\ No newline at end of file
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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 journal 2
# of the License, or (at your option) any later journal.
#
# 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.
# This module holds the formatting methods that each journal has.
# It provides the hooks to apply different formatting to the details
# of a specific journal.
module JournalDeprecated
unloadable
# Old timestamps. created_at is what t.timestamps creates in recent Rails journals
def created_on
created_at
end
# Old naming
def journalized
journaled
end
# Old naming
def journalized= obj
journaled = obj
end
# Shortcut from more issue-specific journals
def attachments
journalized.respond_to?(:attachments) ? journalized.attachments : nil
end
# deprecate :created_on => "use #created_at"
# deprecate :journalized => "use journaled"
# deprecate :attachments => "implement it yourself"
end
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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 journal 2
# of the License, or (at your option) any later journal.
#
# 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.
# This module holds the formatting methods that each journal has.
# It provides the hooks to apply different formatting to the details
# of a specific journal.
module JournalFormatter
unloadable
mattr_accessor :formatters, :registered_fields
include ApplicationHelper
include CustomFieldsHelper
include ActionView::Helpers::TagHelper
include ActionView::Helpers::UrlHelper
extend Redmine::I18n
def self.register(hash)
if hash[:class]
klazz = hash.delete(:class)
registered_fields[klazz] ||= {}
registered_fields[klazz].merge!(hash)
else
formatters.merge(hash)
end
end
# TODO: Document Formatters (can take up to three params, value, journaled, field ...)
def self.default_formatters
{ :plaintext => (Proc.new {|v,*| v.try(:to_s) }),
:datetime => (Proc.new {|v,*| format_date(v.to_date) }),
:named_association => (Proc.new do |value, journaled, field|
association = journaled.class.reflect_on_association(field.to_sym)
if association
record = association.class_name.constantize.find_by_id(value.to_i)
record.name if record
end
end),
:fraction => (Proc.new {|v,*| "%0.02f" % v.to_f }),
:decimal => (Proc.new {|v,*| v.to_i.to_s }),
:id => (Proc.new {|v,*| "##{v}" }) }
end
self.formatters = default_formatters
self.registered_fields = {}
def format_attribute_detail(key, values, no_html=false)
field = key.to_s.gsub(/\_id$/, "")
label = l(("field_" + field).to_sym)
if format = JournalFormatter.registered_fields[self.class.name.to_sym][key]
formatter = JournalFormatter.formatters[format]
old_value = formatter.call(values.first, journaled, field) if values.first
value = formatter.call(values.last, journaled, field) if values.last
[label, old_value, value]
else
return nil
end
end
def format_custom_value_detail(custom_field, values, no_html)
label = custom_field.name
old_value = format_value(values.first, custom_field.field_format) if values.first
value = format_value(values.last, custom_field.field_format) if values.last
[label, old_value, value]
end
def format_attachment_detail(key, values, no_html)
label = l(:label_attachment)
old_value = values.first
value = values.last
[label, old_value, value]
end
def format_html_attachment_detail(key, value)
if !value.blank? && a = Attachment.find_by_id(key.to_i)
# Link to the attachment if it has not been removed
# FIXME: this is broken => link_to_attachment(a)
a.filename
else
content_tag("i", h(value)) if value.present?
end
end
def format_html_detail(label, old_value, value)
label = content_tag('strong', label)
old_value = content_tag("i", h(old_value)) if old_value && !old_value.blank?
old_value = content_tag("strike", old_value) if old_value and value.blank?
value = content_tag("i", h(value)) if value.present?
value ||= ""
[label, old_value, value]
end
def property(detail)
key = prop_key(detail)
if key.start_with? "custom_values"
:custom_field
elsif key.start_with? "attachments"
:attachment
elsif journaled.class.columns.collect(&:name).include? key
:attribute
end
end
def prop_key(detail)
if detail.respond_to? :to_ary
detail.first
else
detail
end
end
def values(detail)
key = prop_key(detail)
if detail != key
detail.last
else
details[key.to_s]
end
end
def old_value(detail)
values(detail).first
end
def value(detail)
values(detail).last
end
def render_detail(detail, no_html=false)
if detail.respond_to? :to_ary
key = detail.first
values = detail.last
else
key = detail
values = details[key.to_s]
end
case property(detail)
when :attribute
attr_detail = format_attribute_detail(key, values, no_html)
when :custom_field
custom_field = CustomField.find_by_id(key.sub("custom_values", "").to_i)
cv_detail = format_custom_value_detail(custom_field, values, no_html)
when :attachment
attachment_detail = format_attachment_detail(key.sub("attachments", ""), values, no_html)
end
label, old_value, value = attr_detail || cv_detail || attachment_detail
Redmine::Hook.call_hook :helper_issues_show_detail_after_setting, {:detail => detail,
:label => label, :value => value, :old_value => old_value }
return nil unless label || old_value || value # print nothing if there are no values
label, old_value, value = [label, old_value, value].collect(&:to_s)
unless no_html
label, old_value, value = *format_html_detail(label, old_value, value)
value = format_html_attachment_detail(key.sub("attachments", ""), value) if attachment_detail
end
unless value.blank?
if attr_detail || cv_detail
unless old_value.blank?
l(:text_journal_changed, :label => label, :old => old_value, :new => value)
else
l(:text_journal_set_to, :label => label, :value => value)
end
elsif attachment_detail
l(:text_journal_added, :label => label, :value => value)
end
else
l(:text_journal_deleted, :label => label, :old => old_value)
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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 journal 2
# of the License, or (at your option) any later journal.
#
# 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Provides the ability to manipulate hashes in the specific format that ActiveRecord gives to
# dirty attribute changes: string keys and unique, two-element array values.
module Changes
def self.included(base) # :nodoc:
Hash.send(:include, HashMethods)
base.class_eval do
include InstanceMethods
after_update :merge_journal_changes
end
end
# Methods available to journaled ActiveRecord::Base instances in order to manage changes used
# for journal creation.
module InstanceMethods
# Collects an array of changes from a record's journals between the given range and compiles
# them into one summary hash of changes. The +from+ and +to+ arguments can each be either a
# version number, a symbol representing an association proxy method, a string representing a
# journal tag or a journal object itself.
def changes_between(from, to)
from_number, to_number = journals.journal_at(from), journals.journal_at(to)
return {} if from_number == to_number
chain = journals.between(from_number, to_number).reject(&:initial?)
return {} if chain.empty?
backward = from_number > to_number
backward ? chain.pop : chain.shift unless from_number == 1 || to_number == 1
chain.inject({}) do |changes, journal|
changes.append_changes!(backward ? journal.changes.reverse_changes : journal.changes)
end
end
private
# Before a new journal is created, the newly-changed attributes are appended onto a hash
# of previously-changed attributes. Typically the previous changes will be empty, except in
# the case that a control block is used where journals are to be merged. See
# VestalVersions::Control for more information.
def merge_journal_changes
journal_changes.append_changes!(incremental_journal_changes)
end
# Stores the cumulative changes that are eventually used for journal creation.
def journal_changes
@journal_changes ||= {}
end
# Stores the incremental changes that are appended to the cumulative changes before journal
# creation. Incremental changes are reset when the record is saved because they represent
# a subset of the dirty attribute changes, which are reset upon save.
def incremental_journal_changes
changes.slice(*journaled_columns)
end
# Simply resets the cumulative changes after journal creation.
def reset_journal_changes
@journal_changes = nil
end
end
# Instance methods included into Hash for dealing with manipulation of hashes in the specific
# format of ActiveRecord::Base#changes.
module HashMethods
# When called on a hash of changes and given a second hash of changes as an argument,
# +append_changes+ will run the second hash on top of the first, updating the last element
# of each array value with its own, or creating its own key/value pair for missing keys.
# Resulting non-unique array values are removed.
#
# == Example
#
# first = {
# "first_name" => ["Steve", "Stephen"],
# "age" => [25, 26]
# }
# second = {
# "first_name" => ["Stephen", "Steve"],
# "last_name" => ["Richert", "Jobs"],
# "age" => [26, 54]
# }
# first.append_changes(second)
# # => {
# "last_name" => ["Richert", "Jobs"],
# "age" => [25, 54]
# }
def append_changes(changes)
changes.inject(self) do |new_changes, (attribute, change)|
new_change = [new_changes.fetch(attribute, change).first, change.last]
new_changes.merge(attribute => new_change)
end.reject do |attribute, change|
change.first == change.last
end
end
# Destructively appends a given hash of changes onto an existing hash of changes.
def append_changes!(changes)
replace(append_changes(changes))
end
# Appends the existing hash of changes onto a given hash of changes. Relates to the
# +append_changes+ method in the same way that Hash#reverse_merge relates to
# Hash#merge.
def prepend_changes(changes)
changes.append_changes(self)
end
# Destructively prepends a given hash of changes onto an existing hash of changes.
def prepend_changes!(changes)
replace(prepend_changes(changes))
end
# Reverses the array values of a hash of changes. Useful for rejournal both backward and
# forward through a record's history of changes.
def reverse_changes
inject({}){|nc,(a,c)| nc.merge!(a => c.reverse) }
end
# Destructively reverses the array values of a hash of changes.
def reverse_changes!
replace(reverse_changes)
end
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Allows for easy application-wide configuration of options passed into the +journaled+ method.
module Configuration
# The VestalVersions module is extended by VestalVersions::Configuration, allowing the
# +configure method+ to be used as follows in a Rails initializer:
#
# VestalVersions.configure do |config|
# config.class_name = "MyCustomVersion"
# config.dependent = :destroy
# end
#
# Each variable assignment in the +configure+ block corresponds directly with the options
# available to the +journaled+ method. Assigning common options in an initializer can keep your
# models tidy.
#
# If an option is given in both an initializer and in the options passed to +journaled+, the
# value given in the model itself will take precedence.
def configure
yield Configuration
end
class << self
# Simply stores a hash of options given to the +configure+ block.
def options
@options ||= {}
end
# If given a setter method name, will assign the first argument to the +options+ hash with
# the method name (sans "=") as the key. If given a getter method name, will attempt to
# a value from the +options+ hash for that key. If the key doesn't exist, defers to +super+.
def method_missing(symbol, *args)
if (method = symbol.to_s).sub!(/\=$/, '')
options[method.to_sym] = args.first
else
options.fetch(method.to_sym, super)
end
end
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Adds the functionality necessary to control journal creation on a journaled instance of
# ActiveRecord::Base.
module Creation
def self.included(base) # :nodoc:
base.class_eval do
extend ClassMethods
include InstanceMethods
after_save :create_journal, :if => :create_journal?
class << self
alias_method_chain :prepare_journaled_options, :creation
end
end
end
# Class methods added to ActiveRecord::Base to facilitate the creation of new journals.
module ClassMethods
# Overrides the basal +prepare_journaled_options+ method defined in VestalVersions::Options
# to extract the <tt>:only</tt> and <tt>:except</tt> options into +vestal_journals_options+.
def prepare_journaled_options_with_creation(options)
result = prepare_journaled_options_without_creation(options)
self.vestal_journals_options[:only] = Array(options.delete(:only)).map(&:to_s).uniq if options[:only]
self.vestal_journals_options[:except] = Array(options.delete(:except)).map(&:to_s).uniq if options[:except]
result
end
end
# Instance methods that determine whether to save a journal and actually perform the save.
module InstanceMethods
private
# Returns whether a new journal should be created upon updating the parent record.
# A new journal will be created if
# a) attributes have changed
# b) no previous journal exists
# c) journal notes were added
# d) the parent record is already saved
def create_journal?
update_journal
(journal_changes.present? or journal_notes.present? or journals.empty?) and !new_record?
end
# Creates a new journal upon updating the parent record.
# "update_journal" has been called in "update_journal?" at this point (to get a hold on association changes)
# It must not be called again here.
def create_journal
journals << self.class.journal_class.create(journal_attributes)
reset_journal_changes
reset_journal
true
rescue Exception => e # FIXME: What to do? This likely means that the parent record is invalid!
p e
p e.message
p e.backtrace
false
end
# Returns an array of column names that should be included in the changes of created
# journals. If <tt>vestal_journals_options[:only]</tt> is specified, only those columns
# will be journaled. Otherwise, if <tt>vestal_journals_options[:except]</tt> is specified,
# all columns will be journaled other than those specified. Without either option, the
# default is to journal all columns. At any rate, the four "automagic" timestamp columns
# maintained by Rails are never journaled.
def journaled_columns
case
when vestal_journals_options[:only] then self.class.column_names & vestal_journals_options[:only]
when vestal_journals_options[:except] then self.class.column_names - vestal_journals_options[:except]
else self.class.column_names
end - %w(created_at updated_at)
end
# Returns the activity type. Should be overridden in the journalized class to offer
# multiple types
def activity_type
self.class.name.underscore.pluralize
end
# Specifies the attributes used during journal creation. This is separated into its own
# method so that it can be overridden by the VestalVersions::Users feature.
def journal_attributes
attributes = { :journaled_id => self.id, :activity_type => activity_type,
:changes => journal_changes, :version => last_version + 1,
:notes => journal_notes, :user_id => (journal_user.try(:id) || User.current) }
end
end
end
end
\ No newline at end of file
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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.
# These hooks make sure journals are properly created and updated with Redmine user detail,
# notes and associated custom fields
module Redmine::Acts::Journalized
module Deprecated
# Old mailer API
def recipients
notified = project.notified_users
notified.reject! {|user| !visible?(user)}
notified.collect(&:mail)
end
def current_journal
last_journal
end
# FIXME: When the new API is settled, remove me
Redmine::Acts::Event::InstanceMethods.instance_methods(false).each do |m|
if m.start_with? "event_"
class_eval(<<-RUBY, __FILE__, __LINE__)
def #{m}
if last_journal.nil?
begin
journals << self.class.journal_class.create(journal_attributes)
reset_journal_changes
reset_journal
true
rescue Exception => e # FIXME: What to do? This likely means that the parent record is invalid!
p e
p e.message
p e.backtrace
false
end
journals.reload
end
return last_journal.#{m}
end
RUBY
end
end
def event_url(options = {})
last_journal.event_url(options)
end
# deprecate :recipients => "use #last_journal.recipients"
# deprecate :current_journal => "use #last_journal"
end
end
# redMine - project management software
# Copyright (C) 2006-2008 Jean-Philippe Lang
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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.
module JournalsHelper
def render_notes(issue, journal, options={})
content = ''
editable = User.current.logged? && (User.current.allowed_to?(:edit_issue_notes, issue.project) || (journal.user == User.current && User.current.allowed_to?(:edit_own_issue_notes, issue.project)))
links = []
if !journal.notes.blank?
links << link_to_remote(image_tag('comment.png'),
{ :url => {:controller => 'journals', :action => 'new', :id => issue, :journal_id => journal} },
:title => l(:button_quote)) if options[:reply_links]
links << link_to_in_place_notes_editor(image_tag('edit.png'), "journal-#{journal.id}-notes",
{ :controller => 'journals', :action => 'edit', :id => journal },
:title => l(:button_edit)) if editable
module Redmine::Acts::Journalized
module FormatHooks
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
# Shortcut to register a formatter for a number of fields
def register_on_journal_formatter(formatter, *field_names)
formatter = formatter.to_sym
field_names.collect(&:to_s).each do |field|
JournalFormatter.register :class => self.journal_class.name.to_sym, field => formatter
end
end
# Shortcut to register a new proc as a named formatter. Overwrites
# existing formatters with the same name
def register_journal_formatter(formatter)
JournalFormatter.register formatter.to_sym => Proc.new
end
end
content << content_tag('div', links.join(' '), :class => 'contextual') unless links.empty?
content << textilizable(journal, :notes)
css_classes = "wiki"
css_classes << " editable" if editable
content_tag('div', content, :id => "journal-#{journal.id}-notes", :class => css_classes)
end
def link_to_in_place_notes_editor(text, field_id, url, options={})
onclick = "new Ajax.Request('#{url_for(url)}', {asynchronous:true, evalScripts:true, method:'get'}); return false;"
link_to text, '#', options.merge(:onclick => onclick)
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Provides +journaled+ options conjournal and cleanup.
module Options
def self.included(base) # :nodoc:
base.class_eval do
extend ClassMethods
end
end
# Class methods that provide preparation of options passed to the +journaled+ method.
module ClassMethods
# The +prepare_journaled_options+ method has three purposes:
# 1. Populate the provided options with default values where needed
# 2. Prepare options for use with the +has_many+ association
# 3. Save user-configurable options in a class-level variable
#
# Options are given priority in the following order:
# 1. Those passed directly to the +journaled+ method
# 2. Those specified in an initializer +configure+ block
# 3. Default values specified in +prepare_journaled_options+
#
# The method is overridden in feature modules that require specific options outside the
# standard +has_many+ associations.
def prepare_journaled_options(options)
options.symbolize_keys!
options.reverse_merge!(Configuration.options)
options.reverse_merge!(
:class_name => 'Journal',
:dependent => :delete_all
)
options.reverse_merge!(
:order => "#{options[:class_name].constantize.table_name}.version ASC"
)
class_inheritable_accessor :vestal_journals_options
self.vestal_journals_options = options.dup
options.merge!(
:extend => Array(options[:extend]).unshift(Versions)
)
end
end
end
end
# Redmine - project management software
# Copyright (C) 2006-2011 Jean-Philippe Lang
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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.
class JournalDetail < ActiveRecord::Base
belongs_to :journal
module Redmine::Acts::Journalized
module Permissions
# Default implementation of journal editing permission
# Is overridden if defined in the journalized model directly
def journal_editable_by?(user)
return true if user.admin?
if respond_to? :editable_by?
editable_by? user
else
permission = :"edit_#{self.class.to_s.pluralize.downcase}"
p = @project || (project if respond_to? :project)
options = { :global => p.present? }
user.allowed_to? permission, p, options
end
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Ties into the existing ActiveRecord::Base#reload method to ensure that journal information
# is properly reset.
module Reload
def self.included(base) # :nodoc:
base.class_eval do
include InstanceMethods
alias_method_chain :reload, :journals
end
end
# Adds instance methods into ActiveRecord::Base to tap into the +reload+ method.
module InstanceMethods
# Overrides ActiveRecord::Base#reload, resetting the instance-variable-cached journal number
# before performing the original +reload+ method.
def reload_with_journals(*args)
reset_journal
reload_without_journals(*args)
end
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Adds the ability to "reset" (or hard revert) a journaled ActiveRecord::Base instance.
module Reset
def self.included(base) # :nodoc:
base.class_eval do
include InstanceMethods
end
end
# Adds the instance methods required to reset an object to a previous journal.
module InstanceMethods
# Similar to +revert_to!+, the +reset_to!+ method reverts an object to a previous journal,
# only instead of creating a new record in the journal history, +reset_to!+ deletes all of
# the journal history that occurs after the journal reverted to.
#
# The action taken on each journal record after the point of rejournal is determined by the
# <tt>:dependent</tt> option given to the +journaled+ method. See the +journaled+ method
# documentation for more details.
def reset_to!(value)
if saved = skip_journal{ revert_to!(value) }
journals.send(:delete_records, journals.after(value))
reset_journal
end
saved
end
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Enables versioned ActiveRecord::Base instances to revert to a previously saved version.
module Reversion
def self.included(base) # :nodoc:
base.class_eval do
include InstanceMethods
end
end
# Provides the base instance methods required to revert a journaled instance.
module InstanceMethods
# Returns the current version number for the versioned object.
def version
@version ||= last_version
end
def last_journal
journals.last
end
# Accepts a value corresponding to a specific journal record, builds a history of changes
# between that journal and the current journal, and then iterates over that history updating
# the object's attributes until the it's reverted to its prior state.
#
# The single argument should adhere to one of the formats as documented in the +at+ method of
# VestalVersions::Versions.
#
# After the object is reverted to the target journal, it is not saved. In order to save the
# object after the rejournal, use the +revert_to!+ method.
#
# The journal number of the object will reflect whatever journal has been reverted to, and
# the return value of the +revert_to+ method is also the target journal number.
def revert_to(value)
to_number = journals.journal_at(value)
changes_between(journal, to_number).each do |attribute, change|
write_attribute(attribute, change.last)
end
reset_journal(to_number)
end
# Behaves similarly to the +revert_to+ method except that it automatically saves the record
# after the rejournal. The return value is the success of the save.
def revert_to!(value)
revert_to(value)
reset_journal if saved = save
saved
end
# Returns a boolean specifying whether the object has been reverted to a previous journal or
# if the object represents the latest journal in the journal history.
def reverted?
version != last_version
end
private
# Returns the number of the last created journal in the object's journal history.
#
# If no associated journals exist, the object is considered at version 0.
def last_version
@last_version ||= journals.maximum(:version) || 0
end
# Clears the cached version number instance variables so that they can be recalculated.
# Useful after a new version is created.
def reset_journal(version = nil)
@last_version = nil if version.nil?
@version = version
end
end
end
end
\ No newline at end of file
# This file is part of the acts_as_journalized plugin for the redMine
# project management software
#
# Copyright (C) 2010 Finn GmbH, http://finn.de
#
# 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.
# These hooks make sure journals are properly created and updated with Redmine user detail,
# notes and associated custom fields
module Redmine::Acts::Journalized
module SaveHooks
def self.included(base)
base.extend ClassMethods
base.class_eval do
before_save :init_journal
after_save :reset_instance_variables
attr_reader :journal_notes, :journal_user
end
end
# Saves the current custom values, notes and journal to include them in the next journal
# Called before save
def init_journal(user = User.current, notes = "")
@journal_notes ||= notes
@journal_user ||= user
@associations_before_save ||= {}
@associations = {}
save_possible_association :custom_values, :key => :custom_field_id, :value => :value
save_possible_association :attachments, :key => :id, :value => :filename
@current_journal ||= last_journal
end
# Saves the notes and custom value changes in the last Journal
# Called before create_journal
def update_journal
unless (@associations || {}).empty?
changed_associations = {}
changed_associations.merge!(possibly_updated_association :custom_values)
changed_associations.merge!(possibly_updated_association :attachments)
end
unless changed_associations.blank?
update_extended_journal_contents(changed_associations)
end
end
def reset_instance_variables
if last_journal != @current_journal
if last_journal.user != @journal_user
last_journal.update_attribute(:user_id, @journal_user.id)
end
end
@associations_before_save = @current_journal = @journal_notes = @journal_user = nil
end
def save_possible_association(method, options)
@associations[method] = options
if self.respond_to? method
@associations_before_save[method] ||= send(method).inject({}) do |hash, cv|
hash[cv.send(options[:key])] = cv.send(options[:value])
hash
end
end
end
def possibly_updated_association(method)
if @associations_before_save[method]
# Has custom values from init_journal_notes
return changed_associations(method, @associations_before_save[method])
end
{}
end
# Saves the notes and changed custom values to the journal
# Creates a new journal, if no immediate attributes were changed
def update_extended_journal_contents(changed_associations)
journal_changes.merge!(changed_associations)
end
def changed_associations(method, previous)
send(method).reload # Make sure the associations are reloaded
send(method).inject({}) do |hash, c|
key = c.send(@associations[method][:key])
new_value = c.send(@associations[method][:value])
if previous[key].blank? && new_value.blank?
# The key was empty before, don't add a blank value
elsif previous[key] != new_value
# The key's value changed
hash["#{method}#{key}"] = [previous[key], new_value]
end
hash
end
end
module ClassMethods
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Provides a way for information to be associated with specific journals as to who was
# responsible for the associated update to the parent.
module Users
def self.included(base) # :nodoc:
Journal.send(:include, JournalMethods)
base.class_eval do
include InstanceMethods
attr_accessor :updated_by
alias_method_chain :journal_attributes, :user
end
end
# Methods added to journaled ActiveRecord::Base instances to enable journaling with additional
# user information.
module InstanceMethods
private
# Overrides the +journal_attributes+ method to include user information passed into the
# parent object, by way of a +updated_by+ attr_accessor.
def journal_attributes_with_user
journal_attributes_without_user.merge(:user => updated_by || User.current)
end
end
# Instance methods added to Redmine::Acts::Journalized::Journal to accomodate incoming
# user information.
module JournalMethods
def self.included(base) # :nodoc:
base.class_eval do
belongs_to :user
alias_method_chain :user=, :name
end
end
# Overrides the +user=+ method created by the polymorphic +belongs_to+ user association.
# Based on the class of the object given, either the +user+ association columns or the
# +user_name+ string column is populated.
def user_with_name=(value)
case value
when ActiveRecord::Base then self.user_without_name = value
else self.user = User.find_by_login(value)
end
end
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# Simply adds a flag to determine whether a model class if journaled.
module Versioned
def self.extended(base) # :nodoc:
base.class_eval do
class << self
alias_method_chain :acts_as_journalized, :flag
end
end
end
# Overrides the +journaled+ method to first define the +journaled?+ class method before
# deferring to the original +journaled+.
def acts_as_journalized_with_flag(*args)
acts_as_journalized_without_flag(*args)
class << self
def journaled?
true
end
end
end
# For all ActiveRecord::Base models that do not call the +journaled+ method, the +journaled?+
# method will return false.
def journaled?
false
end
end
end
# This file included as part of the acts_as_journalized plugin for
# the redMine project management 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.
#
# The original copyright and license conditions are:
# Copyright (c) 2009 Steve Richert
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module Redmine::Acts::Journalized
# An extension module for the +has_many+ association with journals.
module Versions
# Returns all journals between (and including) the two given arguments. See documentation for
# the +at+ extension method for what arguments are valid. If either of the given arguments is
# invalid, an empty array is returned.
#
# The +between+ method preserves returns an array of journal records, preserving the order
# given by the arguments. If the +from+ value represents a journal before that of the +to+
# value, the array will be ordered from earliest to latest. The reverse is also true.
def between(from, to)
from_number, to_number = journal_at(from), journal_at(to)
return [] if from_number.nil? || to_number.nil?
condition = (from_number == to_number) ? to_number : Range.new(*[from_number, to_number].sort)
all(
:conditions => {:version => condition},
:order => "#{aliased_table_name}.version #{(from_number > to_number) ? 'DESC' : 'ASC'}"
)
end
# Returns all journal records created before the journal associated with the given value.
def before(value)
return [] if (version = journal_at(value)).nil?
all(:conditions => "#{aliased_table_name}.version < #{version}")
end
# Returns all journal records created after the journal associated with the given value.
#
# This is useful for dissociating records during use of the +reset_to!+ method.
def after(value)
return [] if (version = journal_at(value)).nil?
all(:conditions => "#{aliased_table_name}.version > #{version}")
end
# Returns a single journal associated with the given value. The following formats are valid:
# * A Date or Time object: When given, +to_time+ is called on the value and the last journal
# record in the history created before (or at) that time is returned.
# * A Numeric object: Typically a positive integer, these values correspond to journal numbers
# and the associated journal record is found by a journal number equal to the given value
# rounded down to the nearest integer.
# * A String: A string value represents a journal tag and the associated journal is searched
# for by a matching tag value. *Note:* Be careful with string representations of numbers.
# * A Symbol: Symbols represent association class methods on the +has_many+ journals
# association. While all of the built-in association methods require arguments, additional
# extension modules can be defined using the <tt>:extend</tt> option on the +journaled+
# method. See the +journaled+ documentation for more information.
# * A Version object: If a journal object is passed to the +at+ method, it is simply returned
# untouched.
def at(value)
case value
when Date, Time then last(:conditions => ["#{aliased_table_name}.created_at <= ?", value.to_time])
when Numeric then find_by_version(value.floor)
when Symbol then respond_to?(value) ? send(value) : nil
when Journal then value
end
end
# Returns the journal number associated with the given value. In many cases, this involves
# simply passing the value to the +at+ method and then returning the subsequent journal number.
# Hoever, for Numeric values, the journal number can be returned directly and for Date/Time
# values, a default value of 1 is given to ensure that times prior to the first journal
# still return a valid journal number (useful for rejournal).
def journal_at(value)
case value
when Date, Time then (v = at(value)) ? v.version : 1
when Numeric then value.floor
when Symbol then (v = at(value)) ? v.version : nil
when String then nil
when Journal then value.version
end
end
end
end
\ No newline at end of file
require File.join(File.dirname(__FILE__), 'test_helper')
class ChangesTest < Test::Unit::TestCase
context "A journal's changes" do
setup do
@user = User.create(:name => 'Steve Richert')
@user.update_attribute(:last_name, 'Jobs')
@changes = @user.journals.last.changes
end
should 'be a hash' do
assert_kind_of Hash, @changes
end
should 'not be empty' do
assert !@changes.empty?
end
should 'have string keys' do
@changes.keys.each do |key|
assert_kind_of String, key
end
end
should 'have array values' do
@changes.values.each do |value|
assert_kind_of Array, value
end
end
should 'have two-element values' do
@changes.values.each do |value|
assert_equal 2, value.size
end
end
should 'have unique-element values' do
@changes.values.each do |value|
assert_equal value.uniq, value
end
end
should "equal the model's changes" do
@user.first_name = 'Stephen'
model_changes = @user.changes
@user.save
changes = @user.journals.last.changes
assert_equal model_changes, changes
end
end
context 'A hash of changes' do
setup do
@changes = {'first_name' => ['Steve', 'Stephen']}
@other = {'first_name' => ['Catie', 'Catherine']}
end
should 'properly append other changes' do
expected = {'first_name' => ['Steve', 'Catherine']}
changes = @changes.append_changes(@other)
assert_equal expected, changes
@changes.append_changes!(@other)
assert_equal expected, @changes
end
should 'properly prepend other changes' do
expected = {'first_name' => ['Catie', 'Stephen']}
changes = @changes.prepend_changes(@other)
assert_equal expected, changes
@changes.prepend_changes!(@other)
assert_equal expected, @changes
end
should 'be reversible' do
expected = {'first_name' => ['Stephen', 'Steve']}
changes = @changes.reverse_changes
assert_equal expected, changes
@changes.reverse_changes!
assert_equal expected, @changes
end
end
context 'The changes between two journals' do
setup do
name = 'Steve Richert'
@user = User.create(:name => name) # 1
@user.update_attribute(:last_name, 'Jobs') # 2
@user.update_attribute(:first_name, 'Stephen') # 3
@user.update_attribute(:last_name, 'Richert') # 4
@user.update_attribute(:name, name) # 5
@version = @user.version
end
should 'be a hash' do
1.upto(@version) do |i|
1.upto(@version) do |j|
changes = @user.changes_between(i, j)
assert_kind_of Hash, changes
end
end
end
should 'have string keys' do
1.upto(@version) do |i|
1.upto(@version) do |j|
changes = @user.changes_between(i, j)
changes.keys.each do |key|
assert_kind_of String, key
end
end
end
end
should 'have array values' do
1.upto(@version) do |i|
1.upto(@version) do |j|
changes = @user.changes_between(i, j)
changes.values.each do |value|
assert_kind_of Array, value
end
end
end
end
should 'have two-element values' do
1.upto(@version) do |i|
1.upto(@version) do |j|
changes = @user.changes_between(i, j)
changes.values.each do |value|
assert_equal 2, value.size
end
end
end
end
should 'have unique-element values' do
1.upto(@version) do |i|
1.upto(@version) do |j|
changes = @user.changes_between(i, j)
changes.values.each do |value|
assert_equal value.uniq, value
end
end
end
end
should 'be empty between identical versions' do
assert @user.changes_between(1, @version).empty?
assert @user.changes_between(@version, 1).empty?
end
should 'be should reverse with direction' do
1.upto(@version) do |i|
i.upto(@version) do |j|
up = @user.changes_between(i, j)
down = @user.changes_between(j, i)
assert_equal up, down.reverse_changes
end
end
end
should 'be empty with invalid arguments' do
1.upto(@version) do |i|
assert @user.changes_between(i, nil)
assert @user.changes_between(nil, i)
end
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class ConditionsTest < Test::Unit::TestCase
context 'Converted :if conditions' do
setup do
User.class_eval do
def true; true; end
end
end
should 'be an array' do
assert_kind_of Array, User.vestal_journals_options[:if]
User.prepare_journaled_options(:if => :true)
assert_kind_of Array, User.vestal_journals_options[:if]
end
should 'have proc values' do
User.prepare_journaled_options(:if => :true)
assert User.vestal_journals_options[:if].all?{|i| i.is_a?(Proc) }
end
teardown do
User.prepare_journaled_options(:if => [])
end
end
context 'Converted :unless conditions' do
setup do
User.class_eval do
def true; true; end
end
end
should 'be an array' do
assert_kind_of Array, User.vestal_journals_options[:unless]
User.prepare_journaled_options(:unless => :true)
assert_kind_of Array, User.vestal_journals_options[:unless]
end
should 'have proc values' do
User.prepare_journaled_options(:unless => :true)
assert User.vestal_journals_options[:unless].all?{|i| i.is_a?(Proc) }
end
teardown do
User.prepare_journaled_options(:unless => [])
end
end
context 'A new journal' do
setup do
User.class_eval do
def true; true; end
def false; false; end
end
@user = User.create(:name => 'Steve Richert')
@count = @user.journals.count
end
context 'with :if conditions' do
context 'that pass' do
setup do
User.prepare_journaled_options(:if => [:true])
@user.update_attribute(:last_name, 'Jobs')
end
should 'be created' do
assert_equal @count + 1, @user.journals.count
end
end
context 'that fail' do
setup do
User.prepare_journaled_options(:if => [:false])
@user.update_attribute(:last_name, 'Jobs')
end
should 'not be created' do
assert_equal @count, @user.journals.count
end
end
end
context 'with :unless conditions' do
context 'that pass' do
setup do
User.prepare_journaled_options(:unless => [:true])
@user.update_attribute(:last_name, 'Jobs')
end
should 'not be created' do
assert_equal @count, @user.journals.count
end
end
context 'that fail' do
setup do
User.prepare_journaled_options(:unless => [:false])
@user.update_attribute(:last_name, 'Jobs')
end
should 'not be created' do
assert_equal @count + 1, @user.journals.count
end
end
end
context 'with :if and :unless conditions' do
context 'that pass' do
setup do
User.prepare_journaled_options(:if => [:true], :unless => [:true])
@user.update_attribute(:last_name, 'Jobs')
end
should 'not be created' do
assert_equal @count, @user.journals.count
end
end
context 'that fail' do
setup do
User.prepare_journaled_options(:if => [:false], :unless => [:false])
@user.update_attribute(:last_name, 'Jobs')
end
should 'not be created' do
assert_equal @count, @user.journals.count
end
end
end
teardown do
User.prepare_journaled_options(:if => [], :unless => [])
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class ConfigurationTest < Test::Unit::TestCase
context 'Global configuration options' do
setup do
module Extension; end
@options = {
'class_name' => 'CustomVersion',
:extend => Extension,
:as => :parent
}
VestalVersions.configure do |config|
@options.each do |key, value|
config.send("#{key}=", value)
end
end
@configuration = VestalVersions::Configuration.options
end
should 'should be a hash' do
assert_kind_of Hash, @configuration
end
should 'have symbol keys' do
assert @configuration.keys.all?{|k| k.is_a?(Symbol) }
end
should 'store values identical to those given' do
assert_equal @options.symbolize_keys, @configuration
end
teardown do
VestalVersions::Configuration.options.clear
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class ControlTest < Test::Unit::TestCase
context 'Within a skip_journal block,' do
setup do
@user = User.create(:name => 'Steve Richert')
@count = @user.journals.count
end
context 'a model update' do
setup do
@user.skip_journal do
@user.update_attribute(:last_name, 'Jobs')
end
end
should 'not create a journal' do
assert_equal @count, @user.journals.count
end
end
context 'multiple model updates' do
setup do
@user.skip_journal do
@user.update_attribute(:first_name, 'Stephen')
@user.update_attribute(:last_name, 'Jobs')
@user.update_attribute(:first_name, 'Steve')
end
end
should 'not create a journal' do
assert_equal @count, @user.journals.count
end
end
end
context 'Within a merge_journal block,' do
setup do
@user = User.create(:name => 'Steve Richert')
@count = @user.journals.count
end
context 'a model update' do
setup do
@user.merge_journal do
@user.update_attribute(:last_name, 'Jobs')
end
end
should 'create a journal' do
assert_equal @count + 1, @user.journals.count
end
end
context 'multiple model updates' do
setup do
@user.merge_journal do
@user.update_attribute(:first_name, 'Stephen')
@user.update_attribute(:last_name, 'Jobs')
@user.update_attribute(:first_name, 'Steve')
end
end
should 'create a journal' do
assert_equal @count + 1, @user.journals.count
end
end
end
context 'Within a append_journal block' do
context '(when no journals exist),' do
setup do
@user = User.create(:name => 'Steve Richert')
@count = @user.journals.count
end
context 'a model update' do
setup do
@user.append_journal do
@user.update_attribute(:last_name, 'Jobs')
end
end
should 'create a journal' do
assert_equal @count + 1, @user.journals.count
end
end
context 'multiple model updates' do
setup do
@user.append_journal do
@user.update_attribute(:first_name, 'Stephen')
@user.update_attribute(:last_name, 'Jobs')
@user.update_attribute(:first_name, 'Steve')
end
end
should 'create a journal' do
assert_equal @count + 1, @user.journals.count
end
end
end
context '(when journals exist),' do
setup do
@user = User.create(:name => 'Steve Richert')
@user.update_attribute(:last_name, 'Jobs')
@user.update_attribute(:last_name, 'Richert')
@last_journal = @user.journals.last
@count = @user.journals.count
end
context 'a model update' do
setup do
@user.append_journal do
@user.update_attribute(:last_name, 'Jobs')
end
end
should 'not create a journal' do
assert_equal @count, @user.journals.count
end
should 'update the last journal' do
last_journal = @user.journals(true).last
assert_equal @last_journal.id, last_journal.id
assert_not_equal @last_journal.attributes, last_journal.attributes
end
end
context 'multiple model updates' do
setup do
@user.append_journal do
@user.update_attribute(:first_name, 'Stephen')
@user.update_attribute(:last_name, 'Jobs')
@user.update_attribute(:first_name, 'Steve')
end
end
should 'not create a journal' do
assert_equal @count, @user.journals.count
end
should 'update the last journal' do
last_journal = @user.journals(true).last
assert_equal @last_journal.id, last_journal.id
assert_not_equal @last_journal.attributes, last_journal.attributes
end
end
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class CreationTest < Test::Unit::TestCase
context 'The number of journals' do
setup do
@name = 'Steve Richert'
@user = User.create(:name => @name)
@count = @user.journals.count
end
should 'initially equal zero' do
assert_equal 0, @count
end
should 'not increase when no changes are made in an update' do
@user.update_attribute(:name, @name)
assert_equal @count, @user.journals.count
end
should 'not increase when no changes are made before a save' do
@user.save
assert_equal @count, @user.journals.count
end
context 'after an update' do
setup do
@user.update_attribute(:last_name, 'Jobs')
end
should 'increase by one' do
assert_equal @count + 1, @user.journals.count
end
end
context 'after multiple updates' do
setup do
@user.update_attribute(:last_name, 'Jobs')
@user.update_attribute(:last_name, 'Richert')
end
should 'increase multiple times' do
assert_operator @count + 1, :<, @user.journals.count
end
end
end
context "A created journal's changes" do
setup do
@user = User.create(:name => 'Steve Richert')
@user.update_attribute(:last_name, 'Jobs')
end
should 'not contain Rails timestamps' do
%w(created_at created_on updated_at updated_on).each do |timestamp|
assert_does_not_contain @user.journals.last.changes.keys, timestamp
end
end
context '(with :only options)' do
setup do
@only = %w(first_name)
User.prepare_journaled_options(:only => @only)
@user.update_attribute(:name, 'Steven Tyler')
end
should 'only contain the specified columns' do
assert_equal @only, @user.journals.last.changes.keys
end
teardown do
User.prepare_journaled_options(:only => nil)
end
end
context '(with :except options)' do
setup do
@except = %w(first_name)
User.prepare_journaled_options(:except => @except)
@user.update_attribute(:name, 'Steven Tyler')
end
should 'not contain the specified columns' do
@except.each do |column|
assert_does_not_contain @user.journals.last.changes.keys, column
end
end
teardown do
User.prepare_journaled_options(:except => nil)
end
end
context '(with both :only and :except options)' do
setup do
@only = %w(first_name)
@except = @only
User.prepare_journaled_options(:only => @only, :except => @except)
@user.update_attribute(:name, 'Steven Tyler')
end
should 'respect only the :only options' do
assert_equal @only, @user.journals.last.changes.keys
end
teardown do
User.prepare_journaled_options(:only => nil, :except => nil)
end
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class OptionsTest < Test::Unit::TestCase
context 'Configuration options' do
setup do
@options = {:dependent => :destroy}
@configuration = {:class_name => 'MyCustomVersion'}
VestalVersions::Configuration.options.clear
@configuration.each{|k,v| VestalVersions::Configuration.send("#{k}=", v) }
@prepared_options = User.prepare_journaled_options(@options.dup)
end
should 'have symbolized keys' do
assert User.vestal_journals_options.keys.all?{|k| k.is_a?(Symbol) }
end
should 'combine class-level and global configuration options' do
combined_keys = (@options.keys + @configuration.keys).map(&:to_sym).uniq
combined_options = @configuration.symbolize_keys.merge(@options.symbolize_keys)
assert_equal @prepared_options.slice(*combined_keys), combined_options
end
teardown do
VestalVersions::Configuration.options.clear
User.prepare_journaled_options({})
end
end
context 'Given no options, configuration options' do
setup do
@prepared_options = User.prepare_journaled_options({})
end
should 'default to "VestalVersions::Version" for :class_name' do
assert_equal 'VestalVersions::Version', @prepared_options[:class_name]
end
should 'default to :delete_all for :dependent' do
assert_equal :delete_all, @prepared_options[:dependent]
end
should 'force the :as option value to :journaled' do
assert_equal :journaled, @prepared_options[:as]
end
should 'default to [VestalVersions::Versions] for :extend' do
assert_equal [VestalVersions::Versions], @prepared_options[:extend]
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class ReloadTest < Test::Unit::TestCase
context 'Reloading a reverted model' do
setup do
@user = User.create(:name => 'Steve Richert')
first_version = @user.version
@user.update_attribute(:last_name, 'Jobs')
@last_version = @user.version
@user.revert_to(first_version)
end
should 'reset the journal number to the most recent journal' do
assert_not_equal @last_journal, @user.journal
@user.reload
assert_equal @last_journal, @user.journal
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class ResetTest < Test::Unit::TestCase
context 'Resetting a model' do
setup do
@original_dependent = User.reflect_on_association(:journals).options[:dependent]
@user, @journals = User.new, []
@names = ['Steve Richert', 'Stephen Richert', 'Stephen Jobs', 'Steve Jobs']
@names.each do |name|
@user.update_attribute(:name, name)
@journals << @user.journal
end
end
should "properly revert the model's attributes" do
@journals.reverse.each_with_index do |journal, i|
@user.reset_to!(journal)
assert_equal @names.reverse[i], @user.name
end
end
should 'dissociate all journals after the target' do
@journals.reverse.each do |journal|
@user.reset_to!(journal)
assert_equal 0, @user.journals(true).after(journal).count
end
end
context 'with the :dependent option as :delete_all' do
setup do
User.reflect_on_association(:journals).options[:dependent] = :delete_all
end
should 'delete all journals after the target journal' do
@journals.reverse.each do |journal|
later_journals = @user.journals.after(journal)
@user.reset_to!(journal)
later_journals.each do |later_journal|
assert_raise ActiveRecord::RecordNotFound do
later_journal.reload
end
end
end
end
should 'not destroy all journals after the target journal' do
VestalVersions::Version.any_instance.stubs(:destroy).raises(RuntimeError)
@journals.reverse.each do |journal|
assert_nothing_raised do
@user.reset_to!(journal)
end
end
end
end
context 'with the :dependent option as :destroy' do
setup do
User.reflect_on_association(:journals).options[:dependent] = :destroy
end
should 'delete all journals after the target journal' do
@journals.reverse.each do |journal|
later_journals = @user.journals.after(journal)
@user.reset_to!(journal)
later_journals.each do |later_journal|
assert_raise ActiveRecord::RecordNotFound do
later_journal.reload
end
end
end
end
should 'destroy all journals after the target journal' do
VestalVersions::Version.any_instance.stubs(:destroy).raises(RuntimeError)
@journals.reverse.each do |journal|
later_journals = @user.journals.after(journal)
if later_journals.empty?
assert_nothing_raised do
@user.reset_to!(journal)
end
else
assert_raise RuntimeError do
@user.reset_to!(journal)
end
end
end
end
end
context 'with the :dependent option as :nullify' do
setup do
User.reflect_on_association(:journals).options[:dependent] = :nullify
end
should 'leave all journals after the target journal' do
@journals.reverse.each do |journal|
later_journals = @user.journals.after(journal)
@user.reset_to!(journal)
later_journals.each do |later_journal|
assert_nothing_raised do
later_journal.reload
end
end
end
end
end
teardown do
User.reflect_on_association(:journals).options[:dependent] = @original_dependent
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class RejournalTest < Test::Unit::TestCase
context 'A model rejournal' do
setup do
@user, @attributes, @times = User.new, {}, {}
names = ['Steve Richert', 'Stephen Richert', 'Stephen Jobs', 'Steve Jobs']
time = names.size.hours.ago
names.each do |name|
@user.update_attribute(:name, name)
@attributes[@user.journal] = @user.attributes
time += 1.hour
if last_journal = @user.journals.last
last_journal.update_attribute(:created_at, time)
end
@times[@user.journal] = time
end
@user.reload.journals.reload
@first_journal, @last_journal = @attributes.keys.min, @attributes.keys.max
end
should 'return the new journal number' do
new_journal = @user.revert_to(@first_journal)
assert_equal @first_journal, new_journal
end
should 'change the journal number when saved' do
current_journal = @user.journal
@user.revert_to!(@first_journal)
assert_not_equal current_journal, @user.journal
end
should 'do nothing for a invalid argument' do
current_journal = @user.journal
[nil, :bogus, 'bogus', (1..2)].each do |invalid|
@user.revert_to(invalid)
assert_equal current_journal, @user.journal
end
end
should 'be able to target a journal number' do
@user.revert_to(1)
assert 1, @user.journal
end
should 'be able to target a date and time' do
@times.each do |journal, time|
@user.revert_to(time + 1.second)
assert_equal journal, @user.journal
end
end
should 'be able to target a journal object' do
@user.journals.each do |journal|
@user.revert_to(journal)
assert_equal journal.number, @user.journal
end
end
should "correctly roll back the model's attributes" do
timestamps = %w(created_at created_on updated_at updated_on)
@attributes.each do |journal, attributes|
@user.revert_to!(journal)
assert_equal attributes.except(*timestamps), @user.attributes.except(*timestamps)
end
end
end
end
ActiveRecord::Base.establish_connection(
:adapter => defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby' ? 'jdbcsqlite3' : 'sqlite3',
:database => File.join(File.dirname(__FILE__), 'test.db')
)
class CreateSchema < ActiveRecord::Migration
def self.up
create_table :users, :force => true do |t|
t.string :first_name
t.string :last_name
t.timestamps
end
create_table :journals, :force => true do |t|
t.belongs_to :journaled, :polymorphic => true
t.belongs_to :user, :polymorphic => true
t.string :user_name
t.text :changes
t.integer :number
t.string :tag
t.timestamps
end
end
end
CreateSchema.suppress_messages do
CreateSchema.migrate(:up)
end
class User < ActiveRecord::Base
journaled
def name
[first_name, last_name].compact.join(' ')
end
def name=(names)
self[:first_name], self[:last_name] = names.split(' ', 2)
end
end
class MyCustomVersion < VestalVersions::Version
end
require File.join(File.dirname(__FILE__), 'test_helper')
class TaggingTest < Test::Unit::TestCase
context 'Tagging a journal' do
setup do
@user = User.create(:name => 'Steve Richert')
@user.update_attribute(:last_name, 'Jobs')
end
should "update the journal record's tag column" do
tag_name = 'TAG'
last_journal = @user.journals.last
assert_not_equal tag_name, last_journal.tag
@user.tag_journal(tag_name)
assert_equal tag_name, last_journal.reload.tag
end
should 'create a journal record for an initial journal' do
@user.revert_to(1)
assert_nil @user.journals.at(1)
@user.tag_journal('TAG')
assert_not_nil @user.journals.at(1)
end
end
context 'A tagged journal' do
setup do
user = User.create(:name => 'Steve Richert')
user.update_attribute(:last_name, 'Jobs')
user.tag_journal('TAG')
@journal = user.journals.last
end
should 'return true for the "tagged?" method' do
assert @journal.respond_to?(:tagged?)
assert_equal true, @journal.tagged?
end
end
end
$: << File.join(File.dirname(__FILE__), '..', 'lib')
$: << File.dirname(__FILE__)
require 'rubygems'
require 'test/unit'
require 'active_record'
require 'shoulda'
require 'mocha'
require 'vestal_versions'
require 'schema'
begin; require 'redgreen'; rescue LoadError; end
require 'test_helper'
class UsersTest < Test::Unit::TestCase
context 'The user responsible for an update' do
setup do
@updated_by = User.create(:name => 'Steve Jobs')
@user = User.create(:name => 'Steve Richert')
end
should 'default to nil' do
@user.update_attributes(:first_name => 'Stephen')
assert_nil @user.journals.last.user
end
should 'accept and return an ActiveRecord user' do
@user.update_attributes(:first_name => 'Stephen', :updated_by => @updated_by)
assert_equal @updated_by, @user.journals.last.user
end
should 'accept and return a string user name' do
@user.update_attributes(:first_name => 'Stephen', :updated_by => @updated_by.name)
assert_equal @updated_by.name, @user.journals.last.user
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class VersionTest < Test::Unit::TestCase
context 'Versions' do
setup do
@user = User.create(:name => 'Stephen Richert')
@user.update_attribute(:name, 'Steve Jobs')
@user.update_attribute(:last_name, 'Richert')
@first_journal, @last_journal = @user.journals.first, @user.journals.last
end
should 'be comparable to another journal based on journal number' do
assert @first_journal == @first_journal
assert @last_journal == @last_journal
assert @first_journal != @last_journal
assert @last_journal != @first_journal
assert @first_journal < @last_journal
assert @last_journal > @first_journal
assert @first_journal <= @last_journal
assert @last_journal >= @first_journal
end
should "not equal a separate model's journal with the same number" do
user = User.create(:name => 'Stephen Richert')
user.update_attribute(:name, 'Steve Jobs')
user.update_attribute(:last_name, 'Richert')
first_journal, last_journal = user.journals.first, user.journals.last
assert_not_equal @first_journal, first_journal
assert_not_equal @last_journal, last_journal
end
should 'default to ordering by number when finding through association' do
order = @user.journals.send(:scope, :find)[:order]
assert_equal 'journals.number ASC', order
end
should 'return true for the "initial?" method when the journal number is 1' do
journal = @user.journals.build(:number => 1)
assert_equal 1, journal.number
assert_equal true, journal.initial?
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class VersionedTest < Test::Unit::TestCase
context 'ActiveRecord models' do
should 'respond to the "journaled?" method' do
assert ActiveRecord::Base.respond_to?(:journaled?)
assert User.respond_to?(:journaled?)
end
should 'return true for the "journaled?" method if the model is journaled' do
assert_equal true, User.journaled?
end
should 'return false for the "journaled?" method if the model is not journaled' do
assert_equal false, ActiveRecord::Base.journaled?
end
end
end
require File.join(File.dirname(__FILE__), 'test_helper')
class VersionsTest < Test::Unit::TestCase
context 'A collection of associated journals' do
setup do
@user, @times = User.new, {}
names = ['Steve Richert', 'Stephen Richert', 'Stephen Jobs', 'Steve Jobs']
time = names.size.hours.ago
names.each do |name|
@user.update_attribute(:name, name)
@user.tag_journal(@user.journal.to_s)
time += 1.hour
@user.journals.last.update_attribute(:created_at, time)
@times[@user.journal] = time
end
end
should 'be searchable between two valid journal values' do
@times.keys.each do |number|
@times.values.each do |time|
assert_kind_of Array, @user.journals.between(number, number)
assert_kind_of Array, @user.journals.between(number, time)
assert_kind_of Array, @user.journals.between(time, number)
assert_kind_of Array, @user.journals.between(time, time)
assert !@user.journals.between(number, number).empty?
assert !@user.journals.between(number, time).empty?
assert !@user.journals.between(time, number).empty?
assert !@user.journals.between(time, time).empty?
end
end
end
should 'return an empty array when searching between a valid and an invalid journal value' do
@times.each do |number, time|
assert_equal [], @user.journals.between(number, nil)
assert_equal [], @user.journals.between(time, nil)
assert_equal [], @user.journals.between(nil, number)
assert_equal [], @user.journals.between(nil, time)
end
end
should 'return an empty array when searching between two invalid journal values' do
assert_equal [], @user.journals.between(nil, nil)
end
should 'be searchable before a valid journal value' do
@times.sort.each_with_index do |(number, time), i|
assert_equal i, @user.journals.before(number).size
assert_equal i, @user.journals.before(time).size
end
end
should 'return an empty array when searching before an invalid journal value' do
assert_equal [], @user.journals.before(nil)
end
should 'be searchable after a valid journal value' do
@times.sort.reverse.each_with_index do |(number, time), i|
assert_equal i, @user.journals.after(number).size
assert_equal i, @user.journals.after(time).size
end
end
should 'return an empty array when searching after an invalid journal value' do
assert_equal [], @user.journals.after(nil)
end
should 'be fetchable by journal number' do
@times.keys.each do |number|
assert_kind_of VestalVersions::Version, @user.journals.at(number)
assert_equal number, @user.journals.at(number).number
end
end
should 'be fetchable by tag' do
@times.keys.map{|n| [n, n.to_s] }.each do |number, tag|
assert_kind_of VestalVersions::Version, @user.journals.at(tag)
assert_equal number, @user.journals.at(tag).number
end
end
should "be fetchable by the exact time of a journal's creation" do
@times.each do |number, time|
assert_kind_of VestalVersions::Version, @user.journals.at(time)
assert_equal number, @user.journals.at(time).number
end
end
should "be fetchable by any time after the model's creation" do
@times.each do |number, time|
assert_kind_of VestalVersions::Version, @user.journals.at(time + 30.minutes)
assert_equal number, @user.journals.at(time + 30.minutes).number
end
end
should "return nil when fetching a time before the model's creation" do
creation = @times.values.min
assert_nil @user.journals.at(creation - 1.second)
end
should 'be fetchable by an association extension method' do
assert_kind_of VestalVersions::Version, @user.journals.at(:first)
assert_kind_of VestalVersions::Version, @user.journals.at(:last)
assert_equal @times.keys.min, @user.journals.at(:first).number
assert_equal @times.keys.max, @user.journals.at(:last).number
end
should 'be fetchable by a journal object' do
@times.keys.each do |number|
journal = @user.journals.at(number)
assert_kind_of VestalVersions::Version, journal
assert_kind_of VestalVersions::Version, @user.journals.at(journal)
assert_equal number, @user.journals.at(journal).number
end
end
should 'return nil when fetching an invalid journal value' do
assert_nil @user.journals.at(nil)
end
should 'provide a journal number for any given numeric journal value' do
@times.keys.each do |number|
assert_kind_of Fixnum, @user.journals.number_at(number)
assert_kind_of Fixnum, @user.journals.number_at(number + 0.5)
assert_equal @user.journals.number_at(number), @user.journals.number_at(number + 0.5)
end
end
should 'provide a journal number for a valid tag' do
@times.keys.map{|n| [n, n.to_s] }.each do |number, tag|
assert_kind_of Fixnum, @user.journals.number_at(tag)
assert_equal number, @user.journals.number_at(tag)
end
end
should 'return nil when providing a journal number for an invalid tag' do
assert_nil @user.journals.number_at('INVALID')
end
should 'provide a journal number of a journal corresponding to an association extension method' do
assert_kind_of VestalVersions::Version, @user.journals.at(:first)
assert_kind_of VestalVersions::Version, @user.journals.at(:last)
assert_equal @times.keys.min, @user.journals.number_at(:first)
assert_equal @times.keys.max, @user.journals.number_at(:last)
end
should 'return nil when providing a journal number for an invalid association extension method' do
assert_nil @user.journals.number_at(:INVALID)
end
should "provide a journal number for any time after the model's creation" do
@times.each do |number, time|
assert_kind_of Fixnum, @user.journals.number_at(time + 30.minutes)
assert_equal number, @user.journals.number_at(time + 30.minutes)
end
end
should "provide a journal number of 1 for a time before the model's creation" do
creation = @times.values.min
assert_equal 1, @user.journals.number_at(creation - 1.second)
end
should 'provide a journal number for a given journal object' do
@times.keys.each do |number|
journal = @user.journals.at(number)
assert_kind_of VestalVersions::Version, journal
assert_kind_of Fixnum, @user.journals.number_at(journal)
assert_equal number, @user.journals.number_at(journal)
end
end
end
end
*SVN* (version numbers are overrated)
* (5 Oct 2006) Allow customization of #versions association options [Dan Peterson]
*0.5.1*
* (8 Aug 2006) Versioned models now belong to the unversioned model. @article_version.article.class => Article [Aslak Hellesoy]
*0.5* # do versions even matter for plugins?
* (21 Apr 2006) Added without_locking and without_revision methods.
Foo.without_revision do
@foo.update_attributes ...
end
*0.4*
* (28 March 2006) Rename non_versioned_fields to non_versioned_columns (old one is kept for compatibility).
* (28 March 2006) Made explicit documentation note that string column names are required for non_versioned_columns.
*0.3.1*
* (7 Jan 2006) explicitly set :foreign_key option for the versioned model's belongs_to assocation for STI [Caged]
* (7 Jan 2006) added tests to prove has_many :through joins work
*0.3*
* (2 Jan 2006) added ability to share a mixin with versioned class
* (2 Jan 2006) changed the dynamic version model to MyModel::Version
*0.2.4*
* (27 Nov 2005) added note about possible destructive behavior of if_changed? [Michael Schuerig]
*0.2.3*
* (12 Nov 2005) fixed bug with old behavior of #blank? [Michael Schuerig]
* (12 Nov 2005) updated tests to use ActiveRecord Schema
*0.2.2*
* (3 Nov 2005) added documentation note to #acts_as_versioned [Martin Jul]
*0.2.1*
* (6 Oct 2005) renamed dirty? to changed? to keep it uniform. it was aliased to keep it backwards compatible.
*0.2*
* (6 Oct 2005) added find_versions and find_version class methods.
* (6 Oct 2005) removed transaction from create_versioned_table().
this way you can specify your own transaction around a group of operations.
* (30 Sep 2005) fixed bug where find_versions() would order by 'version' twice. (found by Joe Clark)
* (26 Sep 2005) added :sequence_name option to acts_as_versioned to set the sequence name on the versioned model
*0.1.3* (18 Sep 2005)
* First RubyForge release
*0.1.2*
* check if module is already included when acts_as_versioned is called
*0.1.1*
* Adding tests and rdocs
*0.1*
* Initial transfer from Rails ticket: http://dev.rubyonrails.com/ticket/1974
\ No newline at end of file
Copyright (c) 2005 Rick Olson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
= acts_as_versioned
This library adds simple versioning to an ActiveRecord module. ActiveRecord is required.
== Resources
Install
* gem install acts_as_versioned
Rubyforge project
* http://rubyforge.org/projects/ar-versioned
RDocs
* http://ar-versioned.rubyforge.org
Subversion
* http://techno-weenie.net/svn/projects/acts_as_versioned
Collaboa
* http://collaboa.techno-weenie.net/repository/browse/acts_as_versioned
Special thanks to Dreamer on ##rubyonrails for help in early testing. His ServerSideWiki (http://serversidewiki.com)
was the first project to use acts_as_versioned <em>in the wild</em>.
\ No newline at end of file
== Creating the test database
The default name for the test databases is "activerecord_versioned". If you
want to use another database name then be sure to update the connection
adapter setups you want to test with in test/connections/<your database>/connection.rb.
When you have the database online, you can import the fixture tables with
the test/fixtures/db_definitions/*.sql files.
Make sure that you create database objects with the same user that you specified in i
connection.rb otherwise (on Postgres, at least) tests for default values will fail.
== Running with Rake
The easiest way to run the unit tests is through Rake. The default task runs
the entire test suite for all the adapters. You can also run the suite on just
one adapter by using the tasks test_mysql_ruby, test_ruby_mysql, test_sqlite,
or test_postresql. For more information, checkout the full array of rake tasks with "rake -T"
Rake can be found at http://rake.rubyforge.org
== Running by hand
Unit tests are located in test directory. If you only want to run a single test suite,
or don't want to bother with Rake, you can do so with something like:
cd test; ruby -I "connections/native_mysql" base_test.rb
That'll run the base suite using the MySQL-Ruby adapter. Change the adapter
and test suite name as needed.
== Faster tests
If you are using a database that supports transactions, you can set the
"AR_TX_FIXTURES" environment variable to "yes" to use transactional fixtures.
This gives a very large speed boost. With rake:
rake AR_TX_FIXTURES=yes
Or, by hand:
AR_TX_FIXTURES=yes ruby -I connections/native_sqlite3 base_test.rb
require 'rubygems'
Gem::manage_gems
require 'rake/rdoctask'
require 'rake/packagetask'
require 'rake/gempackagetask'
require 'rake/testtask'
require 'rake/contrib/rubyforgepublisher'
PKG_NAME = 'acts_as_versioned'
PKG_VERSION = '0.3.1'
PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
PROD_HOST = "technoweenie@bidwell.textdrive.com"
RUBY_FORGE_PROJECT = 'ar-versioned'
RUBY_FORGE_USER = 'technoweenie'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the calculations plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the calculations plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = "#{PKG_NAME} -- Simple versioning with active record models"
rdoc.options << '--line-numbers --inline-source'
rdoc.rdoc_files.include('README', 'CHANGELOG', 'RUNNING_UNIT_TESTS')
rdoc.rdoc_files.include('lib/**/*.rb')
end
spec = Gem::Specification.new do |s|
s.name = PKG_NAME
s.version = PKG_VERSION
s.platform = Gem::Platform::RUBY
s.summary = "Simple versioning with active record models"
s.files = FileList["{lib,test}/**/*"].to_a + %w(README MIT-LICENSE CHANGELOG RUNNING_UNIT_TESTS)
s.files.delete "acts_as_versioned_plugin.sqlite.db"
s.files.delete "acts_as_versioned_plugin.sqlite3.db"
s.files.delete "test/debug.log"
s.require_path = 'lib'
s.autorequire = 'acts_as_versioned'
s.has_rdoc = true
s.test_files = Dir['test/**/*_test.rb']
s.add_dependency 'activerecord', '>= 1.10.1'
s.add_dependency 'activesupport', '>= 1.1.1'
s.author = "Rick Olson"
s.email = "technoweenie@gmail.com"
s.homepage = "http://techno-weenie.net"
end
Rake::GemPackageTask.new(spec) do |pkg|
pkg.need_tar = true
end
desc "Publish the API documentation"
task :pdoc => [:rdoc] do
Rake::RubyForgePublisher.new(RUBY_FORGE_PROJECT, RUBY_FORGE_USER).upload
end
desc 'Publish the gem and API docs'
task :publish => [:pdoc, :rubyforge_upload]
desc "Publish the release files to RubyForge."
task :rubyforge_upload => :package do
files = %w(gem tgz).map { |ext| "pkg/#{PKG_FILE_NAME}.#{ext}" }
if RUBY_FORGE_PROJECT then
require 'net/http'
require 'open-uri'
project_uri = "http://rubyforge.org/projects/#{RUBY_FORGE_PROJECT}/"
project_data = open(project_uri) { |data| data.read }
group_id = project_data[/[?&]group_id=(\d+)/, 1]
raise "Couldn't get group id" unless group_id
# This echos password to shell which is a bit sucky
if ENV["RUBY_FORGE_PASSWORD"]
password = ENV["RUBY_FORGE_PASSWORD"]
else
print "#{RUBY_FORGE_USER}@rubyforge.org's password: "
password = STDIN.gets.chomp
end
login_response = Net::HTTP.start("rubyforge.org", 80) do |http|
data = [
"login=1",
"form_loginname=#{RUBY_FORGE_USER}",
"form_pw=#{password}"
].join("&")
http.post("/account/login.php", data)
end
cookie = login_response["set-cookie"]
raise "Login failed" unless cookie
headers = { "Cookie" => cookie }
release_uri = "http://rubyforge.org/frs/admin/?group_id=#{group_id}"
release_data = open(release_uri, headers) { |data| data.read }
package_id = release_data[/[?&]package_id=(\d+)/, 1]
raise "Couldn't get package id" unless package_id
first_file = true
release_id = ""
files.each do |filename|
basename = File.basename(filename)
file_ext = File.extname(filename)
file_data = File.open(filename, "rb") { |file| file.read }
puts "Releasing #{basename}..."
release_response = Net::HTTP.start("rubyforge.org", 80) do |http|
release_date = Time.now.strftime("%Y-%m-%d %H:%M")
type_map = {
".zip" => "3000",
".tgz" => "3110",
".gz" => "3110",
".gem" => "1400"
}; type_map.default = "9999"
type = type_map[file_ext]
boundary = "rubyqMY6QN9bp6e4kS21H4y0zxcvoor"
query_hash = if first_file then
{
"group_id" => group_id,
"package_id" => package_id,
"release_name" => PKG_FILE_NAME,
"release_date" => release_date,
"type_id" => type,
"processor_id" => "8000", # Any
"release_notes" => "",
"release_changes" => "",
"preformatted" => "1",
"submit" => "1"
}
else
{
"group_id" => group_id,
"release_id" => release_id,
"package_id" => package_id,
"step2" => "1",
"type_id" => type,
"processor_id" => "8000", # Any
"submit" => "Add This File"
}
end
query = "?" + query_hash.map do |(name, value)|
[name, URI.encode(value)].join("=")
end.join("&")
data = [
"--" + boundary,
"Content-Disposition: form-data; name=\"userfile\"; filename=\"#{basename}\"",
"Content-Type: application/octet-stream",
"Content-Transfer-Encoding: binary",
"", file_data, ""
].join("\x0D\x0A")
release_headers = headers.merge(
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
)
target = first_file ? "/frs/admin/qrs.php" : "/frs/admin/editrelease.php"
http.post(target + query, data, release_headers)
end
if first_file then
release_id = release_response.body[/release_id=(\d+)/, 1]
raise("Couldn't get release id") unless release_id
end
first_file = false
end
end
end
\ No newline at end of file
require 'acts_as_versioned'
\ No newline at end of file
# Copyright (c) 2005 Rick Olson
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module ActiveRecord #:nodoc:
module Acts #:nodoc:
# Specify this act if you want to save a copy of the row in a versioned table. This assumes there is a
# versioned table ready and that your model has a version field. This works with optimistic locking if the lock_version
# column is present as well.
#
# The class for the versioned model is derived the first time it is seen. Therefore, if you change your database schema you have to restart
# your container for the changes to be reflected. In development mode this usually means restarting WEBrick.
#
# class Page < ActiveRecord::Base
# # assumes pages_versions table
# acts_as_versioned
# end
#
# Example:
#
# page = Page.create(:title => 'hello world!')
# page.version # => 1
#
# page.title = 'hello world'
# page.save
# page.version # => 2
# page.versions.size # => 2
#
# page.revert_to(1) # using version number
# page.title # => 'hello world!'
#
# page.revert_to(page.versions.last) # using versioned instance
# page.title # => 'hello world'
#
# page.versions.earliest # efficient query to find the first version
# page.versions.latest # efficient query to find the most recently created version
#
#
# Simple Queries to page between versions
#
# page.versions.before(version)
# page.versions.after(version)
#
# Access the previous/next versions from the versioned model itself
#
# version = page.versions.latest
# version.previous # go back one version
# version.next # go forward one version
#
# See ActiveRecord::Acts::Versioned::ClassMethods#acts_as_versioned for configuration options
module Versioned
CALLBACKS = [:set_new_version, :save_version_on_create, :save_version?, :clear_altered_attributes]
def self.included(base) # :nodoc:
base.extend ClassMethods
end
module ClassMethods
# == Configuration options
#
# * <tt>class_name</tt> - versioned model class name (default: PageVersion in the above example)
# * <tt>table_name</tt> - versioned model table name (default: page_versions in the above example)
# * <tt>foreign_key</tt> - foreign key used to relate the versioned model to the original model (default: page_id in the above example)
# * <tt>inheritance_column</tt> - name of the column to save the model's inheritance_column value for STI. (default: versioned_type)
# * <tt>version_column</tt> - name of the column in the model that keeps the version number (default: version)
# * <tt>sequence_name</tt> - name of the custom sequence to be used by the versioned model.
# * <tt>limit</tt> - number of revisions to keep, defaults to unlimited
# * <tt>if</tt> - symbol of method to check before saving a new version. If this method returns false, a new version is not saved.
# For finer control, pass either a Proc or modify Model#version_condition_met?
#
# acts_as_versioned :if => Proc.new { |auction| !auction.expired? }
#
# or...
#
# class Auction
# def version_condition_met? # totally bypasses the <tt>:if</tt> option
# !expired?
# end
# end
#
# * <tt>if_changed</tt> - Simple way of specifying attributes that are required to be changed before saving a model. This takes
# either a symbol or array of symbols. WARNING - This will attempt to overwrite any attribute setters you may have.
# Use this instead if you want to write your own attribute setters (and ignore if_changed):
#
# def name=(new_name)
# write_changed_attribute :name, new_name
# end
#
# * <tt>extend</tt> - Lets you specify a module to be mixed in both the original and versioned models. You can also just pass a block
# to create an anonymous mixin:
#
# class Auction
# acts_as_versioned do
# def started?
# !started_at.nil?
# end
# end
# end
#
# or...
#
# module AuctionExtension
# def started?
# !started_at.nil?
# end
# end
# class Auction
# acts_as_versioned :extend => AuctionExtension
# end
#
# Example code:
#
# @auction = Auction.find(1)
# @auction.started?
# @auction.versions.first.started?
#
# == Database Schema
#
# The model that you're versioning needs to have a 'version' attribute. The model is versioned
# into a table called #{model}_versions where the model name is singlular. The _versions table should
# contain all the fields you want versioned, the same version column, and a #{model}_id foreign key field.
#
# A lock_version field is also accepted if your model uses Optimistic Locking. If your table uses Single Table inheritance,
# then that field is reflected in the versioned model as 'versioned_type' by default.
#
# Acts_as_versioned comes prepared with the ActiveRecord::Acts::Versioned::ActMethods::ClassMethods#create_versioned_table
# method, perfect for a migration. It will also create the version column if the main model does not already have it.
#
# class AddVersions < ActiveRecord::Migration
# def self.up
# # create_versioned_table takes the same options hash
# # that create_table does
# Post.create_versioned_table
# end
#
# def self.down
# Post.drop_versioned_table
# end
# end
#
# == Changing What Fields Are Versioned
#
# By default, acts_as_versioned will version all but these fields:
#
# [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]
#
# You can add or change those by modifying #non_versioned_columns. Note that this takes strings and not symbols.
#
# class Post < ActiveRecord::Base
# acts_as_versioned
# self.non_versioned_columns << 'comments_count'
# end
#
def acts_as_versioned(options = {}, &extension)
# don't allow multiple calls
return if self.included_modules.include?(ActiveRecord::Acts::Versioned::ActMethods)
send :include, ActiveRecord::Acts::Versioned::ActMethods
cattr_accessor :versioned_class_name, :versioned_foreign_key, :versioned_table_name, :versioned_inheritance_column,
:version_column, :max_version_limit, :track_altered_attributes, :version_condition, :version_sequence_name, :non_versioned_columns,
:version_association_options
# legacy
alias_method :non_versioned_fields, :non_versioned_columns
alias_method :non_versioned_fields=, :non_versioned_columns=
class << self
alias_method :non_versioned_fields, :non_versioned_columns
alias_method :non_versioned_fields=, :non_versioned_columns=
end
send :attr_accessor, :altered_attributes
self.versioned_class_name = options[:class_name] || "Version"
self.versioned_foreign_key = options[:foreign_key] || self.to_s.foreign_key
self.versioned_table_name = options[:table_name] || "#{table_name_prefix}#{base_class.name.demodulize.underscore}_versions#{table_name_suffix}"
self.versioned_inheritance_column = options[:inheritance_column] || "versioned_#{inheritance_column}"
self.version_column = options[:version_column] || 'version'
self.version_sequence_name = options[:sequence_name]
self.max_version_limit = options[:limit].to_i
self.version_condition = options[:if] || true
self.non_versioned_columns = [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]
self.version_association_options = {
:class_name => "#{self.to_s}::#{versioned_class_name}",
:foreign_key => versioned_foreign_key,
:dependent => :delete_all
}.merge(options[:association_options] || {})
if block_given?
extension_module_name = "#{versioned_class_name}Extension"
silence_warnings do
self.const_set(extension_module_name, Module.new(&extension))
end
options[:extend] = self.const_get(extension_module_name)
end
class_eval do
has_many :versions, version_association_options do
# finds earliest version of this record
def earliest
@earliest ||= find(:first, :order => 'version')
end
# find latest version of this record
def latest
@latest ||= find(:first, :order => 'version desc')
end
end
before_save :set_new_version
after_create :save_version_on_create
after_update :save_version
after_save :clear_old_versions
after_save :clear_altered_attributes
unless options[:if_changed].nil?
self.track_altered_attributes = true
options[:if_changed] = [options[:if_changed]] unless options[:if_changed].is_a?(Array)
options[:if_changed].each do |attr_name|
define_method("#{attr_name}=") do |value|
write_changed_attribute attr_name, value
end
end
end
include options[:extend] if options[:extend].is_a?(Module)
end
# create the dynamic versioned model
const_set(versioned_class_name, Class.new(ActiveRecord::Base)).class_eval do
def self.reloadable? ; false ; end
# find first version before the given version
def self.before(version)
find :first, :order => 'version desc',
:conditions => ["#{original_class.versioned_foreign_key} = ? and version < ?", version.send(original_class.versioned_foreign_key), version.version]
end
# find first version after the given version.
def self.after(version)
find :first, :order => 'version',
:conditions => ["#{original_class.versioned_foreign_key} = ? and version > ?", version.send(original_class.versioned_foreign_key), version.version]
end
def previous
self.class.before(self)
end
def next
self.class.after(self)
end
def versions_count
page.version
end
end
versioned_class.cattr_accessor :original_class
versioned_class.original_class = self
versioned_class.set_table_name versioned_table_name
versioned_class.belongs_to self.to_s.demodulize.underscore.to_sym,
:class_name => "::#{self.to_s}",
:foreign_key => versioned_foreign_key
versioned_class.send :include, options[:extend] if options[:extend].is_a?(Module)
versioned_class.set_sequence_name version_sequence_name if version_sequence_name
end
end
module ActMethods
def self.included(base) # :nodoc:
base.extend ClassMethods
end
# Finds a specific version of this record
def find_version(version = nil)
self.class.find_version(id, version)
end
# Saves a version of the model if applicable
def save_version
save_version_on_create if save_version?
end
# Saves a version of the model in the versioned table. This is called in the after_save callback by default
def save_version_on_create
rev = self.class.versioned_class.new
self.clone_versioned_model(self, rev)
rev.version = send(self.class.version_column)
rev.send("#{self.class.versioned_foreign_key}=", self.id)
rev.save
end
# Clears old revisions if a limit is set with the :limit option in <tt>acts_as_versioned</tt>.
# Override this method to set your own criteria for clearing old versions.
def clear_old_versions
return if self.class.max_version_limit == 0
excess_baggage = send(self.class.version_column).to_i - self.class.max_version_limit
if excess_baggage > 0
sql = "DELETE FROM #{self.class.versioned_table_name} WHERE version <= #{excess_baggage} AND #{self.class.versioned_foreign_key} = #{self.id}"
self.class.versioned_class.connection.execute sql
end
end
def versions_count
version
end
# Reverts a model to a given version. Takes either a version number or an instance of the versioned model
def revert_to(version)
if version.is_a?(self.class.versioned_class)
return false unless version.send(self.class.versioned_foreign_key) == self.id and !version.new_record?
else
return false unless version = versions.find_by_version(version)
end
self.clone_versioned_model(version, self)
self.send("#{self.class.version_column}=", version.version)
true
end
# Reverts a model to a given version and saves the model.
# Takes either a version number or an instance of the versioned model
def revert_to!(version)
revert_to(version) ? save_without_revision : false
end
# Temporarily turns off Optimistic Locking while saving. Used when reverting so that a new version is not created.
def save_without_revision
save_without_revision!
true
rescue
false
end
def save_without_revision!
without_locking do
without_revision do
save!
end
end
end
# Returns an array of attribute keys that are versioned. See non_versioned_columns
def versioned_attributes
self.attributes.keys.select { |k| !self.class.non_versioned_columns.include?(k) }
end
# If called with no parameters, gets whether the current model has changed and needs to be versioned.
# If called with a single parameter, gets whether the parameter has changed.
def changed?(attr_name = nil)
attr_name.nil? ?
(!self.class.track_altered_attributes || (altered_attributes && altered_attributes.length > 0)) :
(altered_attributes && altered_attributes.include?(attr_name.to_s))
end
# keep old dirty? method
alias_method :dirty?, :changed?
# Clones a model. Used when saving a new version or reverting a model's version.
def clone_versioned_model(orig_model, new_model)
self.versioned_attributes.each do |key|
new_model.send("#{key}=", orig_model.send(key)) if orig_model.has_attribute?(key)
end
if orig_model.is_a?(self.class.versioned_class)
new_model[new_model.class.inheritance_column] = orig_model[self.class.versioned_inheritance_column]
elsif new_model.is_a?(self.class.versioned_class)
new_model[self.class.versioned_inheritance_column] = orig_model[orig_model.class.inheritance_column]
end
end
# Checks whether a new version shall be saved or not. Calls <tt>version_condition_met?</tt> and <tt>changed?</tt>.
def save_version?
version_condition_met? && changed?
end
# Checks condition set in the :if option to check whether a revision should be created or not. Override this for
# custom version condition checking.
def version_condition_met?
case
when version_condition.is_a?(Symbol)
send(version_condition)
when version_condition.respond_to?(:call) && (version_condition.arity == 1 || version_condition.arity == -1)
version_condition.call(self)
else
version_condition
end
end
# Executes the block with the versioning callbacks disabled.
#
# @foo.without_revision do
# @foo.save
# end
#
def without_revision(&block)
self.class.without_revision(&block)
end
# Turns off optimistic locking for the duration of the block
#
# @foo.without_locking do
# @foo.save
# end
#
def without_locking(&block)
self.class.without_locking(&block)
end
def empty_callback() end #:nodoc:
protected
# sets the new version before saving, unless you're using optimistic locking. In that case, let it take care of the version.
def set_new_version
self.send("#{self.class.version_column}=", self.next_version) if new_record? || (!locking_enabled? && save_version?)
end
# Gets the next available version for the current record, or 1 for a new record
def next_version
return 1 if new_record?
(versions.calculate(:max, :version) || 0) + 1
end
# clears current changed attributes. Called after save.
def clear_altered_attributes
self.altered_attributes = []
end
def write_changed_attribute(attr_name, attr_value)
# Convert to db type for comparison. Avoids failing Float<=>String comparisons.
attr_value_for_db = self.class.columns_hash[attr_name.to_s].type_cast(attr_value)
(self.altered_attributes ||= []) << attr_name.to_s unless self.changed?(attr_name) || self.send(attr_name) == attr_value_for_db
write_attribute(attr_name, attr_value_for_db)
end
module ClassMethods
# Finds a specific version of a specific row of this model
def find_version(id, version = nil)
return find(id) unless version
conditions = ["#{versioned_foreign_key} = ? AND version = ?", id, version]
options = { :conditions => conditions, :limit => 1 }
if result = find_versions(id, options).first
result
else
raise RecordNotFound, "Couldn't find #{name} with ID=#{id} and VERSION=#{version}"
end
end
# Finds versions of a specific model. Takes an options hash like <tt>find</tt>
def find_versions(id, options = {})
versioned_class.find :all, {
:conditions => ["#{versioned_foreign_key} = ?", id],
:order => 'version' }.merge(options)
end
# Returns an array of columns that are versioned. See non_versioned_columns
def versioned_columns
self.columns.select { |c| !non_versioned_columns.include?(c.name) }
end
# Returns an instance of the dynamic versioned model
def versioned_class
const_get versioned_class_name
end
# Rake migration task to create the versioned table using options passed to acts_as_versioned
def create_versioned_table(create_table_options = {})
# create version column in main table if it does not exist
if !self.content_columns.find { |c| %w(version lock_version).include? c.name }
self.connection.add_column table_name, :version, :integer
end
self.connection.create_table(versioned_table_name, create_table_options) do |t|
t.column versioned_foreign_key, :integer
t.column :version, :integer
end
updated_col = nil
self.versioned_columns.each do |col|
updated_col = col if !updated_col && %(updated_at updated_on).include?(col.name)
self.connection.add_column versioned_table_name, col.name, col.type,
:limit => col.limit,
:default => col.default,
:scale => col.scale,
:precision => col.precision
end
if type_col = self.columns_hash[inheritance_column]
self.connection.add_column versioned_table_name, versioned_inheritance_column, type_col.type,
:limit => type_col.limit,
:default => type_col.default,
:scale => type_col.scale,
:precision => type_col.precision
end
if updated_col.nil?
self.connection.add_column versioned_table_name, :updated_at, :timestamp
end
end
# Rake migration task to drop the versioned table
def drop_versioned_table
self.connection.drop_table versioned_table_name
end
# Executes the block with the versioning callbacks disabled.
#
# Foo.without_revision do
# @foo.save
# end
#
def without_revision(&block)
class_eval do
CALLBACKS.each do |attr_name|
alias_method "orig_#{attr_name}".to_sym, attr_name
alias_method attr_name, :empty_callback
end
end
block.call
ensure
class_eval do
CALLBACKS.each do |attr_name|
alias_method attr_name, "orig_#{attr_name}".to_sym
end
end
end
# Turns off optimistic locking for the duration of the block
#
# Foo.without_locking do
# @foo.save
# end
#
def without_locking(&block)
current = ActiveRecord::Base.lock_optimistically
ActiveRecord::Base.lock_optimistically = false if current
result = block.call
ActiveRecord::Base.lock_optimistically = true if current
result
end
end
end
end
end
end
ActiveRecord::Base.send :include, ActiveRecord::Acts::Versioned
\ No newline at end of file
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activesupport/lib')
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activerecord/lib')
$:.unshift(File.dirname(__FILE__) + '/../lib')
require 'test/unit'
begin
require 'active_support'
require 'active_record'
require 'active_record/fixtures'
rescue LoadError
require 'rubygems'
retry
end
require 'acts_as_versioned'
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
ActiveRecord::Base.configurations = {'test' => config[ENV['DB'] || 'sqlite3']}
ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test'])
load(File.dirname(__FILE__) + "/schema.rb")
# set up custom sequence on widget_versions for DBs that support sequences
if ENV['DB'] == 'postgresql'
ActiveRecord::Base.connection.execute "DROP SEQUENCE widgets_seq;" rescue nil
ActiveRecord::Base.connection.remove_column :widget_versions, :id
ActiveRecord::Base.connection.execute "CREATE SEQUENCE widgets_seq START 101;"
ActiveRecord::Base.connection.execute "ALTER TABLE widget_versions ADD COLUMN id INTEGER PRIMARY KEY DEFAULT nextval('widgets_seq');"
end
Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
$:.unshift(Test::Unit::TestCase.fixture_path)
class Test::Unit::TestCase #:nodoc:
# Turn off transactional fixtures if you're working with MyISAM tables in MySQL
self.use_transactional_fixtures = true
# Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
self.use_instantiated_fixtures = false
# Add more helper methods to be used by all tests here...
end
\ No newline at end of file
sqlite:
:adapter: sqlite
:dbfile: acts_as_versioned_plugin.sqlite.db
sqlite3:
:adapter: sqlite3
:dbfile: acts_as_versioned_plugin.sqlite3.db
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: acts_as_versioned_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: rails
:password:
:database: acts_as_versioned_plugin_test
\ No newline at end of file
caged:
id: 1
name: caged
mly:
id: 2
name: mly
\ No newline at end of file
class Landmark < ActiveRecord::Base
acts_as_versioned :if_changed => [ :name, :longitude, :latitude ]
end
washington:
id: 1
landmark_id: 1
version: 1
name: Washington, D.C.
latitude: 38.895
longitude: -77.036667
washington:
id: 1
name: Washington, D.C.
latitude: 38.895
longitude: -77.036667
version: 1
welcome:
id: 1
title: Welcome to the weblog
lock_version: 24
type: LockedPage
thinking:
id: 2
title: So I was thinking
lock_version: 24
type: SpecialLockedPage
welcome_1:
id: 1
page_id: 1
title: Welcome to the weblg
version: 23
version_type: LockedPage
welcome_2:
id: 2
page_id: 1
title: Welcome to the weblog
version: 24
version_type: LockedPage
thinking_1:
id: 3
page_id: 2
title: So I was thinking!!!
version: 23
version_type: SpecialLockedPage
thinking_2:
id: 4
page_id: 2
title: So I was thinking
version: 24
version_type: SpecialLockedPage
class AddVersionedTables < ActiveRecord::Migration
def self.up
create_table("things") do |t|
t.column :title, :text
end
Thing.create_versioned_table
end
def self.down
Thing.drop_versioned_table
drop_table "things" rescue nil
end
end
\ No newline at end of file
class Page < ActiveRecord::Base
belongs_to :author
has_many :authors, :through => :versions, :order => 'name'
belongs_to :revisor, :class_name => 'Author'
has_many :revisors, :class_name => 'Author', :through => :versions, :order => 'name'
acts_as_versioned :if => :feeling_good? do
def self.included(base)
base.cattr_accessor :feeling_good
base.feeling_good = true
base.belongs_to :author
base.belongs_to :revisor, :class_name => 'Author'
end
def feeling_good?
@@feeling_good == true
end
end
end
module LockedPageExtension
def hello_world
'hello_world'
end
end
class LockedPage < ActiveRecord::Base
acts_as_versioned \
:inheritance_column => :version_type,
:foreign_key => :page_id,
:table_name => :locked_pages_revisions,
:class_name => 'LockedPageRevision',
:version_column => :lock_version,
:limit => 2,
:if_changed => :title,
:extend => LockedPageExtension
end
class SpecialLockedPage < LockedPage
end
class Author < ActiveRecord::Base
has_many :pages
end
\ No newline at end of file
welcome_2:
id: 1
page_id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24
author_id: 1
revisor_id: 1
welcome_1:
id: 2
page_id: 1
title: Welcome to the weblg
body: Such a lovely day
version: 23
author_id: 2
revisor_id: 2
welcome:
id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24
author_id: 1
revisor_id: 1
\ No newline at end of file
class Widget < ActiveRecord::Base
acts_as_versioned :sequence_name => 'widgets_seq', :association_options => {
:dependent => :nullify, :order => 'version desc'
}
non_versioned_columns << 'foo'
end
\ No newline at end of file
require File.join(File.dirname(__FILE__), 'abstract_unit')
if ActiveRecord::Base.connection.supports_migrations?
class Thing < ActiveRecord::Base
attr_accessor :version
acts_as_versioned
end
class MigrationTest < Test::Unit::TestCase
self.use_transactional_fixtures = false
def teardown
if ActiveRecord::Base.connection.respond_to?(:initialize_schema_information)
ActiveRecord::Base.connection.initialize_schema_information
ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0"
else
ActiveRecord::Base.connection.initialize_schema_migrations_table
ActiveRecord::Base.connection.assume_migrated_upto_version(0)
end
Thing.connection.drop_table "things" rescue nil
Thing.connection.drop_table "thing_versions" rescue nil
Thing.reset_column_information
end
def test_versioned_migration
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
# take 'er up
ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
t = Thing.create :title => 'blah blah', :price => 123.45, :type => 'Thing'
assert_equal 1, t.versions.size
# check that the price column has remembered its value correctly
assert_equal t.price, t.versions.first.price
assert_equal t.title, t.versions.first.title
assert_equal t[:type], t.versions.first[:type]
# make sure that the precision of the price column has been preserved
assert_equal 7, Thing::Version.columns.find{|c| c.name == "price"}.precision
assert_equal 2, Thing::Version.columns.find{|c| c.name == "price"}.scale
# now lets take 'er back down
ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/')
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
end
end
end
ActiveRecord::Schema.define(:version => 0) do
create_table :pages, :force => true do |t|
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
t.column :author_id, :integer
t.column :revisor_id, :integer
end
create_table :page_versions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
t.column :author_id, :integer
t.column :revisor_id, :integer
end
create_table :authors, :force => true do |t|
t.column :page_id, :integer
t.column :name, :string
end
create_table :locked_pages, :force => true do |t|
t.column :lock_version, :integer
t.column :title, :string, :limit => 255
t.column :type, :string, :limit => 255
end
create_table :locked_pages_revisions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :version_type, :string, :limit => 255
t.column :updated_at, :datetime
end
create_table :widgets, :force => true do |t|
t.column :name, :string, :limit => 50
t.column :foo, :string
t.column :version, :integer
t.column :updated_at, :datetime
end
create_table :widget_versions, :force => true do |t|
t.column :widget_id, :integer
t.column :name, :string, :limit => 50
t.column :version, :integer
t.column :updated_at, :datetime
end
create_table :landmarks, :force => true do |t|
t.column :name, :string
t.column :latitude, :float
t.column :longitude, :float
t.column :version, :integer
end
create_table :landmark_versions, :force => true do |t|
t.column :landmark_id, :integer
t.column :name, :string
t.column :latitude, :float
t.column :longitude, :float
t.column :version, :integer
end
end
require File.join(File.dirname(__FILE__), 'abstract_unit')
require File.join(File.dirname(__FILE__), 'fixtures/page')
require File.join(File.dirname(__FILE__), 'fixtures/widget')
class VersionedTest < Test::Unit::TestCase
fixtures :pages, :page_versions, :locked_pages, :locked_pages_revisions, :authors, :landmarks, :landmark_versions
set_fixture_class :page_versions => Page::Version
def test_saves_versioned_copy
p = Page.create! :title => 'first title', :body => 'first body'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_equal 1, p.version
assert_instance_of Page.versioned_class, p.versions.first
end
def test_saves_without_revision
p = pages(:welcome)
old_versions = p.versions.count
p.save_without_revision
p.without_revision do
p.update_attributes :title => 'changed'
end
assert_equal old_versions, p.versions.count
end
def test_rollback_with_version_number
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_versioned_class_name
assert_equal 'Version', Page.versioned_class_name
assert_equal 'LockedPageRevision', LockedPage.versioned_class_name
end
def test_versioned_class
assert_equal Page::Version, Page.versioned_class
assert_equal LockedPage::LockedPageRevision, LockedPage.versioned_class
end
def test_special_methods
assert_nothing_raised { pages(:welcome).feeling_good? }
assert_nothing_raised { pages(:welcome).versions.first.feeling_good? }
assert_nothing_raised { locked_pages(:welcome).hello_world }
assert_nothing_raised { locked_pages(:welcome).versions.first.hello_world }
end
def test_rollback_with_version_class
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_rollback_fails_with_invalid_revision
p = locked_pages(:welcome)
assert !p.revert_to!(locked_pages(:thinking))
end
def test_saves_versioned_copy_with_options
p = LockedPage.create! :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
end
def test_rollback_with_version_number_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_rollback_with_version_class_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first), "Couldn't revert to 1"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_saves_versioned_copy_with_sti
p = SpecialLockedPage.create! :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_rollback_with_version_number_with_sti
p = locked_pages(:thinking)
assert_equal 'So I was thinking', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 1"
assert_equal 'So I was thinking!!!', p.title
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_lock_version_works_with_versioning
p = locked_pages(:thinking)
p2 = LockedPage.find(p.id)
p.title = 'fresh title'
p.save
assert_equal 2, p.versions.size # limit!
assert_raises(ActiveRecord::StaleObjectError) do
p2.title = 'stale title'
p2.save
end
end
def test_version_if_condition
p = Page.create! :title => "title"
assert_equal 1, p.version
Page.feeling_good = false
p.save
assert_equal 1, p.version
Page.feeling_good = true
end
def test_version_if_condition2
# set new if condition
Page.class_eval do
def new_feeling_good() title[0..0] == 'a'; end
alias_method :old_feeling_good, :feeling_good?
alias_method :feeling_good?, :new_feeling_good
end
p = Page.create! :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'new title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.class_eval { alias_method :feeling_good?, :old_feeling_good }
end
def test_version_if_condition_with_block
# set new if condition
old_condition = Page.version_condition
Page.version_condition = Proc.new { |page| page.title[0..0] == 'b' }
p = Page.create! :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'b title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.version_condition = old_condition
end
def test_version_no_limit
p = Page.create! :title => "title", :body => 'first body'
p.save
p.save
5.times do |i|
assert_page_title p, i
end
end
def test_version_max_limit
p = LockedPage.create! :title => "title"
p.update_attributes(:title => "title1")
p.update_attributes(:title => "title2")
5.times do |i|
assert_page_title p, i, :lock_version
assert p.versions(true).size <= 2, "locked version can only store 2 versions"
end
end
def test_track_altered_attributes_default_value
assert !Page.track_altered_attributes
assert LockedPage.track_altered_attributes
assert SpecialLockedPage.track_altered_attributes
end
def test_version_order
assert_equal 23, pages(:welcome).versions.first.version
assert_equal 24, pages(:welcome).versions.last.version
end
def test_track_altered_attributes
p = LockedPage.create! :title => "title"
assert_equal 1, p.lock_version
assert_equal 1, p.versions(true).size
p.title = 'title'
assert !p.save_version?
p.save
assert_equal 2, p.lock_version # still increments version because of optimistic locking
assert_equal 1, p.versions(true).size
p.title = 'updated title'
assert p.save_version?
p.save
assert_equal 3, p.lock_version
assert_equal 1, p.versions(true).size # version 1 deleted
p.title = 'updated title!'
assert p.save_version?
p.save
assert_equal 4, p.lock_version
assert_equal 2, p.versions(true).size # version 1 deleted
end
def assert_page_title(p, i, version_field = :version)
p.title = "title#{i}"
p.save
assert_equal "title#{i}", p.title
assert_equal (i+4), p.send(version_field)
end
def test_find_versions
assert_equal 2, locked_pages(:welcome).versions.size
assert_equal 1, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%weblog%']).length
assert_equal 2, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length
assert_equal 0, locked_pages(:thinking).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length
assert_equal 2, locked_pages(:welcome).versions.length
end
def test_find_version
assert_equal page_versions(:welcome_1), Page.find_version(pages(:welcome).id, 23)
assert_equal page_versions(:welcome_2), Page.find_version(pages(:welcome).id, 24)
assert_equal pages(:welcome), Page.find_version(pages(:welcome).id)
assert_equal page_versions(:welcome_1), pages(:welcome).find_version(23)
assert_equal page_versions(:welcome_2), pages(:welcome).find_version(24)
assert_equal pages(:welcome), pages(:welcome).find_version
assert_raise(ActiveRecord::RecordNotFound) { Page.find_version(pages(:welcome).id, 1) }
assert_raise(ActiveRecord::RecordNotFound) { Page.find_version(0, 23) }
end
def test_with_sequence
assert_equal 'widgets_seq', Widget.versioned_class.sequence_name
3.times { Widget.create! :name => 'new widget' }
assert_equal 3, Widget.count
assert_equal 3, Widget.versioned_class.count
end
def test_has_many_through
assert_equal [authors(:caged), authors(:mly)], pages(:welcome).authors
end
def test_has_many_through_with_custom_association
assert_equal [authors(:caged), authors(:mly)], pages(:welcome).revisors
end
def test_referential_integrity
pages(:welcome).destroy
assert_equal 0, Page.count
assert_equal 0, Page::Version.count
end
def test_association_options
association = Page.reflect_on_association(:versions)
options = association.options
assert_equal :delete_all, options[:dependent]
assert_equal 'version', options[:order]
association = Widget.reflect_on_association(:versions)
options = association.options
assert_equal :nullify, options[:dependent]
assert_equal 'version desc', options[:order]
assert_equal 'widget_id', options[:foreign_key]
widget = Widget.create! :name => 'new widget'
assert_equal 1, Widget.count
assert_equal 1, Widget.versioned_class.count
widget.destroy
assert_equal 0, Widget.count
assert_equal 1, Widget.versioned_class.count
end
def test_versioned_records_should_belong_to_parent
page = pages(:welcome)
page_version = page.versions.last
assert_equal page, page_version.page
end
def test_unaltered_attributes
landmarks(:washington).attributes = landmarks(:washington).attributes.except("id")
assert !landmarks(:washington).changed?
end
def test_unchanged_string_attributes
landmarks(:washington).attributes = landmarks(:washington).attributes.except("id").inject({}) { |params, (key, value)| params.update(key => value.to_s) }
assert !landmarks(:washington).changed?
end
def test_should_find_earliest_version
assert_equal page_versions(:welcome_1), pages(:welcome).versions.earliest
end
def test_should_find_latest_version
assert_equal page_versions(:welcome_2), pages(:welcome).versions.latest
end
def test_should_find_previous_version
assert_equal page_versions(:welcome_1), page_versions(:welcome_2).previous
assert_equal page_versions(:welcome_1), pages(:welcome).versions.before(page_versions(:welcome_2))
end
def test_should_find_next_version
assert_equal page_versions(:welcome_2), page_versions(:welcome_1).next
assert_equal page_versions(:welcome_2), pages(:welcome).versions.after(page_versions(:welcome_1))
end
def test_should_find_version_count
assert_equal 24, pages(:welcome).versions_count
assert_equal 24, page_versions(:welcome_1).versions_count
assert_equal 24, page_versions(:welcome_2).versions_count
end
end
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment