Initial commit (#8593)

This commit is contained in:
Anton Argirov 2012-08-31 15:35:07 +07:00
commit c105acf3b8
24 changed files with 809 additions and 0 deletions

3
README.rdoc Normal file
View File

@ -0,0 +1,3 @@
= custom_workflow
Description goes here

View File

@ -0,0 +1,71 @@
class CustomWorkflowsController < ApplicationController
unloadable
layout 'admin'
before_filter :require_admin
before_filter :find_workflow, :only => [:show, :edit, :update, :destroy]
def index
@workflows = CustomWorkflow.all
respond_to do |format|
format.html
end
end
def show
respond_to do |format|
format.html { redirect_to edit_custom_workflow_path }
end
end
def edit
end
def new
@workflow = CustomWorkflow.new
respond_to do |format|
format.html
end
end
def create
@workflow = CustomWorkflow.new(params[:custom_workflow])
respond_to do |format|
if @workflow.save
flash[:notice] = l(:notice_successful_create)
format.html { redirect_to(custom_workflows_path) }
else
format.html { render :action => "new" }
end
end
end
def update
respond_to do |format|
if @workflow.update_attributes(params[:custom_workflow])
flash[:notice] = l(:notice_successful_update)
format.html { redirect_to(custom_workflows_path) }
else
format.html { render :action => :edit }
end
end
end
def destroy
@workflow.destroy
respond_to do |format|
flash[:notice] = l(:notice_successful_delete)
format.html { redirect_to(custom_workflows_path) }
end
end
private
def find_workflow
@workflow = CustomWorkflow.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
end

View File

@ -0,0 +1,36 @@
class WorkflowError < StandardError
attr_accessor :error
def initialize(message)
@error = message.dup
super message
end
end
class CustomWorkflow < ActiveRecord::Base
has_and_belongs_to_many :projects
acts_as_list
default_scope :order => 'position ASC'
validates_presence_of :script
validates_presence_of :name
validates_uniqueness_of :name, :case_sensitive => false
validate :validate_syntax
def eval_script(context)
context.each { |k, v| instance_variable_set ("@#{k}").to_sym, v }
eval(script)
end
def validate_syntax
begin
eval_script(:issue => Issue.new)
rescue WorkflowError => e
rescue Exception => e
errors.add :script, :invalid_script, :error => e
end
end
def to_s
name
end
end

View File

@ -0,0 +1,24 @@
<div class="box tabular">
<p><%= f.text_field :name, :required => true, :size => 50 %></p>
<p><%= f.text_area :description, :cols => 80, :rows => 5 %></p>
<p><%= f.text_area :script, :cols => 80, :rows => 20, :wrap => 'off', :required => true %>
<em class="info"><%= l(:text_custom_workflow_script_note) %></em>
</p>
</div>
<script type="text/javascript">
jQuery('#custom_workflow_script').taboverride(2, true);
</script>
<%= wikitoolbar_for :custom_workflow_description %>
<% content_for :header_tags do %>
<% if (Redmine::VERSION::ARRAY <=> [2,1,0]) < 0 %>
<%= javascript_include_tag "jquery-1.8.1.min.js", :plugin => 'redmine_custom_workflows' %>
<script type="text/javascript">
jQuery.noConflict();
</script>
<% end %>
<%= javascript_include_tag "tab_override", :plugin => 'redmine_custom_workflows' %>
<% end %>

View File

@ -0,0 +1,12 @@
<h2><%= link_to l(:label_custom_workflow_plural), custom_workflows_path %> &#187; <%= @workflow %></h2>
<%= error_messages_for @workflow %>
<% form = labelled_form_for @workflow do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<%= submit_tag l(:button_save) %>
<% end %>
<%= form if Redmine::VERSION::MAJOR >= 2 %>
<% html_title(l(:label_custom_workflow_plural), @workflow, l(:label_administration)) -%>

View File

@ -0,0 +1,35 @@
<% html_title(l(:label_custom_workflow_plural)) -%>
<div class="contextual">
<%= link_to l(:label_custom_workflow_new), new_custom_workflow_path, :class => 'icon icon-add' %>
</div>
<h2><%=l(:label_custom_workflow_plural)%></h2>
<div class="autoscroll">
<% if @workflows.any? %>
<table class="custom-workflows list">
<thead>
<tr>
<th><%=l(:field_name)%></th>
<th><%=l(:field_description)%></th>
<th style="width:15%;"><%= l(:button_sort) %></th>
<th style="width:10%;"></th>
</tr>
</thead>
<tbody>
<% @workflows.each do |workflow| %>
<tr class="<%= cycle("odd", "even") %>">
<td class="name"><%= link_to(workflow.name, edit_custom_workflow_path(workflow)) %></td>
<td><%= textilizable(workflow.description) %></td>
<td align="center"><%= reorder_links("custom_workflow", {:action => 'update', :id => workflow}, :put) %></td>
<td class="buttons">
<%= link_to(l(:button_delete), workflow, :class => 'icon icon-del', :data => {:confirm => l(:text_are_you_sure)}, :confirm => l(:text_are_you_sure), :method => :delete) %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
</div>

View File

@ -0,0 +1,12 @@
<h2><%= link_to l(:label_custom_workflow_plural), custom_workflows_path %> &#187; <%= l(:label_custom_workflow_new) %></h2>
<%= error_messages_for @workflow %>
<% form = labelled_form_for @workflow do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<%= submit_tag l(:button_create) %>
<% end %>
<%= form if Redmine::VERSION::MAJOR >= 2 %>
<% html_title(l(:label_custom_workflow_plural), l(:label_custom_workflow_new), l(:label_administration)) -%>

View File

@ -0,0 +1,20 @@
<% form = form_for @project do |f| %>
<%= hidden_field_tag :tab, 'custom_workflow' %>
<%= hidden_field_tag 'project[custom_workflow_ids][]', '' %>
<fieldset>
<legend><%= l(:text_select_project_custom_workflows) %></legend>
<% if CustomWorkflow.exists? %>
<dl>
<% CustomWorkflow.all.each do |w| %>
<dt><label><%= check_box_tag 'project[custom_workflow_ids][]', w.id, @project.custom_workflow_ids.include?(w.id) %> <%= w.name %></label></dt>
<dd><em><%= textilizable(w.description) %></em></dd>
<% end %>
</dl>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
</fieldset>
<%= submit_tag l(:button_save) %>
<% end %>
<%= form if Redmine::VERSION::MAJOR >= 2 %>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,324 @@
(function($) {
$.fn.taboverride = function(tabSize, autoIndent) {
this.each(function() {
$(this).data('taboverride', new TabOverride(this, tabSize, autoIndent));
});
};
function TabOverride(element, tabSize, autoIndent) {
var ta = document.createElement('textarea');
ta.value = '\n';
this.newline = ta.value;
this.newlineLen = this.newline.length;
this.autoIndent = autoIndent;
this.inWhitespace = false;
this.element = element;
this.setTabSize(tabSize);
$(element).on('keypress', $.proxy(this.overrideKeyPress, this));
$(element).on('keydown', $.proxy(this.overrideKeyDown, this));
}
TabOverride.prototype = {
/**
* Returns the current tab size. 0 represents the tab character.
*
* @return {Number} the size (length) of the tab string or 0 for the tab character
*
* @name getTabSize
* @function
*/
getTabSize:function () {
return this.aTab === '\t' ? 0 : this.aTab.length;
},
/**
* Sets the tab size for all elements that have Tab Override enabled.
* 0 represents the tab character.
*
* @param {Number} size the tab size (default = 0)
*
* @name setTabSize
* @function
*/
setTabSize:function (size) {
var i;
if (!size) { // size is 0 or not specified (or falsy)
this.aTab = '\t';
} else if (typeof size === 'number' && size > 0) {
this.aTab = '';
for (i = 0; i < size; i += 1) {
this.aTab += ' ';
}
}
},
/**
* Prevents the default action for the keyPress event when tab or enter are
* pressed. Opera (and Firefox) also fire a keypress event when the tab or
* enter key is pressed. Opera requires that the default action be prevented
* on this event or the textarea will lose focus.
*
* @param {Event} e the event object
* @private
*/
overrideKeyPress:function (e) {
var key = e.keyCode;
if ((key === 9 || (key === 13 && this.autoIndent && !this.inWhitespace)) && !e.ctrlKey && !e.altKey) {
e.preventDefault();
}
},
/**
* Inserts / removes tabs and newlines on the keyDown event for the tab or enter key.
*
* @param {Event} e the event object
*
* @private
*/
overrideKeyDown:function (e) {
var key = e.keyCode, // the key code for the key that was pressed
tab, // the string representing a tab
tabLen, // the length of a tab
text, // initial text in the textarea
range, // the IE TextRange object
tempRange, // used to calculate selection start and end positions in IE
preNewlines, // the number of newline character sequences before the selection start (for IE)
selNewlines, // the number of newline character sequences within the selection (for IE)
initScrollTop, // initial scrollTop value used to fix scrolling in Firefox
selStart, // the selection start position
selEnd, // the selection end position
sel, // the selected text
startLine, // for multi-line selections, the first character position of the first line
endLine, // for multi-line selections, the last character position of the last line
numTabs, // the number of tabs inserted / removed in the selection
startTab, // if a tab was removed from the start of the first line
preTab, // if a tab was removed before the start of the selection
whitespace, // the whitespace at the beginning of the first selected line
whitespaceLen; // the length of the whitespace at the beginning of the first selected line
// don't do any unnecessary work
if ((key !== 9 && (key !== 13 || !this.autoIndent)) || e.ctrlKey || e.altKey) {
return;
}
// initialize variables used for tab and enter keys
this.inWhitespace = false; // this will be set to true if enter is pressed in the leading whitespace
text = this.element.value;
// this is really just for Firefox, but will be used by all browsers that support
// selectionStart and selectionEnd - whenever the textarea value property is reset,
// Firefox scrolls back to the top - this is used to set it back to the original value
// scrollTop is nonstandard, but supported by all modern browsers
initScrollTop = this.element.scrollTop;
// get the text selection
// prefer the nonstandard document.selection way since it allows for
// automatic scrolling to the cursor via the range.select() method
if (document.selection) { // IE
range = document.selection.createRange();
sel = range.text;
tempRange = range.duplicate();
tempRange.moveToElementText(this.element);
tempRange.setEndPoint('EndToEnd', range);
selEnd = tempRange.text.length;
selStart = selEnd - sel.length;
// whenever the value of the textarea is changed, the range needs to be reset
// IE <9 (and Opera) use both \r and \n for newlines - this adds an extra character
// that needs to be accounted for when doing position calculations with ranges
// these values are used to offset the selection start and end positions
if (this.newlineLen > 1) {
preNewlines = text.slice(0, selStart).split(this.newline).length - 1;
selNewlines = sel.split(this.newline).length - 1;
} else {
preNewlines = selNewlines = 0;
}
} else if (typeof this.element.selectionStart !== 'undefined') {
selStart = this.element.selectionStart;
selEnd = this.element.selectionEnd;
sel = text.slice(selStart, selEnd);
} else {
return; // cannot access textarea selection - do nothing
}
// tab key - insert / remove tab
if (key === 9) {
// initialize tab variables
tab = this.aTab;
tabLen = tab.length;
numTabs = 0;
startTab = 0;
preTab = 0;
// multi-line selection
if (selStart !== selEnd && sel.indexOf('\n') !== -1) {
if (text.charAt(selEnd - 1) === '\n') {
selEnd = selEnd - this.newlineLen;
sel = text.slice(selStart, selEnd);
}
// for multiple lines, only insert / remove tabs from the beginning of each line
// find the start of the first selected line
if (selStart === 0 || text.charAt(selStart - 1) === '\n') {
// the selection starts at the beginning of a line
startLine = selStart;
} else {
// the selection starts after the beginning of a line
// set startLine to the beginning of the first partially selected line
// subtract 1 from selStart in case the cursor is at the newline character,
// for instance, if the very end of the previous line was selected
// add 1 to get the next character after the newline
// if there is none before the selection, lastIndexOf returns -1
// when 1 is added to that it becomes 0 and the first character is used
startLine = text.lastIndexOf('\n', selStart - 1) + 1;
}
// find the end of the last selected line
if (selEnd === text.length || text.charAt(selEnd) === '\n') {
// the selection ends at the end of a line
endLine = selEnd;
} else {
// the selection ends before the end of a line
// set endLine to the end of the last partially selected line
endLine = text.indexOf('\n', selEnd);
if (endLine === -1) {
endLine = text.length;
}
}
// if the shift key was pressed, remove tabs instead of inserting them
if (e.shiftKey) {
if (text.slice(startLine).indexOf(tab) === 0) {
// is this tab part of the selection?
if (startLine === selStart) {
// it is, remove it
sel = sel.slice(tabLen);
} else {
// the tab comes before the selection
preTab = tabLen;
}
startTab = tabLen;
}
this.element.value = text.slice(0, startLine) + text.slice(startLine + preTab, selStart) +
sel.replace(new RegExp('\n' + tab, 'g'), function () {
numTabs += 1;
return '\n';
}) + text.slice(selEnd);
// set start and end points
if (range) { // IE
// setting end first makes calculations easier
range.collapse();
range.moveEnd('character', selEnd - startTab - (numTabs * tabLen) - selNewlines - preNewlines);
range.moveStart('character', selStart - preTab - preNewlines);
range.select();
} else {
// set start first for Opera
this.element.selectionStart = selStart - preTab; // preTab is 0 or tabLen
// move the selection end over by the total number of tabs removed
this.element.selectionEnd = selEnd - startTab - (numTabs * tabLen);
}
} else { // no shift key
numTabs = 1; // for the first tab
// insert tabs at the beginning of each line of the selection
this.element.value = text.slice(0, startLine) + tab + text.slice(startLine, selStart) +
sel.replace(/\n/g, function () {
numTabs += 1;
return '\n' + tab;
}) + text.slice(selEnd);
// set start and end points
if (range) { // IE
range.collapse();
range.moveEnd('character', selEnd + (numTabs * tabLen) - selNewlines - preNewlines);
range.moveStart('character', selStart + tabLen - preNewlines);
range.select();
} else {
// the selection start is always moved by 1 character
this.element.selectionStart = selStart + (selStart == startLine ? 0 : tabLen);
// move the selection end over by the total number of tabs inserted
this.element.selectionEnd = selEnd + (numTabs * tabLen);
this.element.scrollTop = initScrollTop;
}
}
} else { // single line selection
// if the shift key was pressed, remove a tab instead of inserting one
if (e.shiftKey) {
// if the character before the selection is a tab, remove it
if (text.slice(selStart - tabLen).indexOf(tab) === 0) {
this.element.value = text.slice(0, selStart - tabLen) + text.slice(selStart);
// set start and end points
if (range) { // IE
// collapses range and moves it by -1 tab
range.move('character', selStart - tabLen - preNewlines);
range.select();
} else {
this.element.selectionEnd = this.element.selectionStart = selStart - tabLen;
this.element.scrollTop = initScrollTop;
}
}
} else { // no shift key - insert a tab
if (range) { // IE
range.text = tab;
range.select();
} else {
this.element.value = text.slice(0, selStart) + tab + text.slice(selEnd);
this.element.selectionEnd = this.element.selectionStart = selStart + tabLen;
this.element.scrollTop = initScrollTop;
}
}
}
} else if (this.autoIndent) { // Enter key
// insert a newline and copy the whitespace from the beginning of the line
// find the start of the first selected line
if (selStart === 0 || text.charAt(selStart - 1) === '\n') {
// the selection starts at the beginning of a line
// do nothing special
this.inWhitespace = true;
return;
} else {
// see explanation under "multi-line selection" above
startLine = text.lastIndexOf('\n', selStart - 1) + 1;
}
// find the end of the first selected line
endLine = text.indexOf('\n', selStart);
// if no newline is found, set endLine to the end of the text
if (endLine === -1) {
endLine = text.length;
}
// get the whitespace at the beginning of the first selected line (spaces and tabs only)
whitespace = text.slice(startLine, endLine).match(/^[ \t]*/)[0];
whitespaceLen = whitespace.length;
// the cursor (selStart) is in the whitespace at beginning of the line
// do nothing special
if (selStart < startLine + whitespaceLen) {
this.inWhitespace = true;
return;
}
if (range) { // IE
// insert the newline and whitespace
range.text = '\n' + whitespace;
range.select();
} else {
// insert the newline and whitespace
this.element.value = text.slice(0, selStart) + '\n' + whitespace + text.slice(selEnd);
// Opera uses \r\n for a newline, instead of \n,
// so use newlineLen instead of a hard-coded value
this.element.selectionEnd = this.element.selectionStart = selStart + this.newlineLen + whitespaceLen;
this.element.scrollTop = initScrollTop;
}
}
e.preventDefault();
}
};
})(jQuery);

View File

@ -0,0 +1,11 @@
#admin-menu a.custom-workflows {
background-image: url(../../../images/ticket_go.png);
}
table.list.custom-workflows td {
vertical-align: middle;
}
#custom_workflow_description, #custom_workflow_script {
width: 99%;
}

