Import archive redmine_contacts_helpdesk-4_2_4-pro

This commit is contained in:
Alexandr Antonov 2024-11-13 17:20:58 +03:00
commit d5f6bb5490
350 changed files with 34073 additions and 0 deletions

112
README.md Normal file
View File

@ -0,0 +1,112 @@
# Helpdesk plugin
### API
API request #index
```
GET /helpdesk_tickets.xml or /helpdesk_tickets.json
```
API request #index with filters
```
GET /helpdesk_tickets.xml?issue_id=1,2,3 or /helpdesk_tickets.json?issue_id=3,1,2
GET /helpdesk_tickets.xml?contact_id=1,2,3 or /helpdesk_tickets.json?contact_id=3,1,2
```
API request #show
```
GET /helpdesk_tickets/<issue_id>.xml or /helpdesk_tickets/<issue_id>.json
```
To get contact with tickets related to it
```
GET /contacts/<contact_id>.json?include=tickets
```
API request #create
Create a ticket without sending an auto answer email
```
POST /helpdesk_tickets.json?key=<your_api_key>
{
"helpdesk_ticket": {
"issue": {
"project_id": "support",
"subject": "test helpdesk api",
"description": "ticket description"
},
"contact": {
"email": "test@example.com",
"first_name": "John"
}
}
}
```
Create a ticket with sending an auto answer email
```
POST /helpdesk_tickets.json?key=<your_api_key>
{
"helpdesk_ticket": {
"issue": {
"project_id": "support",
"subject": "test helpdesk api",
"description": "ticket description"
},
"contact": {
"email": "test@example.com",
"first_name": "John"
},
"send_as": "auto_answer"
}
}
```
parameter send_as: "auto_answer", "inital_message"
Old version API request #create
```
POST /helpdesk/create_ticket.json?key=<your_api_key>
{
"ticket": {
"issue": {
"project_id": "support",
"tracker_id": 1,
"subject": "New ticket subject",
"description": "New ticket description",
"assigned_to_id": 1
},
"contact": {
"email": "test@example.com",
"first_name": "John",
"custom_fields: {
type": "array",
"custom_field: {
"id": 1,
"value": "Test"
}
}
}
}
}
```
Send email reply to an existed Helpdesk ticket
```
POST /helpdesk/email_note.json
{
"message": {
"issue_id": 1,
"status_id": 2,
"content": "New replay to ticket"
}
}
```
Update ticket
```
PUT /helpdesk_tickets/<ticket_id>.json?key=<your_api_key>
{
"helpdesk_ticket": {
"from_address": "new_email@mail.com"
}
}
```

View File

@ -0,0 +1,131 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class CannedResponsesController < ApplicationController
before_action :find_canned_response, :except => [:new, :create, :index]
before_action :find_optional_project, :only => [:new, :create, :edit, :update, :add, :destroy]
before_action :find_issue, :only => [:add]
before_action :require_admin, :only => [:index]
accept_api_auth :index
def index
case params[:format]
when 'xml', 'json'
@offset, @limit = api_offset_and_limit
else
@limit = per_page_option
end
scope = CannedResponse.visible
scope = scope.in_project_or_public(@project) if @project
@canned_response_count = scope.count
@canned_response_pages = Paginator.new @canned_response_count, @limit, params['page']
@offset ||= @canned_response_pages.offset
@canned_responses = scope.limit(@limit).offset(@offset).order("#{CannedResponse.table_name}.name")
respond_to do |format|
format.html
end
end
def add
@content = HelpdeskMailSupport.apply_text_macro(@canned_response.content, @issue.customer, @issue, User.current)
@attachments = @canned_response.attachments.map do |attachment|
new_attachment = attachment.copy(container: nil)
new_attachment.save
new_attachment
end
end
def new
@canned_response = CannedResponse.new
@canned_response.user = User.current
@canned_response.project = @project
end
def create
@canned_response = CannedResponse.new
@canned_response.project = @project
@canned_response.safe_attributes = params[:canned_response]
@canned_response.user = User.current
@canned_response.project = nil if params[:canned_response_is_for_all]
@canned_response.save_attachments(params[:attachments] || (params[:canned_response] && params[:canned_response][:uploads]))
if @canned_response.save
flash[:notice] = l(:notice_successful_create)
redirect_to_project_or_global
else
render :action => 'new', :layout => !request.xhr?
end
end
def edit
end
def update
@canned_response.project = @project
@canned_response.safe_attributes = params[:canned_response]
@canned_response.project = nil if params[:canned_response_is_for_all]
@canned_response.save_attachments(params[:attachments] || (params[:canned_response] && params[:canned_response][:uploads]))
if @canned_response.save
flash[:notice] = l(:notice_successful_update)
redirect_to_project_or_global
else
render :action => 'edit'
end
end
def destroy
@canned_response.destroy
redirect_to_project_or_global
end
private
def redirect_to_project_or_global
redirect_to @project ? settings_project_path(@project, :tab => 'helpdesk_canned_responses') : path_to_global_setting
end
def path_to_global_setting
{
:action => 'plugin',
:id => 'redmine_contacts_helpdesk',
:controller => 'settings',
:tab => 'canned_responses'
}
end
def find_issue
@issue = Issue.find(params[:issue_id])
@project = @issue.project
rescue ActiveRecord::RecordNotFound
render_404
end
def find_canned_response
@canned_response = CannedResponse.find(params[:id])
@project = @canned_response.project
rescue ActiveRecord::RecordNotFound
render_404
end
end

View File

@ -0,0 +1,40 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskBaseController < ApplicationController
def process_helpdesk_send_as(send_as = nil)
return unless @issue && User.current.allowed_to?(:send_response, @project)
return unless send_as
case send_as
when 'auto_answer'
HelpdeskMailer.auto_answer(@issue.customer, @issue)
when 'initial_message'
if msg = HelpdeskMailer.initial_message(@issue.customer, @issue)
@issue.helpdesk_ticket.message_id = msg.message_id
@issue.helpdesk_ticket.is_incoming = false
@issue.helpdesk_ticket.from_address = @issue.helpdesk_ticket.from_address || @issue.customer.primary_email
@issue.helpdesk_ticket.save
end
else
HelpdeskMailer.auto_answer(@contact, @issue) if HelpdeskSettings['helpdesk_send_notification', @project].to_i > 0
end
end
end

View File

@ -0,0 +1,307 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskController < HelpdeskBaseController
helper :attachments
include AttachmentsHelper
skip_before_action :check_if_login_required, :only => [:message_link]
before_action :find_project, :authorize, :except => [
:email_note,
:update_customer_email,
:message_link
]
accept_api_auth :email_note, :create_ticket
helper :attachments
include AttachmentsHelper
def save_settings
if request.put?
set_settings
flash[:notice] = l(:notice_successful_update)
end
redirect_to :controller => 'projects', :action => 'settings', :tab => params[:tab] || 'helpdesk', :id => @project
end
def show_original
@attachment = Attachment.find(params[:id])
email = Mail.read(@attachment.diskfile)
part = email.text_part || email.html_part || email
body_charset = Mail::RubyVer.pick_encoding(part.charset).to_s rescue part.charset
plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, body_charset)
headers = email.header.fields.map { |f| "#{f.name}: #{Mail::Encodings.unquote_and_convert_to(f.value, 'utf-8')}" }.join("\n")
@content = headers + "\n\n" + plain_text_body
render 'attachments/file'
end
def delete_spam
if User.current.allowed_to?(:delete_issues, @project) && User.current.allowed_to?(:delete_contacts, @project)
begin
@issue = Issue.find(params[:issue_id])
@customer = @issue.customer
rescue ActiveRecord::RecordNotFound
render_404
end
ActiveRecord::Base.transaction do
ContactsSetting['helpdesk_blacklist', @project.id] = (ContactsSetting['helpdesk_blacklist', @project.id].split("\n") | [@issue.customer.primary_email.strip]).join("\n")
@customer.tickets.map(&:destroy)
@customer.destroy
end
respond_to do |format|
format.html { redirect_back_or_default(:controller => 'issues', :action => 'index', :project_id => @project) }
format.api { render_api_ok }
end
else
deny_access
end
end
def email_note
raise Exception, "Param 'message' should be set" unless params[:message]
@issue = Issue.find(params[:message][:issue_id])
raise Exception, "Issue with ID: #{params[:message][:issue_id].to_i} should be present and relate to customer" if @issue.nil? || @issue.customer.nil?
@journal = @issue.init_journal(User.current)
if params[:message][:status_id].present?
issue_status = IssueStatus.where(id: params[:message][:status_id]).first
@issue.status_id = issue_status.id if issue_status
end
@journal.notes = params[:message][:content]
@issue.save_attachments(params[:attachments] || (params[:issue] && params[:issue][:uploads]))
@issue.save!
contact = @issue.customer
if HelpdeskMailer.issue_response(contact, @journal, params)
@journal_message = JournalMessage.create(:from_address => '',
:to_address => contact.primary_email.downcase,
:is_incoming => false,
:message_date => Time.now,
:contact => contact,
:journal => @journal)
end
respond_to do |format|
format.api { render :action => 'show', :status => :created }
end
rescue Exception => e
respond_to do |format|
format.api do
@error_messages = [e.message]
render template: 'common/error_messages', format: [:api], status: :unprocessable_entity, layout: nil
end
end
end
def create_ticket
raise Exception, "Param 'ticket' should be set" if params[:ticket].blank?
@issue = Issue.new
@issue.project = @project
@issue.author ||= User.current
@issue.safe_attributes = params[:ticket][:issue]
raise Exception, 'Contact should have email address' unless params[:ticket][:contact] || params[:ticket][:contact][:email]
@contact = Contact.find_by_emails([params[:ticket][:contact][:email]]).first
unless @contact
@contact = Contact.new
@contact.safe_attributes = params[:ticket][:contact]
end
@contact.projects << @project unless @contact.projects.include?(@project)
helpdesk_ticket = HelpdeskTicket.new(:from_address => @contact.primary_email,
:to_address => '',
:ticket_date => Time.now,
:customer => @contact,
:is_incoming => true,
:issue => @issue,
:source => HelpdeskTicket::HELPDESK_WEB_SOURCE)
@issue.helpdesk_ticket = helpdesk_ticket
@issue.assigned_to = @contact.find_assigned_user(@project, @issue.assigned_to)
@issue.save_attachments(params[:attachments] || (params[:ticket][:issue] && params[:ticket][:issue][:uploads]))
if @issue.save
process_helpdesk_send_as(process_helpdesk_send_as(params[:ticket][:send_as]))
HelpdeskMailer.auto_answer(@contact, @issue) if HelpdeskSettings['helpdesk_send_notification', @project].to_i > 0
HelpdeskTicket.autoclose(@project)
respond_to do |format|
format.api { redirect_on_create(params) }
end
else
raise Exception, "Can't create issue: #{@issue.errors.full_messages}"
end
rescue Exception => e
respond_to do |format|
format.api do
@error_messages = [e.message]
HelpdeskLogger.error "API Create Ticket Error: #{e.message}" if HelpdeskLogger
render template: 'common/error_messages', format: [:api], status: :unprocessable_entity, layout: nil
end
end
end
def get_mail
set_settings
msg_count = HelpdeskMailer.check_project(@project.id)
respond_to do |format|
format.js do
@message = "<div class='flash notice'> #{l(:label_helpdesk_get_mail_success, :count => msg_count)} </div>"
flash.discard
end
format.html { redirect_to :back }
end
rescue Exception => e
respond_to do |format|
format.js do
@message = "<div class='flash error'> Error: #{e.message} </div>"
Rails.logger.error "Helpdesk MailHandler Error: #{e.message}" if Rails.logger && Rails.logger.error
flash.discard
end
format.html { redirect_to :back }
end
end
def update_customer_email
@journal = Journal.find(params[:journal_id])
@issue = @journal.journalized
@project = @issue.project
@display = HelpdeskSettings[:send_note_by_default, @project] ? 'inline' : 'none'
if @journal.is_incoming?
@contact = @journal.contact
@email = @journal.journal_message.from_address
from_address = HelpdeskSettings['helpdesk_answer_from', @issue.project].blank? ? Setting.mail_from : HelpdeskSettings['helpdesk_answer_from', @issue.project]
@cc_emails = (@issue.helpdesk_ticket.cc_addresses + @journal.journal_message.cc_address.split(',') - [@email, from_address]).uniq
else
@contact = @issue.helpdesk_ticket.last_reply_customer
@email = @issue.helpdesk_ticket.default_to_address
@cc_emails = @issue.helpdesk_ticket.cc_addresses - [@email]
end
end
def message_link
viewable_object = JournalMessage.not_viewed.where(view_id: params[:vid]).first
viewable_object ||= HelpdeskTicket.not_viewed.where(view_id: params[:vid]).first
viewable_object.update_attribute(:viewed_on, Time.zone.now) if viewable_object
head :ok
end
private
def find_project
project_id = params[:project_id] || (params[:ticket] && params[:ticket][:issue] && params[:ticket][:issue][:project_id])
@project = Project.find(project_id)
rescue ActiveRecord::RecordNotFound
render_404
end
def set_settings
set_settings_param('helpdesk_answer_from')
set_settings_param('helpdesk_cc_address')
set_settings_param('helpdesk_bcc_address')
set_settings_param('helpdesk_send_notification')
set_settings_param('helpdesk_is_not_create_contacts')
set_settings_param('helpdesk_created_contact_tag')
set_settings_param('helpdesk_blacklist')
set_settings_param('helpdesk_emails_header')
set_settings_param('helpdesk_answer_subject')
set_settings_param('helpdesk_first_answer_subject')
set_settings_param('helpdesk_first_answer_template')
set_settings_param('helpdesk_emails_footer')
set_settings_param('helpdesk_answered_status')
set_settings_param('helpdesk_reopen_status')
set_settings_param('helpdesk_tracker')
set_settings_param('helpdesk_assigned_to')
set_settings_param('helpdesk_lifetime')
set_settings_param('helpdesk_autoclose_subject')
set_settings_param('helpdesk_autoclose_template')
set_settings_param(:helpdesk_protocol)
set_settings_param(:helpdesk_host)
set_settings_param(:helpdesk_port)
set_settings_param(:helpdesk_password)
set_settings_param(:helpdesk_username)
set_settings_param(:helpdesk_use_ssl)
set_settings_param(:helpdesk_use_starttls)
set_settings_param(:helpdesk_imap_folder)
set_settings_param(:helpdesk_move_on_success)
set_settings_param(:helpdesk_move_on_failure)
set_settings_param(:helpdesk_apop)
set_settings_param(:helpdesk_delete_unprocessed)
set_settings_param(:helpdesk_smtp_use_default_settings)
set_settings_param(:helpdesk_smtp_server)
set_settings_param(:helpdesk_smtp_domain)
set_settings_param(:helpdesk_smtp_port)
set_settings_param(:helpdesk_smtp_authentication)
set_settings_param(:helpdesk_smtp_username)
set_settings_param(:helpdesk_smtp_password)
set_settings_param(:helpdesk_smtp_tls)
set_settings_param(:helpdesk_smtp_ssl)
set_settings_param(:helpdesk_smtp_skip_verification)
set_settings_param(:helpdesk_outlook_folder)
set_settings_param(:helpdesk_outlook_move_on_success)
set_settings_param(:helpdesk_outlook_move_on_failure)
set_settings_param(:helpdesk_outlook_tenant_id)
set_settings_param(:helpdesk_send_protocol)
set_settings_param(:helpdesk_google_folder)
set_settings_param(:helpdesk_google_move_on_success)
set_settings_param(:helpdesk_google_move_on_failure)
end
def set_settings_param(param)
if param == 'helpdesk_created_contact_tag'
if params[param].nil?
params.delete(param)
else
params[param] = params[param].blank? ? '' : params[param].reject(&:empty?).join(',')
end
end
if param == :helpdesk_password || param == :helpdesk_smtp_password
ContactsSetting[param, @project.id] = params[param] if params[param] && !params[param].blank?
else
ContactsSetting[param, @project.id] = params[param].strip if params[param]
end
end
def redirect_on_create(options)
if options[:redirect_on_success].to_s.match('^(http|https):\/\/')
redirect_to options[:redirect_on_success].to_s
else
response_key = :plain
render response_key => "Issue #{@issue.id} created", :status => :created, :location => issue_url(@issue)
end
end
end

View File

@ -0,0 +1,109 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailRulesController < ApplicationController
helper :helpdesk
before_action :find_helpdesk_mail_rule, only: [:edit, :update, :destroy]
before_action :require_admin
def new
@helpdesk_mail_rule = HelpdeskMailRule.new
end
def create
@helpdesk_mail_rule = HelpdeskMailRule.new
@helpdesk_mail_rule.safe_attributes = mail_rule_params
@helpdesk_mail_rule.user = User.current
if @helpdesk_mail_rule.save
flash[:notice] = l(:notice_successful_create)
redirect_to_settings_tab
else
render action: 'new', layout: !request.xhr?
end
end
def edit
end
def update
@helpdesk_mail_rule.safe_attributes = mail_rule_params
if @helpdesk_mail_rule.save
@helpdesk_mail_rule.insert_at(safe_params['position']) if safe_params['position'].present?
respond_to do |format|
format.html do
flash[:notice] = l(:notice_successful_update)
redirect_to_settings_tab
end
format.js { head 200 }
end
else
respond_to do |format|
format.html do
render action: 'edit'
end
format.js { head 422 }
end
end
end
def destroy
@helpdesk_mail_rule.destroy
redirect_to_settings_tab
end
private
def safe_params
@safe_params ||= params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash : params
end
def mail_rule_params
rule_params = {}
if safe_params['f'].is_a?(Array) && (safe_params['v'].nil? || safe_params['v'].is_a?(Hash))
rule_params['conditions'] = {}
safe_params['f'].each do |field|
next if field.blank?
rule_params['conditions'][field] = { operator: safe_params['op'].try(:[], field), values: (safe_params['v'].try(:[], field) || ['']) }
end
end
if safe_params['a'].is_a?(Array) && (safe_params['v'].nil? || safe_params['v'].is_a?(Hash))
rule_params['actions'] = {}
safe_params['a'].each do |action|
next if action.blank?
rule_params['actions'][action] = { operator: safe_params['op'].try(:[], action), values: (safe_params['v'].try(:[], action) || ['']) }
end
end
rule_params['mail_type'] = safe_params['mail_type'] if safe_params['mail_type'].present?
rule_params['move_to'] = safe_params['move_to'] if safe_params['move_to'].present?
rule_params
end
def find_helpdesk_mail_rule
@helpdesk_mail_rule = HelpdeskMailRule.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_404
end
def redirect_to_settings_tab
redirect_to plugin_settings_path(id: :redmine_contacts_helpdesk, tab: :email_rules)
end
end

View File

@ -0,0 +1,62 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailerController < ActionController::Base
before_action :check_credential
# Submits an incoming email to ContactsMailer
def index
options = params.dup
if options[:issue].present?
project = Project.where(identifier: options[:issue][:project]).first
options = HelpdeskMailSupport.issue_options(options, project.id) if project
end
email = options.delete(:email)
if HelpdeskMailer.receive(email, options)
head :created
else
head :unprocessable_entity
end
end
def get_mail
msg_count = 0
errors = []
projects = Project.active.has_module(:contacts_helpdesk)
projects = projects.where(:id => params[:project_id]) if params[:project_id].present?
projects.each do |project|
begin
msg_count += HelpdeskMailer.check_project(project.id)
rescue Exception => e
errors << e.message
end
end
response_key = :plain
render :status => :ok, response_key => { :count => msg_count, :errors => errors }.to_json
end
private
def check_credential
User.current = nil
unless Setting.mail_handler_api_enabled? && params[:key].to_s == Setting.mail_handler_api_key
render plain: 'Access denied. Incoming emails WS is disabled or key is invalid.', status: 403
end
end
end

View File

@ -0,0 +1,87 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskOauthController < HelpdeskBaseController
before_action :find_project, except: [:resp]
before_action :authorize, except: [:resp]
def auth
ContactsSetting[:helpdesk_protocol, @project.id] = params[:provider]
oauth_provider = build_oauth_provider(params[:provider], @project.id)
return render_404 unless oauth_provider
redirect_to oauth_provider.authorize_url(generate_state_secret)
end
def auth_remove
oauth_provider = build_oauth_provider(params[:provider], @project.id)
return render_404 unless oauth_provider
oauth_provider.reset_token
redirect_to settings_project_path(@project, tab: 'helpdesk')
end
def resp
secret_data = extract_secret_data
return render_404 unless secret_data
oauth_provider = build_oauth_provider(secret_data[:provider], secret_data[:project])
oauth_provider.receive_tokens(params[:code])
redirect_to settings_project_path(secret_data[:project], tab: 'helpdesk')
end
private
def build_oauth_provider(provider, project_id)
oauth_provider = HelpdeskOauthProvider.find_by_protocol(provider)
return nil unless oauth_provider
oauth_provider.new(project_id)
end
def find_project
@project = Project.find_by(identifier: params[:project])
render_404 unless @project
end
def generate_state_secret
secret = SecureRandom.uuid
secret_data = { 'project' => @project.id, 'provider' => params[:provider] }
state_secrets = RedmineHelpdesk.settings['helpdesk_oauth_state_secrets'] || {}
old_secret = state_secrets.key(secret_data)
state_secrets.delete(old_secret) if old_secret
RedmineHelpdesk.settings = { 'helpdesk_oauth_state_secrets' => state_secrets.merge(secret => secret_data) }
secret
end
def extract_secret_data
state_secrets = RedmineHelpdesk.settings['helpdesk_oauth_state_secrets'] || {}
data = state_secrets[params[:state]]
return unless data
state_secrets.delete(params[:state])
RedmineHelpdesk.settings = { 'helpdesk_oauth_state_secrets' => state_secrets }
data
end
end

View File

@ -0,0 +1,69 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskReportsController < ApplicationController
menu_item :issues
helper :helpdesk
helper :queries
include QueriesHelper
before_action :find_optional_project
before_action :authorize_global
before_action :find_query
def show
return render_404 unless @query
if @query.valid?
@collector = HelpdeskDataCollectorManager.new.collect_data(@query)
end
end
private
def find_query
report_query_class =
case params[:report]
when 'first_response_time'
HelpdeskReportsFirstResponseQuery
when 'busiest_time_of_day'
HelpdeskReportsBusiestTimeQuery
when 'customer_satisfaction'
HelpdeskReportsCustomerSatisfactionQuery
end
return unless report_query_class
if params[:set_filter] || session["helpdesk_reports_#{params[:report]}"].blank?
@query = report_query_class
.new(:name => params[:report], :project => @project)
.build_from_params(params)
session["helpdesk_reports_#{params[:report]}"] = {
:project_id => @query.project_id,
:filters => @query.filters
}
else
@query = report_query_class.new(
:name => params[:report],
:project => @project,
:filters => session["helpdesk_reports_#{params[:report]}"][:filters])
end
end
end

View File

@ -0,0 +1,291 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskTicketsController < HelpdeskBaseController
before_action :find_issue, except: [:index, :create, :bulk_edit_reply, :bulk_update_reply, :preview_bulk_reply]
before_action :find_project, except: [:index, :bulk_edit_reply, :bulk_update_reply, :preview_bulk_reply]
before_action :authorize, except: [:index, :preview_bulk_reply, :bulk_edit_reply, :bulk_update_reply]
before_action :find_helpdesk_ticket, only: [:show, :edit, :update, :destroy]
before_action :find_optional_project, only: [:index]
before_action :find_issues, only: [:bulk_edit_reply, :bulk_update_reply]
accept_api_auth :index, :create, :show, :update, :destroy
helper :helpdesk
helper :helpdesk_api
helper :attachments
helper :queries
include QueriesHelper
def index
retrieve_query(HelpdeskTicketQuery)
if @query.valid?
respond_to do |format|
format.api {
@offset, @limit = api_offset_and_limit
@tickets_count = @query.tickets_count
@tickets = @query.tickets(offset: @offset, limit: @limit)
Issue.load_visible_relations(@issues) if include_in_api_response?('relations')
}
format.any { head 200 }
end
else
respond_to do |format|
format.any(:atom, :csv, :pdf) { head 422 }
format.api { render_validation_errors(@query) }
end
end
rescue ActiveRecord::RecordNotFound
render_404
end
def create
return unless validate_create_params
@issue = Issue.new
@issue.project = @project
@issue.author ||= User.current
@issue.safe_attributes = params[:helpdesk_ticket][:issue]
@contact = Contact.find_by_emails([params[:helpdesk_ticket][:contact][:email]]).first
unless @contact
@contact = Contact.new
@contact.safe_attributes = params[:helpdesk_ticket][:contact]
end
@contact.projects << @project unless @contact.projects.include?(@project)
@helpdesk_ticket = HelpdeskTicket.new(ticket_date: Time.now, customer: @contact, is_incoming: true, issue: @issue)
@helpdesk_ticket.safe_attributes = params[:helpdesk_ticket].except(:issue, :contact)
@helpdesk_ticket.from_address = @contact.primary_email
@helpdesk_ticket.source = find_ticket_source
@issue.helpdesk_ticket = @helpdesk_ticket
@issue.assigned_to = @contact.find_assigned_user(@project, @issue.assigned_to)
@issue.save_attachments(params[:helpdesk_ticket][:attachments] || (params[:helpdesk_ticket][:issue] && params[:helpdesk_ticket][:issue][:uploads]))
if @issue.save
process_helpdesk_send_as(params[:helpdesk_ticket][:send_as])
HelpdeskTicket.autoclose(@project)
return render_api_view(action: :create, status: :created)
else
return render_api_error(message: @issue.errors.full_messages)
end
rescue Exception => e
render_api_error(message: "#{e.message} (#{e.backtrace.first})")
end
def show
@show_form = 'false'
respond_to do |format|
format.js
format.api
end
end
def edit
@helpdesk_ticket = @issue.helpdesk_ticket || @issue.build_helpdesk_ticket(ticket_date: Time.now)
@show_form = 'true'
respond_to do |format|
format.js
end
end
def update
@helpdesk_ticket.safe_attributes = params[:helpdesk_ticket]
if params[:helpdesk_ticket][:cc_address]
cc_address = params[:helpdesk_ticket][:cc_address]
cc_address = cc_address.reject(&:empty?).join(',') if cc_address.is_a?(Array)
@helpdesk_ticket.cc_address = cc_address
end
if params[:helpdesk_ticket][:from_address].present?
from_param = params[:helpdesk_ticket][:from_address]
if from_param =~ /^\d+$/
customer = Contact.where(id: from_param).first
@helpdesk_ticket.customer = customer
@helpdesk_ticket.from_address = customer.try(:primary_email)
else
@helpdesk_ticket.customer = HelpdeskMailSupport.create_contact_from_address(from_param, from_param, @project)
end
else
@helpdesk_ticket.from_address = @helpdesk_ticket.customer.try(:primary_email)
end
@helpdesk_ticket.source = find_ticket_source if params[:helpdesk_ticket][:source]
if @helpdesk_ticket.save
flash[:notice] = l(:notice_successful_update)
respond_to do |format|
format.html { redirect_back_or_default(issue_path(@issue)) }
format.api { render_api_view(action: :show, status: 200) }
end
else
flash[:error] = @helpdesk_ticket.errors.full_messages.flatten.join("\n")
respond_to do |format|
format.html { redirect_back_or_default(issue_path(@issue)) }
format.api { render_api_error(message: @issue.errors.full_messages) }
end
end
end
def destroy
if @helpdesk_ticket.destroy
flash[:notice] = l(:notice_successful_delete)
respond_to do |format|
format.html { redirect_back_or_default(issue_path(@issue)) }
format.api { render_api_view(status: :no_content) }
end
else
flash[:error] = l(:notice_unsuccessful_save)
respond_to do |format|
format.html { redirect_back_or_default(issue_path(@issue)) }
format.api { render_api_error(message: @helpdesk_ticket.errors.full_messages) }
end
end
end
def bulk_edit_reply
@project = Project.find(params[:project_id])
@contacts = @issues.map { |issue| issue.helpdesk_ticket.try(:contact_id) }
unless @issues.all?(&:is_ticket?)
render_404
else
render 'bulk_reply'
end
end
def bulk_update_reply
unsaved_issues = []
@issues.each do |issue|
journal = issue.init_journal(User.current, params[:notes])
to_address = issue.helpdesk_ticket.default_to_address
cc = params[:journal_message][:cc_address]
bcc = params[:journal_message][:bcc_address]
options = params.merge(journal_message: { to_address: [to_address], cc_address: [cc], bcc_address: [bcc] })
if issue.helpdesk_ticket && params[:helpdesk] && params[:helpdesk][:is_send_mail].present? && User.current.allowed_to?(:send_response, issue.project)
HelpdeskTicket.send_reply_by_issue(issue, options)
end
unsaved_issues << issue unless issue.save
end
if unsaved_issues.empty?
flash[:notice] = l(:notice_successful_update)
redirect_back_or_default(bulk_edit_reply_helpdesk_tickets_path(ids: @issues.ids))
else
bulk_edit_reply
render :action => 'bulk_edit_reply'
end
end
def preview_bulk_reply
contact = params[:ids].present? ? Contact.visible.where(id: params[:ids][0]).first : nil
@text = contact.nil? ? params[:text] : mail_macro(contact, params[:text])
render :partial => 'common/preview'
end
private
def find_issue
# TODO: Remove params[:issue_id] when new API will be applied
@issue = Issue.find(params[:issue_id] || params[:id])
rescue ActiveRecord::RecordNotFound
render_api_error(message: 'Issue not found', status: :not_found)
end
def find_issues
@issues = Issue.where(id: (params[:ids] || params[:issue]))
render_404 if @issues.empty?
end
def find_project
return @project = @issue.project if @issue
project_id = params[:helpdesk_ticket] && params[:helpdesk_ticket][:issue] && params[:helpdesk_ticket][:issue][:project_id]
@project = Project.find_by_param(project_id)
rescue ActiveRecord::RecordNotFound
return render_api_error(message: 'Project not found', status: :not_found) unless @project
end
def find_helpdesk_ticket
@helpdesk_ticket = @issue.helpdesk_ticket
@helpdesk_ticket ||= @issue.build_helpdesk_ticket(ticket_date: Time.now) unless request.format.api?
return render_api_error(message: 'Helpdesk ticket not found', status: :not_found) unless @helpdesk_ticket
end
def validate_create_params
return render_api_error(message: 'HelpdeskTicket params cannot be blank') if params[:helpdesk_ticket].blank?
return render_api_error(message: 'Issue params cannot be blank') if params[:helpdesk_ticket][:issue].blank?
return render_api_error(message: 'Contact params cannot be blank') if params[:helpdesk_ticket][:contact].blank?
return render_api_error(message: 'Contact should have email address') if params[:helpdesk_ticket][:contact][:email].blank?
true
end
def find_ticket_source
case params[:helpdesk_ticket][:source]
when 'email', '0'
HelpdeskTicket::HELPDESK_EMAIL_SOURCE
when 'web', '1'
HelpdeskTicket::HELPDESK_WEB_SOURCE
when 'phone', '2'
HelpdeskTicket::HELPDESK_PHONE_SOURCE
when 'twitter', '3'
HelpdeskTicket::HELPDESK_TWITTER_SOURCE
when 'conversation', '4'
HelpdeskTicket::HELPDESK_CONVERSATION_SOURCE
else
HelpdeskTicket::HELPDESK_WEB_SOURCE
end
end
def render_api_view(opts)
status = opts[:status] || 200
respond_to do |format|
format.api do
return head status if opts[:action].blank?
render action: opts[:action], status: status
end
format.any { head status }
end
true
end
def render_api_error(opts)
@message = opts[:message]
@error_messages = [opts[:message]]
@status = opts[:status] || :unprocessable_entity
respond_to do |format|
format.html do
render_404
end
format.api do
render template: 'common/error_messages', format: [:api], status: @status
end
format.any { head @status }
end
false
end
end

View File

@ -0,0 +1,63 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskVotesController < ApplicationController
layout 'public_tickets'
skip_before_action :check_if_login_required
before_action :find_ticket, :authorize_ticket
before_action :fill_data
helper :issues
def vote
@ticket.update_vote(params[:vote], params[:vote_comment]) if params[:vote]
end
def fast_vote
if RedmineHelpdesk.vote_comment_allow?
@ticket.vote = params[:vote] if params[:vote]
render :action => 'show'
else
@ticket.update_vote(params[:vote]) if params[:vote]
render :action => 'vote'
end
end
private
def find_ticket
@ticket = HelpdeskTicket.find(params[:id])
@issue = @ticket.issue
@project = @issue.project
rescue ActiveRecord::RecordNotFound
render_404
end
def authorize_ticket(_action = params[:action])
allow = true
allow &&= (@ticket.token == params[:hash]) && RedmineHelpdesk.vote_allow?
allow &&= !@issue.is_private
render_404 unless allow
end
def fill_data
@previous_tickets = @ticket.customer.tickets.where(:is_private => false).includes([:status, :helpdesk_ticket]).order_by_status
@total_spent_hours = @previous_tickets.map.sum(&:total_spent_hours)
end
end

View File

@ -0,0 +1,251 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskWidgetController < ApplicationController
layout false
helper :custom_fields
protect_from_forgery :except => [:widget, :animation, :load_form, :load_custom_fields, :avatar, :create_ticket, :iframe]
skip_before_action :check_if_login_required, :only => [:widget, :animation, :load_form, :load_custom_fields, :avatar, :create_ticket, :iframe]
before_action :prepare_data, :only => [:load_custom_fields, :create_ticket]
after_action :set_access_control_header
def load_form
render :json => schema.to_json
end
def load_custom_fields
@issue = @project.issues.build(:tracker => @tracker) if @tracker
@enabled_cf = HelpdeskSettings["helpdesk_widget_available_custom_fields", nil]
end
def avatar
user = User.where(:login => params[:login]).first
return render :nothing => true, :status => 404 unless user
if user.try(:avatar).nil?
avatar_thumb, avatar_type = gravatar_avatar(user) if Setting.gravatar_enabled?
else
avatar_thumb, avatar_type = local_avatar(user.avatar)
end
return render :nothing => true, :status => 404 unless avatar_thumb
send_avatar(avatar_thumb, avatar_type)
end
def send_avatar(avatar_thumb, avatar_type)
send_file avatar_thumb, :filename => (request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(params[:login]) : params[:login]),
:type => avatar_type,
:disposition => 'inline'
end
def gravatar_avatar(user)
email = user.mail if user.respond_to?(:mail)
email = user.to_s[/<(.+?)>/, 1] unless email
return [nil, nil] unless email
default = Setting.gravatar_default ? CGI::escape(Setting.gravatar_default) : ''
temp_file = Tempfile.new([user.login, '.jpeg'])
temp_file.binmode
gravatar_url = Redmine::Configuration['avatar_server_url'] || 'http://www.gravatar.com'
open("#{gravatar_url}/avatar/#{Digest::MD5.hexdigest(email)}?rating=PG&size=54&default=#{default}") do |url_file|
temp_file.write(url_file.read)
end
temp_file.rewind
[temp_file, 'image/jpeg']
end
def local_avatar(user_avatar)
return nil unless user_avatar.readable? || user_avatar.thumbnailable?
if (defined?(RedmineContacts::Thumbnail) == 'constant') && Redmine::Thumbnail.convert_available?
target = File.join(user_avatar.class.thumbnails_storage_path, "#{user_avatar.id}_#{user_avatar.digest}_54x54.thumb")
thumbnail = RedmineContacts::Thumbnail.generate(user_avatar.diskfile, target, '54x54')
elsif Redmine::Thumbnail.convert_available?
thumbnail = user_avatar.thumbnail(:size => '54x54')
else
thumbnail = user_avatar.diskfile
end
[thumbnail, detect_content_type(user_avatar)]
end
def create_ticket
@issue = prepare_issue
@issue.helpdesk_ticket = prepare_helpdesk_ticket
result =
if privacy_accepted? && valid_email? && @issue.save
save_attachment(@issue.reload)
container = HelpdeskMailContainer.new(widget_message_as_email, logger: logger, contact: @widget_contact, issue: @issue)
container.is_new_issue = true
HelpdeskMailRule.apply_rules(:incoming, container)
HelpdeskMailer.auto_answer(@issue.helpdesk_ticket.customer, @issue) if HelpdeskSettings['helpdesk_send_notification', @project].to_i > 0
HelpdeskTicket.autoclose(@project)
{ :result => true, :errors => [] }
else
{ :result => false, :errors => prepared_errors }
end
render :json => result
end
private
def prepare_data
@project = Project.find(params[:project_id])
@tracker = @project.trackers.where(:id => params[:tracker_id]).first
end
def schema
if HelpdeskSettings['helpdesk_widget_enable', nil].to_i > 0
projects = Project.has_module('contacts_helpdesk').where(:id => HelpdeskSettings[:helpdesk_widget_available_projects, nil])
else
projects = []
end
data_schema = {}
data_schema[:projects] = Hash[projects.map { |project| [project.name.capitalize, project.id] }]
data_schema[:projects_data] = {}
projects.each do |project|
data_schema[:projects_data][project.id] = {}
if HelpdeskSettings['helpdesk_tracker', project] && HelpdeskSettings['helpdesk_tracker', project] != 'all'
data_schema[:projects_data][project.id][:trackers] = Hash[Tracker.where(id: HelpdeskSettings['helpdesk_tracker', project])
.map { |tracker| [tracker.name, tracker.id] }]
else
data_schema[:projects_data][project.id][:trackers] = Hash[project.trackers.map { |tracker| [tracker.name, tracker.id] }]
end
end
data_schema[:custom_fields] = Hash[IssueCustomField.where(id: HelpdeskSettings['helpdesk_widget_available_custom_fields', nil])
.map { |custom_field| [custom_field.name, custom_field.id] }]
data_schema[:avatar] = HelpdeskSettings[:helpdesk_widget_avatar_login, nil]
data_schema
end
def prepared_errors
errors_hash = @issue.errors.dup
# Username
if errors_hash[:'helpdesk_ticket.customer.first_name'].present?
@issue.errors.delete(:'helpdesk_ticket.customer.first_name')
@issue.errors.add(:username, errors_hash[:'helpdesk_ticket.customer.first_name'].collect { |error| ['Username', error].join(' ') })
end
# Subject
if errors_hash[:subject].present?
errors = errors_hash[:subject].collect { |error| ['Subject', error].join(' ') }
@issue.errors[:subject].clear
@issue.errors.add(:subject, errors)
end
# Description
if params[:issue][:description].blank?
@issue.errors.add(:description, I18n.t(:label_helpdesk_widget_ticket_error_description))
end
# Nested objects
if errors_hash[:'helpdesk_ticket.customer.projects'].present?
@issue.errors.delete(:'helpdesk_ticket.customer.projects')
end
@issue.errors
end
def prepare_issue
redmine_user = User.where(id: params[:redmine_user]).first
redmine_user ||= User.find_by_mail(params[:email])
issue_category = @project.issue_categories.find_by(name: params[:issue_category])
issue = @project.issues.build(tracker: @tracker, author: redmine_user.present? ? redmine_user : User.anonymous, category: issue_category)
issue.safe_attributes = params[:issue].deep_dup
issue.assigned_to = widget_contact.find_assigned_user(@project, HelpdeskSettings['helpdesk_assigned_to', @project])
issue
end
def prepare_helpdesk_ticket
HelpdeskTicket.new(:from_address => params[:email],
:ticket_date => Time.now,
:customer => widget_contact,
:issue => @issue,
:source => HelpdeskTicket::HELPDESK_WEB_SOURCE)
end
def save_attachment(issue)
return unless params[:attachment].present?
attachment_hash = split_base64(params[:attachment])
attachment = Attachment.new(file: Base64.decode64(attachment_hash[:data]))
attachment.filename = params[:attachment_name] || [Redmine::Utils.random_hex(16), attachment_hash[:extension]].join('.')
attachment.content_type = attachment_hash[:type]
attachment.author = User.anonymous
issue.attachments << attachment
issue.save
end
def split_base64(uri)
matcher = uri.match(/^data:(.*?)\;(.*?),(.*)$/)
{ type: matcher[1],
encoder: matcher[2],
data: matcher[3],
extension: matcher[1].split('/')[1] }
end
def widget_contact
return @widget_contact if @widget_contact
contacts = Contact.find_by_emails([params[:email]])
return @widget_contact = contacts.first if contacts.any?
@widget_contact = Contact.new(:email => params[:email])
@widget_contact.first_name, @widget_contact.last_name = params[:username].split(' ')
@widget_contact.projects << @project
@widget_contact
end
def set_access_control_header
headers['Access-Control-Allow-Origin'] = '*'
headers['X-Frame-Options'] = '*'
end
def valid_email?
if params[:email].empty?
@issue.errors.add(:email, 'Email cannot be empty')
return false
elsif params[:email].match(/\A([\w\.\+\-]+)@([\w\-]+\.)+([\w]{2,})\z/i).nil?
@issue.errors.add(:email, 'Email is incorrect')
return false
end
true
end
def detect_content_type(attachment)
content_type = attachment.content_type
if content_type.blank?
content_type = Redmine::MimeType.of(attachment.filename)
end
content_type.to_s
end
def privacy_accepted?
return true unless RedmineHelpdesk.add_widget_privacy_policy_checkbox?
params[:privacy_policy] == '1'
end
def widget_message_as_email
message_sender = params[:email]
message_subject = params[:issue][:subject]
message_body = params[:issue][:description]
Mail.new(message_sender, message_subject, message_body) do
from message_sender
subject message_subject
text_part do
body message_body
end
end
end
end

View File

@ -0,0 +1,117 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class JournalMessagesController < ApplicationController
before_action :find_issue
before_action :find_project
before_action :authorize
accept_api_auth :create
helper :helpdesk
helper :helpdesk_api
helper :attachments
def create
return unless validate_create_params
journal = @issue.init_journal(User.current)
if params[:journal_message][:status_id].present?
issue_status = IssueStatus.where(id: params[:journal_message][:status_id]).first
@issue.status_id = issue_status.id if issue_status
end
journal.notes = params[:journal_message][:content]
@issue.save_attachments(params[:attachments] || params[:journal_message][:uploads])
if @issue.save
contact = @issue.customer
@journal_message = JournalMessage.create!(from_address: params[:journal_message][:from_address],
to_address: params[:journal_message][:to_address] || contact.primary_email.downcase,
cc_address: params[:journal_message][:cc_address],
bcc_address: params[:journal_message][:bcc_address],
is_incoming: false,
message_date: Time.now,
contact: contact,
journal: journal)
@journal_message.update(message_id: HelpdeskMailer.issue_response(contact, journal, params[:journal_message]).message_id)
return render_api_view(action: :create, status: :created)
else
return render_api_error(message: @issue.errors.full_messages)
end
rescue Exception => e
render_api_error(message: "#{e.message} (#{e.backtrace.first})")
end
private
def find_issue
@issue = Issue.find(params[:journal_message] && params[:journal_message][:issue_id])
rescue ActiveRecord::RecordNotFound
render_api_error(message: 'Issue not found', status: :not_found)
end
def find_project
@project = @issue.project
return render_api_error(message: 'Project not found', status: :not_found) unless @project
end
def validate_create_params
return render_api_error(message: 'JournalMessage params cannot be blank') if params[:journal_message].blank?
return render_api_error(message: 'Content data cannot be blank') if params[:journal_message][:content].blank?
true
end
def render_api_error(opts)
@error_messages = [opts[:message]]
@status = opts[:status] || :unprocessable_entity
respond_to do |format|
format.any { head @status }
format.api do
render template: 'common/error_messages', format: [:api], status: @status, layout: opts[:layout]
end
end
false
end
def render_api_view(opts)
status = opts[:status] || 200
respond_to do |format|
format.api do
return head status if opts[:action].blank?
render action: opts[:action], status: status
end
format.any { head status }
end
true
end
end
# {
# "journal_message": {
# "id": 51,
# "from_address": "ar@kodep.ru",
# "to_address": "reshetov_test@yandex.ru",
# "cc_address": "_viruz_@mail.ru",
# "bcc_address": "bcc@mail.ru",
# "content": "Hello, I'm message text",
# "message_date": "2019-05-29T13:21:58Z",
# "message_id": "5cee8776327d8_4d922af2ed82386c68127@notebook.mail"
# }
# }

View File

@ -0,0 +1,104 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class MailFetcherController < ApplicationController
require 'timeout'
require 'redmine/imap.rb'
before_action :check_credential
def receive_imap
imap_options = { :host => params['host'],
:port => params['port'],
:ssl => params['ssl'],
:username => params['username'],
:password => params['password'],
:folder => params['folder'],
:move_on_success => params['move_on_success'],
:move_on_failure => params['move_on_failure'] }
options = { :issue => {} }
%w(project status tracker category priority).each { |a| options[:issue][a.to_sym] = params[a] if params[a] }
options[:allow_override] = params['allow_override'] if params['allow_override']
options[:unknown_user] = params['unknown_user'] if params['unknown_user']
options[:no_permission_check] = params['no_permission_check'] if params['no_permission_check']
begin
Timeout::timeout(15) { Redmine::IMAP.check(imap_options, options) }
rescue Exception => e
@error_messages = [e.message]
end
if @error_messages.blank?
respond_to do |format|
format.html { head :ok }
format.api { render_api_ok }
end
else
respond_to do |format|
response_key = :plain
format.html { render response_key => @error_messages, :status => :unprocessable_entity, :layout => nil }
format.api { render template: 'common/error_messages', status: :unprocessable_entity, layout: nil }
end
end
end
def receive_pop3
pop_options = { :host => params['host'],
:port => params['port'],
:apop => params['apop'],
:username => params['username'],
:password => params['password'],
:delete_unprocessed => params['delete_unprocessed'] }
options = { :issue => {} }
%w(project status tracker category priority).each { |a| options[:issue][a.to_sym] = params[a] if params[a] }
options[:allow_override] = params['allow_override'] if params['allow_override']
options[:unknown_user] = params['unknown_user'] if params['unknown_user']
options[:no_permission_check] = params['no_permission_check'] if params['no_permission_check']
begin
Timeout::timeout(15) { Redmine::POP3.check(pop_options, options) }
rescue Exception => e
@error_messages = [e.message]
end
if @error_messages.blank?
respond_to do |format|
format.html { render :nothing => true, :status => :ok }
format.api { render_api_ok }
end
else
respond_to do |format|
format.html { render :text => @error_messages, :status => :unprocessable_entity, :layout => nil }
format.api { render template: 'common/error_messages', status: :unprocessable_entity, layout: nil }
end
end
end
private
def check_credential
User.current = nil
unless Setting.mail_handler_api_enabled? && params[:key].to_s == Setting.mail_handler_api_key
render :text => 'Access denied. Incoming emails WS is disabled or key is invalid.', :status => 403
end
end
end

View File

@ -0,0 +1,91 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class PublicTicketsController < ApplicationController
layout 'public_tickets'
skip_before_action :check_if_login_required
before_action :find_ticket, :authorize_ticket
helper :issues
helper :attachments
helper :journals
helper :custom_fields
def show
@previous_tickets = @ticket.customer.tickets.where(:is_private => false).includes([:status, :helpdesk_ticket]).order_by_status
@total_spent_hours = @previous_tickets.map.sum(&:total_spent_hours)
@journals = @issue.journals.includes(:user).
includes(:details).
order("#{Journal.table_name}.created_on ASC").
where(:private_notes => false).
where("EXISTS (SELECT * FROM #{JournalMessage.table_name} WHERE #{JournalMessage.table_name}.journal_id = #{Journal.table_name}.id)")
@journals = @journals.each_with_index { |j, i| j.indice = i + 1 }.to_a
@journals.reverse! if User.current.wants_comments_in_reverse_order?
@journal = @issue.journals.new
@allowed_statuses = @issue.new_statuses_allowed_to(User.current)
@edit_allowed = User.current.allowed_to?(:edit_issues, @project)
@priorities = IssuePriority.active
@time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
prepend_view_path 'app/views/issues'
end
def add_comment
@journal = @issue.init_journal(User.current, params[:journal][:notes])
@issue.status_id = HelpdeskSettings['helpdesk_reopen_status', @issue.project_id] unless HelpdeskSettings['helpdesk_reopen_status', @issue.project_id].blank?
@journal.journal_message = JournalMessage.new(:from_address => @ticket.customer_email,
:contact => @ticket.customer,
:journal => @journal,
:is_incoming => true,
:message_date => Time.now)
flash[:notice] = l(:notice_successful_create) if @issue.save
redirect_back_or_default(public_ticket_path(@ticket, @ticket.token))
end
def render_404(_options = {})
@message = l(:notice_file_not_found)
respond_to do |format|
format.html { render :template => 'common/error', :status => 404 }
format.any { head 404 }
end
false
end
private
def find_ticket
@ticket = HelpdeskTicket.find(params[:id])
@issue = @ticket.issue
@project = @issue.project
rescue ActiveRecord::RecordNotFound
render_404
end
def authorize_ticket(action = params[:action])
allow = true
allow &&= RedmineHelpdesk.public_comments? if action.to_s == 'add_comment'
allow &&= (@ticket.token == params[:hash]) && RedmineHelpdesk.public_tickets?
allow &&= !@issue.is_private
render_404 unless allow
end
end

View File

@ -0,0 +1,85 @@
# encoding: utf-8
#
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
module HelpdeskApiHelper
def render_api_helpdesk_ticket(helpdesk_ticket, api)
api.id helpdesk_ticket.issue.id
api.from_address helpdesk_ticket.from_address
api.to_address helpdesk_ticket.to_address || ''
api.cc_address helpdesk_ticket.cc_address
api.message_id helpdesk_ticket.message_id
api.ticket_date format_date(helpdesk_ticket.ticket_date)
api.content helpdesk_ticket.issue.description
api.source helpdesk_ticket.ticket_source_name
api.is_incoming helpdesk_ticket.is_incoming
api.reaction_time helpdesk_ticket.reaction_time || ''
api.first_response_time helpdesk_ticket.first_response_time || ''
api.resolve_time helpdesk_ticket.resolve_time || ''
api.last_agent_response_at format_date(helpdesk_ticket.last_agent_response_at) || ''
api.last_customer_response_at format_date(helpdesk_ticket.last_customer_response_at) || ''
api.contact(id: helpdesk_ticket.contact_id, name: helpdesk_ticket.customer.name) if helpdesk_ticket.customer.present?
api.vote helpdesk_ticket.vote
api.vote_comment helpdesk_ticket.vote_comment
api.message_file do
render_api_attachment(helpdesk_ticket.message_file, api)
end if helpdesk_ticket.message_file.present?
api.array :journal_messages do
helpdesk_ticket.issue.journal_messages.each do |journal_message|
api.journal_message do
api.contact(id: journal_message.contact_id, name: journal_message.contact.name) if journal_message.contact.present?
api.from_address journal_message.from_address
api.to_address journal_message.to_address
api.cc_address journal_message.cc_address
api.bcc_address journal_message.bcc_address
api.message_date format_date(journal_message.message_date)
api.is_incoming journal_message.is_incoming
api.content journal_message.content
api.message_id journal_message.message_id
api.journal_id journal_message.journal_id
api.viewed_on journal_message.viewed_on
api.message_file do
render_api_attachment(journal_message.message_file, api)
end if journal_message.message_file.present?
end
end
end if include_in_api_response?('journal_messages')
api.array :journals do
helpdesk_ticket.journals.each do |journal|
api.journal do
api.id journal.id
api.notes journal.notes
api.created_on journal.created_on
end
end
end if include_in_api_response?('journals')
end
def render_api_journal_message(journal_message, api)
api.id journal_message.journal_id
api.from_address journal_message.from_address
api.to_address journal_message.to_address
api.cc_address journal_message.cc_address
api.bcc_address journal_message.bcc_address
api.content journal_message.journal.notes
api.message_date format_date(journal_message.message_date)
api.message_id journal_message.message_id
end
end

View File

@ -0,0 +1,275 @@
# encoding: utf-8
#
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
module HelpdeskHelper
def helpdesk_ticket_source_icon(helpdesk_ticket)
case helpdesk_ticket.source
when HelpdeskTicket::HELPDESK_EMAIL_SOURCE
'icon-email'
when HelpdeskTicket::HELPDESK_PHONE_SOURCE
'icon-call'
when HelpdeskTicket::HELPDESK_WEB_SOURCE
'icon-web'
when HelpdeskTicket::HELPDESK_TWITTER_SOURCE
'icon-twitter'
else
'icon-helpdesk'
end
end
def helpdesk_tickets_source_for_select
[[l(:label_helpdesk_tickets_email), HelpdeskTicket::HELPDESK_EMAIL_SOURCE.to_s],
[l(:label_helpdesk_tickets_phone), HelpdeskTicket::HELPDESK_PHONE_SOURCE.to_s],
[l(:label_helpdesk_tickets_web), HelpdeskTicket::HELPDESK_WEB_SOURCE.to_s],
[l(:label_helpdesk_tickets_conversation), HelpdeskTicket::HELPDESK_CONVERSATION_SOURCE.to_s]
]
end
def helpdesk_send_as_for_select
[[l(:label_helpdesk_not_send), ''],
[l(:label_helpdesk_send_as_notification), HelpdeskTicket::SEND_AS_NOTIFICATION.to_s],
[l(:label_helpdesk_send_as_message), HelpdeskTicket::SEND_AS_MESSAGE.to_s]
]
end
def show_customer_vote(vote, comment)
case vote
when 2
generate_vote_link(vote, 'icon-awesome', comment)
when 1
generate_vote_link(vote, 'icon-justok', comment)
when 0
generate_vote_link(vote, 'icon-notgood', comment)
end
end
def generate_vote_link(vote, vote_class, title)
"<div class='icon #{vote_class}' title='#{title}'>#{HelpdeskTicket.vote_message(vote)}</div>".html_safe
end
def render_helpdesk_chart(report_name, issues_scope)
render :partial => 'helpdesk_reports/chart', :locals => { :report => report_name, :issues_scope => issues_scope }
end
def helpdesk_time_label(seconds)
hours, minutes = seconds.divmod(60).first.divmod(60)
"#{hours}<span>#{l(:label_helpdesk_hour)}</span> #{minutes}<span>#{l(:label_helpdesk_minute)}</span>".html_safe
end
def slim_helpdesk_time_label(seconds)
hours, minutes = seconds.divmod(60).first.divmod(60)
"#{hours}#{l(:label_helpdesk_hour)} #{minutes}#{l(:label_helpdesk_minute)}".html_safe
end
def progress_in_percents(value)
return '0%'.html_safe if value.zero?
"<span class='caret #{value > 0 ? 'pos' : 'neg'}'></span>#{value}%".html_safe
end
def mirror_progress_in_percents(value)
return '0%'.html_safe if value.zero?
"<span class='caret #{value < 0 ? 'mirror_pos' : 'mirror_neg'}'></span>#{value}%".html_safe
end
def process_deviation(before, now, time = true)
["#{l(:label_helpdesk_report_previous)}: #{time ? slim_helpdesk_time_label(before) : before}",
"#{l(:label_helpdesk_report_deviation)}: #{time ? slim_helpdesk_time_label(calculate_deviation(before, now)) : calculate_deviation(before, now)}"].join("\n").html_safe
end
def calculate_deviation(before, now)
before > now ? before - now : now - before
end
def helpdesk_reply_link
link_to l(:label_helpdesk_reply), edit_issue_path(@issue), :onclick => 'showAndScrollTo("update", "issue_notes"); showConfiguredSend("update", "issue_notes", true); return false',
:class => 'icon icon-helpdesk-reply'
end
def helpdesk_mail_rule_mail_types_for_select(selected = nil)
mail_types_options = [
[l(:label_helpdesk_mail_rule_mail_type_incoming), HelpdeskMailRule::INCOMING],
[l(:label_helpdesk_mail_rule_mail_type_outgoing), HelpdeskMailRule::OUTGOING]
# TODO: Manual rules
# [l(:label_helpdesk_mail_rule_mail_type_manually), HelpdeskMailRule::MANUALLY]
]
options_for_select(mail_types_options, selected)
end
def helpdesk_mail_rule_attrs_for_select(collection)
elements = []
collection.each do |element, element_options|
elements << [element_options[:name], element]
end
options_for_select([[]] + elements)
end
def helpdesk_mail_rule_conditions_to_html(conditions)
return content_tag('p', l(:label_helpdesk_mail_rule_condition_for_all), class: 'helpdesk_mail_rule_condition') unless conditions.any?
conditions.map do |condition, options|
next unless HelpdeskMailRule.available_conditions[condition]
condition = HelpdeskMailRule.available_conditions[condition].new
content_tag('p',
[
condition.options[:name],
l(Query.operators[options[:operator]]),
content_tag('b', condition.label_for(options) || options[:values].join(','))
].join(' ').html_safe,
class: 'helpdesk_mail_rule_condition')
end.join.html_safe
end
def helpdesk_mail_rule_actions_to_html(actions)
actions.map do |action, options|
rule = HelpdeskMailRule.available_actions[action].try(:new)
next unless rule
content_tag('p',
helpdesk_mail_rule_change_to_label(rule, options).html_safe,
class: "helpdesk_mail_rule_condition #{'red' if rule.is_a?(RedmineHelpdesk::MailRules::Actions::Stop)}")
end.compact.join.html_safe
end
def helpdesk_mail_rule_change_to_label(rule, options)
return rule.options[:name] if HelpdeskMailRule::NO_VALUES_ACTION_KEYS.include?(rule.field)
[
rule.options[:name],
l(:label_helpdesk_mail_rule_action_change_to),
content_tag('b', rule.label_for(options) || options[:values].join(','))
].join(' ')
end
def helpdesk_select_customer_tag(name, select_values = [], options = {})
cross_project_contacts = ContactsSetting.cross_project_contacts? || !!options.delete(:cross_project_contacts)
s = select2_tag(
name,
options_for_select(select_values, options[:selected]),
url: auto_complete_contacts_path(project_id: (cross_project_contacts ? nil : @project), is_company: (options[:is_company] ? '1' : nil), multiaddress: options[:multiaddress]),
placeholder: '',
style: options[:style] || 'width: 60%',
width: options[:width] || '60%',
include_blank: true,
format_state: (options[:multiaddress] ? 'formatStateWithMultiaddress' : 'formatStateWithAvatar'),
format_selection: 'formatSelectionWithEmails',
allow_clear: !!options[:include_blank],
multiple: options[:multiple]
)
if options[:add_contact] && @project.try(:persisted?)
if authorize_for('contacts', 'new')
s << link_to(
image_tag('add.png', style: 'vertical-align: middle; margin-left: 5px;'),
new_project_contact_path(@project, contact_field_name: name, contacts_is_company: !!options[:is_company]),
remote: true,
method: 'get',
title: l(:label_crm_contact_new),
id: "#{sanitize_to_id(name)}_add_link",
tabindex: 200
)
end
s << javascript_include_tag('attachments')
end
s.html_safe
end
def helpdesk_issue_contact_emails(issue)
settings_cc_address = HelpdeskSettings["helpdesk_cc_address", issue.project.id]
settings_bcc_address = HelpdeskSettings["helpdesk_bcc_address", issue.project.id]
contact_emails = []
ticket = issue.helpdesk_ticket
([ticket.from_address] + ticket.cc_address.to_s.split(',') + ticket.response_addresses).uniq.each do |email|
contact_emails << [email, email.try(:downcase)]
end
(issue.contacts + [issue.customer]).uniq.compact.each do |contact|
contact.emails.each { |email| contact_emails << [contact.email_name, email.downcase] }
end
contact_emails << [settings_cc_address, settings_cc_address] if settings_cc_address.present?
contact_emails << [settings_bcc_address, settings_bcc_address] if settings_bcc_address.present?
contact_emails.uniq(&:last)
end
def selected_cc_addresses(issue)
cc_addresses = []
settings_cc_address = HelpdeskSettings["helpdesk_cc_address", issue.project.id]
customer_email = issue.customer.primary_email
cc_addresses << customer_email if customer_email.downcase != issue.helpdesk_ticket.default_to_address.try(:downcase)
cc_addresses += issue.helpdesk_ticket.cc_addresses
cc_addresses << settings_cc_address if settings_cc_address.present?
cc_addresses
end
def selected_bcc_addresses(issue)
settings_bcc_address = HelpdeskSettings["helpdesk_bcc_address", issue.project.id]
bcc_addresses = []
bcc_addresses << settings_bcc_address if settings_bcc_address.present?
bcc_addresses
end
def helpdesk_issue_customer_text_with_email(helpdesk_ticket)
return '' unless helpdesk_ticket.customer
return helpdesk_ticket.customer.name_with_company unless helpdesk_ticket.from_address
"#{helpdesk_ticket.customer.name_with_company} <#{helpdesk_ticket.from_address}>"
end
def helpdesk_incoming_protocols
default_protocols = [
['', ''], ['pop3', 'pop3'], ['imap', 'imap'], ['Gmail', 'gmail'], ['Yahoo', 'yahoo'], ['Yandex', 'yandex']
]
return default_protocols unless RedmineHelpdesk.oauth_enabled?
default_protocols << ['Microsoft Outlook', 'outlook'] if RedmineHelpdesk.settings['helpdesk_oauth_outlook_client_id'].present?
default_protocols << ['Google OAuth', 'google'] if RedmineHelpdesk.settings['helpdesk_oauth_google_client_id'].present?
default_protocols
end
def helpdesk_outgoing_protocols(project)
default_protocols = [
['smtp', 'smtp'],
]
return default_protocols unless RedmineHelpdesk.oauth_enabled?
default_protocols << ['Microsoft Outlook', 'outlook'] if HelpdeskSettings[:helpdesk_protocol, project.id] == 'outlook'
default_protocols << ['Google OAuth', 'google'] if HelpdeskSettings[:helpdesk_protocol, project.id] == 'google'
default_protocols
end
def helpdesk_outgoing_value(project)
value = HelpdeskSettings[:helpdesk_send_protocol, @project.id]
return 'outlook' if RedmineHelpdesk.oauth_enabled? && HelpdeskSettings[:helpdesk_protocol, project.id] == 'outlook' && value == 'outlook'
return 'google' if RedmineHelpdesk.oauth_enabled? && HelpdeskSettings[:helpdesk_protocol, project.id] == 'google' && value == 'google'
'smtp'
end
def helpdesk_outlook_folders(project)
HelpdeskOauthProvider::Microsoft.new(project.id).folders
end
def helpdesk_google_folders(project)
HelpdeskOauthProvider::Google.new(project.id).folders
end
end