23
config/locales/en.yml Normal file
View File

@ -0,0 +1,23 @@
en:
project_module_custom_workflows_module: "Custom workflows"
permission_manage_project_workflow: "Manage project custom workflows"
label_custom_workflow: "Custom workflows"
label_custom_workflow_plural: "Custom workflows"
label_custom_workflow_new: "Create a custom workflow"
field_script: "Workflow script"
field_is_enabled: "Enabled"
field_custom_workflow:
script: "Workflow script"
activerecord:
errors:
messages:
invalid_script: "contains error: %{error}"
custom_workflow_error: "Custom workflow error (please contact administrator)"
new_status_invalid: "transition from '%{old_status}' to '%{new_status}' is prohibited"
text_select_project_custom_workflows: Select project custom workflows
text_custom_workflow_script_note: Use the variable @issue for an access to the issue. To finish the process with an error, use raise WorkflowError, "Message to user"

23
config/locales/ru.yml Normal file
View File

@ -0,0 +1,23 @@
ru:
project_module_custom_workflows_module: "Пользовательские рабочие процессы"
permission_manage_project_workflow: "Управление пользовательскими рабочими процессами в проекте"
label_custom_workflow: "Пользовательский рабочий процесс"
label_custom_workflow_plural: "Пользовательские рабочие процессы"
label_custom_workflow_new: "Новый процесс"
field_script: "Скрипт управления"
field_is_enabled: "Разрешено"
field_custom_workflow:
script: "Скрипт управления"
activerecord:
errors:
messages:
invalid_script: "содержит ошибку: %{error}"
custom_workflow_error: "Ошибка в рабочем процессе (обратитесь к администратору)"
new_status_invalid: "- переход от '%{old_status}' к '%{new_status}' невозможен"
text_select_project_custom_workflows: Выберите процессы для данного проекта
text_custom_workflow_script_note: Используйте переменную @issue для доступа к задаче. Для завершения процесса с ошибкой используйте исключение WorkflowError.

9
config/routes.rb Normal file
View File

@ -0,0 +1,9 @@
if Redmine::VERSION::MAJOR >= 2
RedmineApp::Application.routes.draw do
resources :custom_workflows
end
else
ActionController::Routing::Routes.draw do |map|
map.resources :custom_workflows
end
end

View File

@ -0,0 +1,14 @@
class CreateCustomWorkflows < ActiveRecord::Migration
def self.up
create_table :custom_workflows, :force => true do |t|
t.references :project
t.text :script
t.boolean :is_enabled
end
add_index :custom_workflows, [:project_id], :unique => true
end
def self.down
drop_table :custom_workflows
end
end

View File

@ -0,0 +1,13 @@
class AlterCustomWorkflows < ActiveRecord::Migration
def self.up
remove_column :custom_workflows, :project_id
remove_column :custom_workflows, :is_enabled
add_column :custom_workflows, :name, :string, :null => false
add_column :custom_workflows, :description, :string, :null => false
add_column :custom_workflows, :position, :integer, :null => false, :default => 1
end
def self.down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -0,0 +1,14 @@
class CreateCustomWorkflowsProjects < ActiveRecord::Migration
def self.up
create_table :custom_workflows_projects, :force => true, :id => false do |t|
t.references :project
t.references :custom_workflow
end
add_index :custom_workflows_projects, [:project_id]
add_index :custom_workflows_projects, [:custom_workflow_id]
end
def self.down
drop_table :custom_workflows_projects
end
end