View File

@ -0,0 +1,46 @@
# encoding: utf-8
#
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
# encoding: utf-8
# include RedCloth
module HelpdeskMailerHelper
def textile(text)
Redmine::WikiFormatting.to_html(Setting.text_formatting, text)
end
def message_sender(email)
return nil unless email
sender = email.reply_to.try(:first) || email.from_addrs.try(:first)
sender.to_s.strip[0, 255]
end
def contact_from_email(email, project, logger = nil)
from = (email.header['reply-to'].to_s.present? ? email.header['reply-to'] : email.header['from']).to_s.strip[0, 255]
addr, name = from, nil
if match_result = from.match(/^"?(.+?)"?\s*<(.+@.+)>$/)
addr, name = match_result[2], match_result[1]
elsif match_result = from.match(/(\S+@\S+)/)
addr = match_result[1]
end
HelpdeskMailSupport.create_contact_from_address(addr, name, project, logger)
end
end

View File

@ -0,0 +1,55 @@
# encoding: utf-8
#
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
module PublicTicketsHelper
include HelpdeskHelper
def authoring_public(journal, options = {})
if journal.journal_message && journal.journal_message.from_address
l(options[:label] || :label_added_time_by, :author => mail_to(journal.journal_message.contact_email), :age => ticket_time_tag(journal.created_on)).html_safe
else
l(options[:label] || :label_added_time_by, :author => journal.user.name, :age => ticket_time_tag(journal.created_on)).html_safe
end
end
def ticket_time_tag(time)
text = distance_of_time_in_words(Time.now, time)
content_tag('acronym', text, :title => format_time(time))
end
def link_to_attachments_with_hash(container, options = {})
options.assert_valid_keys(:author, :thumbnails)
if container.attachments.any?
options = { :deletable => container.attachments_deletable?, :author => true }.merge(options)
render :partial => 'attachment_links',
:locals => { :attachments => container.attachments, :options => options, :thumbnails => (options[:thumbnails] && Setting.thumbnails_enabled?) }
end
end
def link_to_attachment_with_hash(attachment, options = {})
text = options.delete(:text) || attachment.filename
route_method = options.delete(:download) ? :hashed_download_named_attachment_path : :hashed_named_attachment_path
html_options = options.slice!(:only_path)
url = send(route_method, attachment, @ticket.id, @ticket.token, attachment.filename, options)
link_to text, url, html_options
end
end

View File

@ -0,0 +1,77 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class CannedResponse < ApplicationRecord
include Redmine::SafeAttributes
attr_accessor :deleted_attachment_ids
safe_attributes(
'name',
'content',
'deleted_attachment_ids',
'project'
)
safe_attributes(
'is_public',
if: lambda do |cr, user|
return true if user.admin?
user.allowed_to?(:manage_public_canned_responses, cr.project)
end
)
belongs_to :project
belongs_to :user
acts_as_attachable
validates_presence_of :name, :content
validates_length_of :name, :maximum => 255
after_save :delete_selected_attachments
scope :visible, lambda { |*args|
user = args.shift || User.current
base = Project.allowed_to_condition(user, :view_helpdesk_tickets, *args)
user_id = user.logged? ? user.id : 0
eager_load(:project).where("(#{CannedResponse.table_name}.project_id IS NULL OR (#{base})) AND (#{CannedResponse.table_name}.is_public = ? OR #{CannedResponse.table_name}.user_id = ?)", true, user_id)
}
scope :in_project_or_public, lambda {|project|
where("(#{CannedResponse.table_name}.project_id IS NULL) OR #{CannedResponse.table_name}.project_id = ?", project)
}
# Returns true if the query is visible to +user+ or the current user.
def visible?(user = User.current)
(project.nil? || user.allowed_to?(:view_helpdesk_tickets, project)) && (is_public? || user_id == user.id)
end
def deleted_attachment_ids
Array(@deleted_attachment_ids).map(&:to_i)
end
def delete_selected_attachments
if deleted_attachment_ids.present?
objects = attachments.where(id: deleted_attachment_ids.map(&:to_i))
attachments.delete(objects)
end
end
end

View File

@ -0,0 +1,167 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskDataCollectorBusiestTime
MAX_WEIGHT = 200
RESPONSE_INTERVALS = {'6_8h' => [6, 8],
'8_10h' => [8, 10],
'10_12h' => [10, 12],
'12_14h' => [12, 14],
'14_16h' => [14, 16],
'16_18h' => [16, 18],
'18_20h' => [18, 20],
'20_22h' => [20, 22],
'22_0h' => [22, 0],
'0_2h' => [0, 2],
'2_4h' => [2, 4],
'4_6h' => [4, 6]}
def columns
@columns ||= collect_columns
end
def issue_weight
@issue_weight ||= (MAX_WEIGHT.to_f / columns.map { |column| column[:issues_count] }.sort.last).ceil
end
# New tickets
def new_issues_count
return @new_issues_count if @new_issues_count
condition = @query.send('sql_for_field', nil, @query.filters['report_date_period'][:operator], nil, 'issues', 'created_on')
@new_issues_count ||= @issues.where(condition).count
end
def previous_new_issues_count
return @previous_new_issues_count if @previous_new_issues_count
condition = previous_query.send('sql_for_field', nil, previous_query.filters['report_date_period'][:operator], nil, 'issues', 'created_on')
@previous_new_issues_count ||= @previous_issues.where(condition).count
end
def new_issue_count_progress
return 0 if previous_new_issues_count.zero?
calculate_progress(previous_new_issues_count, new_issues_count)
end
# New contacts
def contacts_count
contacts.count
end
def previous_contacts_count
previous_contacts.count
end
def total_contacts_count_progress
return 0 if previous_contacts_count.zero?
calculate_progress(previous_contacts_count, contacts_count)
end
# Total incoming
def issues_count
@issues_count ||= @issues.count + @journal_messages.count
end
def previous_issues_count
@previous_issues_count ||= @previous_issues.count + @previous_journal_messages.count
end
def issue_count_progress
return 0 if previous_issues_count.zero?
calculate_progress(previous_issues_count, issues_count)
end
private
def initialize(query)
@query = query
@issues = with_created_issues(@query)
@journal_messages = JournalMessage.where(:is_incoming => true).
where(@query.send('sql_for_field', nil, @query.filters['report_date_period'][:operator], nil, 'journal_messages', 'message_date'))
@previous_issues = with_created_issues(previous_query)
@previous_journal_messages = JournalMessage.where(:is_incoming => true).
where(previous_query.send('sql_for_field', nil, @query.filters['report_date_period'][:operator], nil, 'journal_messages', 'message_date'))
end
def collect_columns
columns = []
RESPONSE_INTERVALS.each do |interval_name, interval_hours|
interval_objects_count = find_incoming_objects_count(interval_hours)
columns << { :name => interval_name, :issues_count => interval_objects_count,
:issues_percent => ((interval_objects_count.to_f / issues_count.to_f) * 100).round(2) }
end
columns
end
def find_incoming_objects_count(interval)
interval_start = interval.first
interval_end = interval.last - 1 < 0 ? 23 : interval.last - 1
interval_issues = @issues.each.select do |issue|
issue_time = timezone ? issue.created_on.in_time_zone(timezone) : issue.created_on.localtime
interval_start <= issue_time.hour && issue_time.hour <= interval_end
end
interval_messages = @journal_messages.each.select do |message|
message_time = timezone ? message.message_date.in_time_zone(timezone) : message.message_date.localtime
interval_start <= message_time.hour && message_time.hour <= interval_end
end
interval_issues.count + interval_messages.count
end
def timezone
@timezone ||= User.current.time_zone
end
def previous_query
return if @query[:filters].nil? || @query[:filters]['report_date_period'].nil? || @query[:filters]['report_date_period'][:operator].nil?
return @previous_query if @previous_query
previous_operator = ['pre_', @query[:filters]['report_date_period'][:operator]].join
previous_filters = @query[:filters].merge('report_date_period' => { :operator => previous_operator, :values => [Date.today.to_s] })
@previous_query = HelpdeskReportsBusiestTimeQuery.new(:name => '_', :project => @query.project, :filters => previous_filters)
@previous_query
end
def with_created_issues(query)
condition = query.send('sql_for_field', nil, query.filters['report_date_period'][:operator], nil, 'issues', 'created_on')
created_ids = Issue.joins(:project).visible.where(:project_id => query.project).where(condition).pluck(:id)
Issue.where(:id => query.issues.pluck(:id) | created_ids).joins(:helpdesk_ticket)
end
def contacts
return @contacts if @contacts
condition = @query.send('sql_for_field', nil, @query.filters['report_date_period'][:operator], nil, 'contacts', 'created_on')
@contacts = Contact.where(:id => @issues.joins(:customer).map(&:customer).map(&:id).uniq).where(condition)
end
def previous_contacts
return @previous_contacts if @previous_contacts
condition = @query.send('sql_for_field', nil, @previous_query.filters['report_date_period'][:operator], nil, 'contacts', 'created_on')
@previous_contacts = Contact.where(:id => @previous_issues.joins(:customer).map(&:customer).map(&:id).uniq).where(condition)
end
def calculate_progress(before, now)
progress =
if before.to_f > now.to_f
100 - (now.to_f * 100 / before.to_f)
else
(100 - (before.to_f * 100 / now.to_f)) * -1
end
progress.round
end
end

View File

@ -0,0 +1,199 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskDataCollectorFirstResponse
MAX_WEIGHT = 200
RESPONSE_INTERVALS = { '0_1h' => [0, 1],
'1_2h' => [1, 2],
'2_4h' => [2, 4],
'4_8h' => [4, 8],
'8_12h' => [8, 12],
'12_24h' => [12, 24],
'24_48h' => [24, 48],
'48_0h' => [48, 0] }
attr_reader :issues
attr_reader :previous_issues
def columns
@columns ||= collect_columns
end
def issue_weight
@issue_weight ||= (MAX_WEIGHT.to_f / columns.map { |column| column[:issues_count] }.sort.last).ceil
end
# First response time
def average_response_time
@average_response_time ||= median(HelpdeskTicket.where(:issue_id => issues.pluck(:id)).pluck(:first_response_time))
end
def previous_average_response_time
return 0 if previous_issues_count.zero?
@previous_average_response_time ||= median(HelpdeskTicket.where(:issue_id => previous_issues.pluck(:id)).pluck(:first_response_time))
end
def average_response_time_progress
return 0 if previous_issues_count.zero?
calculate_progress(previous_average_response_time, average_response_time)
end
# Time to close
def average_close_time
return @average_close_time if @average_close_time
closed_issue_ids = issues.joins(:status).where("#{IssueStatus.table_name}.is_closed = ?", true).pluck(:id)
return 0 if closed_issue_ids.empty?
@average_close_time = median(HelpdeskTicket.where(:issue_id => closed_issue_ids).pluck(:resolve_time))
end
def previous_average_close_time
return @previous_average_close_time if @previous_average_close_time.present?
closed_issue_ids = previous_issues.joins(:status).where("#{IssueStatus.table_name}.is_closed = ?", true).pluck(:id)
return 0 if closed_issue_ids.empty?
@previous_average_close_time ||= median(HelpdeskTicket.where(:issue_id => closed_issue_ids).pluck(:resolve_time))
end
def average_close_time_progress
closed_issue_ids = previous_issues.joins(:status).where("#{IssueStatus.table_name}.is_closed = ?", true).pluck(:id)
return 0 if closed_issue_ids.empty?
calculate_progress(previous_average_close_time, average_close_time)
end
# Average responses count
def average_response_count
return @average_response_count if @average_response_count
closed_issue_ids = issues.joins(:status).where("#{IssueStatus.table_name}.is_closed = ?", true).pluck(:id)
return @average_response_count = 0 if closed_issue_ids.empty?
journal_ids = JournalMessage.joins(:journal).where("journals.journalized_type = 'Issue'").
where(journal_message_date_condition(@query)).
where("journals.journalized_id IN (#{closed_issue_ids.join(',')})").pluck(:journal_id)
@average_response_count = median(Journal.where(:id => journal_ids).group(:journalized_id).count(:id).values)
end
def previous_average_response_count
return 0 if previous_issues_count.zero?
return @previous_average_response_count if @previous_average_response_count
closed_issue_ids = previous_issues.joins(:status).where("#{IssueStatus.table_name}.is_closed = ?", true).pluck(:id)
return @previous_average_response_count = 0 if closed_issue_ids.empty?
journal_ids = JournalMessage.joins(:journal).where("journals.journalized_type = 'Issue'").
where(journal_message_date_condition(previous_query)).
where("journals.journalized_id IN (#{closed_issue_ids.join(',')})").pluck(:journal_id)
@previous_average_response_count = median(Journal.where(:id => journal_ids).group(:journalized_id).count(:id).values)
end
def average_response_count_progress
return 0 if previous_average_response_count.zero?
calculate_progress(previous_average_response_count, average_response_count)
end
# Total replies
def total_response_count
return @total_response_count if @total_response_count
@total_response_count = JournalMessage.joins(:journal).where("journals.journalized_type = 'Issue'").
where(journal_message_date_condition(@query)).
where("journals.journalized_id IN (#{issues.pluck(:id).join(',')})").
count
end
def previous_total_response_count
return 0 if previous_issues_count.zero?
return @previous_total_response_count if @previous_total_response_count
@previous_total_response_count ||= JournalMessage.joins(:journal).where("journals.journalized_type = 'Issue'").
where(journal_message_date_condition(previous_query)).
where("journals.journalized_id IN (#{previous_issues.pluck(:id).join(',')})").
count
end
def total_response_count_progress
return 0 if previous_total_response_count.zero?
calculate_progress(previous_total_response_count, total_response_count)
end
def issues_count
@issues_count ||= issues.count
end
def previous_issues_count
@previous_issues_count ||= previous_issues.count
end
private
def initialize(query)
@query = query
@issues = @query.issues.joins(:helpdesk_ticket).where('helpdesk_tickets.first_response_time > 0')
@issues = @issues.distinct
@previous_issues = previous_query.issues.joins(:helpdesk_ticket).where('helpdesk_tickets.first_response_time > 0')
@previous_issues = @previous_issues.distinct
end
def collect_columns
columns = []
RESPONSE_INTERVALS.each do |interval_name, interval_hours|
interval_issues_count = find_issues_count(interval_hours)
columns << { :name => interval_name, :issues_count => interval_issues_count,
:issues_percent => ((interval_issues_count.to_f / issues_count.to_f) * 100).round(2) }
end
columns
end
def find_issues_count(interval)
interval_start = (interval.first.hours + 1).to_i
interval_end = interval.last.hours.to_i
interval_issues =
if interval.last > 0
issues.joins(:helpdesk_ticket).where('helpdesk_tickets.first_response_time BETWEEN ? AND ?', interval_start, interval_end)
else
issues.joins(:helpdesk_ticket).where('helpdesk_tickets.first_response_time > ?', interval_start)
end
interval_issues.count
end
def previous_query
return if @query[:filters].nil? || @query[:filters]['report_date_period'].nil? || @query[:filters]['report_date_period'][:operator].nil?
return @previous_query if @previous_query
previous_operator = ['pre_', @query[:filters]['report_date_period'][:operator]].join
previous_filters = @query[:filters].merge('report_date_period' => { :operator => previous_operator, :values => [Date.today.to_s] })
@previous_query = HelpdeskReportsFirstResponseQuery.new(:name => '_', :project => @query.project, :filters => previous_filters)
@previous_query
end
def journal_message_date_condition(query)
query.send('sql_for_field', nil, query.filters['report_date_period'][:operator], nil, 'journal_messages', 'message_date')
end
def median(array)
return 0 if array.compact.empty?
range = array.compact.sort.reverse
middle = range.count / 2
(range.count % 2).zero? ? (range[middle - 1] + range[middle]) / 2 : range[middle]
end
def calculate_progress(before, now)
progress =
if before.to_f > now.to_f
100 - (now.to_f * 100 / before.to_f)
else
(100 - (before.to_f * 100 / now.to_f)) * -1
end
progress.round
end
end

View File

@ -0,0 +1,29 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskDataCollectorManager
def collect_data(query)
case query.name
when 'first_response_time'
HelpdeskDataCollectorFirstResponse.new(query)
when 'busiest_time_of_day'
HelpdeskDataCollectorBusiestTime.new(query)
end
end
end

View File

@ -0,0 +1,142 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailContainer
include HelpdeskMailerHelper
class UnauthorizedAction < StandardError; end
class MissingInformation < StandardError; end
CONTAINER_ACCESSORS = [:email, :options, :logger, :contact, :target_project, :text_body, :from_addr, :from_user, :issue, :is_new_issue].freeze
attr_accessor *CONTAINER_ACCESSORS
def initialize(email_or_raw, options)
email_or_raw.force_encoding('ASCII-8BIT') if email_or_raw.respond_to?(:force_encoding)
@email = email_or_raw.is_a?(Mail) ? email_or_raw : Mail.new(email_or_raw)
@options = options
fill_accessors_from_options(options)
@text_body ||= HelpdeskMailSupport.escaped_cleaned_up_text_body(email)
@from_addr ||= message_sender(email)
@from_user ||= User.find_by_mail(@from_addr) || User.anonymous
end
def validate
return false if HelpdeskMailSupport.check_ignored_headers?(email, logger)
return logged_error("#{email.message_id}: Ignored duplicated email with Message-ID #{email.message_id}") if duplicated_message_id?
return logged_error("#{email.message_id}: Email ignored because no FROM address found") if from_addr.blank?
return logged_error("#{email.message_id}: Contacts and issues modules should be enable for #{target_project.name} project") if modules_disabled?
return logged_error("#{email.message_id}: Ignoring email from Redmine emission address [#{from_addr}]") if sent_from_emission?
return logged_error("#{email.message_id}: Email #{from_addr} ignored because in blacklist") if HelpdeskMailSupport.check_blacklist?(from_addr, target_project)
return logged_error("#{email.message_id}: could not create/found contact for [#{@from_addr}]") unless contact
# TODO: Add custom validate hook
true
end
def dispatch
# TODO: Before after dispatch hook
m = email.subject && email.subject.match(HelpdeskMailSupport::ISSUE_REPLY_SUBJECT_RE)
message_identifier = [email.in_reply_to, email.references].flatten.reject(&:blank?)
journal_message = JournalMessage.where(message_id: message_identifier).first
helpdesk_ticket = HelpdeskTicket.where(message_id: message_identifier).first
if journal_message && journal_message.journal && journal_message.journal.issue
HelpdeskMailRecipient::IssueReplyRecipient.new(self).receive(journal_message.journal.issue.id)
elsif helpdesk_ticket && helpdesk_ticket.issue
HelpdeskMailRecipient::IssueReplyRecipient.new(self).receive(helpdesk_ticket.issue.id)
elsif m && Issue.find_by(id: m[1].to_i)&.project&.module_enabled?('contacts_helpdesk')
HelpdeskMailRecipient::IssueReplyRecipient.new(self).receive(m[1].to_i)
else
@is_new_issue = true
HelpdeskMailRecipient::IssueRecipient.new(self).receive
end
# TODO: Add after dispatch hook
rescue MissingInformation => e
logged_error("#{email.message_id}: missing information from #{from_user}: #{e.message}")
logged_error(e.backtrace.first)
rescue UnauthorizedAction => _e
logged_error("#{email.message_id}: unauthorized attempt from #{from_user}")
rescue Exception => e
logged_error("#{email.message_id}: dispatch error #{e.message}")
logged_error(e.backtrace.first)
end
def target_project
return @target_project if @target_project
@target_project ||= Project.where(identifier: get_keyword(:project) || get_keyword(:project_id)).first
@target_project ||= Project.where(id: get_keyword(:project_id)).first
raise MissingInformation.new('Unable to determine @target_project project') if @target_project.nil?
@target_project
end
def get_keyword(attr, extra_options = {})
@keywords ||= {}
key_options = options.merge(extra_options)
unless @keywords[attr]
if attr_overridable?(attr, key_options)
@keywords[attr] = HelpdeskMailSupport.extract_from_text!(text_body, attr, from_user, key_options[:format])
end
@keywords[attr] ||= key_options[:issue][attr] if key_options[:issue].present? && key_options[:issue][attr].present?
@keywords[attr] ||= key_options[attr] if key_options[attr].present?
end
@keywords[attr]
end
def contact
@contact ||= contact_from_email(email, target_project, logger)
end
private
def fill_accessors_from_options(options)
CONTAINER_ACCESSORS.each do |accessor|
send("#{accessor}=", options[accessor]) if options[accessor]
end
end
def modules_disabled?
[:contacts, :issue_tracking].any? { |m| !target_project.module_enabled?(m) }
end
def sent_from_emission?
from_addr.downcase == Setting.mail_from.to_s.strip.downcase
end
def with_ignored_header?
HelpdeskMailSupport.ignored_headers(self, email)
end
def duplicated_message_id?
HelpdeskTicket.joins(:issue).where("#{Issue.table_name}.project_id": target_project.id)
.find_by(message_id: email.message_id.to_s.slice(0, 255))
end
def logged_error(message = nil)
logger.error(message) if message
false
end
def attr_overridable?(attr, attr_options)
attr_options[:override] ||
(attr_options[:allow_override].present? && attr_options[:allow_override].include?('all')) ||
(attr_options[:allow_override].present? && attr_options[:allow_override].include?(attr.to_s))
end
end

View File

@ -0,0 +1,111 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailMessenger < MailHandler
include HelpdeskMailerHelper
helper :helpdesk_mailer
class MissingInformation < StandardError; end
attr_reader :logger, :contact, :object, :options, :email
def prepare_email(contact, object, options = {})
@logger = options[:logger]
@contact = contact
@object = object
@options = (options.respond_to?(:to_unsafe_hash) ? options.to_unsafe_hash : options).with_indifferent_access
@email = build_email
@container = HelpdeskMailContainer.new(email, logger: logger, contact: contact, issue: issue)
set_delivery_options
HelpdeskMailRule.apply_rules(:outgoing, @container)
logger.try(:info, "##{object.id}: Sending #{self.class.name} to #{to_address}")
email
end
def self.default_url_options
{ host: Setting.host_name, protocol: Setting.protocol }
end
def build_email
raise 'Method should be implemented in inherited classes'
end
def project
raise 'Method should be implemented in inherited classes'
end
private
def content_transfer_encoding
'binary' if HelpdeskSettings['helpdesk_send_protocol', project] == 'outlook'
'binary' if HelpdeskSettings['helpdesk_send_protocol', project] == 'google'
end
def set_delivery_options
return if HelpdeskSettings['helpdesk_smtp_use_default_settings', project].to_i == 0
case HelpdeskSettings['helpdesk_send_protocol', project]
when 'outlook'
email.delivery_method(HelpdeskOauthMailer::Microsoft)
email.delivery_method.settings.merge!(project: project)
when 'google'
email.delivery_method(HelpdeskOauthMailer::Google)
email.delivery_method.settings.merge!(project: project)
else
email.delivery_method(:smtp)
email.delivery_method.settings.merge!(address: HelpdeskSettings['helpdesk_smtp_server', project],
port: HelpdeskSettings['helpdesk_smtp_port', project] || 25,
authentication: HelpdeskSettings['helpdesk_smtp_authentication', project] || 'plain',
user_name: HelpdeskSettings['helpdesk_smtp_username', project],
password: HelpdeskSettings['helpdesk_smtp_password', project],
domain: HelpdeskSettings['helpdesk_smtp_domain', project],
enable_starttls_auto: true,
openssl_verify_mode: verification_mode,
ssl: HelpdeskSettings['helpdesk_smtp_ssl', project].to_i > 0 &&
HelpdeskSettings['helpdesk_smtp_tls', project].to_i == 0)
end
end
def validate; end
def verification_mode
HelpdeskSettings['helpdesk_smtp_skip_verification', project] ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
end
def from_address(journal_user = nil)
mail_from ||= options[:from_address]
mail_from ||= HelpdeskSettings['helpdesk_answer_from', project].blank? ? Setting.mail_from : HelpdeskSettings['helpdesk_answer_from', project]
logger.try(:error, "##{object.id}: From address couldn't be blank") if mail_from.blank?
HelpdeskMailSupport.apply_from_macro(mail_from.to_s, journal_user)
end
def to_address
options[:to_address] || contact.primary_email
end
def cc_addresses
options[:cc_addresses].to_s
end
def bcc_addresses
options[:bcc_addresses].to_s
end
end

View File