View File

@ -0,0 +1,9 @@
class ChangeCustomWorkflowsDescriptionType < ActiveRecord::Migration
def self.up
change_column :custom_workflows, :description, :text, :null => false
end
def self.down
change_column :custom_workflows, :description, :string, :null => false
end
end

View File

@ -0,0 +1,33 @@
class CreateExampleWorkflow < ActiveRecord::Migration
def self.up
CustomWorkflow.create(:name => "Duration/Done Ratio/Status correlation", :description => <<EOD, :script => <<EOS)
Set up a correlation between the start date, due date, done ratio and status of issues.
* If done ratio is changed to 100% and status is "In Process", status changes to "Resolved"
* If status is "New", "Resolved" or "Feedback" and done ratio is changed to value less than 100%, status changes to "In process"
* If status is changed to "In process" and start date is not set, then it sets to current date
* If status is changed to "Resolved" and end date is not set, then it set to due date
To use this script properly, turn off "Use current date as start date for new issues" option in the settings as this script already do it own way.
EOD
if @issue.done_ratio_changed?
if @issue.done_ratio==100 && @issue.status_id=2
@issue.status_id=3
elsif [1,3,4].include?(@issue.status_id) && @issue.done_ratio<100
@issue.status_id=2
end
end
if @issue.status_id_changed?
if @issue.status_id==2
@issue.start_date ||= Time.now
end
if @issue.status_id==3
@issue.done_ratio = 100
@issue.start_date ||= @issue.created_on
@issue.due_date ||= Time.now
end
end
EOS
end
end

35
init.rb Normal file
View File

@ -0,0 +1,35 @@
require 'redmine_custom_workflows/hooks'
to_prepare = Proc.new do
unless Project.included_modules.include?(RedmineCustomWorkflows::ProjectPatch)
Project.send(:include, RedmineCustomWorkflows::ProjectPatch)
end
unless ProjectsHelper.included_modules.include?(RedmineCustomWorkflows::ProjectsHelperPatch)
ProjectsHelper.send(:include, RedmineCustomWorkflows::ProjectsHelperPatch)
end
unless Issue.included_modules.include?(RedmineCustomWorkflows::IssuePatch)
Issue.send(:include, RedmineCustomWorkflows::IssuePatch)
end
end
if Redmine::VERSION::MAJOR >= 2
Rails.configuration.to_prepare(&to_prepare)
else
require 'dispatcher'
Dispatcher.to_prepare(:redmine_custom_workflows, &to_prepare)
end
Redmine::Plugin.register :redmine_custom_workflows do
name 'Redmine Custom Workflow plugin'
author 'Anton Argirov'
description 'Allows to create custom workflows for issues, defined in the plain Ruby language'
version '0.0.1'
url 'http://redmine.academ.org'
menu :admin_menu, :custom_workflows, {:controller => 'custom_workflows', :action => 'index'}, :caption => :label_custom_workflow_plural
project_module :custom_workflows_module do
permission :manage_project_workflow, {}, :require => :member
end
end