@ -0,0 +1,54 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailMessenger::AutoAnswerMessage < HelpdeskMailMessenger
def project
object.project
end
def issue
object
end
def build_email
set_headers
@email_stylesheet = HelpdeskSettings['helpdesk_helpdesk_css', project].to_s.html_safe
@email_body = HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_first_answer_template', project], contact, issue)
validate
mail from: from_address.to_s,
to: to_address.to_s,
cc: HelpdeskSettings["helpdesk_cc_address", issue.project.id].to_s + cc_addresses,
bcc: HelpdeskSettings["helpdesk_bcc_address", issue.project.id].to_s + bcc_addresses,
subject: HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_first_answer_subject', project], contact, issue) || "Helpdesk auto answer [Case ##{issue.id}]",
in_reply_to: "<#{issue.helpdesk_ticket.try(:message_id)}>" do |format|
format.text(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' }
format.html(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' } unless RedmineHelpdesk.add_plain_text_mail?
end
end
private
def set_headers
headers['X-Redmine-Ticket-ID'] = issue.id.to_s
headers['X-Auto-Response-Suppress'] = 'oof'
end
end

View File

@ -0,0 +1,59 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailMessenger::AutocloseMessage < HelpdeskMailMessenger
def project
object.project
end
def issue
object
end
def build_email
set_headers
@email_stylesheet = HelpdeskSettings['helpdesk_helpdesk_css', project].to_s.html_safe
@email_body = HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_autoclose_template', project], contact, issue)
@email_body = I18n.t('label_helpdesk_autoclosed_ticket') unless @email_body.present?
validate
mail from: from_address.to_s,
to: to_address.to_s,
cc: issue.helpdesk_ticket.try(:cc_address).to_s,
subject: HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_autoclose_subject', project], contact, issue) || "Helpdesk autoclose message [Case ##{issue.id}]",
in_reply_to: "<#{issue.helpdesk_ticket.try(:message_id)}>" do |format|
format.text(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' }
format.html(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' } unless RedmineHelpdesk.add_plain_text_mail?
end
end
private
def set_headers
headers['X-Redmine-Ticket-ID'] = issue.id.to_s
end
def validate
raise MissingInformation.new(l(:text_helpdesk_message_body_cant_be_blank)) if @email_body.blank?
raise MissingInformation.new(l(:text_helpdesk_from_address_cant_be_blank)) if from_address.blank?
raise MissingInformation.new(l(:text_helpdesk_recipients_cant_be_blank)) if to_address.blank? && cc_address.blank? && bcc_address.blank?
end
end

View File

@ -0,0 +1,80 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailMessenger::InitialMessage < HelpdeskMailMessenger
def project
object.project
end
def issue
object
end
def build_email
set_headers
@email_stylesheet = HelpdeskSettings['helpdesk_helpdesk_css', project].to_s.html_safe
@email_header = HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_emails_header', project], contact, issue, issue.author)
@email_body = HelpdeskMailSupport.apply_text_macro(issue.description, contact, issue, issue.author)
@email_body = HelpdeskMailSupport.apply_attachment_macro(attachments, @email_body, issue)
@email_footer = HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_emails_footer', project], contact, issue, issue.author)
@image_url = object.helpdesk_ticket.try(:view_id)
validate
add_attachments_to_mail(options[:attachments])
mail from: from_address.to_s,
to: to_address.to_s,
cc: HelpdeskSettings["helpdesk_cc_address", issue.project.id].to_s + cc_addresses,
bcc: HelpdeskSettings["helpdesk_bcc_address", issue.project.id].to_s + bcc_addresses,
subject: HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_first_answer_subject', project.try(:id)], contact, issue) || issue.subject do |format|
format.text(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' }
format.html(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' } unless RedmineHelpdesk.add_plain_text_mail?
end
end
private
def set_headers
headers['X-Redmine-Ticket-ID'] = issue.id.to_s
end
def validate
raise MissingInformation.new(l(:text_helpdesk_message_body_cant_be_blank)) if @email_body.blank?
raise MissingInformation.new(l(:text_helpdesk_message_contact_email_blank, contact_name: contact.name)) if contact.email.blank?
end
def add_attachments_to_mail(attachments_hash)
return if attachments_hash.blank?
attachments_hash.each_value do |mail_attachment|
if file = mail_attachment['file']
file.rewind
attachments[file.original_filename] = file.read
elsif token = mail_attachment['token']
if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
attachment_id, attachment_digest = $1, $2
if a = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
attachments[a.filename] = File.open(a.diskfile, 'rb') { |io| io.read }
end
end
end
end
end
end

View File

@ -0,0 +1,96 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailMessenger::IssueResponseMessage < HelpdeskMailMessenger
def project
object.project
end
def issue
object.issue
end
def build_email
set_headers
@email_stylesheet = HelpdeskSettings[:helpdesk_helpdesk_css, project].to_s.html_safe
@email_header = HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_emails_header', project], contact, issue, object.user)
@email_body = HelpdeskMailSupport.apply_text_macro(object.notes, contact, issue, object.user)
@email_body = HelpdeskMailSupport.apply_attachment_macro(attachments, @email_body, issue)
@email_footer = HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_emails_footer', project], contact, issue, object.user)
@image_url = object.journal_message.try(:calculate_view_id)
validate
subject_macro = HelpdeskMailSupport.apply_text_macro(HelpdeskSettings['helpdesk_answer_subject', project], contact, issue)
add_attachments_to_mail(object)
if object.details.blank? && object.private_notes? && object.notes.present?
details_journal = object.class.where('id != ?', object.id).where(:created_on => object.created_on).first
add_attachments_to_mail(details_journal) if details_journal
end
mail :from => from_address(object.user),
:to => to_address.to_s,
:cc => cc_address.to_s,
:bcc => bcc_address.to_s,
:in_reply_to => in_reply_to.to_s,
:subject => (subject_macro.blank? ? issue.subject + " [#{issue.tracker} ##{issue.id}]" : subject_macro) do |format|
format.text(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' }
format.html(content_transfer_encoding: content_transfer_encoding) { render 'email_layout' } unless RedmineHelpdesk.add_plain_text_mail?
end
end
private
def set_headers
headers['X-Redmine-Ticket-ID'] = issue.id.to_s
end
def validate
raise MissingInformation.new(l(:text_helpdesk_message_body_cant_be_blank)) if @email_body.blank?
raise MissingInformation.new(l(:text_helpdesk_from_address_cant_be_blank)) if from_address.blank?
raise MissingInformation.new(l(:text_helpdesk_recipients_cant_be_blank)) if to_address.blank? && cc_address.blank? && bcc_address.blank?
end
def to_address
options[:to_address] || (object.journal_message && object.journal_message.to_address) || contact.primary_email
end
def cc_address
options[:cc_address] || (object.journal_message && object.journal_message.cc_address)
end
def bcc_address
options[:bcc_address] || (object.journal_message && object.journal_message.bcc_address)
end
def in_reply_to
options[:in_reply_to] || (issue.helpdesk_ticket.blank? || issue.helpdesk_ticket.message_id.blank?) ? '' : "<#{issue.helpdesk_ticket.message_id}>"
end
def add_attachments_to_mail(journal)
journal.details.where(:property => 'attachment').each do |attachment_detail|
if attach = Attachment.where(:id => attachment_detail.prop_key).first
attachments[attach.filename] = File.open(attach.diskfile, 'rb') { |io| io.read }
end
end
end
end

View File

@ -0,0 +1,50 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailProvider
attr_reader :mail_options, :options
def initialize(mail_options = nil, options = {})
@mail_options = mail_options
@options = options
end
def self.receive(raw_message, options = {})
new.receive(raw_message, options)
end
def receive(raw_message, options = {})
mail_container = HelpdeskMailContainer.new(raw_message, options)
mail_container.validate ? mail_container.dispatch : false
rescue Exception => e
Rails.logger.error("#{mail_container.try(:email).try(:message_id)}: receive error #{e.message}")
Rails.logger.error(e.backtrace.first)
false
end
def check
raise 'Implement this method in inherited class'
end
private
def logger
::Rails.logger
end
end

View File

@ -0,0 +1,23 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailProvider::ApiProvider < HelpdeskMailProvider
def check
end
end

View File

@ -0,0 +1,42 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailProvider::GoogleProvider < HelpdeskMailProvider
def check
msg_count = 0
oauth_provider = HelpdeskOauthProvider::Google.new(mail_options[:project_id])
folder = mail_options[:folder]
success_folder = mail_options[:move_on_success]
fail_folder = mail_options[:move_on_failure]
oauth_provider.messages(folder).each do |mid, msg|
logger.info "HelpdeskMailProvider::GoogleProvider: Receiving message #{mid}" if logger && logger.info?
msg_count += 1
oauth_provider.read_message(mid)
if self.receive(msg, options)
logger.info "HelpdeskMailProvider::GoogleProvider: Message #{mid} successfully received" if logger && logger.info?
oauth_provider.move_message(mid, success_folder) if success_folder && success_folder != folder
else
logger.info "HelpdeskMailProvider::GoogleProvider: Message #{mid} can not be processed" if logger && logger.info?
oauth_provider.move_message(mid, fail_folder) if fail_folder && fail_folder != folder
end
end
msg_count
end
end

View File

@ -0,0 +1,24 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailProvider::ImapProvider < HelpdeskMailProvider
def check
RedmineContacts::Mailer.check_imap(self, mail_options, options)
end
end

View File

@ -0,0 +1,42 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailProvider::MicrosoftProvider < HelpdeskMailProvider
def check
msg_count = 0
oauth_provider = HelpdeskOauthProvider::Microsoft.new(mail_options[:project_id])
folder = mail_options[:folder]
success_folder = mail_options[:move_on_success]
fail_folder = mail_options[:move_on_failure]
oauth_provider.messages(folder).each do |mid, msg|
logger.info "HelpdeskMailProvider::MicrosoftProvider: Receiving message #{mid}" if logger && logger.info?
msg_count += 1
oauth_provider.read_message(mid)
if self.receive(msg, options)
logger.info "HelpdeskMailProvider::MicrosoftProvider: Message #{mid} successfully received" if logger && logger.info?
oauth_provider.move_message(mid, success_folder) if success_folder && success_folder != folder
else
logger.info "HelpdeskMailProvider::MicrosoftProvider: Message #{mid} can not be processed" if logger && logger.info?
oauth_provider.move_message(mid, fail_folder) if fail_folder && fail_folder != folder
end
end
msg_count
end
end

View File

@ -0,0 +1,24 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailProvider::Pop3Provider < HelpdeskMailProvider
def check
RedmineContacts::Mailer.check_pop3(self, mail_options, options)
end
end

View File

@ -0,0 +1,131 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailRecipient
attr_reader :container
delegate :email, to: :container
delegate :logger, to: :container
delegate :get_keyword, to: :container
delegate :contact, to: :container
def initialize(mail_container)
@container = mail_container
end
def receive
raise 'Implement this method in inherited class'
end
private
def project
container.target_project
end
def cc_contacts
email[:cc].to_s
email.cc_addrs.each_with_index.map do |cc_addr, index|
cc_name = email[:cc].display_names[index]
HelpdeskMailSupport.create_contact_from_address(cc_addr, cc_name, project, logger)
end.compact
end
def to_recipients
except_service_emails(email.to_addrs)
end
def except_service_emails(emails)
project_helpdesk_username = HelpdeskSettings['helpdesk_username', project.id]
project_from_address = HelpdeskSettings['helpdesk_answer_from', project].scan(/([^<\ ,]+@[^>\ ,]+)/)
system_from_address = Setting.mail_from.scan(/([^<\ ,]+@[^>\ ,]+)/)
service_addresses = RedmineHelpdesk.service_email_aliases
lowcase(emails) - lowcase(([project_helpdesk_username, project_from_address, system_from_address] + service_addresses).flatten.compact)
end
def save_email_as_attachment(object, filename = 'message.eml')
Attachment.create(container: object,
file: email.raw_source.to_s,
author: container.from_user,
filename: filename,
content_type: 'message/rfc822')
end
def save_email_as_calendar(object, filename = 'calendar_event.ics')
return unless email.mime_type == 'text/calendar'
Attachment.create(container: object,
file: email.body.to_s,
author: container.from_user,
filename: filename,
content_type: 'text/calendar')
end
def add_attachments(obj)
email_attachments = []
email_attachments << Mail::Part.new(container.email) if container.email.mime_type && container.email.mime_type.starts_with?('image/')
fwd_attachments = email.parts.map do |part|
if part.content_type =~ /message\/rfc822/
Mail.new(part.body).attachments
elsif part.parts.empty?
part if part.attachment?
else
part.attachments
end
end
fwd_attachments = fwd_attachments.flatten.compact
email_attachments |= fwd_attachments | email.attachments
return if email_attachments.blank?
email_attachments.each do |attachment|
attachment_filename = HelpdeskMailSupport.convert_to_utf8(attachment.filename, 'binary')
attachment_filename = [attachment_filename, '.', attachment.mime_type.split('/').last].join if attachment.inline? && attachment_filename.exclude?('.')
new_attachment = Attachment.new(container: obj,
file: (attachment.decoded rescue nil) || (attachment.decode_body rescue nil) || attachment.raw_source,
filename: attachment_filename,
author: container.from_user,
content_type: attachment.mime_type)
if valid_attachment?(obj, attachment.body, new_attachment)
obj.attachments << new_attachment
logger.try(:info, "#{email.message_id}: attachment #{attachment_filename} added to ticket: '#{obj.subject}'")
else
logger.try(:error, "#{email.message_id}: attachment #{attachment_filename} skipped for ticket: #{new_attachment.errors.full_messages.to_sentence}")
end
end
end
def valid_attachment?(obj, body, new_attachment)
new_attachment.valid? &&
obj.attachments.where(digest: HelpdeskMailSupport.attachment_digest(body.to_s)).empty? &&
HelpdeskMailSupport.attachment_acceptable?(new_attachment)
end
def slice_emails(emails_string)
return emails_string if emails_string.size <= 255
sliced_string = emails_string.slice(0, 255)
sliced_string.slice(0, sliced_string.rindex(',').to_i)
end
def lowcase(array)
array.map(&:downcase)
end
end

View File

@ -0,0 +1,194 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailRecipient::IssueRecipient < HelpdeskMailRecipient
def receive
issue = Issue.new(author: container.from_user, project: project)
issue.tracker_id = tracker_id(issue)
issue.safe_attributes = helpdesk_issue_attributes(issue)
issue.safe_attributes = { 'custom_field_values' => helpdesk_custom_field_values(issue) }
issue.subject = cleaned_up_subject(email)
issue.subject = '(no subject)' if issue.subject.blank?
issue.description = container.text_body
issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date?
issue.assigned_to = contact.find_assigned_user(project, issue.assigned_to_id || HelpdeskSettings['helpdesk_assigned_to', project])
helpdesk_ticket = HelpdeskTicket.new(from_address: slice_emails(container.from_addr.downcase.to_s),
to_address: slice_emails(email.to_addrs.join(',').downcase.to_s),
cc_address: slice_emails((to_recipients + except_service_emails(email.cc_addrs)).join(',').downcase.to_s),
ticket_date: email.date || Time.now,
message_id: email.message_id.to_s.slice(0, 255),
is_incoming: true,
customer: contact,
issue: issue,
source: HelpdeskTicket::HELPDESK_EMAIL_SOURCE)
issue.helpdesk_ticket = helpdesk_ticket
add_attachments(issue)
save_email_as_calendar(issue)
save_email_as_attachment(helpdesk_ticket)
Redmine::Hook.call_hook(:helpdesk_mailer_receive_issue_before_save, { issue: issue, contact: contact, helpdesk_ticket: helpdesk_ticket, email: email })
ActiveRecord::Base.transaction do
issue.reload if issue.persisted?
issue.save!(validate: false)
container.issue = issue.reload
HelpdeskMailRule.apply_rules(:incoming, container)
add_contact_note(issue) && send_auto_answer(issue)
logger.try(:info, "#{email.message_id}: issue ##{issue.id} created by #{container.from_user} for #{contact.name}")
issue
end
end
private
def add_contact_note(issue)
return true if HelpdeskSettings['helpdesk_add_contact_notes', issue.project].to_i == 0
ContactNote.create(content: "*#{issue.subject}* [#{issue.tracker.name} - ##{issue.id}]\n\n" + issue.description,
type_id: Note.note_types[:email],
source: contact,
author_id: issue.author_id)
end
def send_auto_answer(issue)
return true if HelpdeskSettings['helpdesk_send_notification', issue.project].to_i == 0
notification = HelpdeskMailer.auto_answer(contact, issue, to_address: slice_emails(container.from_addr.downcase.to_s))
logger.try(:info, "#{email.message_id}: notification was sent to #{notification.to_addrs.first}") if notification
rescue Exception => e
logger.try(:error, "#{email.message_id}: notification was not sent #{e.message}")
end
def helpdesk_issue_attributes(issue)
attrs = {
'status_id' => status_id,
'priority_id' => priority_id,
'category_id' => category_id(issue),
'assigned_to_id' => assigned_to_id(issue),
'fixed_version_id' => version_id(issue),
'start_date' => start_date,
'due_date' => due_date,
'estimated_hours' => estimated_hours,
'done_ratio' => done_ratio,
'is_private' => is_private,
'parent_issue_id' => parent_issue_id,
}.delete_if { |_k, v| v.blank? }
attrs
end
def helpdesk_custom_field_values(customized)
customized.custom_field_values.inject({}) do |h, v|
if value = get_keyword(v.custom_field.name, override: true)
h[v.custom_field.id.to_s] = value
end
h
end
end
def tracker_id(issue)
issue.project.trackers.named(get_keyword(:tracker, override: true)).map(&:id).first ||
issue.project.trackers.where(id: get_keyword(:tracker_id)).first.try(:id) ||
issue.project.trackers.first.try(:id)
end
def status_id
IssueStatus.named(get_keyword(:status)).map(&:id).first || IssueStatus.where(id: get_keyword(:status_id)).first.try(:id)
end
def priority_id
IssuePriority.named(get_keyword(:priority)).map(&:id).first || IssuePriority.where(id: get_keyword(:priority_id)).first.try(:id)
end
def category_id(issue)
key = get_keyword(:category, override: true).try(:squish)
issue.project.issue_categories.named(key).map(&:id).first if key
end
def assigned_to_id(issue)
key = get_keyword(:assigned_to, override: true).try(:squish) || get_keyword(:assigned_to_id, override: true)
assigned = Principal.detect_by_keyword(issue.assignable_users, key) || User.where(id: key).first
assigned ||= Group.where(id: key).first if Setting.issue_group_assignment? && key
assigned.try(:id)
end
def version_id(issue)
issue.project.shared_versions.named(get_keyword(:fixed_version, override: true)).map(&:id).first
end
def start_date
get_keyword(:start_date, override: true, format: '\d{4}-\d{2}-\d{2}')
end
def due_date
get_keyword(:due_date, override: true, format: '\d{4}-\d{2}-\d{2}')
end
def estimated_hours
get_keyword(:estimated_hours, override: true)
end
def done_ratio
get_keyword(:done_ratio, override: true, format: '(\d|10)?0')
end
def is_private
get_keyword(:is_private, override: true)
end
def parent_issue_id
get_keyword(:parent_issue, override: true)
end
def cleaned_up_subject(email)
return '' if email[:subject].blank?
subject = decode_subject(email[:subject].respond_to?(:unparsed_value) ? email[:subject].unparsed_value : email[:subject].value)
subject = HelpdeskMailSupport.convert_to_utf8(subject)
subject.gsub!(/./) { |c| c.bytesize == 4 ? '' : c }
subject.strip[0, 255]
rescue Exception => e
logger.try(:error, "#{email.message_id}: Message subject processing error - #{e.message}")
'(Unprocessable subject)'
end
def decode_subject(str)
# Optimization: If there's no encoded-words in the string, just return it
return str unless str.index('=?')
str = str.gsub(/\?=(\s*)=\?/, '?=????=?') # Replace whitespaces between 'encoded-word's on special symbols
str.split('????').map do |text|
if text.index('=?') .nil?
text
else
text.gsub!(/[\r\n]/, '')
text.gsub(/\=\?.+?\?[qQbB]\?.+?\?\=/) do |part|
if part.index(/\=\?.+\?[Bb]\?.+\?\=/m)
part.gsub(/\=\?.+\?[Bb]\?.+\?=/m) { |substr| Mail::Encodings.b_value_decode(substr) }
elsif part.index(/\=\?.+\?[Qq]\?.+\?\=/m)
part.gsub(/\=\?.+\?[Qq]\?.+\?\=/m) { |substr| Mail::Encodings.q_value_decode(substr) }
end
end
end
end.join('')
end
end

View File

@ -0,0 +1,68 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailRecipient::IssueReplyRecipient < HelpdeskMailRecipient
def receive(issue_id)
issue = Issue.where(id: issue_id).first
return unless issue
return receive_new_issue if HelpdeskSettings['helpdesk_lifetime', project].present? &&
HelpdeskSettings['helpdesk_lifetime', project].to_i >= 0 &&
lifetime_expired?(issue)
journal = issue.init_journal(container.from_user)
journal.notes = container.text_body
journal_message = JournalMessage.create(from_address: slice_emails(container.from_addr.downcase),
to_address: slice_emails(email.to_addrs.join(',').downcase),
bcc_address: slice_emails(email.bcc_addrs.join(',').downcase),
cc_address: slice_emails((to_recipients + except_service_emails(email.cc_addrs)).join(',').downcase.to_s),
message_id: email.message_id,
is_incoming: true,
message_date: email.date || Time.now,
contact: contact,
journal: journal)
add_attachments(issue)
save_email_as_calendar(issue)
save_email_as_attachment(journal_message, "reply-#{DateTime.now.strftime('%d.%m.%y-%H.%M.%S')}.eml")
issue.status_id = reopen_status_id if reopen_status_id
issue.save!(:validate => false)
container.issue = issue.reload
HelpdeskMailRule.apply_rules(:incoming, container)
logger.try(:info, "#{email.message_id}: issue ##{issue.id} updated by #{container.from_user}")
journal
end
private
def receive_new_issue
email.subject = email.subject.to_s.gsub(MailHandler::ISSUE_REPLY_SUBJECT_RE, '')
HelpdeskMailRecipient::IssueRecipient.new(container).receive
end
def reopen_status_id
IssueStatus.named(get_keyword(:reopen_status)).first.try(:id) || IssueStatus.where(id: get_keyword(:reopen_status_id)).first.try(:id)
end
def lifetime_expired?(issue)
last_date = (issue.journals.last || issue).created_on.to_date
Date.today - last_date >= HelpdeskSettings['helpdesk_lifetime', project].to_i
end
end

View File

@ -0,0 +1,229 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailRule < ApplicationRecord
include Redmine::SafeAttributes
include Redmine::I18n
NO_VALUES_ACTION_KEYS = %w[stop]
INCOMING = 0
OUTGOING = 1
MANUALLY = 2
validate :presence_actions
validate :validate_conditions_and_actions
belongs_to :user
serialize :conditions
serialize :actions
cattr_accessor :available_conditions
cattr_accessor :available_actions
up_acts_as_list
safe_attributes 'mail_type', 'conditions', 'actions', 'move_to', 'position'
scope :by_position, -> { order(:position) }
scope :incoming, -> { where(mail_type: INCOMING) }
scope :outgoing, -> { where(mail_type: OUTGOING) }
scope :manually, -> { where(mail_type: MANUALLY) }
def self.add_condition(condition_class)
self.available_conditions ||= {}
self.available_conditions[condition_class.new.field] = condition_class
true
end
def self.add_action(action_class)
self.available_actions ||= {}
self.available_actions[action_class.new.field] = action_class
true
end
def self.apply_rules(direction, container)
return unless [:incoming, :outgoing, :manually].include?(direction)
with_initialized_journal(container) do
send(direction).by_position.find_each { |rule| break unless rule.apply_to(container) }
end
end
def self.with_initialized_journal(container)
rule_changes_journal = Journal.new(journalized: container.issue,
user: container.from_user,
notes: l(:label_helpdesk_email_rules_changes))
yield
rule_changes_journal.save if rule_changes_journal.details.any?
end
def apply_to(container)
return true unless suitable_conditions?(container)
result = apply_actions(container)
actions.keys.include?(RedmineHelpdesk::MailRules::Actions::Stop.new.field) ? false : result
end
def available_conditions
return @available_conditions if @available_conditions
@available_conditions = ActiveSupport::OrderedHash.new
Hash[self.class.available_conditions.sort].each_value do |condition_class|
condition = condition_class.new
@available_conditions[condition.field] = HelpdeskMailRuleElement.new(condition.field, condition.options.merge(values: condition.values(self)))
end
@available_conditions
end
def available_actions
return @available_actions if @available_actions
@available_actions = ActiveSupport::OrderedHash.new
Hash[self.class.available_actions.sort].each_value do |action_class|
action = action_class.new
@available_actions[action.field] = HelpdeskMailRuleElement.new(action.field, action.options.merge(values: action.values(self)))
end
@available_actions
end
def available_conditions_as_json
json = {}
available_conditions.each do |field, condition|
options = { type: condition[:type], name: condition[:name] }
options[:remote] = true if condition.remote
if has_condition?(field) || !condition.remote
options[:values] = condition.values
if options[:values] && condition_values_for(field)
missing = Array(condition_values_for(field)).select(&:present?) - options[:values].map(&:last)
if missing.any? && respond_to?(method = "find_#{field}_filter_values")
options[:values] += send(method, missing)
end
end
end
json[field] = options.stringify_keys
end
json
end
def available_actions_as_json
json = {}
available_actions.each do |field, action|
options = { type: action[:type], name: action[:name] }
options[:remote] = true if action.remote
if has_action?(field) || !action.remote
options[:values] = action.values
if options[:values] && action_values_for(field)
missing = Array(action_values_for(field)).select(&:present?) - options[:values].map(&:last)
if missing.any? && respond_to?(method = "find_#{field}_filter_values")
options[:values] += send(method, missing)
end
end
end
json[field] = options.stringify_keys
end
json
end
def conditions
super || {}
end
def actions
super || {}
end
def condition_values_for(field)
has_condition?(field) ? conditions[field][:values] : []
end
def action_values_for(field)
has_action?(field) ? actions[field][:values] : []
end
def operator_for_condition(field)
has_condition?(field) ? conditions[field][:operator] : nil
end
def operator_for_action(field)
has_action?(field) ? actions[field][:operator] : nil
end
private
def presence_actions
errors.add(:base, l(:mail_rule_should_have_action)) unless actions.any?
end
def validate_conditions_and_actions
check_condition_values
check_action_values
end
def has_condition?(field)
conditions && conditions[field]
end
def has_action?(field)
actions && actions[field]
end
def suitable_conditions?(container)
return true if conditions.empty?
begin
constions_result = conditions.map do |condition, options|
condition_class = self.class.available_conditions[condition]
next unless condition_class
condition_class.new.check(container, options[:operator], options[:values])
end
constions_result.all?
rescue Exception => e
HelpdeskLogger.try(:error, "Rule condition error: #{[e.message, e.backtrace.first].join("\n")}")
false
end
end
def apply_actions(container)
actions.each do |action, options|
action_class = self.class.available_actions[action]
next unless action_class
begin
action_class.new.apply(container, options[:operator], options[:values])
rescue Exception => e
HelpdeskLogger.try(:error, "Rule action applying error: #{[e.message, e.backtrace.first].join("\n")}")
next
end
end
end
def check_condition_values
conditions.each do |key, opt|
add_error(key, :blank) if ['~', '=', '!~', '!'].include?(opt[:operator]) && (opt[:values].blank? || opt[:values].first.blank?)
end
end
def check_action_values
actions.except(*NO_VALUES_ACTION_KEYS).each do |key, opt|
add_error(key, :blank) if [nil, '~', '=', '!~', '!'].include?(opt[:operator]) && (opt[:values].blank? || opt[:values].first.blank?)
end
end
def add_error(elem, message)
errors.add(:base, elem.upcase + ' ' + l(message, :scope => 'activerecord.errors.messages'))
end
end

View File

@ -0,0 +1,52 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailRuleElement
include Redmine::I18n
def initialize(field, options)
@field = field.to_s
@options = options
@options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
# Consider filters with a Proc for values as remote by default
@remote = options.key?(:remote) ? options[:remote] : options[:values].is_a?(Proc)
end
def [](arg)
if arg == :values
values
else
@options[arg]
end
end
def values
@values ||= begin
values = @options[:values]
if values.is_a?(Proc)
values = values.call
end
values
end
end
def remote
@remote
end
end

View File

@ -0,0 +1,347 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
require 'digest/md5'
class HelpdeskMailSupport < MailHandler
URL_HELPER = Rails.application.routes.url_helpers
class << self
def ignored_helpdesk_headers
helpdesk_headers = {
'X-Auto-Response-Suppress' => /\A(all|AutoReply|oof)/
}
ignored_emails_headers.merge(helpdesk_headers)
end
def options_for_check(project_id, options = {})
[
common_options(project_id).merge(provider_options(project_id)),
options.merge(issue_options({}, project_id))
]
end
def issue_options(options, project_id)
options = { :issue => {} } unless options[:issue]
options[:issue][:project_id] = project_id
options[:issue][:status_id] ||= HelpdeskSettings[:helpdesk_new_status, project_id]
options[:issue][:assigned_to_id] ||= HelpdeskSettings[:helpdesk_assigned_to, project_id]
options[:issue][:tracker_id] ||= HelpdeskSettings[:helpdesk_tracker, project_id]
options[:issue][:priority_id] ||= HelpdeskSettings[:helpdesk_issue_priority, project_id]
options[:issue][:due_date] ||= HelpdeskSettings[:helpdesk_issue_due_date, project_id]
options[:issue][:reopen_status_id] ||= HelpdeskSettings[:helpdesk_reopen_status, project_id]
options
end
def check_ignored_headers?(email, logger = nil)
ignored_helpdesk_headers.each do |key, ignored_value|
value = email.header[key].to_s.downcase
next if value.blank?
if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
logger.try(:info, "#{email.message_id}: ignoring email with #{key}:#{value} header")
return true
end
end
false
end
def check_blacklist?(email, project)
return false if HelpdeskSettings['helpdesk_blacklist', project].blank?
cond = '(' + HelpdeskSettings['helpdesk_blacklist', project].split("\n").map { |u| u.strip if u.present? }.compact.join('|') + ')'
!!email.match(cond)
end
def create_contact_from_address(addr, fullname, project, logger = nil)
contacts = Contact.find_by_emails([addr])
if contacts.present?
contact = contacts.first
if contact.projects.blank? || HelpdeskSettings['helpdesk_add_contact_to_project', project].to_i > 0
contact.projects << project
contact.save!
end
return contact
end
if HelpdeskSettings['helpdesk_is_not_create_contacts', project].to_i > 0
logger.try(:error, "#{email.message_id}: can't find contact with email: #{addr} in whitelist. Not create new contacts option enable")
nil
else
contact = contact_from_attributes(addr, fullname)
contact.projects << project
contact.tag_list = HelpdeskSettings['helpdesk_created_contact_tag', project] if HelpdeskSettings['helpdesk_created_contact_tag', project]
if contact.save
contact
else
logger.try(:error, "Helpdeks MailHandler: failed to create Contact: #{contact.errors.full_messages}")
nil
end
end
end
def convert_to_utf8(str, encoding = 'UTF-8')
return str if str.nil?
if str.respond_to?(:force_encoding)
begin
cleaned = str.force_encoding('UTF-8')
cleaned = cleaned.encode('UTF-8', encoding) if encoding.upcase == 'ISO-2022-JP'
unless cleaned.valid_encoding?
cleaned = str.encode('UTF-8', encoding, :invalid => :replace, :undef => :replace, :replace => '').chars.select { |i| i.valid_encoding? }.join
end
str = cleaned
rescue EncodingError
str.encode!('UTF-8', :invalid => :replace, :undef => :replace)
end
elsif RUBY_PLATFORM == 'java'
begin
ic = Iconv.new('UTF-8', encoding + '//IGNORE')
str = ic.iconv(str)
rescue
str = str.gsub(%r{[^\r\n\t\x20-\x7e]}, '?')
end
else
ic = Iconv.new('UTF-8', encoding + '//IGNORE')
txtar = ''
begin
txtar += ic.iconv(str)
rescue Iconv::IllegalSequence
txtar += $!.success
str = '?' + $!.failed[1, $!.failed.length]
retry
rescue
txtar += $!.success
end
str = txtar
end
str
end
def escaped_cleaned_up_text_body(email)
text_body = new.substitution_method(:cleaned_up_text_body, email)
# replacing inline images for office365, gmail e.g. [cid:image001.png@01D8C6A5.EA89EA00] or [image: image.png]
text_body = replace_img_tags(text_body, email)
return text_body if (ActiveRecord::Base.connection.adapter_name =~ /mysql/i).blank?
text_body.gsub(/./) { |c| c.bytesize == 4 ? ' ' : c }
end
def extract_from_text!(text, attr, user, format = nil)
new.substitution_method(:extract_keyword!, nil, user, text, attr, format)
end
def attachment_digest(file_source)
encoder = Digest::SHA256.new
encoder.update(file_source)
encoder.hexdigest
end
def attachment_acceptable?(attachment)
new.send(:accept_attachment?, attachment)
end
def apply_text_macro(text, contact, issue, journal_user = nil)
return '' if text.blank?
text = text.gsub(/%%NAME%%|\{%contact.first_name%\}/, contact.first_name)
text = text.gsub(/%%FULL_NAME%%|\{%contact.name%\}/, contact.name)
text = text.gsub(/%%COMPANY%%|\{%contact.company%\}/, contact.company) if contact.company
text = text.gsub(/%%LAST_NAME%%|\{%contact.last_name%\}/, contact.last_name.to_s)
text = text.gsub(/%%MIDDLE_NAME%%|\{%contact.middle_name%\}/, contact.middle_name.to_s)
text = text.gsub(/\{%contact.email%\}/, contact.primary_email.to_s)
text = text.gsub(/%%DATE%%|\{%date%\}/, ApplicationHelper.format_date(Date.today))
text = text.gsub(/%%ASSIGNEE%%|\{%ticket.assigned_to%\}/, issue.assigned_to.blank? ? '' : issue.assigned_to.name)
text = text.gsub(/%%ISSUE_ID%%|\{%ticket.id%\}/, issue.id.to_s) if issue.id
text = text.gsub(/%%ISSUE_TRACKER%%|\{%ticket.tracker%\}/, issue.tracker.name) if issue.tracker
text = text.gsub(/%%QUOTED_ISSUE_DESCRIPTION%%|\{%ticket.quoted_description%\}/, issue.description.gsub(/^/, '> ')) if issue.description
text = text.gsub(/%%PROJECT%%|\{%ticket.project%\}/, issue.project.name) if issue.project
text = text.gsub(/%%SUBJECT%%|\{%ticket.subject%\}/, issue.subject) if issue.subject
text = text.gsub(/%%NOTE_AUTHOR%%|\{%response.author%\}/, journal_user.name) if journal_user
text = text.gsub(/%%NOTE_AUTHOR.FIRST_NAME%%|\{%response.author.first_name%\}/, journal_user.firstname) if journal_user
text = text.gsub(/%%NOTE_AUTHOR.LAST_NAME%%|\{%response.author.last_name%\}/, journal_user.lastname) if journal_user
text = text.gsub(/\{%ticket.status%\}/, issue.status.name) if issue.status
text = text.gsub(/\{%ticket.priority%\}/, issue.priority.name) if issue.priority
text = text.gsub(/\{%ticket.estimated_hours%\}/, issue.estimated_hours ? issue.estimated_hours.to_s : '')
text = text.gsub(/\{%ticket.done_ratio%\}/, issue.done_ratio.to_s) if issue.done_ratio
text = text.gsub(/\{%ticket.closed_on%\}/, issue.closed_on ? ApplicationHelper.format_date(issue.closed_on) : '') if issue.respond_to?(:closed_on)
text = text.gsub(/\{%ticket.due_date%\}/, issue.due_date ? ApplicationHelper.format_date(issue.due_date) : '')
text = text.gsub(/\{%ticket.start_date%\}/, issue.start_date ? ApplicationHelper.format_date(issue.start_date) : '')
text = text.gsub(/\{%ticket.public_url%\}/, full_url(URL_HELPER.public_ticket_path(issue.helpdesk_ticket.id, issue.helpdesk_ticket.token))) if text.match(/\{%ticket.public_url%\}/) && issue.helpdesk_ticket
if RedmineHelpdesk.vote_allow?
text = text.gsub(/\{%ticket.voting%\}/, full_url(URL_HELPER.helpdesk_votes_show_path(issue.helpdesk_ticket.id, issue.helpdesk_ticket.token))) if text.match(/\{%ticket.voting%\}/)
text = text.gsub(/\{%ticket.voting.good%\}/, full_url(URL_HELPER.helpdesk_votes_fast_vote_path(issue.helpdesk_ticket.id, 2, issue.helpdesk_ticket.token))) if text.match(/\{%ticket.voting.good%\}/)
text = text.gsub(/\{%ticket.voting.okay%\}/, full_url(URL_HELPER.helpdesk_votes_fast_vote_path(issue.helpdesk_ticket.id, 1, issue.helpdesk_ticket.token))) if text.match(/\{%ticket.voting.okay%\}/)
text = text.gsub(/\{%ticket.voting.bad%\}/, full_url(URL_HELPER.helpdesk_votes_fast_vote_path(issue.helpdesk_ticket.id, 0, issue.helpdesk_ticket.token))) if text.match(/\{%ticket.voting.bad%\}/)
end
if text =~ /\{%ticket.history%\}/
ticket_history = ''
journals = issue.journals.eager_load(:journal_message)
unless User.current.allowed_to?(:view_private_notes, issue.project)
journals = journals.select do |journal|
!journal.private_notes? || journal.user == user
end
end
journals.map(&:journal_message).compact.each do |journal_message|
message_author = "*#{l(:label_crm_added_by)} #{journal_message.is_incoming? ? journal_message.from_address : journal_message.journal.user.name}, #{format_time(journal_message.message_date)}*"
ticket_history = (message_author + "\n" + journal_message.journal.notes + "\n" + ticket_history).gsub(/^/, '> ')
end
text = text.gsub(/\{%ticket.history%\}/, ticket_history)
end
issue.custom_field_values.each do |value|
text = text.gsub(/%%#{value.custom_field.name}%%/, value.value.to_s)
end
contact.custom_field_values.each do |value|
text = text.gsub(/%%#{value.custom_field.name}%%/, value.value.to_s)
end if contact.respond_to?('custom_field_values')
journal_user.custom_field_values.each do |value|
text = text.gsub(/\{%response.author.custom_field: #{value.custom_field.name}%\}/, value.value.to_s)
end if journal_user
text
end
def apply_attachment_macro(mail_attachments, text, issue)
text.scan(/\{\{send_file\(([^%\}]+)\)\}\}/).flatten.each do |file_name|
attachment = file_name.match(/^(\d)+$/) ? Attachment.where(:id => file_name).first : issue.attachments.where(:filename => file_name).first
mail_attachments[attachment.filename] = { mime_type: attachment.content_type, content: File.open(attachment.diskfile, 'rb') { |io| io.read } } if attachment
end
text.gsub(/\{\{send_file\(([^%\}]+)\)\}\}/, '')
end
def apply_from_macro(text, journal_user = nil)
return text unless text =~ /\A\{%.+%\}.*?<.+@.+\..{2,}>\z/
text = text[/<.*>/] if journal_user.nil? || journal_user == User.anonymous
text = text.gsub(/\{%response.author%\}/, journal_user.name) if text =~ /\{%response.author%\}/
text = text.gsub(/\{%response.author.first_name%\}/, journal_user.firstname) if text =~ /\{%response.author.first_name%\}/
text
end
private
def provider_options(project_id)
case HelpdeskSettings[:helpdesk_protocol, project_id]
when 'gmail'
{ protocol: 'imap', host: 'imap.gmail.com', port: '993', ssl: '1', verify_ssl: false }
when 'yahoo'
{ protocol: 'imap', host: 'imap.mail.yahoo.com', port: '993', ssl: '1' }
when 'yandex'
{ protocol: 'imap', host: 'imap.yandex.ru', port: '993', ssl: '1' }
when 'outlook'
{
protocol: HelpdeskSettings[:helpdesk_protocol, project_id],
project_id: project_id,
folder: HelpdeskSettings[:helpdesk_outlook_folder, project_id],
move_on_success: HelpdeskSettings[:helpdesk_outlook_move_on_success, project_id],
move_on_failure: HelpdeskSettings[:helpdesk_outlook_move_on_failure, project_id],
}
when 'google'
{
protocol: HelpdeskSettings[:helpdesk_protocol, project_id],
project_id: project_id,
folder: HelpdeskSettings[:helpdesk_google_folder, project_id],
move_on_success: HelpdeskSettings[:helpdesk_google_move_on_success, project_id],
move_on_failure: HelpdeskSettings[:helpdesk_google_move_on_failure, project_id],
}
else
{ protocol: HelpdeskSettings[:helpdesk_protocol, project_id],
host: HelpdeskSettings[:helpdesk_host, project_id],
port: HelpdeskSettings[:helpdesk_port, project_id],
ssl: HelpdeskSettings[:helpdesk_use_ssl, project_id] != '1' ? nil : '1',
starttls: HelpdeskSettings[:helpdesk_use_starttls, project_id] != '1' ? nil : '1' }
end
end
def common_options(project_id)
{
apop: HelpdeskSettings[:helpdesk_apop, project_id],
username: HelpdeskSettings[:helpdesk_username, project_id],
password: HelpdeskSettings[:helpdesk_password, project_id],
folder: HelpdeskSettings[:helpdesk_imap_folder, project_id],
move_on_success: HelpdeskSettings[:helpdesk_move_on_success, project_id],
move_on_failure: HelpdeskSettings[:helpdesk_move_on_failure, project_id],
delete_unprocessed: HelpdeskSettings[:helpdesk_delete_unprocessed, project_id].to_i
}
end
def contact_from_attributes(email, fullname = nil)
contact = Contact.new
# Truncating the email address would result in an invalid format
contact.email = email
names = fullname.blank? ? email.gsub(/@.*$/, '').split('.') : fullname.split
contact.first_name = names.shift.slice(0, 255)
contact.last_name = names.join(' ').slice(0, 255)
contact.company = email.downcase.slice(0, 255)
contact.last_name = '-' if contact.last_name.blank?
if contact.last_name =~ %r(\((.*)\))
contact.last_name, contact.company = $`, $1
end
if contact.first_name =~ /,$/
contact.first_name = contact.last_name
contact.last_name = $` # everything before the match
end
contact
end
def full_url(path)
[Setting.protocol, '://', Setting.host_name, path].join
end
def replace_img_tags(text, email)
inline_cids = Hash[email.attachments.select(&:content_id).map { |attachment| [attachment.content_id.to_s.gsub(/<|>/, ''), attachment.filename] }]
text = text.gsub(/\[cid:\s*(.+)\]/) { |img| inline_cids[$1] ? "!#{inline_cids[$1]}!" : $1 }
text = text.gsub(/"cid:\s*(.+?)"/) { |img| inline_cids[$1] ? "\"#{inline_cids[$1]}\"" : $1 }
text.gsub(/\[image:\s*(.+)\]/) { |img| inline_cids[$1] ? "!#{inline_cids[$1]}!" : "!#{$1}!" }
end
end
def plain_text_body
return @plain_text_body unless @plain_text_body.nil?
return '' if email.mime_type && email.mime_type.starts_with?('image/')
part = RedmineHelpdesk.html_body_prefer? ? email.html_part : email.text_part || email.html_part
unless part
return @plain_text_body = '' if email.parts.present? && email.parts.all? { |em_part| em_part.attachment? }
part = email
end
part_charset = pick_encoding(part.charset).to_s rescue part.charset
@plain_text_body = self.class.convert_to_utf8(part.body.decoded, part_charset)
if email.mime_type == 'text/calendar'
@plain_text_body = email.body.to_s.scan(/^DESCRIPTION:(.+)$/) && $1.to_s
elsif (email.text_part.present? || email.mime_type == 'text/plain')
@plain_text_body = self.class.plain_text_body_to_text(@plain_text_body)
@plain_text_body = @plain_text_body.gsub(/[\r|\n]/, '') if RedmineHelpdesk.html_body_prefer?
@plain_text_body
elsif (email.html_part.present? || email.mime_type == 'text/html' || ['multipart/related', 'multipart/alternative'].include?(email.mime_type))
@plain_text_body = self.class.html_body_to_text(@plain_text_body)
else
@plain_text_body = self.class.plain_text_body_to_text(@plain_text_body)
end
rescue Exception => e
HelpdeskLogger.error "#{email && email.message_id}: Message body processing error - #{e.message}"
@plain_text_body = '(Unprocessable message body)'
end
def pick_encoding(enc)
Mail::RubyVer.respond_to?(:pick_encoding) ? Mail::RubyVer.pick_encoding(enc) : Mail::Utilities.pick_encoding(enc)
end
end

View File

@ -0,0 +1,92 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskMailer
class << self
def check_project(project_id, options = {})
project = Project.where(id: project_id).first
return 0 if project.nil? || HelpdeskSettings[:helpdesk_protocol, project.id].blank?
mail_options, options = HelpdeskMailSupport.options_for_check(project_id, options)
msg_count =
case mail_options[:protocol]
when 'pop3'
HelpdeskMailProvider::Pop3Provider.new(mail_options, with_logger(options)).check
when 'imap'
HelpdeskMailProvider::ImapProvider.new(mail_options, with_logger(options)).check
when 'api'
HelpdeskMailProvider::ApiProvider.new(mail_options, with_logger(options)).check
when 'outlook'
HelpdeskMailProvider::MicrosoftProvider.new(mail_options, with_logger(options)).check
when 'google'
HelpdeskMailProvider::GoogleProvider.new(mail_options, with_logger(options)).check
end
HelpdeskTicket.autoclose(project)
msg_count
end
def receive(raw_message, options = {})
HelpdeskMailProvider.receive(raw_message, with_logger(options))
end
def initial_message(contact, issue, options = {})
with_performed_delivery do
HelpdeskMailMessenger::InitialMessage.prepare_email(contact, issue, with_logger(options)).deliver
end
end
def auto_answer(contact, issue, options = {})
with_performed_delivery do
HelpdeskMailMessenger::AutoAnswerMessage.prepare_email(contact, issue, with_logger(options)).deliver
end
end
def issue_response(contact, journal, options = {})
with_performed_delivery do
HelpdeskMailMessenger::IssueResponseMessage.prepare_email(contact, journal, with_logger(options)).deliver
end
end
def autoclose_message(contact, issue, options = {})
with_performed_delivery do
HelpdeskMailMessenger::AutocloseMessage.prepare_email(contact, issue, with_logger(options)).deliver
end
end
private
def logger
HelpdeskLogger
end
def with_logger(options)
options.merge(logger: logger)
end
def with_performed_delivery
perform_delivery_state = ActionMailer::Base.perform_deliveries
ActionMailer::Base.perform_deliveries = true
yield
ensure
ActionMailer::Base.perform_deliveries = perform_delivery_state
end
end
end

View File

@ -0,0 +1,34 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskOauthMailer
attr_accessor :settings
DEFAULTS = {
project: nil
}
def initialize(settings = {})
self.settings = DEFAULTS.merge(settings)
end
def deliver!(mail)
raise 'Should be implement in inherited class'
end
end

View File

@ -0,0 +1,26 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskOauthMailer::Google < HelpdeskOauthMailer
def deliver!(mail)
encoded_mail = Base64.urlsafe_encode64(mail.to_s)
HelpdeskOauthProvider::Google.new(settings[:project].id).send_message(encoded_mail)
self
end
end

View File

@ -0,0 +1,26 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskOauthMailer::Microsoft < HelpdeskOauthMailer
def deliver!(mail)
encoded_mail = Base64.strict_encode64(mail.to_s)
HelpdeskOauthProvider::Microsoft.new(settings[:project].id).send_message(encoded_mail)
self
end
end

View File

@ -0,0 +1,136 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskOauthProvider
class SendMailError < StandardError; end
PROVIDERS = {
outlook: HelpdeskOauthProvider::Microsoft,
google: HelpdeskOauthProvider::Google
}.freeze
attr_accessor :project, :redirect_uri
def self.find_by_protocol(protocol)
PROVIDERS[protocol&.to_sym]
end
def initialize(project_id)
@project = Project.find(project_id)
@redirect_uri = Rails.application.routes.url_helpers.helpdesk_oauth_resp_url(protocol: Setting.protocol, host: Setting.host_name)
end
def reset_token
write_tokens(nil, nil)
end
private
def logger
HelpdeskLogger
end
def logged_error(message = nil)
logger.error(message) if message
false
end
def access_token_name
raise NoMethodError, 'Should be implemented in a subclass'
end
def refresh_token_name
raise NoMethodError, 'Should be implemented in a subclass'
end
def update_token
raise NoMethodError, 'Should be implemented in a subclass'
end
def write_tokens(access, refresh)
ContactsSetting[access_token_name, project.id] = access
ContactsSetting[refresh_token_name, project.id] = refresh
end
def is_ok?(code)
[200, 202].include?(code.to_i)
end
def execute(type, url, params = {})
uri = URI(url)
req = send("build_#{type.downcase}_req", url, params) if [:GET, :FPOST, :JPOST, :TPOST, :PATCH, :RFPOST].include?(type)
raise 'Incorrect request type' unless req
req['Authorization'] = "Bearer #{HelpdeskSettings[access_token_name, project]}"
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |conn|
conn.request(req)
end
code, data = execute_with_reloaded_token(type, url, params) if resp.is_a?(Net::HTTPUnauthorized) && !params[:reloaded]
[(code || resp.code).to_i, data || (params[:raw] ? resp.body : JSON.parse(resp.body))]
end
def execute_with_reloaded_token(type, url, params)
update_token
execute(type, url, params.merge(reloaded: true))
end
def build_get_req(url, params)
get_url = url
if params&.any?
quoted_params = params.to_query
quoted_params = quoted_params.gsub('labelIds%5B%5D', 'labelIds') if params[:g_labels]
get_url = get_url + '?' + quoted_params
end
Net::HTTP::Get.new(get_url)
end
def build_fpost_req(url, params)
post_req = Net::HTTP::Post.new(url)
post_req.set_form_data(params) if params
post_req
end
def build_jpost_req(url, params)
post_req = Net::HTTP::Post.new(url)
post_req.content_type = "application/json"
post_req.body = JSON.dump(params) if params
post_req
end
def build_rfpost_req(url, params)
post_req = Net::HTTP::Post.new(url)
post_req.content_type = "message/rfc822"
post_req.body = JSON.dump(params) if params
post_req
end
def build_tpost_req(url, params)
post_req = Net::HTTP::Post.new(url)
post_req.content_type = "text/plain"
post_req.body = params[:body] if params
post_req
end
def build_patch_req(url, params)
patch_req = Net::HTTP::Patch.new(url)
patch_req.content_type = "application/json"
patch_req.body = JSON.dump(params) if params
patch_req
end
end

View File

@ -0,0 +1,189 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskOauthProvider::Google < HelpdeskOauthProvider
BASE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'.freeze
TOKEN_URL = 'https://oauth2.googleapis.com/token'.freeze
MAILBOX_URL = 'https://gmail.googleapis.com/gmail/v1/users/<email>'.freeze
FOLDERS_URL = MAILBOX_URL + '/labels'.freeze
MESSAGES_URL = MAILBOX_URL + '/messages'.freeze
SENDMAIL_URL = MESSAGES_URL + '/send'.freeze
SCOPE = 'https://www.googleapis.com/auth/gmail.modify'.freeze
def authorize_url(state_secret)
params = {
client_id: RedmineHelpdesk.settings['helpdesk_oauth_google_client_id'],
scope: SCOPE,
redirect_uri: redirect_uri,
access_type: 'offline',
response_type: 'code'.freeze,
response_mode: 'query'.freeze,
state: state_secret,
}
BASE_URL + '?' + params.to_query
end
def receive_tokens(code)
params = {
client_id: RedmineHelpdesk.settings['helpdesk_oauth_google_client_id'],
client_secret: RedmineHelpdesk.settings['helpdesk_oauth_google_secret'],
scope: SCOPE,
redirect_uri: redirect_uri,
code: code,
grant_type: 'authorization_code'.freeze,
}
code, data = execute(:FPOST, TOKEN_URL, params)
logged_error("HelpdeskOauthProvider::Google#receive_tokens error:\nCode: #{code}\nBody: #{data}") unless is_ok?(code)
write_tokens(data['access_token'], data['refresh_token']) if is_ok?(code) && data
end
def update_token
params = {
client_id: RedmineHelpdesk.settings['helpdesk_oauth_google_client_id'],
client_secret: RedmineHelpdesk.settings['helpdesk_oauth_google_secret'],
refresh_token: HelpdeskSettings['helpdesk_google_refresh_token', project],
grant_type: 'refresh_token'.freeze,
}
code, data = execute(:FPOST, TOKEN_URL, params)
logged_error("HelpdeskOauthProvider::Google#update_token error:\nCode: #{code}\nBody: #{data}") unless is_ok?(code)
if !is_ok?(code) || data.empty?
reset_token
else
write_tokens(data['access_token'], HelpdeskSettings['helpdesk_google_refresh_token', project])
end
end
def folders
return [] if HelpdeskSettings[:helpdesk_username, project.id].blank?
Rails.cache.fetch("google_folders_#{project.id}", expires_in: 1.minute) do
params = {}
folders_url = FOLDERS_URL.gsub('<email>', HelpdeskSettings[:helpdesk_username, project.id])
code, data = execute(:GET, folders_url, params)
return [] if !is_ok?(code) || !data || !data['labels']
[['INBOX', 'INBOX']] + data['labels'].filter { |fld| fld['type'] == 'user'}.sort_by { |fld| fld['name'] }.map do |fld|
full_path = fld['name'].split('/')
[
'--' * (full_path.size - 1) + ' ' + full_path.last,
fld['id']
]
end
end
end
def messages(folder = nil)
message_ids = new_message_ids(folder)
message_ids.map do |mid|
[mid, message_raw(mid)]
end.to_h
end
def move_message(mid, folder)
params = {
removeLabelIds: ['INBOX'],
addLabelIds: [folder]
}
read_url = message_full_url(mid) + '/modify'
code, data = execute(:JPOST, read_url, params)
is_ok?(code) ? data : ''
end
def read_message(mid)
params = {
removeLabelIds: ['UNREAD'],
}
read_url = message_full_url(mid) + '/modify'
code, data = execute(:JPOST, read_url, params)
is_ok?(code) ? data : ''
end
def send_message(encoded_message)
params = {
raw: encoded_message
}
sendmail_url = SENDMAIL_URL.gsub('<email>', HelpdeskSettings[:helpdesk_username, project.id])
code, data = execute(:RFPOST, sendmail_url, params)
logged_error("HelpdeskOauthProvider::Microsoft#send_message error:\nCode: #{code}\nBody: #{data}") unless is_ok?(code)
raise SendMailError, data unless is_ok?(code)
data
end
private
def access_token_name
:helpdesk_google_access_token
end
def refresh_token_name
:helpdesk_google_refresh_token
end
def message_full_url(mid)
MESSAGES_URL.gsub('<email>', HelpdeskSettings[:helpdesk_username, project.id]) + "/#{mid}"
end
def new_message_ids(folder)
params = {
maxResults: 500,
labelIds: [folder, "UNREAD"],
g_labels: true,
}
_code, data = execute_with_pages(:GET, MESSAGES_URL.gsub('<email>', HelpdeskSettings[:helpdesk_username, project.id]), params)
(data || []).map { |mes| mes['id'] }
end
def message_raw(mid)
params = {
format: 'raw'
}
code, data = execute(:GET, message_full_url(mid), params)
is_ok?(code) ? Base64.urlsafe_decode64(data['raw']) : ''
end
def execute_with_pages(type, url, params = {})
total_data = []
page_url = url
page_params = params
next_page = true
while next_page
next_page = false
code, data = execute(type, page_url, page_params)
if is_ok?(code) && data
total_data += data['messages'] if data['messages']
if data['nextPageToken']
next_page = true
page_params = page_params.merge(pageToken: data['nextPageToken'])
end
end
end
[code.to_i, total_data]
end
end

View File

@ -0,0 +1,207 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskOauthProvider::Microsoft < HelpdeskOauthProvider
BASE_URL = 'https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/authorize'.freeze
TOKEN_URL = 'https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/token'.freeze
MAILBOX_URL = 'https://graph.microsoft.com/v1.0/users/<email>'.freeze
FOLDERS_URL = MAILBOX_URL + '/mailFolders'.freeze
MESSAGES_URL = MAILBOX_URL + '/mailFolders/<mail_folder>/messages'.freeze
SENDMAIL_URL = MAILBOX_URL + '/sendMail'.freeze
SCOPE = 'openid offline_access mail.readwrite mail.send mail.readwrite.shared mail.send.shared'.freeze
def authorize_url(state_secret)
params = {
client_id: RedmineHelpdesk.settings['helpdesk_oauth_outlook_client_id'],
scope: SCOPE,
redirect_uri: redirect_uri,
response_type: 'code'.freeze,
response_mode: 'query'.freeze,
state: state_secret,
}
clarified_url(BASE_URL) + '?' + params.to_query
end
def receive_tokens(code)
params = {
client_id: RedmineHelpdesk.settings['helpdesk_oauth_outlook_client_id'],
client_secret: RedmineHelpdesk.settings['helpdesk_oauth_outlook_secret'],
scope: SCOPE,
redirect_uri: redirect_uri,
code: code,
grant_type: 'authorization_code'.freeze,
}
code, data = execute(:FPOST, clarified_url(TOKEN_URL), params)
logged_error("HelpdeskOauthProvider::Microsoft#receive_tokens error:\nCode: #{code}\nBody: #{data}") unless is_ok?(code)
write_tokens(data['access_token'], data['refresh_token']) if is_ok?(code) && data
end
def update_token
params = {
client_id: RedmineHelpdesk.settings['helpdesk_oauth_outlook_client_id'],
client_secret: RedmineHelpdesk.settings['helpdesk_oauth_outlook_secret'],
refresh_token: HelpdeskSettings['helpdesk_outlook_refresh_token', project],
grant_type: 'refresh_token'.freeze,
}
code, data = execute(:FPOST, clarified_url(TOKEN_URL), params)
logged_error("HelpdeskOauthProvider::Microsoft#update_token error:\nCode: #{code}\nBody: #{data}") unless is_ok?(code)
if !is_ok?(code) || data.empty?
reset_token
else
write_tokens(data['access_token'], data['refresh_token'])
end
end
def folders
return [] if HelpdeskSettings[:helpdesk_username, project.id].blank?
Rails.cache.fetch("outlook_folders_#{project.id}", expires_in: 1.minute) do
folders_for(nil, 0).flatten.map {|fld| [fld[:name], fld[:id]]}
end
end
def messages(folder = nil)
message_ids = new_message_ids(folder)
message_ids.map do |mid|
[mid, message_raw(mid)]
end.to_h
end
def move_message(mid, folder)
params = {
destinationId: folder
}
move_url = message_full_url + "/#{mid}/move"
code, data = execute(:JPOST, move_url, params)
is_ok?(code) ? data : ''
end
def read_message(mid)
params = {
isRead: true,
}
read_url = message_full_url + "/#{mid}"
code, data = execute(:PATCH, read_url, params)
is_ok?(code) ? data : ''
end
def send_message(encoded_message)
params = {
raw: true,
body: encoded_message
}
sendmail_url = SENDMAIL_URL.gsub('<email>', HelpdeskSettings[:helpdesk_username, project.id])
code, data = execute(:TPOST, sendmail_url, params)
logged_error("HelpdeskOauthProvider::Microsoft#send_message error:\nCode: #{code}\nBody: #{data}") unless is_ok?(code)
raise SendMailError, data unless is_ok?(code)
data
end
private
def access_token_name
:helpdesk_outlook_access_token
end
def refresh_token_name
:helpdesk_outlook_refresh_token
end
def clarified_url(url)
tenant_id = RedmineHelpdesk.settings['helpdesk_outlook_tenant_id'].presence || 'common'
url.gsub('<tenant_id>', tenant_id)
end
def message_full_url(folder = nil)
message_url = MESSAGES_URL
message_url = folder ? message_url.gsub('<mail_folder>', folder) : message_url.gsub('/mailFolders/<mail_folder>', '')
message_url.gsub('<email>', HelpdeskSettings[:helpdesk_username, project.id])
end
def new_message_ids(folder)
params = {
count: true,
filter: 'IsRead eq false',
select: 'id',
'$orderby': 'createdDateTime',
'$top': 100
}
code, data = execute_with_pages(:GET, message_full_url(folder), params)
(data || []).map { |mes| mes['id'] }
end
def message_raw(mid)
params = {
raw: true
}
raw_url = message_full_url + "/#{mid}/$value"
code, data = execute(:GET, raw_url, params)
is_ok?(code) ? data : ''
end
def folders_for(fid, level)
params = {
'$top': 100
}
folders_url = FOLDERS_URL.gsub('<email>', HelpdeskSettings[:helpdesk_username, project.id])
folders_url += "/#{fid}/childFolders" if fid.present?
code, data = execute_with_pages(:GET, folders_url, params)
return [] if !is_ok?(code) || !data
subfolders = data.map do |folder|
{
name: '--' * level + ' ' + folder['displayName'],
id: folder['id'],
descendants: folder['childFolderCount']
}
end
subfolders.map { |item| [item, item[:descendants] > 0 ? folders_for(item[:id], level + 1) : []] }
end
def execute_with_pages(type, url, params = {})
total_data = []
page_url = url
page_params = params
next_page = true
while next_page
next_page = false
code, data = execute(type, page_url, page_params)
if is_ok?(code) && data
total_data += data['value'] if data['value']
if data['@odata.nextLink']
next_page = true
page_url = data['@odata.nextLink']
page_params = {}
end
end
end
[code.to_i, total_data]
end
end

View File

@ -0,0 +1,44 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskReportsBusiestTimeQuery < HelpdeskReportsQuery
def sql_for_staff_field(_field, operator, value)
issue_table = Issue.table_name
compare = operator == '=' ? 'IN' : 'NOT IN'
staff_ids = value.join(',')
"#{issue_table}.id IN(SELECT #{issue_table}.id FROM #{issue_table} WHERE (#{issue_table}.assigned_to_id #{compare} (#{staff_ids})))"
end
def sql_for_report_date_period_field(_field, operator, value)
sql_for_field('message_date', operator, value, queried_class.table_name, 'message_date')
end
private
def answered_user_ids
@answered_user_ids ||= begin
scope = Issue.visible.joins(:project)
.where("#{Issue.table_name}.assigned_to_id IS NOT NULL")
scope = scope.distinct
scope.pluck(:assigned_to_id)
end
end
end

View File

@ -0,0 +1,177 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskReportsCustomerSatisfactionQuery < HelpdeskReportsQuery
include HelpdeskHelper
self.queried_class = HelpdeskTicket
def initialize_available_filters
add_available_filter 'report_date_period', :type => :date_past, :name => l(:label_helpdesk_filter_time_interval)
add_available_filter 'customer', type: :contact, name: l(:label_helpdesk_contact)
add_available_filter 'customer_company', :type => :string, :name => l(:label_helpdesk_contact_company)
add_available_filter 'source', :type => :list, :name => l(:label_helpdesk_ticket_source), :values => helpdesk_tickets_source_for_select
if Redmine::Plugin.registered_plugins.keys.include?(:redmineup_tags)
add_available_filter 'tags', :type => :list, :values => tags_values
end
add_available_filter 'assigned_to_id', :type => :list_optional, :values => assigned_to_values
add_available_filter 'author_id', :type => :list, :values => author_values
if project
add_available_filter 'category_id', :type => :list_optional, :values => project.issue_categories.map { |c| [c.name, c.id.to_s] }
end
add_available_filter 'priority_id', :type => :list, :values => IssuePriority.all.map { |p| [p.name, p.id.to_s] }
add_available_filter 'tracker_id', :type => :list, :values => trackers.map { |t| [t.name, t.id.to_s] }
end
def base_scope
Issue.visible.joins(:project, :helpdesk_ticket)
end
def tickets(options = {})
base_scope.where(statement).where(options[:conditions])
rescue ::ActiveRecord::StatementInvalid => e
raise StatementInvalid.new(e.message)
end
def tickets_with_votes_count
@tickets_with_votes_count ||= tickets(:conditions => 'vote IS NOT NULL').count
end
def satisfaction_score
awesome_percentage - not_good_percentage
end
def total_votes
percentage(tickets_with_votes_count, tickets.count)
end
# Create _count and _percentage methods for votes
%w(not_good just_ok awesome).each_with_index do |name, i|
define_method "#{name}_count" do
cached = instance_variable_get("@#{name}_count")
return cached if cached
instance_variable_set("@#{name}_count", tickets(:conditions => "vote = #{i}").count)
end
define_method "#{name}_percentage" do
cached = instance_variable_get("@#{name}_percentage")
return cached if cached
instance_variable_set("@#{name}_percentage", percentage(send("#{name}_count"), tickets_with_votes_count))
end
end
# Query issues table for the following fields, not helpdesk_tickets
%w(assigned_to_id
author_id
category_id
priority_id
tracker_id).each do |field|
define_method "sql_for_#{field}_field" do |f, op, val|
sql_for_field(f, op, val, Issue.table_name, f)
end
end
def sql_for_tags_field(_field, operator, value)
operator = operator_for('tags').eql?('=') ? 'IN' : 'NOT IN'
ids = Issue.tagged_with(value).map(&:id).join(',')
"(#{Issue.table_name}.id #{operator} (#{ids}))"
end
def sql_for_customer_field(_field, operator, value)
where_clause = ''
if operator.in?(['=', '!'])
where_clause = "WHERE #{HelpdeskTicket.table_name}.contact_id IN (#{value.join(',')})"
end
operator = operator.start_with?('!') ? 'NOT IN' : 'IN'
ids = <<-SQL
SELECT #{'DISTINCT' if where_clause.blank?}
#{HelpdeskTicket.table_name}.issue_id
FROM #{HelpdeskTicket.table_name}
#{where_clause}
SQL
"(#{Issue.table_name}.id #{operator} (#{ids}))"
end
def sql_for_customer_company_field(_field, operator, value)
like_statement =
case operator
when '='
"LIKE '#{value.first.to_s.downcase}'"
when '!*'
"IS NULL OR #{Contact.table_name}.company = ''"
when '*'
"IS NOT NULL OR #{Contact.table_name}.company <> ''"
when '~', '!~'
"LIKE '%#{self.class.connection.quote_string(value.first.to_s.downcase)}%'"
end
operator = operator.start_with?('!') ? 'NOT IN' : 'IN'
ids = <<-SQL
SELECT #{HelpdeskTicket.table_name}.issue_id
FROM #{HelpdeskTicket.table_name}
WHERE #{HelpdeskTicket.table_name}.contact_id
IN (SELECT #{Contact.table_name}.id
FROM #{Contact.table_name}
WHERE LOWER(#{Contact.table_name}.company) #{like_statement})
SQL
"(#{Issue.table_name}.id #{operator} (#{ids}))"
end
def sql_for_report_date_period_field(field, operator, value)
sql_for_field(field, operator, value, queried_class.table_name, 'last_agent_response_at')
end
private
def percentage(part, total)
return 0 if total.zero?
part / total.to_f * 100
end
def author_values
return @author_values if @author_values
@author_values = []
@author_values << ["<< #{l(:label_me)} >>", 'me'] if User.current.logged?
Principal.where(:id => base_scope.pluck(:author_id).compact.uniq).sort.each { |p| @author_values << [p.name, p.id.to_s] }
@author_values
end
def assigned_to_values
return @assigned_to_values if @assigned_to_values
@assigned_to_values = []
@assigned_to_values << ["<< #{l(:label_me)} >>", 'me'] if User.current.logged?
Principal.where(:id => base_scope.pluck(:assigned_to_id).compact.uniq).sort.each { |p| @assigned_to_values << [p.name, p.id.to_s] }
@assigned_to_values
end
def tags_values
optional_project = project ? {:project => project} : {}
Issue.available_tags(optional_project).map { |t| [t.name, t.name] }
end
end

View File

@ -0,0 +1,25 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskReportsFirstResponseQuery < HelpdeskReportsQuery
def sql_for_report_date_period_field(_field, operator, value)
sql_for_field('message_date', operator, value, queried_class.table_name, 'message_date')
end
end

View File

@ -0,0 +1,129 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskReportsQuery < Query
self.queried_class = JournalMessage
operators_by_filter_type[:time_interval] = ['t', 'ld', 'w', 'l2w', 'm', 'lm', 'y']
def initialize(attributes = nil, *args)
super attributes
self.filters ||= {}
# Add report_date_period filter unless it's already there.
filters.merge!('report_date_period' => {:operator => 'm', :values => ['']}) { |_, old_value| old_value }
end
def initialize_available_filters
add_available_filter 'report_date_period', :type => :time_interval, :name => l(:label_helpdesk_filter_time_interval)
author_values = collect_answered_users.collect { |user| [user.name, user.id.to_s] }
add_available_filter 'staff', :type => :list, :name => l(:field_assigned_to), :values => author_values
end
def build_from_params(params)
if params[:fields] || params[:f]
add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
else
available_filters.keys.each do |field|
add_short_filter(field, params[field]) if params[field]
end
end
self
end
def issues(options = {})
scope = issue_scope.eager_load((options[:include] || []).uniq).
where(options[:conditions]).
limit(options[:limit]).
offset(options[:offset])
scope
rescue ::ActiveRecord::StatementInvalid => e
raise StatementInvalid.new(e.message)
end
def sql_for_staff_field(_field, operator, value)
issue_table = Issue.table_name
journal_table = Journal.table_name
compare = operator == '=' ? 'IN' : 'NOT IN'
staff_ids = value.join(',')
"#{issue_table}.id IN(SELECT #{issue_table}.id FROM #{issue_table} INNER JOIN #{journal_table} ON #{journal_table}.journalized_id = #{issue_table}.id AND #{journal_table}.journalized_type = 'Issue' WHERE (#{journal_table}.user_id #{compare} (#{staff_ids})))"
end
private
def answered_user_ids
@answered_user_ids ||= begin
scope = Issue.visible
.joins(:project, :journals, journals: :journal_message)
.where("#{Issue.table_name}.assigned_to_id IS NOT NULL")
scope = scope.distinct
scope.pluck(:assigned_to_id)
end
end
def collect_answered_users
project ? User.where(id: answered_user_ids) : []
end
def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter = false)
sql = ''
first_day_of_week = l(:general_first_day_of_week).to_i
date = Date.today
day_of_week = date.cwday
case operator
when 'pre_t'
sql = date_clause_selector(db_table, db_field, -1, -1, is_custom_filter)
when 'pre_ld'
sql = date_clause_selector(db_table, db_field, -2, -2, is_custom_filter)
when 'pre_w'
days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
sql = date_clause_selector(db_table, db_field, - days_ago - 7, - days_ago - 1, is_custom_filter)
when 'pre_l2w'
days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
sql = date_clause_selector(db_table, db_field, - days_ago - 28, - days_ago - 14 - 1, is_custom_filter)
when 'pre_m'
sql = date_clause_selector_for_date(db_table, db_field, (date - 1.month).beginning_of_month, (date - 1.month).end_of_month, is_custom_filter)
when 'pre_lm'
sql = date_clause_selector_for_date(db_table, db_field, (date - 2.months).beginning_of_month, (date - 2.months).end_of_month, is_custom_filter)
when 'pre_y'
sql = date_clause_selector_for_date(db_table, db_field, (date - 1.year).beginning_of_year, (date - 1.year).end_of_year, is_custom_filter)
end
sql = super(field, operator, value, db_table, db_field, is_custom_filter) if sql.blank?
sql
end
def date_clause_selector(table, field, from, to, is_custom_filter)
date_clause(table, field, (from ? Date.today + from : nil), (to ? Date.today + to : nil), is_custom_filter)
end
def date_clause_selector_for_date(table, field, date_from, date_to, is_custom_filter)
date_clause(table, field, date_from, date_to, is_custom_filter)
end
def issue_scope
issues = Issue.visible.joins(:project).where(statement)
issues = issues.
joins("INNER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_id = #{Issue.table_name}.id AND #{Journal.table_name}.journalized_type = 'Issue'").
joins("INNER JOIN #{JournalMessage.table_name} ON #{JournalMessage.table_name}.journal_id = #{Journal.table_name}.id")
issues.distinct
end
end

View File

@ -0,0 +1,308 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskTicket < ApplicationRecord
include Redmine::SafeAttributes
include RedmineHelpdesk::Concerns::Viewable
include RedmineHelpdesk::Concerns::HelpdeskTicketsVisible
HELPDESK_EMAIL_SOURCE = 0
HELPDESK_WEB_SOURCE = 1
HELPDESK_PHONE_SOURCE = 2
HELPDESK_TWITTER_SOURCE = 3
HELPDESK_CONVERSATION_SOURCE = 4
SEND_AS_NOTIFICATION = 1
SEND_AS_MESSAGE = 2
safe_attributes 'vote', 'vote_comment', 'from_address',
'to_address', 'cc_address', 'ticket_date',
'message_id', 'is_incoming', 'customer', 'issue', 'source', 'contact_id', 'ticket_time'
attr_accessor :ticket_time
attr_accessor :is_send_mail
belongs_to :customer, class_name: 'Contact', foreign_key: 'contact_id'
belongs_to :issue
has_one :message_file, class_name: 'Attachment', as: :container, dependent: :destroy
has_many :journals, through: :issue
acts_as_attachable view_permission: :view_issues, delete_permission: :edit_issues
acts_as_activity_provider type: 'helpdesk_tickets',
timestamp: "#{table_name}.ticket_date",
author_key: "#{Issue.table_name}.author_id",
scope: joins(issue: :project)
acts_as_event datetime: :ticket_date,
project_key: "#{Project.table_name}.id",
url: Proc.new { |o| { controller: 'issues', action: 'show', id: o.issue_id } },
type: Proc.new { |o| 'icon icon-email' + (o.issue.closed? ? ' closed' : '') if o.issue },
title: Proc.new { |o| "##{o.issue.id} (#{o.issue.status}): #{o.issue.subject}" if o.issue },
author: Proc.new { |o| o.customer },
description: Proc.new { |o| o.issue.description if o.issue }
accepts_nested_attributes_for :customer
before_save :calculate_metrics
validates_presence_of :customer, :ticket_date
after_commit :set_ticket_private, on: :create
def self.joined_model
:issue
end
def initialize(attributes = nil, *args)
super
if new_record?
# set default values for new records only
self.ticket_date ||= Time.now
self.source ||= HelpdeskTicket::HELPDESK_EMAIL_SOURCE
end
end
def to_s
l(:label_helpdesk_string, subject: issue.subject, author: customer)
end
def ticket_time
self.ticket_date.strftime("%H:%M") unless self.ticket_date.blank?
end
def ticket_time=(val)
if !self.ticket_date.blank? && val.to_s.gsub(/\s/, '').match(/^(\d{1,2}):(\d{1,2})$/)
timezone_name = ticket_date.time_zone.name if ticket_date.respond_to?(:time_zone) && ticket_date.time_zone
timezone = timezone_name || Time.zone.name
self.ticket_date = ActiveSupport::TimeZone.new(timezone)
.local_to_utc(self.ticket_date.utc)
.in_time_zone(timezone)
.change(hour: $1.to_i % 24, min: $2.to_i % 60)
end
end
def default_to_address
return last_response_address if last_journal_message && last_journal_message.is_incoming?
address = from_address.blank? ? '' : from_address.downcase.strip
(customer.emails.include?(address) ? address : customer.primary_email).downcase
end
def last_reply_customer
return customer unless default_to_address
customer.primary_email == default_to_address ? customer : Contact.find_by_emails([default_to_address]).first
end
def cc_addresses
@cc_addresses = ((issue.contacts ? issue.contacts.map(&:primary_email) : []) | cc_address.to_s.split(',')).compact.uniq
end
def project
issue.project if issue
end
def author
issue.author if issue
end
def customer_name
customer.name if customer
end
def responses
@responses ||= JournalMessage.joins(:journal).
where(:journals => { :journalized_id => issue_id }).
order("#{JournalMessage.table_name}.message_date ASC")
end
def reaction_date
@reaction_date ||= issue
.journals
.eager_load(:journal_message)
.where("#{JournalMessage.table_name}.journal_id IS NULL
OR #{JournalMessage.table_name}.is_incoming IS NULL
OR #{JournalMessage.table_name}.is_incoming = ?", false)
.order(:created_on)
.first
.try(:created_on)
.try(:utc)
end
def response_addresses
responses.where(:is_incoming => true).map { |response| response.from_address }.uniq
end
def first_response_date
@first_response_date ||= responses.select { |r| !r.is_incoming? }.first.try(:message_date).try(:utc)
end
def last_response_time
@last_response_time ||= last_journal_message && last_journal_message.is_incoming? && !issue.closed? ? last_journal_message.message_date.utc : nil
end
def last_response_address
response_addresses.last
end
def last_agent_response
@last_agent_response ||= responses.select { |r| !r.is_incoming? }.last
end
def last_journal_message
@last_journal_message ||= responses.last
end
def last_customer_response
@last_customer_response ||= responses.select { |r| r.is_incoming? }.last
end
def average_response_time
end
def ticket_source_name
case self.source
when HelpdeskTicket::HELPDESK_EMAIL_SOURCE then l(:label_helpdesk_tickets_email)
when HelpdeskTicket::HELPDESK_PHONE_SOURCE then l(:label_helpdesk_tickets_phone)
when HelpdeskTicket::HELPDESK_WEB_SOURCE then l(:label_helpdesk_tickets_web)
when HelpdeskTicket::HELPDESK_TWITTER_SOURCE then l(:label_helpdesk_tickets_twitter)
when HelpdeskTicket::HELPDESK_CONVERSATION_SOURCE then l(:label_helpdesk_tickets_conversation)
else ''
end
end
def ticket_source_icon
case self.source
when HelpdeskTicket::HELPDESK_EMAIL_SOURCE then 'icon-email'
when HelpdeskTicket::HELPDESK_PHONE_SOURCE then 'icon-call'
when HelpdeskTicket::HELPDESK_WEB_SOURCE then 'icon-web'
when HelpdeskTicket::HELPDESK_TWITTER_SOURCE then 'icon-twitter'
else 'icon-helpdesk'
end
end
def content
issue.description if issue
end
def customer_email
customer.primary_email if customer
end
def last_message
@last_message ||= JournalMessage.eager_load(:journal => :issue).where(:issues => { :id => issue.id }).order("#{Journal.table_name}.created_on ASC").last || self
end
def last_message_date
last_message.is_a?(HelpdeskTicket) ? self.ticket_date : last_message.message_date if last_message
end
def ticket_date
return nil if super.blank?
zone = User.current.time_zone
zone ? super.in_time_zone(zone) : (super.utc? ? super.localtime : super)
end
def token
secret = Rails.application.secret_key_base || Rails.application.config.secret_token
Digest::MD5.hexdigest("#{issue.id}:#{self.ticket_date.utc}:#{secret}")
end
def calculate_metrics
self.reaction_time = reaction_date - ticket_date.utc if reaction_date && ticket_date
self.first_response_time = first_response_date - ticket_date.utc if first_response_date && ticket_date
self.resolve_time = self.issue.closed? ? self.issue.closed_on - ticket_date.utc : nil if ticket_date && issue.closed_on && last_agent_response
self.last_agent_response_at = last_agent_response.message_date if last_agent_response
self.last_customer_response_at = last_customer_response.message_date if last_customer_response
end
def self.vote_message(vote)
case vote.to_i
when 0
l(:label_helpdesk_mark_notgood)
when 1
l(:label_helpdesk_mark_justok)
when 2
l(:label_helpdesk_mark_awesome)
else
''
end
end
def update_vote(new_vote, comment = nil)
old_vote = vote
old_vote_comment = vote_comment
if update(vote: new_vote, vote_comment: comment)
if old_vote != vote || old_vote_comment != vote_comment
journal = Journal.new(:journalized => issue, :user => User.current)
journal.details << JournalDetail.new(:property => 'attr',
:prop_key => 'vote',
:old_value => old_vote,
:value => vote) if old_vote != vote
journal.details << JournalDetail.new(:property => 'attr',
:prop_key => 'vote_comment',
:old_value => old_vote_comment,
:value => vote_comment) if old_vote_comment != vote_comment
journal.save
end
end
end
def self.autoclose(project)
return unless RedmineHelpdesk.autoclose_tickets_after > 0
pids = [project.id] + project.children.select { |ch| ch.module_enabled?(:contacts_helpdesk) }.map(&:id)
issues = Issue.includes(:journals, :helpdesk_ticket).where(project_id: pids).
where(status_id: RedmineHelpdesk.autoclose_from_status)
issues.find_each do |issue|
last_event = [issue.created_on, (issue.journals.order(:created_on).last.try(:created_on) || Time.now)].max
next if last_event > Time.now - RedmineHelpdesk.autoclose_time_interval
issue.init_journal(User.anonymous)
issue.current_journal.notes = I18n.t('label_helpdesk_autoclosed_ticket')
issue.status_id = RedmineHelpdesk.autoclose_to_status
issue.save
HelpdeskMailer.autoclose_message(issue.customer, issue)
end
end
def set_ticket_private
return unless RedmineHelpdesk.assign_contact_user?
issue.reload.update(is_private: true) if RedmineHelpdesk.create_private_tickets?
end
def self.send_reply_by_issue(issue, params = {})
unless params.empty?
issue.helpdesk_ticket.is_send_mail = params[:helpdesk][:is_send_mail]
issue.current_journal.build_journal_message
if params[:journal_message]
message_params = params.respond_to?(:to_unsafe_hash) ? params.to_unsafe_hash['journal_message'] : params[:journal_message]
journal_params = message_params.merge(Hash[message_params.slice('to_address', 'cc_address', 'bcc_address').map { |k, v| [k, v.join(',')] }])
issue.current_journal.journal_message.safe_attributes = journal_params
issue.current_journal.journal_message.save
end
issue.current_journal.journal_message.to_address ||= issue.customer.primary_email
issue.current_journal.is_send_note = true
issue.current_journal.notes = HelpdeskMailSupport.apply_text_macro(issue.current_journal.notes, issue.customer, issue, User.current)
end
end
end

View File

@ -0,0 +1,60 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class HelpdeskTicketQuery < Query
self.queried_class = HelpdeskTicket
self.view_permission = :view_helpdesk_tickets
def initialize(attributes=nil, *args)
super attributes
self.filters ||= { }
end
def initialize_available_filters
add_available_filter "ticket_date", :type => :date_past
end
def build_from_params(params, defaults={})
super
@params = params
self
end
def tickets(options = {})
scope = base_scope
scope = scope.joins(issue: { journals: :journal_message }) if @params[:include] && (%w[journals] & @params[:include].split(',')).any?
scope = scope.where("contact_id IN (?)", @params[:contact_id].split(',')) if @params[:contact_id]
scope = scope.where("issue_id IN (?)", @params[:issue_id].split(',')) if @params[:issue_id]
scope = scope.where("source = ?", @params[:source]) if @params[:source]
scope = scope.where("from_address = ?", @params[:from_address]) if @params[:from_address]
scope = scope.where("message_id = ?", @params[:message_id]) if @params[:message_id]
scope.limit(options[:limit])
.offset(options[:offset])
end
def tickets_count
tickets.count
end
private
def base_scope
HelpdeskTicket.where(statement)
end
end

View File

@ -0,0 +1,74 @@
# This file is a part of Redmine Helpdesk (redmine_helpdesk) plugin,
# customer support management plugin for Redmine
#
# Copyright (C) 2011-2024 RedmineUP
# http://www.redmineup.com/
#
# redmine_helpdesk 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 3 of the License, or
# (at your option) any later version.
#
# redmine_helpdesk 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 redmine_helpdesk. If not, see <http://www.gnu.org/licenses/>.
class JournalMessage < ApplicationRecord
include Redmine::SafeAttributes
include RedmineHelpdesk::Concerns::Viewable
include RedmineHelpdesk::Concerns::HelpdeskTicketsVisible
belongs_to :contact
belongs_to :journal
has_one :message_file, class_name: 'Attachment', as: :container, dependent: :destroy
safe_attributes 'source', 'from_address', 'to_address', 'bcc_address', 'cc_address', 'message_id', 'is_incoming', 'message_date', 'contact', 'journal'
acts_as_attachable view_permission: :view_issues,
delete_permission: :edit_issues
acts_as_activity_provider type: 'helpdesk_tickets',
permission: :view_helpdesk_tickets,
timestamp: "#{table_name}.message_date",
author_key: "#{Journal.table_name}.user_id",
scope: eager_load({ journal: [{ issue: [:project, :tracker] }, :details, :user] }, :contact)
acts_as_event title: Proc.new { |o| "#{o.journal.issue.tracker} ##{o.journal.issue.id}: #{o.journal.issue.subject}" if o.journal && o.journal.issue },
datetime: :message_date,
group: :helpdesk_ticket,
project_key: "#{Project.table_name}.id",
url: Proc.new { |o| { controller: 'issues', action: 'show', id: o.journal.issue.id, anchor: "change-#{o.id}" } if o.journal },
type: Proc.new { |o| ('icon' + (o.is_incoming? ? ' icon-email' : ' icon-email-to')) },
author: Proc.new { |o| o.is_incoming? ? o.contact : o.journal.user },
description: Proc.new { |o| o.journal.notes if o.journal }
validates_presence_of :contact, :journal, :message_date
def self.joined_model
:journal
end
def project
journal.project
end
def contact_name
contact.name
end
def contact_email
contact.emails.first
end
def helpdesk_ticket
journal.issue.helpdesk_ticket
end
def content
journal.notes
end
end

View File

@ -0,0 +1,38 @@
<%= back_url_hidden_field_tag %>
<%= error_messages_for 'canned_response' %>
<p><%= f.text_field :name, :size => 80, :required => true %></p>
<% if User.current.admin? || User.current.allowed_to?(:manage_public_canned_responses, @project) %>
<% if @canned_response.user %>
<p>
<label><%= l(:field_author) %></label>
<%= @canned_response.user.name %>
</p>
<% end %>
<p>
<%= f.check_box :is_public,
:label => l(:field_is_public),
:onchange => (User.current.admin? ? nil : 'if (this.checked) {$("#canned_response_is_for_all").removeAttr("checked"); $("#canned_response_is_for_all").attr("disabled", true);} else {$("#canned_response_is_for_all").removeAttr("disabled");}') %>
</p>
<% end %>
<p><label for="canned_response_is_for_all"><%=l(:field_is_for_all)%></label>
<%= check_box_tag 'canned_response_is_for_all', 1, @canned_response.project.nil?,
:disabled => !@canned_response.new_record? && (@canned_response.project.nil? || (@canned_response.is_public? && !User.current.admin?)) %></p>
<p><%= f.text_area :content, :required => true, :class => 'wiki-edit', :rows => 5 %>
<em class="info"><%= l(:text_helpdesk_answer_macros, :macro => HelpdeskSettings::MACRO_LIST.map{|m| link_to m, "#", :class => "mail-macro"}.join(', ')).html_safe %></em>
<%= wikitoolbar_for 'canned_response_content' %>
</p>
<script type="text/javascript" charset="utf-8">
$(".info a.mail-macro").bind("click", function() {
$('#canned_response_content').insertAtCaret($(this).html());
return false;
});
</script>
<% content_for :header_tags do %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<% end %>

View File

@ -0,0 +1,37 @@
<div class="contextual">
<%= link_to l(:label_helpdesk_new_canned_response), {:controller => "canned_responses", :action => 'new'}, :class => 'icon icon-add' %>
</div>
<h3><%= l(:label_helpdesk_canned_response_plural) %></h3>
<% if @canned_responses.any? %>
<table class="list">
<thead><tr>
<th><%= l(:field_name) %></th>
<th><%= l(:field_content) %></th>
<th><%= l(:field_is_public) %></th>
<th><%= l(:field_author) %></th>
<th><%= l(:field_project) %></th>
<th></th>
</tr></thead>
<tbody>
<% @canned_responses.each do |canned_response| %>
<tr class="<%= cycle 'odd', 'even' %>">
<td class="name"><%= canned_response.name %></td>
<td class="name"><em class="info"><%= canned_response.content.gsub(/$/, ' ').truncate(250) %></em></td>
<td class="tick"><%= checked_image canned_response.is_public? %></td>
<td class="project"><%= canned_response.user.try(:name) %></td>
<td class="project"><%= canned_response.project ? canned_response.project.name : l(:field_is_for_all) %></td>
<td class="buttons">
<%= link_to l(:button_edit), edit_canned_response_path(canned_response), :class => 'icon icon-edit' %>
<%= delete_link canned_response_path(canned_response, :project_id => canned_response.project) %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% if @canned_response_pages %>
<p class="pagination"><%= pagination_links_full @canned_response_pages %></p>
<% end %>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>

View File

@ -0,0 +1,52 @@
(function($) {
$.fn.insertAtCaret = function (myValue) {
return this.each(function() {
//IE support
if (document.selection) {
this.focus();
sel = document.selection.createRange();
sel.text = myValue;
this.focus();
} else if (this.selectionStart || this.selectionStart == '0') {
//MOZILLA / NETSCAPE support
var startPos = this.selectionStart;
var endPos = this.selectionEnd;
var scrollTop = this.scrollTop;
this.value = this.value.substring(0, startPos)+ myValue+ this.value.substring(endPos,this.value.length);
this.focus();
this.selectionStart = startPos + myValue.length;
this.selectionEnd = startPos + myValue.length;
this.scrollTop = scrollTop;
} else {
this.value += myValue;
this.focus();
}
});
};
})(jQuery);
$('#issue_notes').insertAtCaret("<%= raw escape_javascript(@content) %>")
$('#helpdesk_canned_response').val("");
if ($('#cke_issue_notes').length > 0) {
CKEDITOR.instances['issue_notes'].insertHtml("<%= raw escape_javascript(@content) %>");
}
<% @attachments.each do |attachment| %>
addAttachment(
'<%= j attachment.filename %>',
'<%= j attachment.token %>',
'<%= j attachment.description %>',
'<%= j attachment_path(attachment, format: 'js') %>'
);
<% end %>

View File

@ -0,0 +1,36 @@
<h2><%=l(:label_helpdesk_canned_response)%></h2>
<%= labelled_form_for :canned_response, @canned_response, url: { action: 'update', project_id: @project }, html: { multipart: true } do |f| %>
<div class="box tabular">
<fieldset class="tabular"><legend><%= l(:label_change_properties) %></legend>
<div id="all_attributes">
<%= render partial: 'canned_responses/form', locals: { f: f } %>
</div>
</fieldset>
<fieldset><legend><%= l(:label_attachment_plural) %></legend>
<% if @canned_response.attachments.any? && @canned_response.safe_attribute?('deleted_attachment_ids') %>
<div class="contextual"><%= link_to l(:label_edit_attachments), '#', onclick: "$('#existing-attachments').toggle(); return false;" %></div>
<div id="existing-attachments" style="<%= @canned_response.deleted_attachment_ids.blank? ? 'display:none;' : '' %>">
<% @canned_response.attachments.each do |attachment| %>
<span class="existing-attachment">
<%= text_field_tag '', attachment.filename, class: 'icon icon-attachment filename', disabled: true %>
<label>
<%= check_box_tag 'canned_response[deleted_attachment_ids][]',
attachment.id,
@canned_response.deleted_attachment_ids.include?(attachment.id),
id: nil, class: 'deleted_attachment' %> <%= l(:button_delete) %>
</label>
</span>
<% end %>
<hr />
</div>
<% end %>
<div id="new-attachments" style="display:inline-block;">
<%= render partial: 'attachments/form', locals: { container: @canned_response } %>
</div>
</fieldset>
</div>
<%= submit_tag l(:button_save) %>
<% end %>

View File

@ -0,0 +1 @@
<%= render :partial => 'index' %>

View File

@ -0,0 +1,14 @@
<h2><%=l(:label_helpdesk_new_canned_response)%></h2>
<%= labelled_form_for :canned_response, @canned_response, url: { action: 'create', project_id: @project }, html: { multipart: true } do |f| %>
<div class="box tabular">
<%= render partial: 'canned_responses/form', locals: { f: f } %>
<p id="attachments_form">
<label><%= l(:label_attachment_plural) %></label>
<%= render partial: 'attachments/form', locals: { container: @canned_response } %>
</p>
</div>
<%= submit_tag l(:button_create) %>
<% end %>

View File

@ -0,0 +1 @@
<%= transform_to_select2 'assignee', url: auto_complete_assignee_path(project_id: @project), multiple: true %>

View File

@ -0,0 +1,9 @@
if authorize_for(:helpdesk_tickets, :show)
root_api.array :helpdesk_tickets do
contact.all_tickets.each do |issue|
root_api.ticket do
render_api_helpdesk_ticket(issue.helpdesk_ticket, root_api)
end
end
end if include_in_api_response?('tickets') && contact.tickets.any? && User.current.allowed_to?(:view_helpdesk_tickets, project)
end

View File

@ -0,0 +1,52 @@
<% tickets_scope = @contact.all_tickets.visible.order_by_status %>
<% tickets = tickets_scope %>
<div id="helpdesk_tickets" class="contact-issues">
<div class="contextual">
<%= link_to l(:label_helpdesk_ticket_new), {:controller => 'issues',
:action => 'new',
:customer_id => @contact,
:issue => {
:tracker_id => HelpdeskSettings["helpdesk_tracker", @project.id]
},
:project_id => @project} if User.current.allowed_to?(:add_issues, @project) && User.current.allowed_to?(:send_response, @project) && HelpdeskSettings["helpdesk_tracker", @project.id] %>
</div>
<h3><%= link_to(l(:label_helpdesk_ticket_plural), {:controller => 'issues',
:action => 'index',
:set_filter => 1,
:customer => [@contact.id],
:status_id => "*",
:c => ["project", "tracker", "status", "subject", "customer", "customer_company", "last_message"],
:sort => 'priority:desc,updated_on:desc'}) %> </h3>
<% if tickets && tickets.any? %>
<%= form_tag({}, :data => {:cm_url => issues_context_menu_path}) do %>
<table class="list tickets">
<tbody>
<% for ticket in tickets %>
<tr id="ticket-<%= h(ticket.id) %>" class="issue hascontextmenu <%= cycle('odd', 'even') %> <%= ticket.css_classes %>">
<td>
<%= check_box_tag("ids[]", ticket.id, false, :style => 'display:none;', :id => nil) %>
<span class="icon <%= ticket.helpdesk_ticket.ticket_source_icon %>"></span>
</td>
<td class="subject">
<%= link_to "##{ticket.id} - #{truncate(ticket.subject, length: 60, escape: false)} (#{ticket.status})", issue_path(ticket), :class => ticket.css_classes %>
</td>
<% if @contact.is_company %>
<td class="customer"><%= contact_tag(ticket.customer, :type => 'plain') %></td>
<% end %>
<td class="last_message"><small>
<%= ticket.description.to_s.truncate(250) %>
</small></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<%= context_menu %>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
</div>

View File

@ -0,0 +1,15 @@
<% if @contact && User.current.allowed_to?(:view_helpdesk_tickets, @project) && User.current.allowed_to?(:add_issues, @project) && HelpdeskSettings["helpdesk_tracker", @project.id] %>
<li><%= context_menu_link l(:label_helpdesk_ticket_new), {:controller => 'issues',
:action => 'new',
:customer_id => @contact,
:issue => {
:tracker_id => HelpdeskSettings["helpdesk_tracker", @project.id]
},
:project_id => @project,
:back_url => @back},
:method => :get,
:class => 'icon icon-support' %>
</li>
<% end %>

View File

@ -0,0 +1,19 @@
<% if @project && @project.module_enabled?(:contacts_helpdesk) &&
User.current.allowed_to?(:add_issue_notes, @project) &&
User.current.allowed_to?(:send_response, @project)
%>
<li>
<%= context_menu_link l(:label_helpdesk_send_reply),
{
ids: @issue_ids,
:controller => 'helpdesk_tickets',
:action => 'bulk_edit_reply',
:issue => @issue,
:project_id => @project,
:back_url => @back
},
:method => :get,
:class => 'icon icon-email-to'
%>
</li>
<% end %>

View File

@ -0,0 +1,80 @@
<div class="contextual">
<% if !@query.new_record? && @query.editable_by?(User.current) %>
<%= link_to l(:button_edit), edit_query_path(@query), :class => 'icon icon-edit' %>
<%= delete_link query_path(@query) %>
<% end %>
</div>
<h2><%= @query.new_record? ? l(:label_issue_plural) : h(@query.name) %></h2>
<% html_title(@query.new_record? ? l(:label_issue_plural) : @query.name) %>
<%= form_tag({ :controller => 'issues', :action => 'index', :project_id => @project },
:method => :get, :id => 'query_form') do %>
<%= hidden_field_tag 'set_filter', '1' %>
<div id="query_form_content" class="hide-when-print">
<fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
<legend class="icon icon-<%= @query.new_record? ? 'expanded icon-expended' : 'collapsed' %>" onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
<div style="<%= @query.new_record? ? "" : "display: none;" %>">
<%= render :partial => 'queries/filters', :locals => {:query => @query} %>
</div>
</fieldset>
<fieldset class="collapsible collapsed">
<legend class="icon icon-collapsed" onclick="toggleFieldset(this);"><%= l(:label_options) %></legend>
<div style="display: none;">
<table>
<tr style="display: none;">
<td><%= l(:field_column_names) %></td>
<td><%= render :partial => 'queries/columns', :locals => {:query => @query} %></td>
</tr>
<tr>
<td><label for='group_by'><%= l(:field_group_by) %></label></td>
<td><%= select_tag('group_by',
options_for_select(
[[]] + @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]},
@query.group_by)
) %></td>
</tr>
<tr>
<td><label for='sort'><%= l(:label_sort) %></label></td>
<td><%= select_tag('sort',
options_for_select(
[[]] + @query.available_columns.select(&:sortable?).collect {|c| [c.caption, "#{c.name.to_s}:desc,id:desc"]},
params[:sort])
) %></td>
</tr>
</table>
</div>
</fieldset>
</div>
<p class="buttons hide-when-print">
<%= link_to_function l(:button_apply), 'submit_query_form("query_form")', :class => 'icon icon-checked' %>
<%= link_to l(:button_clear), { :set_filter => 1, :project_id => @project }, :class => 'icon icon-reload' %>
<% if @query.new_record? && User.current.allowed_to?(:save_queries, @project, :global => true) %>
<%= link_to_function l(:button_save),
"$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }'); submit_query_form('query_form')",
:class => 'icon icon-save' %>
<% end %>
</p>
<% end %>
<%= error_messages_for 'query' %>
<% if @query.valid? %>
<% if @issues.empty? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<%= render :partial => 'helpdesk/list', :locals => {:issues => @issues, :query => @query} %>
<span class="pagination"><%= pagination_links_full @issue_pages, @issue_count %></span>
<% end %>
<% end %>
<% content_for :sidebar do %>
<%= render :partial => 'issues/sidebar' %>
<% end %>
<% content_for :header_tags do %>
<%= stylesheet_link_tag :contacts, :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :helpdesk, :plugin => 'redmine_contacts_helpdesk' %>
<% end %>
<%= context_menu issues_context_menu_path %>

View File

@ -0,0 +1,66 @@
<%= form_tag({}) do -%>
<%= hidden_field_tag 'back_url', url_for(params) %>
<%= hidden_field_tag 'project_id', @project.id if @project %>
<table class="contacts tickets index">
<tbody>
<% previous_group = false %>
<% @issues.each do |issue| %>
<% if @query.grouped? && (group = @query.group_by_column.value(issue)) != previous_group %>
<% reset_cycle %>
<tr class="group open">
<td colspan="<%= @query.columns.size + 2 %>">
<span class="expander icon icon-expanded" onclick="toggleRowGroup(this);">&nbsp;</span>
<%= group.blank? ? 'None' : column_content(@query.group_by_column, issue) %> <span class="count">(<%= @issue_count_by_group[group] %>)</span>
<%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}", "toggleAllRowGroups(this)", :class => 'toggle-all') %>
</td>
</tr>
<% previous_group = group %>
<% end %>
<tr class="hascontextmenu">
<td class="checkbox">
<%= check_box_tag("ids[]", issue.id, false, :id => nil) %>
</td>
<% if Setting.gravatar_enabled? %>
<td class="avatar">
<% if issue.customer %>
<%= link_to avatar_to(issue.customer, :size => "32"), {:controller => 'contacts', :action => 'show', :project_id => @project, :id => issue.customer.id}, :id => "avatar" %>
<% else %>
<%= avatar(issue.author, :size => 3) %>
<% end %>
</td>
<% end %>
<td class="name ticket-name">
<h1 class="ticket_name"><%= link_to "#{issue.subject}", {:controller => :issues, :action => :show, :id => issue.id} %> <span id="ticket-id">#<%= issue.id %></span></h1>
<p class="ticket-description" >
<small><%= issue.description.gsub("(\n|\r)", "").strip.truncate(100) unless issue.description.blank? %></small>
</p>
<p class="contact-info">
<%= issue.customer ? "#{content_tag('span', '', :class => "icon icon-email", :title => l(:label_note_type_email))} #{l(:label_helpdesk_from)}: #{link_to_source(issue.customer)}, ".html_safe : "#{l(:label_helpdesk_from)}: #{link_to_user issue.author}, ".html_safe %>
<%= l(:label_updated_time, time_tag(issue.updated_on)).html_safe %>
</p>
</td>
<td class="status">
<%= content_tag(:span, issue.status.name, :class => "deal-status ticket-status tags status-#{issue.status.id}") %>
</td>
<td class="info ticket-info">
<% if issue.assigned_to %>
<div class="ticket-sum"><%= l(:field_assigned_to) %>: <strong><%= link_to_user issue.assigned_to %><%# "#{issue.currency} " if issue.currency %><%# issue_price(issue.amount) %></strong>
</div>
<% end %>
<div class="ticket-priority"><%= l(:field_priority) %>: <strong><%= issue.priority.name %><%# "#{issue.currency} " if issue.currency %><%# issue_price(issue.amount) %></strong>
</div>
<% if issue.due_date %>
<div class="ticket-due-date"><%= l(:field_due_date) %>: <strong><%= format_date issue.due_date %><%# "#{issue.currency} " if issue.currency %><%# issue_price(issue.amount) %></strong>
</div>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

View File

@ -0,0 +1 @@
$('#test_connection_messages').html('<%= escape_javascript @message.html_safe %>')

View File

@ -0,0 +1,7 @@
api.message do
api.journal_id @journal.id
api.content @journal.notes
api.to_address @journal_message.to_address
api.message_date format_date(@journal_message.message_date)
api.customer(:id => @issue.customer.id, :name => @issue.customer.name) unless @issue.customer.nil?
end

View File

@ -0,0 +1,332 @@
<%= render :partial => 'issues/action_menu' %>
<script type="text/javascript" charset="utf-8">
$(document).ready(function() {
toggleSendMail($('#is_send_mail').get(0));
});
function toggleSendMail(element) {
if (element.checked) {
$('#journal_contacts').show();
$('#helpdesk_cc').show();
<% unless HelpdeskSettings["helpdesk_emails_footer", @project].blank? %>
$('#email_footer').insertAfter($('#is_send_mail').parents().eq(1).find('.jstEditor .wiki-edit'));
$('#email_footer').show();
<% end %>
<% unless HelpdeskSettings["helpdesk_emails_header", @project].blank? %>
$('#email_header').insertBefore($('#is_send_mail').parents().eq(1).find('.jstElements'));
$('#email_header').show();
<% end %>
$('#issue_status_id').val("<%= HelpdeskSettings["helpdesk_answered_status", @project] %>");
} else {
$('#journal_contacts').hide();
$('#helpdesk_cc').hide();
<% unless HelpdeskSettings["helpdesk_emails_footer", @project].blank? %>
$('#email_footer').hide();;
<% end %>
<% unless HelpdeskSettings["helpdesk_emails_header", @project].blank? %>
$('#email_header').hide();;
<% end %>
}
}
</script>
<% unless HelpdeskSettings["helpdesk_emails_header", @project].blank? %>
<div id="email_header" style="display: none;" class="email-template">
<%= textilizable(HelpdeskMailSupport.apply_text_macro(HelpdeskSettings["helpdesk_emails_header", @project], @issue.contact, @issue, User.current)).html_safe %>
</div>
<% end %>
<% unless HelpdeskSettings["helpdesk_emails_footer", @project].blank? %>
<div id="email_footer" style="display: none;" class="email-template">
<%= textilizable(HelpdeskMailSupport.apply_text_macro(HelpdeskSettings["helpdesk_emails_footer", @project], @issue.contact, @issue, User.current)).html_safe %>
</div>
<% end %>
<h2><%= issue_heading(@issue) %></h2>
<div class="<%= @issue.css_classes %> details">
<% if @prev_issue_id || @next_issue_id %>
<div class="next-prev-links contextual">
<%= link_to_if @prev_issue_id,
"\xc2\xab #{l(:label_previous)}",
(@prev_issue_id ? issue_path(@prev_issue_id) : nil),
:title => "##{@prev_issue_id}" %> |
<% if @issue && @issue %>
<span class="position"><%= l(:label_item_position, :position => @issue, :count => @issue) %></span> |
<% end %>
<%= link_to_if @next_issue_id,
"#{l(:label_next)} \xc2\xbb",
(@next_issue_id ? issue_path(@next_issue_id) : nil),
:title => "##{@next_issue_id}" %>
</div>
<% end %>
<div class="subject">
<%= render_issue_subject_with_tree(@issue) %>
</div>
<p class="author icon icon-email">
<%= l(:label_added_time_by, :author => @issue.author.instance_of?(AnonymousUser) ? link_to_source(@issue.contacts.first) : link_to_user(@issue.author), :age => time_tag(@issue.created_on)).html_safe %>
<% if @issue.created_on != @issue.updated_on %>
<%= l(:label_updated_time, time_tag(@issue.updated_on)).html_safe %>.
<% end %>
</p>
<% if @issue.description? || @issue.attachments.any? -%>
<hr />
<% if @issue.description? %>
<div class="contextual">
<%= link_to l(:button_quote),
{:controller => 'journals', :action => 'new', :id => @issue},
:remote => true,
:method => 'post',
:class => 'icon icon-comment' if authorize_for('issues', 'edit') %>
</div>
<div class="wiki">
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
</div>
<% end %>
<%= link_to_attachments @issue, :thumbnails => true %>
<% end -%>
</div>
<% if @journals.present? %>
<div id="ticket-history">
<h3><%=l(:label_history)%></h3>
<% reply_links = authorize_for('issues', 'edit') -%>
<% for journal in @journals.select{|j| !j.notes.blank? } %>
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %> ticket-note">
<% if journal.is_incoming? %>
<%= link_to avatar_to(journal.contacts.first, :size => "32"), {:controller => 'contacts', :action => 'show', :project_id => @project, :id => journal.contacts.first.id}, :id => "avatar", :class => "ticket-avatar gravatar" unless journal.contacts.blank? %>
<% else %>
<%= avatar(journal.user, size: 32, :class => "ticket-avatar gravatar") %>
<% end %>
<div id="note-<%= journal.indice %>" class="ticket-note-content">
<h4>
<%= link_to_in_place_notes_editor(image_tag('edit.png'), "journal-#{journal.id}-notes",
{ :controller => 'journals', :action => 'edit', :id => journal, :format => 'js' },
:title => l(:button_edit),
:class => "journal-link") if reply_links %>
<%= link_to(image_tag('comment.png'),
{:controller => 'journals', :action => 'new', :id => @issue, :journal_id => journal},
:remote => true,
:method => 'post',
:title => l(:button_quote),
:class => "journal-link") %>
<% if journal.contacts && journal.contacts.any? && User.current.allowed_to?(:view_helpdesk_tickets, @project) %>
<span class="icon <%= journal.is_incoming? ? 'icon-email' : 'icon-email-to' %>">
<% if journal.is_incoming? %>
<%= "#{link_to_source journal.contacts.first} (#{journal.journal_messages.first.email})".html_safe unless journal.contacts.blank? %>
<% if journal.journal_messages.first.attachments.any? %>
<% attachment = journal.journal_messages.first.attachments.first %>
<span class="attachment" style="white-space: nowrap;display: inline-block;">
<%= link_to_attachment attachment, :text => l(:label_helpdesk_original), :class => 'icon icon-attachment' -%>
<%= h(" - #{attachment.description}") unless attachment.description.blank? %>
<span class="size">(<%= number_to_human_size attachment.filesize %>)</span>
<%= link_to_if_authorized image_tag('magnifier.png', :plugin => "redmine_contacts_helpdesk"),
:controller => 'helpdesk', :action => 'show_original',
:id => attachment, :project_id => @project %>
</span>
<% end %>
<% else %>
<%= link_to_user journal.user %>
<span class="sent-to">
<%= l(:label_sent_to) %>
<% journal.journal_messages.each do |journal_message| %>
<span class="contact" style="white-space: nowrap;display: inline-block;">
<%= link_to_source(journal_message.contact) %>
(<%= journal_message.email %>)
</span>
<% end %>
</span>
<% end %>
- <%= format_time(@issue.updated_on).html_safe %>.
</span>
<%# authoring journal.created_on, journal.user, :label => :label_updated_time_by %>
<% end %>
</h4>
<div class="wiki editable" id="journal-<%= journal.id %>-notes">
<%= textilizable(journal, :notes) %>
</div>
</div>
</div>
<% end %>
<% heads_for_wiki_formatter if User.current.allowed_to?(:edit_issue_notes, @issue.project) || User.current.allowed_to?(:edit_own_issue_notes, @issue.project) %>
</div>
<% end %>
<div style="clear: both;"></div>
<%= render :partial => 'issues/action_menu' %>
<div style="clear: both;"></div>
<% if authorize_for('issues', 'edit') %>
<div id="update" style="display:none;">
<h3><%= l(:button_update) %></h3>
<%# labelled_form_for @issue, :html => {:id => 'issue-form', :multipart => true} do |f| %>
<%= form_tag({:controller => "helpdesk_tickets", :action => "update"}, :id => 'issue-form', :multipart => true, :method => :put) do |f| %>
<%= error_messages_for 'issue' %>
<%= render :partial => 'issues/conflict' if @conflict %>
<div class="box">
<p>
<%= label_tag :is_send_mail, l(:label_is_send_mail), :class => "icon icon-email-to", :style => "" %>
<%= check_box_tag 'is_send_mail', 1, HelpdeskSettings["send_note_by_default", @project], :onclick => "toggleSendMail(this);" %>
<span id="journal_contacts" style="display: none;">
<% @issue.contacts.each do |contact| %>
<%= contact_tag(contact) %>
(<%= contact.emails.first %>)
<% end %>
<div id="helpdesk_cc" style="display: none;">
<p>
<%= label_tag :email_cc, l(:label_email_cc) %>
<%= text_field_tag :email_cc, '', :size => "80%" %>
</p>
<p>
<%= label_tag :email_bcc, l(:label_email_bcc) %>
<%= text_field_tag :email_bcc, '', :size => "80%" %>
</p>
</div>
</span>
</p>
<%= text_area_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit' %>
<%= wikitoolbar_for 'notes' %>
<p><%=l(:label_attachment_plural)%><br /><%= render :partial => 'attachments/form', :locals => {:container => @issue} %></p>
</div>
<%# f.hidden_field :lock_version %>
<%# hidden_field_tag 'last_journal_id', params[:last_journal_id] || @issue.last_journal_id %>
<%= submit_tag l(:button_submit) %>
<%= preview_link preview_edit_issue_path(:project_id => @project, :id => @issue), 'issue-form' %>
<% end %>
<div id="preview" class="wiki"></div>
</div>
<% end %>
<% other_formats_links do |f| %>
<%= f.link_to 'Atom', :url => {:key => User.current.rss_key} %>
<%= f.link_to 'PDF' %>
<% end %>
<% html_title "#{@issue.tracker.name} ##{@issue.id}: #{@issue.subject}" %>
<% content_for :sidebar do %>
<div id="ticket_attributes">
<% if authorize_for('issues', 'edit') %>
<div class="contextual">
<%= link_to l(:button_update), :onclick => '#' %>
</div>
<% end %>
<h3><%= l(:label_helpdesk_ticket_attributes) %></h3>
<table class="attributes">
<%= issue_fields_rows do |rows|
rows.left l(:field_status), h(@issue.status.name), :class => 'status'
rows.left l(:field_priority), h(@issue.priority.name), :class => 'priority'
unless @issue.disabled_core_fields.include?('assigned_to_id')
rows.left l(:field_assigned_to), avatar(@issue.assigned_to, :size => "14").to_s.html_safe + (@issue.assigned_to ? link_to_user(@issue.assigned_to) : "-"), :class => 'assigned-to'
end
unless @issue.disabled_core_fields.include?('category_id') || @issue.category.blank?
rows.left l(:field_category), h(@issue.category ? @issue.category.name : "-"), :class => 'category'
end
unless @issue.disabled_core_fields.include?('fixed_version_id') || @issue.fixed_version.blank?
rows.left l(:field_fixed_version), (@issue.fixed_version ? link_to_version(@issue.fixed_version) : "-"), :class => 'fixed-version'
end
unless @issue.disabled_core_fields.include?('start_date') || @issue.start_date.blank?
rows.left l(:field_start_date), format_date(@issue.start_date), :class => 'start-date'
end
unless @issue.disabled_core_fields.include?('due_date') || @issue.due_date.blank?
rows.left l(:field_due_date), format_date(@issue.due_date), :class => 'due-date'
end
unless @issue.disabled_core_fields.include?('done_ratio')
rows.left l(:field_done_ratio), progress_bar(@issue.done_ratio, :width => '80px', :legend => "#{@issue.done_ratio}%"), :class => 'progress'
end
unless @issue.disabled_core_fields.include?('estimated_hours')
unless @issue.estimated_hours.nil?
rows.left l(:field_estimated_hours), l_hours(@issue.estimated_hours), :class => 'estimated-hours'
end
end
if User.current.allowed_to?(:view_time_entries, @project) && @issue.total_spent_hours > 0
rows.left l(:label_spent_time), (@issue.total_spent_hours > 0 ? (link_to l_hours(@issue.total_spent_hours), {:controller => 'timelog', :action => 'index', :project_id => @project, :issue_id => @issue}) : "-"), :class => 'spent-time'
end
end %>
<%= render_custom_fields_rows(@issue) %>
</table>
</div>
<div id="contacts_previous_issues">
<style type="text/css">
#contacts_previous_issues ul {margin: 0; padding: 0;}
#contacts_previous_issues li {list-style-type:none; margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
</style>
<% if RedmineHelpdesk.show_contact_card? %>
<h3><%= l(:label_helpdesk_contact) %></h3>
<% @issue.contacts.each do |contact| %>
<span class="small-card">
<%= render :partial => 'contacts/contact_card', :object => contact %>
</span>
<% end %>
<% end %>
<% if (issues_count = Issue.count(:include => :contacts, :conditions => ["#{Contact.table_name}.id IN (#{@issue.contact_ids.join(', ')})"]) - 1) > 0 %>
<h3><%= "#{l(:label_helpdesk_contact_activity)} (#{issues_count})" %> </h3>
<ul>
<% (Issue.visible.find(:all, :include => :contacts, :conditions => ["#{Contact.table_name}.id IN (#{@issue.contact_ids.join(', ')})"], :order => "#{Issue.table_name}.status_id, #{Issue.table_name}.due_date DESC, #{Issue.table_name}.updated_on DESC", :limit => RedmineHelpdesk.last_message_count > 0 ? RedmineHelpdesk.last_message_count : 11) - [@issue]).each do |issue| %>
<li>
<%= link_to_issue(issue, :truncate => 60, :project => (@project != issue.project)) %>
</li>
<% end %>
</ul>
<div class="contextual">
<%= link_to l(:label_issue_view_all), {:controller => 'issues',
:action => 'index',
:set_filter => 1,
:f => [:contacts, :status_id],
:v => {:contacts => @issue.contact_ids},
:op => {:contacts => '=', :status_id => '*'}} %>
</div>
<% end %>
</div>
<% if User.current.allowed_to?(:add_issue_watchers, @project) ||
(@issue.watchers.present? && User.current.allowed_to?(:view_issue_watchers, @project)) %>
<div id="watchers">
<%= render :partial => 'watchers/watchers', :locals => {:watched => @issue} %>
</div>
<% end %>
<% end %>
<% content_for :header_tags do %>
<%= stylesheet_link_tag :contacts, :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :helpdesk, :plugin => 'redmine_contacts_helpdesk' %>
<%= auto_discovery_link_tag(:atom, {:format => 'atom', :key => User.current.rss_key}, :title => "#{@issue.project} - #{@issue.tracker} ##{@issue.id}: #{@issue.subject}") %>
<% end %>
<%= context_menu issues_context_menu_path %>

View File

@ -0,0 +1,9 @@
$('#customer_to_email').html('<%= escape_javascript(render :partial => "issues/customer_to_email", :locals => {:contact => @contact, :contact_email => @email}) %>')
$("#helpdesk_to").val("<%= @email %>");
$("#helpdesk_to").attr("value", "<%= @email %>");
$("#helpdesk_to").trigger('change');
$("#helpdesk_cc").val(["<%= @cc_emails.join('","').html_safe %>"]);
$("#helpdesk_cc").trigger('change');

View File

@ -0,0 +1 @@
$('#customer_profile_and_issues').html('<%= escape_javascript(render :partial => "issues/helpdesk_customer_profile") %>')

View File

@ -0,0 +1,26 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css" media="screen">
<%= @email_stylesheet %>
</style>
</head>
<body>
<div id="email_header">
<%= textile(@email_header.to_s).html_safe unless @email_header.blank? %>
</div>
<div class="wrapper" id="email_body">
<%= textile(@email_body.to_s).html_safe %>
</div>
<div id="email_footer">
<%= textile(@email_footer.to_s).html_safe unless @email_footer.blank? %>
</div>
<% if RedmineHelpdesk.add_readmark? && @image_url %>
<span style="display: none;">
<img src="<%= message_link_url(vid: @image_url) %>">
</span>
<% end %>
</body>
</html>

View File

@ -0,0 +1,3 @@
<%= @email_header %>
<%= @email_body %>
<%= @email_footer %>

View File

@ -0,0 +1,61 @@
<% include_calendar_headers_tags %>
<%= back_url_hidden_field_tag %>
<%= error_messages_for 'helpdesk_mail_rule' %>
<fieldset class="mail_rule_block"><legend><%= l(:label_helpdesk_mail_rule_trigger) %></legend>
<div id="mail_type">
<%= label_tag('mail_type', l(:label_helpdesk_mail_rule_mail_type_on)) %>
<%= select_tag 'mail_type', helpdesk_mail_rule_mail_types_for_select(@helpdesk_mail_rule.mail_type) %>
</div>
</fieldset>
<fieldset class="mail_rule_block"><legend><%= l(:label_helpdesk_mail_rule_condition_plural) %></legend>
<table id="filters-table">
<% if @helpdesk_mail_rule.conditions.empty? %>
<tr class="all-emails-label">
<td><%= l(:label_helpdesk_mail_rule_condition_for_all) %></td>
</tr>
<% end %>
</table>
<div class="add-filter">
<%= label_tag('add_filter_select', l(:label_helpdesk_mail_rule_condition_add)) %>
<%= select_tag 'add_filter_select', helpdesk_mail_rule_attrs_for_select(@helpdesk_mail_rule.available_conditions), :name => nil %>
</div>
<%= hidden_field_tag 'f[]', '' %>
</fieldset>
<fieldset class="mail_rule_block"><legend><%= l(:label_helpdesk_mail_rule_action_plural) %></legend>
<table id="actions-table"></table>
<div class="add-filter">
<%= label_tag('add_action_select', l(:label_helpdesk_mail_rule_action_add)) %>
<%= select_tag 'add_action_select', helpdesk_mail_rule_attrs_for_select(@helpdesk_mail_rule.available_actions), :name => nil %>
</div>
<%= hidden_field_tag 'a[]', '' %>
</fieldset>
<%= javascript_tag do %>
var operatorLabels = <%= raw_json Query.operators_labels %>;
var operatorByType = <%= raw_json Query.operators_by_filter_type %>;
var availableFilters = <%= raw_json @helpdesk_mail_rule.available_conditions_as_json %>;
var availableActions = <%= raw_json @helpdesk_mail_rule.available_actions_as_json %>;
var labelDayPlural = <%= raw_json l(:label_day_plural) %>;
var filtersUrl = <%= raw_json helpdesk_mail_rules_condition_path %>;
var actionsUrl = <%= raw_json helpdesk_mail_rules_action_path %>;
$(document).ready(function(){
initFilters();
initActions();
<% @helpdesk_mail_rule.conditions.each do |condition, options| %>
addFilter("<%= condition %>", <%= raw_json @helpdesk_mail_rule.operator_for_condition(condition) %>, <%= raw_json @helpdesk_mail_rule.condition_values_for(condition) %>);
<% end %>
<% @helpdesk_mail_rule.actions.each do |action, options| %>
addAction("<%= action %>", <%= raw_json @helpdesk_mail_rule.operator_for_action(action) %>, <%= raw_json @helpdesk_mail_rule.action_values_for(action) %>);
<% end %>
toogleMailRuleButton();
});
<% end %>

View File

@ -0,0 +1,29 @@
<table class="list">
<thead><tr>
<th><%= l(:label_helpdesk_mail_rule_condition_plural) %></th>
<th><%= l(:label_helpdesk_mail_rule_action_plural) %></th>
<th></th>
<th></th>
</tr></thead>
<tbody>
<% helpdesk_mail_rules.each do |mail_rule| %>
<tr class="<%= cycle 'odd', 'even' %>">
<td class="conditions">
<%= helpdesk_mail_rule_conditions_to_html(mail_rule.conditions) %>
</td>
<td class="actions">
<%= helpdesk_mail_rule_actions_to_html(mail_rule.actions) %>
</td>
<td><%= stocked_reorder_link(mail_rule, '', {:controller => "helpdesk_mail_rules", :action => 'update', :id => mail_rule}, :put) %></td>
<td class="buttons">
<%= link_to l(:button_edit), edit_helpdesk_mail_rule_path(mail_rule), :class => 'icon icon-edit' %>
<%= delete_link helpdesk_mail_rule_path(mail_rule) %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= javascript_tag do %>
$(function() { $("table.list tbody").positionedItems(); });
<% end %>

View File

@ -0,0 +1,23 @@
<div class="contextual">
<%= link_to l(:label_helpdesk_mail_rule_new), {:controller => "helpdesk_mail_rules", :action => 'new'}, :class => 'icon icon-add' %>
</div>
<h3><%= l(:label_helpdesk_mail_rule_plural) %></h3>
<% if @helpdesk_mail_rules.any? %>
<h4><%= l(:label_helpdesk_mail_rule_incoming) %></h4>
<%= render partial: 'helpdesk_mail_rules/helpdesk_mail_rules_table', locals: { helpdesk_mail_rules: @helpdesk_mail_rules.incoming.by_position } %>
<h4><%= l(:label_helpdesk_mail_rule_outgoing) %></h4>
<%= render partial: 'helpdesk_mail_rules/helpdesk_mail_rules_table', locals: { helpdesk_mail_rules: @helpdesk_mail_rules.outgoing.by_position } %>
<!-- TODO: Manual rules -->
<!-- <h4><%# l(:label_helpdesk_mail_rule_manually) %></h4> -->
<%# render partial: 'helpdesk_mail_rules/helpdesk_mail_rules_table', locals: { helpdesk_mail_rules: @helpdesk_mail_rules.manually } %>
<% if @helpdesk_mail_rules_pages %>
<p class="pagination"><%= pagination_links_full @helpdesk_mail_rules_pages %></p>
<% end %>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>

View File

@ -0,0 +1,10 @@
<h2><%=l(:label_helpdesk_mail_rule)%></h2>
<%= labelled_form_for :helpdesk_mail_rule, @helpdesk_mail_rule, url: { action: 'update' } do |f| %>
<%= render :partial => 'helpdesk_mail_rules/form', :locals => { f: f } %>
<%= submit_tag l(:button_save), class: 'mail-rule-button' %>
<% end %>
<% content_for(:header_tags) do %>
<% javascript_include_tag :redmine_helpdesk_mail_rule, plugin: 'redmine_contacts_helpdesk' %>
<% end %>

View File

@ -0,0 +1 @@
<%= render :partial => 'index' %>

View File

@ -0,0 +1,10 @@
<h2><%= l(:label_helpdesk_mail_rule) %></h2>
<%= labelled_form_for :helpdesk_mail_rule, @helpdesk_mail_rule, url: { action: 'create' } do |f| %>
<%= render partial: 'helpdesk_mail_rules/form', locals: { f: f } %>
<%= submit_tag l(:button_create), class: 'mail-rule-button' %>
<% end %>
<% content_for(:header_tags) do %>
<% javascript_include_tag :redmine_helpdesk_mail_rule, plugin: 'redmine_contacts_helpdesk' %>
<% end %>

View File

@ -0,0 +1,35 @@
<% if @collector.issues_count.zero? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<div class="helpdesk_chart">
<table class="chart_table">
<%= render :partial => 'table_data' %>
<tr class="metrics">
<td colspan="<%= @collector.columns.count / 2 %>">
<p><%= l(:label_helpdesk_busiest_time_of_day_new_tickets) %></p>
<div class="num"><%= @collector.new_issues_count %></div>
<div class="change" title="<%= process_deviation(@collector.previous_new_issues_count, @collector.new_issues_count, false) %>">
<%= progress_in_percents(-@collector.new_issue_count_progress) %>
</div>
</td>
<td colspan="<%= @collector.columns.count / 2 %>">
<p><%= l(:label_helpdesk_busiest_time_of_day_new_contacts) %></p>
<div class="num"><%= @collector.contacts_count %></div>
<div class="change" title="<%= process_deviation(@collector.previous_contacts_count, @collector.contacts_count, false) %>">
<%= progress_in_percents(-@collector.total_contacts_count_progress) %>
</div>
</td>
</tr>
<tr class="metrics">
<td colspan="<%= @collector.columns.count / 2 %>">
<p><%= l(:label_helpdesk_busiest_time_of_day_total_incoming) %></p>
<div class="num"><%= @collector.issues_count %></div>
<div class="change" title="<%= process_deviation(@collector.previous_issues_count, @collector.issues_count, false) %>">
<%= progress_in_percents(-@collector.issue_count_progress) %>
</div>
</td>
</tr>
</table>
</div>
<% end %>

View File

@ -0,0 +1,141 @@
<% content_for :header_tags do %>
<%= chartjs_assets %>
<% end %>
<% if @query.tickets_with_votes_count.zero? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<div class="not-too-wide">
<div class="helpdesk-charts-layout">
<div class="chart">
<canvas id="awesome"></canvas>
</div>
<div class="chart">
<canvas id="just-ok"></canvas>
</div>
<div class="chart">
<canvas id="not-good"></canvas>
</div>
</div>
<table class="metrics">
<tr>
<td>
<p><%= l(:label_helpdesk_satisfaction_score) %></p>
<div id="satisfaction-score" class="num">
<%= @query.satisfaction_score.round %><span>%</span>
</div>
</td>
<td>
<p><%= l(:label_helpdesk_total_votes) %></p>
<div id="total-votes" class="num">
<%= @query.total_votes.round %><span>%</span>
</div>
</td>
</tr>
</table>
</div>
<script>
var drawChart = function(el, options) {
new Chart(
el,
{
type: "doughnut",
data: {
labels: [
" " + options["title"],
" <%= l(:label_helpdesk_mark_otherwise) %>"
],
datasets: [{
data: options["data"],
backgroundColor: [
options["color"],
"#ddd"
]
}]
},
options: {
legend: {
display: false
},
responsive: true,
title: {
display: true,
fontSize: 20,
text: options["title"]
}
}
}
);
/* Show percentage in the middle of the chart. */
Chart.pluginService.register({
beforeDraw: function(chart) {
if (chart.id != options["id"]) return;
var width = chart.chart.width,
height = chart.chart.height,
ctx = chart.chart.ctx;
ctx.restore();
var fontSize = (height / 114).toFixed(2);
ctx.font = fontSize + "em sans-serif";
ctx.fillStyle = "black";
ctx.textBaseline = "middle";
var text = Math.round(options["percentage"]) + "%",
textWidth = Math.round((width - ctx.measureText(text).width) / 2),
textHeight = (height + chart.chart.titleBlock.height) / 2;
ctx.fillText(text, textWidth, textHeight);
ctx.save();
}
});
};
$(document).ready(function() {
drawChart(
$("#awesome"),
{
id: 0,
title: "<%= l(:label_helpdesk_mark_awesome) %>",
color: "#00762a",
data: [
<%= @query.awesome_count %>,
<%= @query.tickets_with_votes_count - @query.awesome_count %>
],
percentage: <%= @query.awesome_percentage %>
}
);
drawChart(
$("#just-ok"),
{
id: 1,
title: "<%= l(:label_helpdesk_mark_justok) %>",
color: "#ffce56",
data: [
<%= @query.just_ok_count %>,
<%= @query.tickets_with_votes_count - @query.just_ok_count %>
],
percentage: <%= @query.just_ok_percentage %>
}
);
drawChart(
$("#not-good"),
{
id: 2,
title: "<%= l(:label_helpdesk_mark_notgood) %>",
color: "#ff6384",
data: [
<%= @query.not_good_count %>,
<%= @query.tickets_with_votes_count - @query.not_good_count %>
],
percentage: <%= @query.not_good_percentage %>
}
);
});
</script>
<% end %>

View File

@ -0,0 +1,43 @@
<% if @collector.issues_count.zero? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<div class="helpdesk_chart">
<table class="chart_table">
<%= render :partial => 'table_data' %>
<tr class="metrics">
<td colspan="<%= @collector.columns.count / 2 %>">
<p><%= l(:label_helpdesk_average_first_response_time) %></p>
<div class="num"><%= helpdesk_time_label(@collector.average_response_time) %></div>
<div class="change" title="<%= process_deviation(@collector.previous_average_response_time, @collector.average_response_time) %>">
<%= mirror_progress_in_percents(@collector.average_response_time_progress) %>
</div>
</td>
<td colspan="<%= @collector.columns.count / 2 %>">
<p><%= l(:label_helpdesk_average_time_to_close) %></p>
<div class="num"><%= helpdesk_time_label(@collector.average_close_time) %></div>
<div class="change" title="<%= process_deviation(@collector.previous_average_close_time, @collector.average_close_time) %>">
<%= mirror_progress_in_percents(@collector.average_close_time_progress) %>
</div>
</td>
</tr>
<tr class="metrics">
<td colspan="<%= @collector.columns.count / 2 %>">
<p><%= l(:label_helpdesk_average_responses_count) %></p>
<div class="num"><%= @collector.average_response_count %></div>
<div class="change" title="<%= process_deviation(@collector.previous_average_response_count, @collector.average_response_count, false) %>">
<%= progress_in_percents(@collector.average_response_count_progress) %>
</div>
</td>
<td colspan="<%= @collector.columns.count / 2 %>">
<p><%= l(:label_helpdesk_total_replies) %></p>
<div class="num"><%= @collector.total_response_count %></div>
<div class="change" title="<%= process_deviation(@collector.previous_total_response_count, @collector.total_response_count, false) %>">
<%= progress_in_percents(-@collector.total_response_count_progress) %>
</div>
</td>
</td>
</tr>
</table>
</div>
<% end %>

View File

@ -0,0 +1,24 @@
<tr class="header">
<% @collector.columns.each do |column| %>
<td class="column_data">
<p class="issues_count"><%= column[:issues_count] %></p>
<p><%= [column[:issues_percent], '%'].join %></p>
</td>
<% end %>
</tr>
<tr class="main_block">
<% @collector.columns.each do |column| %>
<td class="column_data">
<% if column[:issues_count] > 0 %>
<div class="percents" style='height: <%= (column[:issues_count] * @collector.issue_weight).ceil %>px'></div>
<% end %>
</td>
<% end %>
</tr>
<tr class="footer">
<% @collector.columns.each do |column| %>
<td class="column_data">
<%= l("label_helpdesk_#{@query.name}_interval_#{column[:name]}") %>
</td>
<% end %>
</tr>

View File

@ -0,0 +1,39 @@
<h2><%= l("label_helpdesk_#{@query.name}") %></h2>
<% html_title(l("label_helpdesk_#{@query.name}")) %>
<%= form_tag({ :controller => 'helpdesk_reports', :action => 'show', :project_id => @project },
:method => :get, :id => 'query_form') do %>
<div id="query_form_with_buttons" class="hide-when-print">
<%= hidden_field_tag 'set_filter', '1' %>
<div id="query_form_content">
<fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
<legend class="icon icon-<%= @query.new_record? ? 'expanded icon-expended' : 'collapsed' %>" onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
<div style="<%= @query.new_record? ? "" : "display: none;" %>">
<%= render :partial => 'queries/filters', :locals => {:query => @query} %>
</div>
</fieldset>
</div>
<p class="buttons">
<%= link_to_function l(:button_apply), '$("#query_form").submit()', :class => 'icon icon-checked' %>
<%= link_to l(:button_clear), { :set_filter => 1, :project_id => @project }, :class => 'icon icon-reload' %>
</p>
</div>
<% end %>
<% if @query.valid? %>
<%= render :partial => @query.name %>
<% else %>
<%= error_messages_for @query %>
<% end %>
<% content_for :sidebar do %>
<%= render :partial => 'issues/helpdesk_reports' %>
<% end %>
<script>
/* Hide report_date_period checkbox so that it couldn't be unchecked. */
$(document).ready(function() {
$("#cb_report_date_period").hide();
$("label[for=cb_report_date_period]").removeAttr("for");
});
</script>

View File

@ -0,0 +1,82 @@
<div id="customer_profile">
<div class="contextual">
<%= link_to l(:button_update),
{:controller => 'helpdesk_tickets',
:action => 'edit',
:issue_id => @issue},
:remote => true if User.current.allowed_to?(:edit_helpdesk_tickets, @project) %>
</div>
<h3><%= l(:label_helpdesk_contact) %></h3>
<% unless !(@show_form == "true") %>
<%= form_for @helpdesk_ticket, :url => {:controller => 'helpdesk_tickets',
:action => 'update',
:issue_id => @issue},
:html => {:id => 'ticket_data_form',
:method => :put} do |f| %>
<% unless @helpdesk_ticket.new_record? %>
<div class="contextual">
<%= link_to image_tag('link_break.png'),
{:controller => 'helpdesk_tickets', :action => 'destroy', :id => @issue},
:method => :delete,
:data => {:confirm => l(:text_are_you_sure)},
:title => l(:label_relation_delete) %>
</div>
<% end %>
<p class="contact_auto_complete"><%= label_tag :helpdesk_ticket_contact_id, l(:label_helpdesk_contact)%><br>
<%= helpdesk_select_customer_tag('helpdesk_ticket[from_address]', [["#{@helpdesk_ticket.customer.try(:name)} <#{@helpdesk_ticket.from_address}>", @helpdesk_ticket.from_address || @helpdesk_ticket.customer.try(:id)]], selected: @helpdesk_ticket.from_address || @helpdesk_ticket.customer.try(:id), include_blank: false, multiaddress: true, add_contact: true, style: 'width: 80%;', width: '85%') %>
</p>
<p><%= label_tag :helpdesk_ticket_source, l(:label_helpdesk_ticket_source)%><br>
<%= f.select :source, helpdesk_tickets_source_for_select %></p>
<p><%= f.text_field :ticket_date, :size => 12, :required => true, :value => @helpdesk_ticket.ticket_date.to_date, :label => l(:label_helpdesk_ticket_date) %> <%= f.text_field :ticket_time, :value => @helpdesk_ticket.ticket_time, :size => 5 %><%= calendar_for('helpdesk_ticket_ticket_date') %> </p>
<p>
<%= label_tag :helpdesk_ticket_cc_address, l(:label_helpdesk_cc_address) %><br>
<% @cc_address = @helpdesk_ticket.cc_address.try(:split, ',') || [] %>
<%= f.select :cc_address, options_for_select(@cc_address.map { |email|[email, email] }, @cc_address), {}, {:multiple => true } %>
<br>
</p>
<%= submit_tag l(:button_update) %>
<%= link_to l(:button_cancel), {}, :onclick => "$('#ticket_data_form').hide(); return false;" %>
<% end %>
<% end %>
<span class="small-card">
<%= render :partial => 'contacts/contact_card', :object => @issue.customer if @issue.customer %>
</span>
</div>
<% if @issue.customer && (customer_issues = @issue.customer.all_tickets.preload(:status, :tracker, :helpdesk_ticket).visible.order_by_status.to_a).count - 1 > 0 %>
<div id="customer_previous_issues">
<div class="contextual">
<%= link_to l(:label_helpdesk_all) + " (#{customer_issues.count})", {:controller => 'issues',
:action => 'index',
:set_filter => 1,
:f => [:customer, :status_id],
:v => {:customer => [@issue.customer.id]},
:op => {:customer => '=', :status_id => '*'}} %>
</div>
<h3><%= l(:label_helpdesk_contact_activity) %> </h3>
<ul>
<% (customer_issues.first(5)).each do |issue| %>
<li title="<%= "#{issue.tracker} (#{issue.status})" if issue.tracker && issue.status %>" >
<span class="icon <%= helpdesk_ticket_source_icon(issue.helpdesk_ticket) %>"></span>
<span class="ticket-title <%= 'selected' if @issue == issue %>">
<%= link_to_issue(issue, :truncate => 60, :project => (@project != issue.project), :tracker => false) %>
</span>
<span class="ticket-meta">
<%= format_time(issue.created_on) %>
<%= "- #{issue.assigned_to.name}" if issue.assigned_to %>
</span>
</li>
<% end %>
</ul>
</div>
<% end %>

View File

@ -0,0 +1,121 @@
<script type="text/javascript" charset="utf-8">
function updateCannedResposeFrom(url, value) {
$.ajax({
url: url,
type: 'post',
data: {id: value}
});
}
</script>
<h2><%= l(:label_helpdesk_send_reply) %></h2>
<div class="box" id="duplicates">
<ul id="bulk-selection">
<% @issues.each do |issue| %>
<% if issue.is_ticket? %>
<%= content_tag 'li' do %>
<%= link_to_issue(issue) %>
- <%= content_tag('span', l(:label_send_to), class: "icon icon-email-to") %>
<span id="journal_contacts" class="email-template">
<span id="customer_to_email" class="email-template">
<%= render partial: "issues/customer_to_email",
:locals => {
contact: Contact.where(email: issue.helpdesk_ticket.default_to_address).first || issue.customer,
contact_email: issue.helpdesk_ticket.default_to_address
}
%>
</span>
</span>
<% end %>
<% else %>
<%= content_tag 'li', link_to_issue(issue) + " - To: empty" %>
<% end %>
<% end %>
</ul>
</div>
<%= form_for(:email_message,
:url => { :controller => 'helpdesk_tickets', :action => 'bulk_update_reply' },
:html => { :multipart => true, :id => 'message-form' }) do %>
<%= @issues.collect{ |issue| hidden_field_tag('ids[]', issue, {id: issue.id, value: issue.id }) }.join.html_safe %>
<%= hidden_field_tag 'helpdesk[is_send_mail]', 1 %>
<%= hidden_field_tag 'issue[custom_field_values]' %>
<%= hidden_field_tag 'journal_message[to_address][]' %>
<%= back_url_hidden_field_tag %>
<div class="box notes_with-send bulk_reply">
<div class="helpdesk-send-response-wrapper">
<div class="helpdesk-send-response">
<div class="helpdeks-ticket-line email-template" style="margin: auto"></div>
</div>
</div>
<div class="tabular">
<p>
<label><%= l(:field_mail_from) %></label>
<%= text_field_tag('from',
"#{HelpdeskMailSupport.apply_from_macro(HelpdeskSettings[:helpdesk_answer_from, @project], User.current)}",
style: "width: 98%;",
disabled: 'disabled') %>
<%= link_to "#{l(:label_crm_contacts_cc)}/#{l(:label_crm_contacts_bcc)}", '#' , :onclick => "$('#cc_fields').show();$(this).hide();" %>
</p>
<div id="cc_fields" style="display:none;">
<% contact_emails = [] %>
<% contact_emails << @issues.map { |issue| helpdesk_issue_contact_emails(issue) } %>
<p class="cc-list-edit reply-list-edit">
<%= label_tag l(:label_helpdesk_cc) %>
<%= helpdesk_select_customer_tag('journal_message[cc_address]',
[
[helpdesk_issue_customer_text_with_email(@issues.first.helpdesk_ticket),
@issues.first.helpdesk_ticket.try(:from_address) || @issues.first.helpdesk_ticket.try(:contact_id)]
],
include_blank: true,
multiaddress: true,
multiple: true) %>
</p>
<div style="clear: both;"></div>
<p class="bcc-list-edit reply-list-edit">
<%= label_tag l(:label_helpdesk_bcc) %>
<%= helpdesk_select_customer_tag('journal_message[bcc_address]',
contact_emails,
include_blank: true,
multiaddress: true,
multiple: true) %>
</p>
</div>
<p>
<label><%= l(:field_message) %></label>
<%= text_area_tag 'notes', '', :cols => 60, :rows => 10, :class => 'wiki-edit', id: 'issue_notes' %>
</p>
</div>
<%= wikitoolbar_for 'issue_notes', "preview_bulk_reply?ids[]=#{@contacts.first}" %>
</div>
<p>
<%= submit_tag l(:button_submit) %>
</p>
<% end %>
<div id="preview" class="wiki"></div>
<% content_for :header_tags do %>
<%= javascript_include_tag :redmine_helpdesk, :plugin => 'redmine_contacts_helpdesk' %>
<%= stylesheet_link_tag :helpdesk, :plugin => 'redmine_contacts_helpdesk' %>
<% canned_responses = CannedResponse.visible.in_project_or_public(@project).order("#{CannedResponse.table_name}.name") %>
<% if canned_responses.any? %>
<%= javascript_tag do %>
// Responses from only one issue
// but need from several
addCannedResponseButton(<%= { url: escape_javascript(add_canned_responses_path(project_id: @project, issue_id: @issues.first, format: 'js')),
responses: canned_responses.map { |cr| {id: cr.id, name: cr.name} } }.to_json.html_safe %>);
<% end %>
<% end %>
<% end %>

View File

@ -0,0 +1,3 @@
api.helpdesk_ticket do
render_api_helpdesk_ticket(@helpdesk_ticket, api)
end

View File

@ -0,0 +1 @@
<h2>HelpdeskTicketsController#destroy</h2>

View File

@ -0,0 +1,30 @@
$('#customer_profile_and_issues').html('<%= escape_javascript(render :partial => "helpdesk_tickets/customer_profile_and_issues") %>')
$('.subject_header .email').html('<%= mail_to @helpdesk_ticket.from_address %>')
$('#helpdesk_ticket_cc_address').select2({
ajax: {
url: '<%= auto_complete_contacts_path(:project_id => (ContactsSetting.cross_project_contacts? ? nil : @project), :is_company => nil) %>',
dataType: 'json',
delay: 250,
data: function (params) {
return { q: params.term };
},
processResults: function (data, params) {
return {
results: $.grep(data, function(elem) {
if (!elem.email) { return false }
elem.id = elem.email;
return elem;
})
};
},
cache: true
},
tags: true,
placeholder: ' ',
minimumInputLength: 1,
width: '100%',
templateResult: ccEmailTagResult,
templateSelection: ccEmailTagSelection,
}).on('select2:open', function (e) {
$('#helpdesk_ticket_cc_address').closest('p').find('.select2-search__field').val(' ').trigger($.Event('input', { which: 13 })).val('');
});

View File

@ -0,0 +1,7 @@
api.array :helpdesk_tickets, api_meta(total_count: @tickets_count, offset: @offset, limit: @limit) do
@tickets.each do |helpdesk_ticket|
api.ticket do
render_api_helpdesk_ticket(helpdesk_ticket, api)
end
end
end

View File

@ -0,0 +1,3 @@
api.helpdesk_ticket do
render_api_helpdesk_ticket(@helpdesk_ticket, api)
end

View File

@ -0,0 +1,2 @@
$('#customer_profile_and_issues').html('<%= escape_javascript(render :partial => "helpdesk_tickets/customer_profile_and_issues") %>')
$('.subject_header .email').html('<%= mail_to @helpdesk_ticket.from_address %>')

View File

@ -0,0 +1 @@
<h2>HelpdeskTicketsController#update</h2>

View File

@ -0,0 +1,28 @@
<div class="vote_form">
<h2><%= l(:label_helpdesk_mark) %></h2>
<%= form_tag helpdesk_votes_vote_path(:id => @ticket.id, :hash => @ticket.token) do %>
<p>
<%= label_tag :vote_2, nil, :class => 'vote-value' do %>
<%= radio_button_tag('vote', 2, @ticket.vote == 2 || @ticket.vote == nil ? true : false) %>
<span class="icon icon-awesome"><%= t(:label_helpdesk_mark_awesome) %></span>
<% end %>
<%= label_tag :vote_1, nil, :class => 'vote-value' do %>
<%= radio_button_tag('vote', 1, @ticket.vote == 1 ? true : false) %>
<span class="icon icon-justok"><%= t(:label_helpdesk_mark_justok) %></span>
<% end %>
<%= label_tag :vote_0, nil, :class => 'vote-value' do %>
<%= radio_button_tag('vote', 0, @ticket.vote == 0 ? true : false) %>
<span class="icon icon-notgood"><%= t(:label_helpdesk_mark_notgood) %></span>
<% end %>
</p>
<%- if RedmineHelpdesk.vote_comment_allow? %>
<%= text_area_tag('vote_comment', nil, { :size => '60x12', :placeholder => t(:label_helpdesk_vote_comment_placeholder) }) %>
<% end %>
<div class='submit'>
<%= submit_tag(t(:label_helpdesk_submit)) %>
</div>
<% end %>
</div>

View File

@ -0,0 +1,3 @@
<div class="vote_form">
<h2><%= t(:label_helpdesk_vote_thank) %></h2>
</div>

View File

@ -0,0 +1,14 @@
.loading {
border: 8px solid #f3f3f3;
border-top: 8px solid #7E8387;
border-radius: 50%;
width: 46px;
height: 46px;
animation: spin 2s linear infinite;
-webkit-animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -0,0 +1 @@
<%= avatar(@user, :size => 54, :id => 'avatar') %>

View File

@ -0,0 +1,262 @@
function getXmlHttp(){
var xmlhttp;
try {
xmlhttp = new ActiveXObject("Msxml2.XMLHTTP");
} catch (e) {
try {
xmlhttp = new ActiveXObject("Microsoft.XMLHTTP");
} catch (E) {
xmlhttp = false;
}
}
if (!xmlhttp && typeof XMLHttpRequest!='undefined') {
xmlhttp = new XMLHttpRequest();
}
return xmlhttp;
}
function serialize(form){
var boundary = String(Math.random()).slice(2);
var boundaryMiddle = '--' + boundary + '\r\n';
var boundaryLast = '--' + boundary + '--\r\n'
var cont_start = 'Content-Disposition: form-data; name="';
var cont_middle = '"\r\n\r\n';
var cont_end = '\r\n';
var field = '';
var body = ['\r\n'];
if (typeof form == 'object' && form.nodeName == "FORM") {
for (index = form.elements.length - 1; index >= 0; index--) {
field = form.elements[index];
if (field.type == 'select-multiple') {
for (option = form.elements[index].options.length - 1; option >= 0; option--) {
if (field.options[option].selected) { body.push(cont_start + field.name + cont_middle + field.options[option].value + cont_end); }
}
} else {
if (field.type != 'submit' && field.type != 'file' && field.type != 'button') {
if ((field.type != 'checkbox' && field.type != 'radio') || field.checked) {
body.push(cont_start + field.name + cont_middle + field.value + cont_end);
}
} else {
if (field.type == 'file'){
if (field.files.length > 0) {
body.push(cont_start + field.name + cont_middle + field.attributes['data-value'] + cont_end);
body.push(cont_start + field.name + '_name' + cont_middle + field.files[0].name + cont_end);
}
}
}
}
}
}
return [boundary, body.join(boundaryMiddle) + boundaryLast];
}
function translation(field){
return RedmineHelpdeskIframe.configuration['translation'] ? RedmineHelpdeskIframe.configuration['translation'][field] : null;
}
function ticketCreated(){
success_div = document.createElement('div');
success_div.id = 'submit_button';
success_div.className = 'success-message';
success_div.style.textAlign = 'center';
success_div.style.margin = '15%';
success_div.style.font = '20px Arial';
success_div.innerHTML = translation('createSuccessLabel') || '<%= t(:label_helpdesk_widget_ticket_created) %>';
success_desc_div = document.createElement('div');
success_desc_div.style.textAlign = 'center';
success_desc_div.style.margin = '5%';
success_desc_div.style.font = '14px Arial';
success_desc_div.innerHTML = translation('createSuccessDescription');
document.getElementById('widget_form').innerHTML = '';
document.getElementById('widget_form').appendChild(success_div);
document.getElementById('widget_form').appendChild(success_desc_div);
}
function ticketErrors(errors){
errors_div = document.createElement('div');
errors_div.id = 'ticket-error-details';
errors_div.className = 'ticket-error-details';
error_p = document.createElement('div');
error_p.innerHTML = translation('createErrorLabel') || '<%= t(:label_helpdesk_widget_ticket_errors) %>';
errors_div.appendChild(error_p);
errors_link = document.createElement('a');
errors_link.id = 'ticket-errors-link';
errors_link.href = 'javascript:void(0)';
errors_link.style.paddingLeft = '10px';
errors_link.addEventListener('click', toggleErrorsList);
errors_link.innerHTML = '<%= t(:label_helpdesk_widget_ticket_error_details) %>';
error_p.appendChild(errors_link);
ul = document.createElement('ul');
ul.id = 'ticket-errors';
ul.className = 'ticket-errors';
ul.style.display = 'none';
errors_div.appendChild(ul);
for (var key in errors) {
if (key != 'base') {
processErrorForField(ul, key, errors[key])
} else {
errors[key].forEach(function(error_text) {
processErrorForCustomField(ul, key, error_text);
});
}
}
document.getElementById('flash').appendChild(errors_div);
}
function createErrorLi(target, text){
li = document.createElement('li');
li.id = 'ticket-error';
li.className = 'ticket-error';
li.innerHTML = text;
target.appendChild(li);
}
function markFieldAsError(element){
element.style.border = '';
element.classList.add('error_field');
element.addEventListener('keyup', checkFieldContent);
}
function markRequireFieldsAsError(){
fields = document.querySelectorAll("[data-require='true'] > input, [data-require='true'] > select, [data-require='true'] > textarea, .required-field");
required_fields = Array.from(fields);
var respose = false;
required_fields.forEach(function(field) {
if (field.value.length == 0) {
markFieldAsError(field);
respose = true;
}
});
return respose;
}
function unmarkFieldsAsError(){
error_fields = Array.from(document.getElementsByClassName('error_field'));
error_fields.forEach(function(field) {
field.classList.remove('error_field');
field.removeEventListener('keyup', checkFieldContent);
});
}
function checkFieldContent(){
if (this.value.length > 0) {
this.style.border = '1px solid #d9d9d9';
} else {
this.style.border = '1px solid red';
}
}
function processErrorForField(ul, key, error_text) {
createErrorLi(ul, error_text);
field = document.getElementById(key);
if (field != null) { markFieldAsError(field); }
}
function processErrorForCustomField(ul, key, error_text) {
checkCustomFieldsOnError(error_text);
createErrorLi(ul, error_text);
}
function checkCustomFieldsOnError(error_text){
custom_fields = Array.from(document.getElementsByClassName('custom_field'));
custom_fields.forEach(function(cfield) {
cfield_regex = new RegExp(cfield.attributes['data-error-key'].value);
if (cfield_regex.test(error_text)){
markFieldAsError(cfield.getElementsByTagName('input')[0]);
}
});
}
function toggleErrorsList(event){
event.preventDefault();
errors_list = document.getElementById('ticket-errors');
if (errors_list == null) { return true }
if (errors_list && errors_list.style.display == 'block') {
errors_list.style.display = 'none';
} else {
errors_list.style.display = 'block';
}
}
function processResponse(response){
if (response['result']) {
ticketCreated();
setTimeout(function() {
parent.postMessage(JSON.stringify({ reload: true }), "*");
}, 3000);
} else {
ticketErrors(response['errors']);
}
var formSubmitBtn = document.getElementById('form-submit-btn');
if (formSubmitBtn){
formSubmitBtn.disabled = false;
}
}
function needReloadProjectData(){
parent.postMessage(JSON.stringify({ project_reload: true }), "*");
}
function arrangeIframe(){
parent.postMessage(JSON.stringify({ arrange: true }), "*");
}
function closeTicketForm(){
parent.postMessage(JSON.stringify({ close: true }), "*");
}
function submitTicketForm(event){
event.preventDefault();
document.getElementById('flash').innerHTML = '';
unmarkFieldsAsError();
if (markRequireFieldsAsError()){ return false; }
var base_url = '<%= Setting.protocol %>://<%= Setting.host_name %>'
var xmlhttp = getXmlHttp();
var serialize_result = serialize(document.getElementById('widget_form'));
var boundary = serialize_result[0];
var form_params = serialize_result[1];
document.getElementById('form-submit-btn').disabled = true;
xmlhttp.open('POST', base_url + '/helpdesk_widget/create_ticket.js', true);
xmlhttp.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + boundary);
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == 4) {
if (xmlhttp.status == 200 || xmlhttp.status == 304) {
processResponse(JSON.parse(xmlhttp.responseText));
} else {
processResponse({result: false, errors: []});
}
}
};
xmlhttp.send(form_params);
}
function createHandlerListeners() {
document.getElementById("widget_form").addEventListener("submit", submitTicketForm);
Array.prototype.forEach.call(document.getElementsByClassName("reloadProjectField"), function(elm) {
elm.addEventListener("change", needReloadProjectData)
})
}
function createMessageListeners() {
var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
var eventer = window[eventMethod];
var messageEvent = eventMethod == "attachEvent" ? "onmessage" : "message";
eventer(messageEvent,function(e) {
data = JSON.parse(e.data);
if (data['action'] == 'configuration') { RedmineHelpdeskIframe.configuration = JSON.parse(data['configuration']); }
}, false);
}
var RedmineHelpdeskIframe = { configuration: {}}
createHandlerListeners();
createMessageListeners();
arrangeIframe();

View File

@ -0,0 +1,6 @@
<% @issue.editable_custom_field_values.each do |value| %>
<% if @enabled_cf && @enabled_cf.include?(value.custom_field_id.to_s) %>
<% required = value.custom_field.is_required? %>
<p class="custom_field" data-error-key="<%= value.custom_field.name %>" data-require="<%= required %>"><%= custom_field_tag_with_label :issue, value, :required => required %></p>
<% end %>
<% end %>

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