View File

@ -0,0 +1,7 @@
module RedmineCustomWorkflows
class Hooks < Redmine::Hook::ViewListener
def view_layouts_base_html_head(context)
stylesheet_link_tag :style, :plugin => 'redmine_custom_workflows'
end
end
end

View File

@ -0,0 +1,44 @@
module RedmineCustomWorkflows
module IssuePatch
unloadable
def self.included(base)
base.send(:include, InstanceMethods)
base.class_eval do
before_save :custom_workflow_eval
validate :validate_status
end
end
module InstanceMethods
def validate_status
if status_id_was != status_id && !new_statuses_allowed_to(User.current, new_record?).collect(&:id).include?(status_id)
status_was = IssueStatus.find_by_id(status_id_was)
status_new = IssueStatus.find_by_id(status_id)
errors.add :status, :new_status_invalid,
:old_status => status_was && status_was.name,
:new_status => status_new && status_new.name
end
end
def custom_workflow_eval
return true unless project && project.module_enabled?(:custom_workflows_module)
saved_attributes = attributes.dup
project.custom_workflows.each do |workflow|
begin
workflow.eval_script(:issue => self)
rescue WorkflowError => e
errors.add :base, e.error
return false
rescue Exception => e
Rails.logger.warn e
errors.add :base, :custom_workflow_error
return false
end
end
saved_attributes == attributes || valid?
end
end
end
end

View File

@ -0,0 +1,14 @@
module RedmineCustomWorkflows
module ProjectPatch
unloadable
def self.included(base)
base.class_eval do
has_and_belongs_to_many :custom_workflows
#accepts_nested_attributes_for :custom_workflow, :update_only => true
safe_attributes :custom_workflow_ids, :if =>
lambda { |project, user| project.new_record? || user.allowed_to?(:manage_project_workflow, project) }
end
end
end
end

View File

@ -0,0 +1,21 @@
module RedmineCustomWorkflows
module ProjectsHelperPatch
unloadable
def self.included(base)
base.send(:include, InstanceMethods)
base.class_eval do
alias_method_chain :project_settings_tabs, :custom_workflows
end
end
module InstanceMethods
def project_settings_tabs_with_custom_workflows
tabs = project_settings_tabs_without_custom_workflows
tabs << {:name => 'custom_workflows', :action => :manage_project_workflow, :partial => 'projects/settings/custom_workflow',
:label => :label_custom_workflow_plural} if User.current.allowed_to?(:manage_project_workflow, @project)
tabs
end
end
end
end