Initial Redmine tooling and local plugin forks

This commit is contained in:
Jason Thistlethwaite
2026-04-24 22:01:18 +00:00
commit 9f682af0eb
683 changed files with 56878 additions and 0 deletions
@@ -0,0 +1,90 @@
pipeline:
tests:
image: redmineup/redmine_contacts_helpdesk
commands:
- service postgresql start && service mysql start && sleep 5
- export PATH=~/.rbenv/shims:$PATH
- export CODEPATH=`pwd`
- /root/run_for.sh redmine_contacts_helpdesk ${RUBY_VER} ${DB} ${REDMINE} redmine_contacts+${LICENSE}
matrix:
include:
- RUBY_VER: ruby-2.2.6
LICENSE: pro
DB: mysql
REDMINE: redmine-3.3
- RUBY_VER: ruby-2.2.6
LICENSE: light
DB: mysql
REDMINE: redmine-3.3
- RUBY_VER: ruby-2.2.6
LICENSE: pro
DB: pg
REDMINE: redmine-3.3
- RUBY_VER: ruby-2.2.6
LICENSE: light
DB: pg
REDMINE: redmine-3.3
- RUBY_VER: ruby-2.2.6
LICENSE: pro
DB: mysql
REDMINE: redmine-3.0
- RUBY_VER: ruby-1.8.7
DB: pg
LICENSE: pro
REDMINE: redmine-2.3
- RUBY_VER: ruby-1.8.7
DB: pg
LICENSE: pro
REDMINE: redmine-2.6
- RUBY_VER: ruby-1.8.7
DB: mysql
LICENSE: pro
REDMINE: redmine-2.3
- RUBY_VER: ruby-1.8.7
DB: mysql
LICENSE: pro
REDMINE: redmine-2.6
- RUBY_VER: ruby-1.9.3
DB: pg
LICENSE: pro
REDMINE: redmine-2.3
- RUBY_VER: ruby-1.9.3
DB: pg
LICENSE: pro
REDMINE: redmine-2.6
- RUBY_VER: ruby-1.9.3
DB: pg
LICENSE: pro
REDMINE: redmine-3.3
- RUBY_VER: ruby-1.9.3
DB: mysql
LICENSE: pro
REDMINE: redmine-3.3
- RUBY_VER: ruby-1.9.3
DB: pg
LICENSE: light
REDMINE: redmine-3.3
- RUBY_VER: ruby-1.8.7
DB: pg
LICENSE: light
REDMINE: redmine-2.3
- RUBY_VER: ruby-1.8.7
DB: pg
LICENSE: light
REDMINE: redmine-2.6
# - RUBY_VER: ruby-2.4.1
# DB: mysql
# LICENSE: pro
# REDMINE: redmine-trunk
# - RUBY_VER: ruby-2.4.1
# DB: mysql
# LICENSE: light
# REDMINE: redmine-trunk
# - RUBY_VER: ruby-2.4.1
# DB: pg
# LICENSE: pro
# REDMINE: redmine-trunk
# - RUBY_VER: ruby-2.2.6
# DB: mysql
# LICENSE: pro
# REDMINE: redmine-trunk
@@ -0,0 +1,26 @@
# Local Change Log
This RedmineUP helpdesk plugin is maintained as local legacy code for the
installed Redmine 3.4.4 environment. Keep entries focused on local behavior,
rollback archives, and LAN test status.
## 2026-04-21 - Helpdesk Search Read API And Outbox Coverage
- Purpose: make helpdesk ticket and message identity first-class for external
operational search and indexing.
- Rollback archives:
- `dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz`
- `dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz`
- Touched behavior:
- Added read-only JSON endpoints under `helpdesk_search/*`.
- Extended the existing `view_helpdesk_tickets` permission to cover those
endpoints.
- Added event outbox callbacks for `HelpdeskTicket` and `JournalMessage` in
the local `redmine_event_outbox` plugin.
- Payload policy:
- Includes ids, direction, message id, source, and non-body address metadata.
- Does not include full email bodies, private notes, attachment data, or BCC
addresses.
- LAN test result: pending. Validate by creating/updating a controlled helpdesk
ticket and journal message, then checking `event_outbox_events` and the
`helpdesk_search/*` JSON endpoints on the LAN Redmine copy.
@@ -0,0 +1,106 @@
class CannedResponsesController < ApplicationController
unloadable
before_filter :find_canned_response, :except => [:new, :create, :index]
before_filter :find_optional_project, :only => [:new, :create, :add, :destroy]
before_filter :find_issue, :only => [:add]
before_filter :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 = HelpdeskMailer.apply_macro(@canned_response.content, @issue.customer, @issue, User.current)
end
def new
@canned_response = CannedResponse.new
@canned_response.user = User.current
@canned_response.project = @project
@canned_response.is_public = false unless User.current.allowed_to?(:manage_public_canned_responses, @project) || User.current.admin?
end
def create
@canned_response = CannedResponse.new(params[:canned_response])
@canned_response.user = User.current
@canned_response.project = params[:canned_response_is_for_all] ? nil : @project
@canned_response.is_public = false unless User.current.allowed_to?(:manage_public_canned_responses, @project) || User.current.admin?
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.attributes = params[:canned_response]
@canned_response.project = nil if params[:canned_response_is_for_all]
@canned_response.is_public = false unless User.current.allowed_to?(:manage_public_canned_responses, @project) || User.current.admin?
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
@@ -0,0 +1,244 @@
class HelpdeskController < ApplicationController
unloadable
before_filter :find_project, :authorize, :except => [:email_note, :update_customer_email]
accept_api_auth :email_note, :create_ticket
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)
@issue.status_id = params[:message][:status_id] if params[:message][:status_id].blank? && IssueStatus.find_by_id(params[:message][:status_id])
@journal.notes = params[:message][:content]
@issue.save!
contact = @issue.customer
HelpdeskMailer.with_activated_perform_deliveries do
if HelpdeskMailer.issue_response(contact, @journal, params).deliver
@journal_message = JournalMessage.create(:from_address => "",
:to_address => contact.primary_email.downcase,
:is_incoming => false,
:message_date => Time.now,
:contact => contact,
:journal => @journal)
end
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.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
@contact ||= Contact.new(params[:ticket][:contact])
@contact.projects << @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
HelpdeskMailer.auto_answer(@contact, @issue).deliver if HelpdeskSettings["helpdesk_send_notification", @project].to_i > 0
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.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
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_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_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_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)
end
def set_settings_param(param)
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] 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
render :text => "Issue #{@issue.id} created", :status => :created, :location => issue_url(@issue)
end
end
end
@@ -0,0 +1,44 @@
class HelpdeskMailerController < ActionController::Base
unloadable
before_filter :check_credential
# Submits an incoming email to ContactsMailer
def index
options = params.dup
if options[:issue].present?
project = Project.find_by_identifier(options[:issue][:project])
options = HelpdeskMailer.get_issue_options(options, project.id) if project
end
email = options.delete(:email)
if HelpdeskMailer.receive(email, options)
render :nothing => true, :status => :created
else
render :nothing => true, :status => :unprocessable_entity
end
end
def get_mail
msg_count = 0
errors = []
Project.active.has_module(:contacts_helpdesk).each do |project|
begin
msg_count += HelpdeskMailer.check_project(project.id)
rescue Exception => e
errors << e.message
end
end
render :status => :ok, :text => {: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 :text => 'Access denied. Incoming emails WS is disabled or key is invalid.', :status => 403
end
end
end
@@ -0,0 +1,35 @@
class HelpdeskReportsController < ApplicationController
unloadable
menu_item :issues
helper :helpdesk
helper :queries
include QueriesHelper
before_filter :find_optional_project, :authorize_global
def show
retrieve_reports_query
@collector = HelpdeskDataCollectorManager.new(@report).collect_data(@query)
return render_404 unless @collector
respond_to do |format|
format.html
end
end
private
def retrieve_reports_query
@report = params[:report] || 'first_response_time'
report_query_class = @report == 'first_response_time' ? HelpdeskReportsFirstResponseQuery : HelpdeskReportsBusiestTimeQuery
if params[:set_filter] || session[:helpdesk_reports_query].nil? || session[:helpdesk_reports_query][:project_id] != (@project ? @project.id : nil)
@query = report_query_class.new(:name => '_', :project => @project)
params.merge!('f' => ['message_date'], 'op' => { 'message_date' => 'm' }) if params['f'].nil? || params['f'].all?(&:blank?)
@query.build_from_params(params)
@query[:filters] = { 'message_date' => { :operator => 'm', :values => [''] } } unless @query[:filters]
session[:helpdesk_reports_query] = { :project_id => @query.project_id, :filters => @query.filters || {} }
else
@query = report_query_class.new(:name => '_', :project => @project, :filters => session[:helpdesk_reports_query][:filters] || {})
end
end
end
@@ -0,0 +1,201 @@
class HelpdeskSearchController < ApplicationController
unloadable
accept_api_auth :ticket_by_issue, :issues_by_contact, :messages_by_issue, :contact_timeline
before_filter :require_login
def usage
# Human/browser probes often stop at /helpdesk_search/issues. Return a
# machine-readable usage response instead of letting Rails raise a route
# exception that looks like an application failure in production.log.
render :json => {
:helpdesk_search => {
:ticket_by_issue => '/helpdesk_search/issues/:issue_id/ticket',
:ticket_by_issue_alias => '/helpdesk_search/issues/:issue_id',
:issues_by_contact => '/helpdesk_search/contacts/:contact_id/issues',
:messages_by_issue => '/helpdesk_search/issues/:issue_id/messages',
:contact_timeline => '/helpdesk_search/contacts/:contact_id/timeline'
}
}, :status => :bad_request
end
def ticket_by_issue
issue = Issue.find(params[:issue_id])
return unless authorize_helpdesk_project!(issue.project)
ticket = HelpdeskTicket.where(:issue_id => issue.id).first
unless ticket
render_404
return
end
render :json => {:helpdesk_ticket => serialize_ticket(ticket)}
rescue ActiveRecord::RecordNotFound
render_404
end
def issues_by_contact
contact = Contact.find(params[:contact_id])
tickets = HelpdeskTicket.
includes(:issue => [:project, :status, :tracker, :assigned_to]).
where(:contact_id => contact.id).
order("#{HelpdeskTicket.table_name}.ticket_date DESC").
limit(api_limit)
render :json => {
:contact_id => contact.id,
:issues => tickets.map { |ticket| serialize_ticket_issue(ticket) }.compact
}
rescue ActiveRecord::RecordNotFound
render_404
end
def messages_by_issue
issue = Issue.find(params[:issue_id])
return unless authorize_helpdesk_project!(issue.project)
messages = JournalMessage.
includes(:contact, :journal).
joins(:journal).
where(:journals => {:journalized_type => 'Issue', :journalized_id => issue.id}).
order("#{JournalMessage.table_name}.message_date ASC").
limit(api_limit)
render :json => {
:issue_id => issue.id,
:journal_messages => messages.map { |message| serialize_journal_message(message) }
}
rescue ActiveRecord::RecordNotFound
render_404
end
def contact_timeline
contact = Contact.find(params[:contact_id])
tickets = HelpdeskTicket.
includes(:issue => [:project, :status, :tracker]).
where(:contact_id => contact.id).
order("#{HelpdeskTicket.table_name}.ticket_date DESC").
limit(api_limit)
messages = JournalMessage.
includes(:contact, :journal).
where(:contact_id => contact.id).
order("#{JournalMessage.table_name}.message_date DESC").
limit(api_limit)
events = []
tickets.each do |ticket|
next unless visible_issue?(ticket.issue)
events << serialize_ticket(ticket).merge(:type => 'helpdesk_ticket', :date => iso8601(ticket.ticket_date))
end
messages.each do |message|
issue = message_issue(message)
next unless visible_issue?(issue)
events << serialize_journal_message(message).merge(:type => 'journal_message', :date => iso8601(message.message_date))
end
events.sort_by! { |event| event[:date].to_s }
events.reverse!
render :json => {
:contact_id => contact.id,
:timeline => events.first(api_limit)
}
rescue ActiveRecord::RecordNotFound
render_404
end
private
def authorize_helpdesk_project!(project)
# Search endpoints expose customer/email context, so they reuse the native
# per-project helpdesk visibility permission instead of adding a new role.
return true if project && User.current.allowed_to?(:view_helpdesk_tickets, project)
deny_access
false
end
def visible_issue?(issue)
issue && issue.project && User.current.allowed_to?(:view_helpdesk_tickets, issue.project)
end
def api_limit
requested_limit = params[:limit].present? ? params[:limit].to_i : 100
[[requested_limit, 1].max, 200].min
end
def serialize_ticket_issue(ticket)
issue = ticket.issue
return nil unless visible_issue?(issue)
serialize_ticket(ticket).merge(
:issue => {
:id => issue.id,
:project_id => issue.project_id,
:tracker_id => issue.tracker_id,
:tracker_name => issue.tracker.try(:name),
:status_id => issue.status_id,
:status_name => issue.status.try(:name),
:assigned_to_id => issue.assigned_to_id,
:assigned_to_name => issue.assigned_to.try(:name),
:subject => issue.subject,
:created_on => iso8601(issue.created_on),
:updated_on => iso8601(issue.updated_on)
}
)
end
def serialize_ticket(ticket)
{
:id => ticket.id,
:issue_id => ticket.issue_id,
:contact_id => ticket.contact_id,
:message_id => ticket.message_id,
:source => ticket.source,
:is_incoming => ticket.is_incoming?,
:from_address => ticket.from_address,
:to_address => ticket.to_address,
:cc_address => ticket.cc_address,
:ticket_date => iso8601(ticket.ticket_date)
}
end
def serialize_journal_message(message)
journal = message.journal
issue = message_issue(message)
# Keep this API metadata-only. The external indexer can fetch journal.notes
# with its own read policy; this endpoint should not leak message bodies,
# private notes, attachments, or BCC addresses by accident.
{
:id => message.id,
:journal_id => message.journal_id,
:issue_id => issue.try(:id),
:project_id => issue.try(:project_id),
:contact_id => message.contact_id,
:message_id => message.message_id,
:source => message.source,
:is_incoming => message.is_incoming?,
:from_address => message.from_address,
:to_address => message.to_address,
:cc_address => message.cc_address,
:has_bcc_address => message.bcc_address.present?,
:message_date => iso8601(message.message_date),
:journal_user_id => journal.try(:user_id),
:journal_private_notes => journal.try(:private_notes?),
:journal_has_notes => journal.try(:notes).present?
}
end
def message_issue(message)
journal = message.journal
return nil unless journal
journal.try(:issue) || journal.try(:journalized)
end
def iso8601(value)
value.try(:utc).try(:iso8601)
end
end
@@ -0,0 +1,67 @@
class HelpdeskTicketsController < ApplicationController
unloadable
before_filter :find_issue, :except => :destroy
before_filter :find_helpdesk_ticket, :only => :destroy
before_filter :authorize
helper :helpdesk
def edit
@show_form = "true"
respond_to do |format|
format.js
end
end
def update
@helpdesk_ticket.attributes = params[:helpdesk_ticket]
@helpdesk_ticket.cc_address = params[:helpdesk_ticket][:cc_address].reject(&:empty?).join(',') if params[:helpdesk_ticket][:cc_address]
@helpdesk_ticket.issue = @issue
@helpdesk_ticket.from_address = @helpdesk_ticket.customer.primary_email if @helpdesk_ticket.customer
if @helpdesk_ticket.save
flash[:notice] = l(:notice_successful_update)
respond_to do |format|
format.html { redirect_back_or_default({:controller => 'issues', :action => 'show', :id => @issue}) }
format.api { render_api_ok }
end
else
flash[:error] = @helpdesk_ticket.errors.full_messages.flatten.join("\n")
respond_to do |format|
format.html { redirect_back_or_default({:controller => 'issues', :action => 'show', :id => @issue}) }
format.api { render_validation_errors(@helpdesk_ticket) }
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({:controller => 'issues', :action => 'show', :id => @issue}) }
format.api { render_api_ok }
end
else
flash[:error] = l(:notice_unsuccessful_save)
end
end
private
def find_helpdesk_ticket
@helpdesk_ticket = HelpdeskTicket.find(params[:id])
@issue = @helpdesk_ticket.issue
@project = @issue.project if @issue
rescue ActiveRecord::RecordNotFound
render_404
end
def find_issue
@issue = Issue.find(params[:issue_id])
@project = @issue.project
@helpdesk_ticket = @issue.helpdesk_ticket || HelpdeskTicket.new(:ticket_date => Time.now, :issue => @issue)
rescue ActiveRecord::RecordNotFound
render_404
end
end
@@ -0,0 +1,46 @@
class HelpdeskVotesController < ApplicationController
unloadable
layout 'public_tickets'
skip_before_filter :check_if_login_required
before_filter :find_ticket, :authorize_ticket
before_filter :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
@@ -0,0 +1,206 @@
class HelpdeskWidgetController < ApplicationController
unloadable
layout false
helper :custom_fields
protect_from_forgery :except => [:widget, :load_form, :load_custom_fields, :avatar, :create_ticket, :iframe]
skip_before_filter :check_if_login_required, :only => [:widget, :load_form, :load_custom_fields, :avatar, :create_ticket, :iframe]
before_filter :prepare_data, :only => [:load_custom_fields, :create_ticket]
after_filter :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
open("http://www.gravatar.com/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 valid_email? && @issue.save
save_attachment(@issue)
HelpdeskMailer.auto_answer(@issue.helpdesk_ticket.customer, @issue).deliver if HelpdeskSettings["helpdesk_send_notification", @project].to_i > 0
{ :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[: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[:subject] = errors
end
# Description
if params[:issue][:description].empty?
@issue.errors[: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
author = redmine_user.present? && redmine_user.allowed_to?(:edit_helpdesk_tickets, @project) ? redmine_user : User.anonymous
issue = @project.issues.build(:tracker => @tracker, :author => author)
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[:email] = 'Email cannot be empty'
return false
elsif params[:email].match(/\A([\w\.\+\-]+)@([\w\-]+\.)+([\w]{2,})\z/i).nil?
@issue.errors[: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
end
@@ -0,0 +1,89 @@
class MailFetcherController < ApplicationController
unloadable
require 'timeout'
before_filter :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 { 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.api', :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.api', :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
@@ -0,0 +1,81 @@
class PublicTicketsController < ApplicationController
unloadable
layout 'public_tickets'
skip_before_filter :check_if_login_required
before_filter :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.journals.new(params[:journal])
@issue.status_id = HelpdeskSettings["helpdesk_reopen_status", @issue.project_id] unless HelpdeskSettings["helpdesk_reopen_status", @issue.project_id].blank?
@journal.user = User.current
@journal.journal_message = JournalMessage.new(:from_address => @ticket.customer_email,
:contact => @ticket.customer,
:journal => @journal,
:is_incoming => true,
:message_date => Time.now)
if @issue.save
flash[:notice] = l(:notice_successful_create)
@journal.save
end
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
return 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
@@ -0,0 +1,83 @@
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 => 'showWithSendAndScrollTo("update", "issue_notes"); return false;', :class => 'icon icon-reply'
end
end
@@ -0,0 +1,13 @@
# encoding: utf-8
# include RedCloth
module HelpdeskMailerHelper
def textile(text)
Redmine::WikiFormatting.to_html(Setting.text_formatting, text)
end
def message_sender(email)
sender = email.reply_to.try(:first) || email.from_addrs.try(:first)
sender.to_s.strip
end
end
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,28 @@
class CannedResponse < ActiveRecord::Base
unloadable
attr_accessible :name, :content, :is_public
belongs_to :project
belongs_to :user
validates_presence_of :name, :content
validates_length_of :name, :maximum => 255
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)) && (self.is_public? || self.user_id == user.id)
end
end
@@ -0,0 +1,148 @@
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['message_date'][: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['message_date'][: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['message_date'][: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['message_date'][: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]['message_date'].nil? || @query[:filters]['message_date'][:operator].nil?
return @previous_query if @previous_query
previous_operator = ['pre_', @query[:filters]['message_date'][:operator]].join
previous_filters = @query[:filters].merge('message_date' => { :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['message_date'][: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['message_date'][: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['message_date'][: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
@@ -0,0 +1,178 @@
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').uniq
@previous_issues = previous_query.issues.joins(:helpdesk_ticket).where('helpdesk_tickets.first_response_time > 0').uniq
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]['message_date'].nil? || @query[:filters]['message_date'][:operator].nil?
return @previous_query if @previous_query
previous_operator = ['pre_', @query[:filters]['message_date'][:operator]].join
previous_filters = @query[:filters].merge('message_date' => { :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['message_date'][:operator], nil, 'journal_messages', 'message_date')
end
def median(array)
return 0 if array.compact.empty?
range = array.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
@@ -0,0 +1,14 @@
class HelpdeskDataCollectorManager
def initialize(report)
@report = report
end
def collect_data(query)
case @report
when 'first_response_time'
HelpdeskDataCollectorFirstResponse.new(query)
when 'busiest_time_of_day'
HelpdeskDataCollectorBusiestTime.new(query)
end
end
end
@@ -0,0 +1,823 @@
require "digest/md5"
class HelpdeskMailer < MailHandler
include HelpdeskMailerHelper
include AbstractController::Callbacks
after_filter :set_delivery_options
attr_reader :contact, :user, :email, :project
def self.default_url_options
{ :host => Setting.host_name, :protocol => Setting.protocol }
end
def self.with_activated_perform_deliveries
perform_delivery_state = ActionMailer::Base.perform_deliveries
ActionMailer::Base.perform_deliveries = true
yield
ensure
ActionMailer::Base.perform_deliveries = perform_delivery_state
end
def issue_response(contact, journal, options={})
@project = journal.issue.project
to_address = options[:to_address] || (journal.journal_message && journal.journal_message.to_address) || contact.primary_email
cc_address = options[:cc_address] || (journal.journal_message && journal.journal_message.cc_address)
bcc_address = options[:bcc_address] || (journal.journal_message && journal.journal_message.bcc_address)
from_address = options[:from_address] || (!HelpdeskSettings["helpdesk_answer_from", journal.issue.project].blank? && HelpdeskSettings["helpdesk_answer_from", journal.issue.project] )|| Setting.mail_from
in_reply_to = options[:in_reply_to] || ((journal.issue.helpdesk_ticket.blank? || journal.issue.helpdesk_ticket.message_id.blank?) ? '' : "<#{journal.issue.helpdesk_ticket.message_id}>")
headers['X-Redmine-Ticket-ID'] = journal.issue.id.to_s
@email_header = self.class.apply_macro(HelpdeskSettings["helpdesk_emails_header", journal.issue.project], contact, journal.issue, journal.user) unless HelpdeskSettings["helpdesk_emails_header", journal.issue.project].blank?
@email_footer = self.class.apply_macro(HelpdeskSettings["helpdesk_emails_footer", journal.issue.project], contact, journal.issue, journal.user) unless HelpdeskSettings["helpdesk_emails_footer", journal.issue.project].blank?
@email_body = self.class.apply_macro(journal.notes, contact, journal.issue, journal.user)
@email_body = attachment_macro(@email_body, journal.issue)
raise MissingInformation.new(l(:text_helpdesk_to_address_cant_be_blank)) if to_address.blank?
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?
subject_macro = self.class.apply_macro(HelpdeskSettings["helpdesk_answer_subject", journal.issue.project], contact, journal.issue)
# subject_macro += " - [##{journal.issue.id}]" if !subject_macro.blank? && !subject_macro.include?("##{journal.issue.id}]")
@email_stylesheet = HelpdeskSettings[:helpdesk_helpdesk_css, journal.issue.project].to_s.html_safe
extract_attachments(journal)
if journal.details.blank? && journal.private_notes? && journal.notes.present?
details_journal = Journal.where('id != ?', journal.id).where(:created_on => journal.created_on).first
extract_attachments(details_journal) if details_journal
end
mail :from => self.class.apply_from_macro(from_address.to_s, journal.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? ? journal.issue.subject + " [#{journal.issue.tracker} ##{journal.issue.id}]" : subject_macro) do |format|
format.text { render 'email_layout' }
format.html { render 'email_layout' } unless RedmineHelpdesk.settings["plain_text_mail"]
end
end
def extract_attachments(journal)
journal.details.where(:property => 'attachment').each do |attachment_journal|
if attach = Attachment.where(:id => attachment_journal.prop_key).first
attachments[attach.filename] = File.open(attach.diskfile, 'rb') { |io| io.read }
end
end
end
def auto_answer(contact, issue)
@project = issue.project
headers['X-Redmine-Ticket-ID'] = issue.id.to_s
headers['X-Auto-Response-Suppress'] = 'oof'
confirmation_body = self.class.apply_macro(HelpdeskSettings["helpdesk_first_answer_template", issue.project_id], contact, issue)
@email_stylesheet = HelpdeskSettings[:helpdesk_helpdesk_css, issue.project_id].to_s.html_safe
@email_body = confirmation_body
from_address = HelpdeskSettings["helpdesk_answer_from", issue.project].blank? ? Setting.mail_from : HelpdeskSettings["helpdesk_answer_from", issue.project]
mail :from => self.class.apply_from_macro(from_address.to_s, nil),
:to => contact.primary_email,
:cc => issue.helpdesk_ticket.try(:cc_address),
:subject => self.class.apply_macro(HelpdeskSettings["helpdesk_first_answer_subject", issue.project_id], contact, issue) || "Helpdesk auto answer [Case ##{issue.id}]",
:in_reply_to => issue.helpdesk_ticket.try(:message_id) do |format|
format.text { render 'email_layout'}
format.html { render 'email_layout' } unless RedmineHelpdesk.settings["plain_text_mail"]
end
logger.info "##{issue.id}: Sending confirmation to #{contact.primary_email}" if logger
end
def initial_message(contact, issue, params)
@project = issue.project
headers['X-Redmine-Ticket-ID'] = issue.id.to_s
@email_header = self.class.apply_macro(HelpdeskSettings["helpdesk_emails_header", issue.project], contact, issue, issue.author) unless HelpdeskSettings["helpdesk_emails_header", issue.project].blank?
@email_footer = self.class.apply_macro(HelpdeskSettings["helpdesk_emails_footer", issue.project], contact, issue, issue.author) unless HelpdeskSettings["helpdesk_emails_footer", issue.project].blank?
@email_body = self.class.apply_macro(issue.description, contact, issue, issue.author)
@email_body = attachment_macro(@email_body, issue)
raise MissingInformation.new("Contact #{contact.name} should have mail") if contact.email.blank?
raise MissingInformation.new("Message shouldn't be blank") if @email_body.blank?
@email_stylesheet = HelpdeskSettings[:helpdesk_helpdesk_css, issue.project].to_s.html_safe
params[:attachments].each_value do |mail_attachment|
if file = mail_attachment['file']
file.rewind if file
attachments[file.original_filename] = file.read
file.rewind if file
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 unless params[:attachments].blank?
to_address = (params[:helpdesk] && !params[:helpdesk][:to_address].blank?) ? params[:helpdesk][:to_address] : contact.primary_email
from_address = HelpdeskSettings["helpdesk_answer_from", issue.project].blank? ? Setting.mail_from : HelpdeskSettings["helpdesk_answer_from", issue.project]
logger.error "##{issue.id}: From address couldn't be black" if from_address.blank? && logger
mail :from => self.class.apply_from_macro(from_address.to_s, nil),
:to => to_address,
:subject => self.class.apply_macro(HelpdeskSettings["helpdesk_first_answer_subject", issue.project_id], contact, issue) || issue.subject do |format|
format.text { render 'email_layout' }
format.html { render 'email_layout' } unless RedmineHelpdesk.settings["plain_text_mail"]
end
end
# Receive email methods
def self.receive(raw_email, options={})
@@helpdesk_mailer_options = options.dup
raw_email.force_encoding('ASCII-8BIT') if raw_email.respond_to?(:force_encoding)
email = Mail.new(raw_email)
new.receive(email)
end
# Processes incoming emails
# Returns the created object (eg. an issue, a message) or false
def receive(email)
@email = email
if !target_project.module_enabled?(:contacts) || !target_project.module_enabled?(:issue_tracking)
logger.error "#{email && email.message_id}: Contacts and issues modules should be enable for #{target_project.name} project" if logger
return false
end
@@helpdesk_mailer_options = HelpdeskMailer.get_issue_options(@@helpdesk_mailer_options, target_project.id)
sender_email = message_sender(email)
# Ignore emails received from the application emission address to avoid hell cycles
if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
logger.info "#{email && email.message_id}: Ignoring email from Redmine emission address [#{sender_email}]" if logger
return false
end
return false unless handle_ignored(email)
if !check_blacklist?(email)
logger.info "#{email && email.message_id}: Email #{sender_email} ignored because in blacklist" if logger
return false
end
@user = User.find_by_mail(sender_email) || User.anonymous
@contact = contact_from_email(email)
User.current = @user
if @contact
logger.info "#{email && email.message_id}: [#{@contact.name}] contact created/founded" if logger
else
logger.error "#{email && email.message_id}: could not create/found contact for [#{sender_email}]" if logger
return false
end
dispatch
end
def self.check_project(project_id)
msg_count = 0
unless Project.find_by_id(project_id).blank? || HelpdeskSettings[:helpdesk_protocol, project_id].blank?
mail_options, options = self.get_mail_options(project_id)
case mail_options[:protocol]
when "pop3" then
msg_count = RedmineContacts::Mailer.check_pop3(self, mail_options, options)
when "imap" then
msg_count = RedmineContacts::Mailer.check_imap(self, mail_options, options)
end
end
if project = Project.find_by_id(project_id)
HelpdeskTicket.autoclose(project)
end
msg_count
end
def self.get_mail_options(project_id)
case HelpdeskSettings[:helpdesk_protocol, project_id]
when "gmail"
protocol = "imap"
host = "imap.gmail.com"
port = "993"
ssl = "1"
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"
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"
end
mail_options = {:protocol => protocol,
:host => host,
:port => port,
:ssl => ssl,
: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 > 0
}
options = get_issue_options({}, project_id)
[mail_options, options]
end
def self.get_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] unless options[:issue][:status_id]
options[:issue][:assigned_to_id] = HelpdeskSettings["helpdesk_assigned_to", project_id] unless options[:issue][:assigned_to_id]
options[:issue][:tracker_id] = HelpdeskSettings["helpdesk_tracker", project_id] unless options[:issue][:tracker_id]
options[:issue][:priority_id] = HelpdeskSettings[:helpdesk_issue_priority, project_id] unless options[:issue][:priority_id]
options[:issue][:due_date] = HelpdeskSettings[:helpdesk_issue_due_date, project_id] unless options[:issue][:due_date]
options[:issue][:reopen_status_id] = HelpdeskSettings["helpdesk_reopen_status", project_id] unless options[:issue][:reopen_status_id]
options
end
def attachment_macro(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
attachments[attachment.filename] = File.open(attachment.diskfile, 'rb'){|io| io.read} if attachment
end
text.gsub(/\{\{send_file\(([^%\}]+)\)\}\}/, '')
end
def self.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
def self.apply_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.blank? ? "" : contact.last_name)
text = text.gsub(/%%MIDDLE_NAME%%|\{%contact.middle_name%\}/, contact.middle_name.blank? ? "" : contact.middle_name)
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%\}/, Setting.protocol + '://' + Setting.host_name + Rails.application.routes.url_helpers.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%\}/, Setting.protocol + '://' + Setting.host_name + Rails.application.routes.url_helpers.helpdesk_votes_show_path(issue.helpdesk_ticket.id, issue.helpdesk_ticket.token) ) if text.match(/\{%ticket.voting%\}/)
text = text.gsub(/\{%ticket.voting.good%\}/, Setting.protocol + '://' + Setting.host_name + Rails.application.routes.url_helpers.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%\}/, Setting.protocol + '://' + Setting.host_name + Rails.application.routes.url_helpers.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%\}/, Setting.protocol + '://' + Setting.host_name + Rails.application.routes.url_helpers.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 = ''
issue.journals.eager_load(:journal_message).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
private
def dispatch
m = email.subject && email.subject.match(ISSUE_REPLY_SUBJECT_RE)
journal_message = !email.in_reply_to.blank? && JournalMessage.find_by_message_id(email.in_reply_to)
helpdesk_ticket = !email.in_reply_to.blank? && HelpdeskTicket.find_by_message_id(email.in_reply_to)
if journal_message && journal_message.journal && journal_message.journal.issue
receive_issue_reply(journal_message.journal.issue.id)
elsif helpdesk_ticket && helpdesk_ticket.issue
receive_issue_reply(helpdesk_ticket.issue.id)
elsif m && Issue.exists?(m[1].to_i)
receive_issue_reply(m[1].to_i)
else
dispatch_to_default
end
rescue MissingInformation => e
logger.error "#{email && email.message_id}: missing information from #{user}: #{e.message}" if logger
false
rescue UnauthorizedAction => e
logger.error "#{email && email.message_id}: unauthorized attempt from #{user}" if logger
false
rescue Exception => e
# TODO: send a email to the user
logger.error "#{email && email.message_id}: dispatch error #{e.message}" if logger
false
end
def dispatch_to_default
receive_issue
end
def target_project
@target_project ||= Project.find_by_identifier(get_keyword(:project) || get_keyword(:project_id))
@target_project ||= Project.find_by_id(get_keyword(:project_id)) if @target_project.nil?
raise MissingInformation.new('Unable to determine @target_project project') if @target_project.nil?
@target_project
end
def helpdesk_issue_attributes_from_keywords(issue)
# assigned_to = ((k = get_keyword(:assigned_to_id, :override => true)) && User.find_by_id(k)) || ((k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k))
assigned_to = ((k = get_keyword(:assigned_to_id, :override => true)) && (User.find_by_id(k) || Group.find_by_id(k))) || ((k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k))
attrs = {
'status_id' => ((k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id) ) || ((k = get_keyword(:status_id)) && IssueStatus.find_by_id(k).try(:id)),
'priority_id' => ((k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id)) || ((k = get_keyword(:priority_id)) && IssuePriority.find_by_id(k).try(:id)),
'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
'assigned_to_id' => assigned_to.try(:id),
'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.named(k).first.try(:id),
'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
'estimated_hours' => get_keyword(:estimated_hours, :override => true),
'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
}.delete_if {|k, v| v.blank? }
attrs
end
def calculated_tracker_id(issue)
issue_tracker_id = ((k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id)) ||
((k = get_keyword(:tracker_id)) && issue.project.trackers.find_by_id(k).try(:id))
issue_tracker_id = issue.project.trackers.first.try(:id) unless issue_tracker_id
issue_tracker_id
end
# Creates a new issue
def receive_issue
project = target_project
issue = Issue.new
issue.author = user
issue.project = project
issue.safe_attributes = helpdesk_issue_attributes_from_keywords(issue)
issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
issue.tracker_id = calculated_tracker_id(issue)
issue.subject = cleaned_up_subject(email)
issue.subject = '(no subject)' if issue.subject.blank?
issue.description = escaped_cleaned_up_text_body
issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date?
helpdesk_ticket = HelpdeskTicket.new(:from_address => message_sender(email).downcase.to_s.slice(0, 255),
:to_address => email.to_addrs.join(',').downcase.to_s.slice(0, 255),
:cc_address => email.cc_addrs.join(',').downcase.to_s.slice(0, 255),
: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
issue.contacts << cc_contacts if HelpdeskSettings["helpdesk_save_cc", target_project.id].to_i > 0
issue.assigned_to = @contact.find_assigned_user(project, issue.assigned_to_id)
save_email_as_attachment(helpdesk_ticket)
add_attachments(issue)
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.save!(:validate => false)
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) if HelpdeskSettings["helpdesk_add_contact_notes", project]
begin
notification = HelpdeskMailer.auto_answer(contact, issue).deliver if HelpdeskSettings["helpdesk_send_notification", project].to_i > 0
logger.info "#{email && email.message_id}: notification was sent to #{notification.to_addrs.first}" if logger && notification
rescue Exception => e
logger.error "#{email && email.message_id}: notification was not sent #{e.message}" if logger
false
end
logger.info "#{email && email.message_id}: issue ##{issue.id} created by #{user} for #{contact.name}" if logger
issue
end #transaction
end
# Adds a note to an existing issue
def receive_issue_reply(issue_id)
issue = Issue.find_by_id(issue_id)
return unless issue
# if lifetime expaired create new issue
if (HelpdeskSettings["helpdesk_lifetime", target_project].to_i > 0) && issue.journals && issue.journals.last && ((Date.today) - issue.journals.last.created_on.to_date > HelpdeskSettings["helpdesk_lifetime", target_project].to_i)
email.subject = email.subject.to_s.gsub(ISSUE_REPLY_SUBJECT_RE, '')
return receive_issue
end
journal = issue.init_journal(user)
journal.notes = escaped_cleaned_up_text_body
journal_message = JournalMessage.create(:from_address => message_sender(email).downcase,
:to_address => email.to_addrs.join(',').downcase,
:bcc_address => email.bcc_addrs.join(',').downcase,
:cc_address => email.cc_addrs.join(',').downcase,
:message_id => email.message_id,
:is_incoming => true,
:message_date => email.date || Time.now,
:contact => contact,
:journal => journal)
issue.contacts << cc_contacts if HelpdeskSettings["helpdesk_save_cc", target_project.id].to_i > 0
add_attachments(issue)
save_email_as_attachment(journal_message, "reply-#{DateTime.now.strftime('%d.%m.%y-%H.%M.%S')}.eml")
if reopen_status_id = ((k = @@helpdesk_mailer_options[:reopen_status]) && IssueStatus.named(k).first.try(:id) ) || ((k = get_keyword(:reopen_status_id)) && IssueStatus.find_by_id(k).try(:id))
issue.status_id = reopen_status_id
end
issue.save!
logger.info "#{email && email.message_id}: issue ##{issue.id} updated by #{user}" if logger
journal
end
# Reply will be added to the issue
def receive_journal_reply(journal_id)
journal = Journal.find_by_id(journal_id)
if journal && journal.journalized_type == 'Issue'
receive_issue_reply(journal.journalized_id)
end
end
def add_attachments(obj)
fwd_attachments = email.parts.map { |p|
if p.content_type =~ /message\/rfc822/
Mail.new(p.body).attachments
elsif p.parts.empty?
p if p.attachment?
else
p.attachments
end
}.flatten.compact
email_attachments = fwd_attachments | email.attachments
unless email_attachments.blank?
email_attachments.each do |attachment|
if RUBY_VERSION < '1.9'
attachment_filename = (attachment[:content_type].filename rescue nil) ||
(attachment[:content_disposition].filename rescue nil) ||
(attachment[:content_location].location rescue nil) ||
"attachment"
attachment_filename = Mail::Encodings.unquote_and_convert_to(attachment_filename, 'utf-8') rescue 'unprocessable_filename'
attachment_filename = helpdesk_to_utf8(attachment_filename)
else
attachment_filename = helpdesk_to_utf8(attachment.filename, 'binary')
end
new_attachment = Attachment.new(:container => obj,
:file => (attachment.decoded rescue nil) || (attachment.decode_body rescue nil) || attachment.raw_source,
:filename => attachment_filename,
:author => user,
:content_type => attachment.mime_type)
if obj.attachments.where(:digest => attachment_digest(attachment.body.to_s)).empty? && accept_attachment?(new_attachment)
obj.attachments << new_attachment
logger.info "#{email && email.message_id}: attachment #{attachment_filename} added to ticket: '#{obj.subject}'" if logger
end
end
end
end
def get_keyword(attr, options = {})
@keywords ||= {}
if !@keywords.has_key?(attr)
if (options[:override] || attr_overridable?(attr)) &&
v = extract_keyword!(escaped_cleaned_up_text_body, attr, options[:format])
@keywords[attr] = v
elsif !@@helpdesk_mailer_options[:issue][attr].blank?
@keywords[attr] = @@helpdesk_mailer_options[:issue][attr]
end
end
@keywords[attr]
end
def attr_overridable?(attr)
@@helpdesk_mailer_options[:allow_override].present? &&
@@helpdesk_mailer_options[:allow_override].include?(attr.to_s)
end
def find_user_from_keyword(keyword)
user ||= User.find_by_mail(keyword)
user ||= User.find_by_login(keyword)
if user.nil? && keyword.match(/ /)
firstname, lastname = *(keyword.split) # "First Last Throwaway"
user ||= User.find_by_firstname_and_lastname(firstname, lastname)
end
user
end
def check_blacklist?(email)
return true if HelpdeskSettings["helpdesk_blacklist", target_project].blank?
addr = email.from_addrs.first.to_s.strip
from_addr = addr # (addr && !addr.spec.blank?) ? addr.spec : email.header["from"].inspect.match(/[-A-z0-9.]+@[-A-z0-9.]+/).to_s
cond = "(" + HelpdeskSettings["helpdesk_blacklist", target_project].split("\n").map{|u| u.strip unless u.blank?}.compact.join('|') + ")"
!from_addr.match(cond)
end
def new_contact_from_attributes(email_address, fullname=nil)
contact = Contact.new
# Truncating the email address would result in an invalid format
contact.email = email_address
names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
contact.first_name = names.shift.slice(0, 255)
contact.last_name = names.join(' ').slice(0, 255)
contact.company = email_address.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.projects << target_project
contact.tag_list = HelpdeskSettings["helpdesk_created_contact_tag", target_project] if HelpdeskSettings["helpdesk_created_contact_tag", target_project]
contact
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]
create_contact_from_address(cc_addr, cc_name)
end.compact
end
def create_contact_from_address(addr, name)
contacts = Contact.find_by_emails([addr])
unless contacts.blank?
contact = contacts.first
if contact.projects.blank? || HelpdeskSettings[:helpdesk_add_contact_to_project, target_project].to_i > 0
contact.projects << target_project
contact.save!
end
return contact
end
if HelpdeskSettings["helpdesk_is_not_create_contacts", target_project].to_i > 0
logger.error "#{email && email.message_id}: can't find contact with email: #{addr} in whitelist. Not create new contacts option enable" if logger
nil
else
contact = new_contact_from_attributes(addr, name)
if contact.save(:validate => false)
contact
else
logger.error "Helpdeks MailHandler: failed to create Contact: #{contact.errors.full_messages}" if logger
nil
end
end
end
# Get or create contact for the +email+ sender
def contact_from_email(email)
# from = email.header['from'].to_s
# debugger
from = cleaned_up_from_address
addr, name = from, nil
if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
addr, name = m[2], m[1]
end
if addr.present?
create_contact_from_address(addr, name)
else
logger.error "#{email && email.message_id}: failed to create Contact: no FROM address found" if logger
nil
end
end
# Returns a Hash of issue custom field values extracted from keywords in the email body
def custom_field_values_from_keywords(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 save_email_as_attachment(container, filename="message.eml")
Attachment.create(:container => container,
:file => email.raw_source.to_s,
:author => user,
:filename => filename,
:content_type => "message/rfc822")
end
def plain_text_body
return @plain_text_body unless @plain_text_body.nil?
part = email.text_part || email.html_part
unless part
return @plain_text_body = '' if email.parts.present? && email.parts.all? { |part| part.attachment? }
part = email
end
is_html = email.text_part.blank?
part_charset = Mail::RubyVer.pick_encoding(part.charset).to_s rescue part.charset
@plain_text_body = helpdesk_to_utf8(part.body.decoded, part_charset)
# strip html tags and remove doctype directive
@plain_text_body.gsub! %r{^[ ]+}, ''
if is_html && RedmineHelpdesk.strip_tags?
@plain_text_body.gsub! %r{<head>(?:.|\n|\r)+?<\/head>}, ""
@plain_text_body.gsub! %r{<\/(li|ol|ul|h1|h2|h3|h4)>}, "\r\n"
@plain_text_body.gsub! %r{<\/(p|div|pre)>}, "\r\n\r\n"
@plain_text_body.gsub! %r{<li>}, " - "
@plain_text_body.gsub! %r{<br[^>]*>}, "\r\n"
@plain_text_body = strip_tags(@plain_text_body.strip)
@plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
end
@plain_text_body.strip
rescue Exception => e
logger.error "#{email && email.message_id}: Message body processing error - #{e.message}" if logger
@plain_text_body = '(Unprocessable message body)'
end
def cleaned_up_subject(email)
return "" if email[:subject].blank?
subject = decode_subject(email[:subject].value)
subject = helpdesk_to_utf8(subject)
subject.strip[0,255]
rescue Exception => e
logger.error "#{email && email.message_id}: Message subject processing error - #{e.message}" if logger
'(Unprocessable subject)'
end
def cleaned_up_from_address
from = email.header['reply-to'].to_s.present? ? email.header['reply-to'] : email.header['from']
from.to_s.strip[0, 255]
end
def logger
HelpdeskLogger
end
def helpdesk_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 attachment_digest(file_source)
encoder = Redmine::VERSION.to_s >= '3.4' || RedmineContacts.unstable_branch? ? Digest::SHA256.new : Digest::MD5.new
encoder.update(file_source)
encoder.hexdigest
end
def set_delivery_options
return false if HelpdeskSettings[:helpdesk_smtp_use_default_settings, project.id].to_i == 0
message.delivery_method(:smtp)
settings = {:address => HelpdeskSettings[:helpdesk_smtp_server, project.id],
:port => HelpdeskSettings[:helpdesk_smtp_port, project.id] || 25,
:domain => HelpdeskSettings[:helpdesk_smtp_domain, project.id],
:enable_starttls_auto => true,
:ssl => HelpdeskSettings[:helpdesk_smtp_ssl, project.id].to_i > 0 &&
HelpdeskSettings[:helpdesk_smtp_tls, project.id].to_i == 0}
authentication = HelpdeskSettings[:helpdesk_smtp_authentication, project.id]
unless authentication.blank?
settings.merge!(:authentication => authentication,
:user_name => HelpdeskSettings[:helpdesk_smtp_username, project.id],
:password => HelpdeskSettings[:helpdesk_smtp_password, project.id])
end
message.delivery_method.settings.merge!(settings)
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.scan(/\=\?.+?\?[qQbB]\?.+?\?\=/).map 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
def self.ignored_helpdesk_headers
helpdesk_headers = {
'X-Auto-Response-Suppress' => /\A(all|AutoReply|oof)/
}
ignored_emails_headers.merge(helpdesk_headers)
end
def handle_ignored(email)
self.class.ignored_helpdesk_headers.each do |key, ignored_value|
value = email.header[key]
if value
value = value.to_s.downcase
if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
if logger
logger.info "#{email && email.message_id}: ignoring email with #{key}:#{value} header"
end
return false
end
end
end
return true
end
def escaped_cleaned_up_text_body
text_body = cleaned_up_text_body
return text_body unless (ActiveRecord::Base.connection.adapter_name =~ /mysql/i).present?
text_body.gsub(/./) { |c| c.bytesize == 4 ? ' ' : c }
end
end
@@ -0,0 +1,18 @@
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
private
def collect_answered_users
return [] unless project
user_ids = Issue.joins(:project).
visible.uniq.
pluck(:assigned_to_id).compact
User.where(:id => user_ids)
end
end
@@ -0,0 +1,21 @@
class HelpdeskReportsFirstResponseQuery < HelpdeskReportsQuery
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 collect_answered_users
return [] unless project
user_ids = Issue.joins(:project).
joins(:journals).
joins(:journals => :journal_message).
visible.uniq.
pluck(:assigned_to_id).compact
User.where(:id => user_ids)
end
end
@@ -0,0 +1,94 @@
class HelpdeskReportsQuery < Query
self.queried_class = JournalMessage
operators_by_filter_type[:time_interval] = ['t', 'ld', 'w', 'l2w', 'm', 'lm', 'y']
def initialize_available_filters
add_available_filter 'message_date', :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]
self.filters = {}
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 collect_answered_users
return [] unless project
user_ids = Issue.joins(:project).
joins(:journals).
joins(:journals => :journal_message).
visible.uniq.
pluck(:assigned_to_id).compact
User.where(:id => 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)
return date_clause(table, field, (from ? Date.today + from : nil), (to ? Date.today + to : nil)) if Redmine::VERSION.to_s < '3.0'
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)
return date_clause(table, field, date_from, date_to) if Redmine::VERSION.to_s < '3.0'
date_clause(table, field, date_from, date_to, is_custom_filter)
end
def issue_scope
Issue.visible.joins(:project, :journals => :journal_message).where(statement).uniq
end
end
@@ -0,0 +1,268 @@
class HelpdeskTicket < ActiveRecord::Base
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
attr_accessible :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
unloadable
belongs_to :customer, :class_name => 'Contact', :foreign_key => 'contact_id'
belongs_to :issue
has_one :message_file, :class_name => "Attachment", :as => :container, :dependent => :destroy
acts_as_attachable :view_permission => :view_issues,
:delete_permission => :edit_issues
if ActiveRecord::VERSION::MAJOR >= 4
acts_as_activity_provider :type => 'helpdesk_tickets',
:permission => :view_helpdesk_tickets,
:timestamp => "#{table_name}.ticket_date",
:author_key => "#{Issue.table_name}.author_id",
:scope => eager_load(:issue => :project)
else
acts_as_activity_provider :type => 'helpdesk_tickets',
:permission => :view_helpdesk_tickets,
:timestamp => "#{table_name}.ticket_date",
:author_key => "#{Issue.table_name}.author_id",
:find_options => {:include => {:issue => :project}}
end
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
after_create :set_ticket_private
before_save :calculate_metrics
validates_presence_of :customer, :ticket_date
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 ticket_time
self.ticket_date.to_s(:time) 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 = ticket_date.try(:time_zone).try(: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 recalculate_events
unless issue.closed?
close_journal_id = nil
end
end
def available_addresses
@available_addresses ||= ([self.default_to_address] | self.customer.emails.map{|e| e} | [self.from_address.blank? ? nil : self.from_address.downcase.strip]).compact.uniq if self.customer
end
def default_to_address
return last_response_address if last_journal_message && last_journal_message.is_incoming?
address = self.from_address.blank? ? "" : self.from_address.downcase.strip
self.customer.emails.include?(address) ? address : self.customer.primary_email
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 = ((self.issue.contacts ? self.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 => self.issue_id}).
order("#{JournalMessage.table_name}.message_date ASC")
end
def reaction_date
@reaction_date ||= self.issue.journals.
joins(:journal_message).
where("#{JournalMessage.table_name}.journal_id IS NULL OR #{JournalMessage.table_name}.is_incoming = ?", false).
order("#{Journal.table_name}.created_on ASC").
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? && !self.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
Digest::MD5.hexdigest("#{issue.id}:#{self.ticket_date.utc}:#{Rails.application.config.secret_token}")
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 && self.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_attributes(: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
issues = Issue.includes(:helpdesk_ticket).where(:project_id => project.id).
where(:status_id => RedmineHelpdesk.autoclose_from_status).
where('created_on < ?', Time.now - RedmineHelpdesk.autoclose_time_interval)
issues.find_each do |issue|
issue.init_journal(User.anonymous)
issue.current_journal.notes = I18n.t('label_helpdesk_autoclosed_ticket')
issue.status_id = RedmineHelpdesk.autoclose_to_status
issue.save
end
end
private
def set_ticket_private
return unless RedmineHelpdesk.settings["helpdesk_assign_contact_user"].to_i > 0
issue.assign_attributes(:is_private => true) if RedmineHelpdesk.settings["helpdesk_create_private_tickets"].to_i > 0
issue.save unless issue.new_record?
end
end
@@ -0,0 +1,58 @@
class JournalMessage < ActiveRecord::Base
unloadable
belongs_to :contact
belongs_to :journal
has_one :message_file, :class_name => "Attachment", :as => :container, :dependent => :destroy
attr_accessible :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
if ActiveRecord::VERSION::MAJOR >= 4
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)
else
acts_as_activity_provider :type => 'helpdesk_tickets',
:permission => :view_helpdesk_tickets,
:timestamp => "#{table_name}.message_date",
:author_key => "#{Journal.table_name}.user_id",
:find_options => {:include => [{:journal => [{:issue => [:project, :tracker]}, :details, :user]}, :contact]}
end
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 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
@@ -0,0 +1,41 @@
<%= back_url_hidden_field_tag %>
<%= error_messages_for 'canned_response' %>
<div class="box tabular">
<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>
</div>
<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 %>
@@ -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 %>
@@ -0,0 +1,43 @@
(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) %>");
}
@@ -0,0 +1,6 @@
<h2><%=l(:label_helpdesk_canned_response)%></h2>
<%= labelled_form_for :canned_response, @canned_response, :url => { :action => 'update', :project_id => @project } do |f| %>
<%= render :partial => 'canned_responses/form', :locals => { :f => f } %>
<%= submit_tag l(:button_save) %>
<% end %>
@@ -0,0 +1 @@
<%= render :partial => 'index' %>
@@ -0,0 +1,6 @@
<h2><%=l(:label_helpdesk_new_canned_response)%></h2>
<%= labelled_form_for :canned_response, @canned_response, :url => { :action => 'create', :project_id => @project } do |f| %>
<%= render :partial => 'canned_responses/form', :locals => { :f => f } %>
<%= submit_tag l(:button_create) %>
<% end %>
@@ -0,0 +1,54 @@
<% 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,
: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)} (#{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.truncate(250) %>
</small></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% if Redmine::VERSION.to_s >= '3.4' || RedmineContacts.unstable_branch? %>
<%= context_menu %>
<% else %>
<%= context_menu issues_context_menu_path %>
<% end %>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
</div>
@@ -0,0 +1,13 @@
<% 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,
:tracker_id => HelpdeskSettings["helpdesk_tracker", @project.id],
:project_id => @project,
:back_url => @back},
:method => :get,
:class => 'icon-support' %>
</li>
<% end %>
@@ -0,0 +1,81 @@
<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 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 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 %>
@@ -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" 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 => "32x32", :height => 32, :width => 32) %>
<% 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 %>
@@ -0,0 +1 @@
$('#test_connection_messages').html('<%= escape_javascript @message.html_safe %>')
@@ -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
@@ -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(HelpdeskMailer.apply_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(HelpdeskMailer.apply_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 => "32x32", :height => 32, :width => 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.settings[: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.settings[:last_message_count].to_i > 0 ? RedmineHelpdesk.settings[:last_message_count].to_i : 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 %>
@@ -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(',') %>")
$("#helpdesk_cc").trigger('change');
@@ -0,0 +1 @@
$('#cusomer_profile_and_issues').html('<%= escape_javascript(render :partial => "issues/helpdesk_customer_profile") %>')
@@ -0,0 +1,21 @@
<!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>
</body>
</html>
@@ -0,0 +1,3 @@
<%= @email_header %>
<%= @email_body %>
<%= @email_footer %>
@@ -0,0 +1,25 @@
<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>
@@ -0,0 +1,33 @@
<% if @collector.issues_count.zero? %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% else %>
<div class="helpdesk_chart">
<table class="chart_table">
<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_#{@report}_interval_#{column[:name]}") %>
</td>
<% end %>
</tr>
<%= render :partial => "#{@report}_metrics" %>
</table>
</div>
<% end %>
@@ -0,0 +1,33 @@
<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>
@@ -0,0 +1,29 @@
<h2><%= l("label_helpdesk_report_names_#{@report}") %></h2>
<% html_title(l("label_helpdesk_report_names_#{@report}")) %>
<%= 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 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 %>
<%= error_messages_for 'query' %>
<%= render :partial => 'chart' %>
<% content_for :sidebar do %>
<%= render :partial => 'issues/helpdesk_reports' %>
<% end %>
@@ -0,0 +1 @@
<h2>HelpdeskTicketsController#destroy</h2>
@@ -0,0 +1,23 @@
$('#cusomer_profile_and_issues').html('<%= escape_javascript(render :partial => "issues/helpdesk_customer_profile") %>')
$('#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){ 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('');
});
@@ -0,0 +1 @@
<h2>HelpdeskTicketsController#update</h2>
@@ -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>
@@ -0,0 +1,3 @@
<div class="vote_form">
<h2><%= t(:label_helpdesk_vote_thank) %></h2>
</div>
@@ -0,0 +1 @@
<%= avatar(@user, :size => 54, :id => 'avatar') %>
@@ -0,0 +1,226 @@
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', function(){ 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(){
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();
parent.postMessage(JSON.stringify({ reload: true }), "*");
} 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 submitTicketForm(){
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);
}
@@ -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 %>
@@ -0,0 +1,135 @@
#widget_form {
padding: 10px;
font-family: helvetica, arial, sans-serif;
}
#widget_form .form-control,
#widget_form .custom_fields input,
#widget_form .custom_fields select,
#widget_form .custom_fields textarea {
background: 0 0;
border: 1px solid #d9d9d9;
border-radius: 0;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
color: #333;
display: block;
font-size: 12px;
font-weight: 400;
height: 36px;
line-height: 16px;
width: 100%;
padding: 8px 12px;
margin-bottom: 12px;
-webkit-appearance: none;
-moz-appearance: none;
}
#widget_form select.form-control,
#widget_form .custom_fields select {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAYAAABiDJ37AAAAAXNSR0IArs4c6QAAATNJREFUKBW1kc1KxDAUhW/CxM2M4EO4m7UwrzDDDBUXRRDxPbpw4cL3EHFhYcTSEh/BhevufAhhOptJabwnmhIjI+JPIDS5P1/OPSX64yW01o/GmNMkSZ5/wy6KYl8pdS0F0UQNBk8Mnv4UiF4wwJLW2qUQYo+srTiRMZTj316YMEMvGI4FgK6qjIS44KDk5N1qvT5L07T5Cpvn+Wh3OLziviMGddx3PpvPL3s1D2U5s1LeOLVE9caYw22+wq8dpe75wTHDXkTXnUwXCw0B0qtAwLTtAd9r3mN4gkd83n8RQw41vGv0eBhqeiAuULRqmgnGdp5IWQa+Or94itJNAWu4Np6iHxnAYH3y1eUivzhmg563kjgQ3iNfWfhHv8Jaf96m0Ofp/QfcgrZp2+N4xL7wvw6vkGme5fEw/bwAAAAASUVORK5CYII=) 97% 14px no-repeat;
background-size: 12px 9px;
}
#widget_form .custom_fields textarea,
#widget_form textarea.form-control {
height: 120px;
}
#widget_form .custom_fields select[multiple] {
height: 120px;
}
#widget_form .title {
color: #333;
padding-bottom: 3px;
font-size: 12px;
}
#widget_form .custom_fields label > span {
color: #333;
padding-bottom: 3px;
font-size: 12px;
}
#widget_form #flash {
color: red;
padding-bottom: 3px;
font-size: 12px;
}
#widget_form input.error_field,
#widget_form select.error_field,
#widget_form textarea.error_field,
#widget_form .custom_fields input.error_field,
#widget_form .custom_fields select.error_field,
#widget_form .custom_fields textarea.error_field {
border: 1px solid red;
}
#widget_form .submit_button{
height: 40px;
}
#widget_form .attach_div{
position: relative;
float: right;
margin: 10px;
min-width: 80px;
text-align: right;
}
#widget_form .attach_link{
position: relative;
font: 14px Arial;
color: #3699ca;
}
#widget_form .attach_field{
position: absolute;
top: 0;
left: -80px;
width: 160px;
opacity: 0;
cursor: pointer;
}
#widget_form .btn {
float: right;
background: #3699ca;
color: #fff;
height: 36px;
padding: 0 14px;
line-height: 30px;
font-weight: 500;
text-align: center;
border: 0px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
-ms-appearance: none;
-o-appearance: none;
appearance: none;
}
#widget_from .attach {
display: inline-block;
float: right;
font-size: 12px;
margin: 10px 16px 0 0;
max-width: 210px;
text-decoration: underline;
color: #2996cc;
}
#helpdesk_widget.open {
border-radius: 0 100% 100% !important;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
-o-transform: rotate(45deg);
}
@@ -0,0 +1,556 @@
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;
}
var RedmineHelpdeskWidget = {
widget: document.getElementById('helpdesk_widget'),
widget_button: null,
width: 400,
height: 500,
margin: 20,
iframe: null,
form: null,
schema: null,
reload: false,
configuration: {},
attachment: null,
base_url: '<%= Setting.protocol %>://<%= Setting.host_name %>',
config: function(configuration){
this.configuration = configuration;
this.apply_config();
},
apply_config: function(){
if (this.configuration['color']) {
this.widget_button.style.backgroundColor = this.configuration['color'];
}
switch (this.configuration['position']) {
case 'topLeft':
this.widget.style.top = '20px';
this.widget.style.left = '20px';
break;
case 'topRight':
this.widget.style.top = '20px';
this.widget.style.right = '20px';
break;
case 'bottomLeft':
this.widget.style.bottom = '20px';
this.widget.style.left = '20px';
break;
case 'bottomRight':
this.widget.style.bottom = '20px';
this.widget.style.right = '20px';
break;
default:
widget.style.bottom = '20px';
widget.style.right = '20px';
}
},
translation: function(field){
return this.configuration['translation'] && this.configuration['translation'][field] ? this.configuration['translation'][field] : null;
},
identify: function(field){
return this.configuration['identify'] && this.configuration['identify'][field] ? this.configuration['identify'][field] : null
},
load: function() {
this.widget.addEventListener('click', function(){ RedmineHelpdeskWidget.toggle() });
this.create_widget_button();
this.decorate_widget_button();
this.create_iframe();
this.decorate_iframe();
this.load_schema();
this.created = true;
},
load_schema: function() {
var xmlhttp = getXmlHttp();
xmlhttp.open('GET', this.base_url + '/helpdesk_widget/load_form.json', true);
xmlhttp.responseType = 'json';
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == 4) {
if (xmlhttp.status == 200 || xmlhttp.status == 304) {
RedmineHelpdeskWidget.schema = xmlhttp.response;
RedmineHelpdeskWidget.fill_form();
} else {
RedmineHelpdeskWidget.schema = {};
}
}
};
xmlhttp.send(null);
},
create_widget_button: function(){
button = document.createElement('div');
button.id = 'widget_button';
button.className = 'widget_button';
button.innerHTML = this.configuration['icon'] || '?';
button.setAttribute('name', 'helpdesk_widget_button');
button.style.backgroundColor = '#7E8387';
button.style.backgroundSize = '15px 15px';
button.style.cursor = 'pointer';
button.style.color = 'white';
button.style.textAlign = 'center';
button.style.fontSize = '32px';
button.style.verticalAlign = 'middle';
button.style.lineHeight = '54px';
button.style.borderRadius = '30px';
button.style.boxShadow = 'rgba(0, 0, 0, 0.258824) 0px 2px 5px 0px';
button.style.display = 'none';
button.style.webkitTransition = "transform 0.2s ease";
this.widget_button = button;
this.widget.appendChild(button);
},
decorate_widget_button: function(){
widget = this.widget;
widget.style.position = 'fixed';
widget.style.bottom = '20px';
widget.style.right = '20px';
widget.style.width = '54px';
widget.style.height = '54px';
widget.style.zIndex = 9999;
},
create_iframe: function(){
this.iframe = document.createElement('iframe');
this.widget.appendChild(this.iframe);
},
decorate_iframe: function(){
iframe = this.iframe;
iframe.setAttribute('id', 'helpdesk_ticket_container');
iframe.setAttribute('width', this.width);
iframe.setAttribute('height', 0);
iframe.setAttribute('frameborder', 0);
iframe.style.visibility = 'hidden';
iframe.style.position = 'absolute';
iframe.style.opacity = '0';
iframe.style.width = this.width;
iframe.style.backgroundColor = 'white';
iframe.style.webkitTransition = "opacity 0.2s ease";
iframe.style.boxShadow = 'rgba(0, 0, 0, 0.258824) 0px 1px 4px 0px';
iframe.setAttribute('name', 'helpdesk_widget_iframe');
},
fill_form: function(){
if (Object.keys(this.schema.projects).length > 0) {
this.apply_avatar();
this.create_form();
this.create_form_title();
this.create_error_flash();
if (this.identify('redmineUserID')) {
this.create_form_hidden(this.form, 'redmine_user', 'redmine_user', 'form-control', this.identify('redmineUserID'));
}
if (this.identify('nameValue')) {
this.create_form_hidden(this.form, 'username', 'username', 'form-control', this.identify('nameValue'));
} else {
this.create_form_text(this.form, 'username', 'username', this.translation('nameLabel') || '<%= t(:label_helpdesk_widget_name) %>', 'form-control', this.identify('nameValue'), true);
}
if (this.identify('emailValue')) {
this.create_form_hidden(this.form, 'email', 'email', 'form-control', this.identify('emailValue'));
} else {
this.create_form_text(this.form, 'email', 'email' , this.translation('emailLabel') || '<%= t(:label_helpdesk_widget_email) %>', 'form-control', this.identify('emailValue'), true);
}
if (this.identify('subjectValue')) {
this.create_form_hidden(this.form, 'subject', 'issue[subject]', 'form-control', this.identify('subjectValue'));
} else {
this.create_form_text(this.form, 'subject', 'issue[subject]' , this.translation('subjectLabel') || '<%= t(:label_helpdesk_widget_subject) %>', 'form-control', this.identify('subjectValue'), true);
}
this.create_projects_selector();
this.create_form_area(this.form, 'description', 'issue[description]' , this.translation('descriptionLabel') || '<%= t(:label_helpdesk_widget_description) %>', 'form-control', true);
var project_id = null;
var tracker_id = null;
if (RedmineHelpdeskWidget.configuration['identify']){
project_id = RedmineHelpdeskWidget.schema.projects[RedmineHelpdeskWidget.configuration['identify']['projectValue']];
if (project_id) {
tracker_id = RedmineHelpdeskWidget.schema.projects_data[project_id].trackers[RedmineHelpdeskWidget.configuration['identify']['trackerValue']];
}
}
this.load_project_data(project_id || this.schema.projects[Object.keys(this.schema.projects)[0]], tracker_id);
this.iframe.contentWindow.document.body.appendChild(this.form);
this.append_stylesheets();
this.append_scripts();
this.create_message_listener();
} else {
this.widget.style.display = 'none';
}
},
apply_avatar: function(){
button = document.getElementById('widget_button');
avatar = RedmineHelpdeskWidget.configuration['user_avatar'];
if (avatar && avatar.length > 0) {
var xmlhttp = getXmlHttp();
xmlhttp.open('GET', RedmineHelpdeskWidget.base_url + '/helpdesk_widget/avatar/' + avatar, true);
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == 4) {
if (xmlhttp.status == 200 || xmlhttp.status == 304) {
button.style.backgroundSize = 'cover';
button.style.backgroundImage = 'url(' + RedmineHelpdeskWidget.base_url + '/helpdesk_widget/avatar/' + avatar + ')' ;
button.style.border = '2px solid';
button.innerHTML = '&nbsp;';
} else {
button.style.backgroundSize = '15px 15px';
button.innerHTML = '?';
}
button.style.display = 'block';
button.style.lineHeight = '50px';
}
};
xmlhttp.send(null);
} else {
button.style.lineHeight = '54px';
button.style.backgroundSize = '15px 15px';
button.innerHTML = '?';
button.style.display = 'block';
}
},
append_stylesheets: function(){
if (this.configuration['styles']) {
styles_css = document.createElement('style');
styles_css.innerHTML = this.configuration['styles'];
styles_css.type = "text/css";
}
widget_css = document.createElement('link');
widget_css.href = this.base_url + '/helpdesk_widget/widget.css';
widget_css.rel = "stylesheet";
widget_css.type = "text/css";
this.iframe.contentWindow.document.head.appendChild(widget_css);
if (this.configuration['styles']) {
this.iframe.contentWindow.document.head.appendChild(styles_css);
}
},
append_scripts: function(){
script = document.createElement('script');
script.type = 'text/javascript';
script.src = this.base_url + '/helpdesk_widget/iframe.js';
this.iframe.contentWindow.document.head.appendChild(script);
config_script = document.createElement('script');
config_script.innerHTML = "var RedmineHelpdeskIframe = {configuration: "+ JSON.stringify(this.configuration) +"}";
this.iframe.contentWindow.document.head.appendChild(config_script);
},
create_form: function(){
this.form = document.createElement('form');
this.form.action = this.base_url + '/helpdesk_widget/create_ticket';
this.form.acceptCharset = 'UTF-8';
this.form.method = 'post';
this.form.id = 'widget_form';
this.form.setAttribute('onSubmit', 'submitTicketForm(); return false;');
this.form.style.marginBottom = 0;
},
create_form_title: function(){
if (this.configuration['title']) {
title_div = document.createElement('div');
title_div.id = 'title';
title_div.className = 'title';
title_div.innerHTML = this.configuration['title'];
this.form.appendChild(title_div);
}
},
create_error_flash: function(){
flash_div = document.createElement('div');
flash_div.id = 'flash';
flash_div.className = 'flash';
this.form.appendChild(flash_div);
},
create_projects_selector: function(){
var project_id = null;
if (RedmineHelpdeskWidget.configuration['identify']){
project_id = RedmineHelpdeskWidget.schema.projects[RedmineHelpdeskWidget.configuration['identify']['projectValue']];
}
if (project_id) {
this.create_form_hidden(this.form, 'project_id', 'project_id', 'form-control projects', project_id);
} else {
this.create_form_select(this.form, 'project_id', 'project_id', RedmineHelpdeskWidget.schema.projects, project_id, 'form-control projects');
}
},
load_project_data: function(project_id, tracker_id){
container_div = this.form.getElementsByClassName('container')[0]
if (container_div) { container_div.remove() };
container_div = document.createElement('div');
container_div.id = 'container';
container_div.className = 'container';
custom_div = document.createElement('div');
custom_div.id = 'custom_fields';
custom_div.className = 'custom_fields';
submit_div = document.createElement('div');
submit_div.id = 'submit_button';
submit_div.className = 'submit_button';
container_div.appendChild(custom_div);
container_div.appendChild(submit_div);
if (RedmineHelpdeskWidget.configuration['identify'] && RedmineHelpdeskWidget.schema.projects_data[project_id].trackers[RedmineHelpdeskWidget.configuration['identify']['trackerValue']]){
tracker_id = RedmineHelpdeskWidget.schema.projects_data[project_id].trackers[RedmineHelpdeskWidget.configuration['identify']['trackerValue']]
this.create_form_hidden(custom_div, 'tracker_id', 'tracker_id', 'form-control trackers', tracker_id);
} else {
this.create_form_select(custom_div, 'tracker_id', 'tracker_id', this.schema.projects_data[project_id].trackers, tracker_id, 'form-control trackers');
tracker_id = custom_div.getElementsByClassName('trackers')[0].value;
}
this.load_custom_fields(custom_div, project_id, tracker_id);
this.create_form_submit(submit_div, this.translation('createButtonLabel') || '<%= l(:label_helpdesk_widget_create_ticket) %>');
this.create_attch_link(submit_div);
this.form.appendChild(container_div);
},
reload_project_data: function(){
project_id = this.form.getElementsByClassName('projects')[0].value;
tracker_id = container_div.getElementsByClassName('trackers')[0].value;
this.load_project_data(project_id, tracker_id);
this.positionate_iframe();
},
create_form_select: function(target, field_id, field_name, values, selected, field_class){
if (Object.keys(values).length == 1) {
field = document.createElement('input');
field.type = 'hidden';
field.id = field_id;
field.name = field_name;
field.className = field_class;
field.value = values[Object.keys(values)[0]];
} else {
field = document.createElement('select');
field.id = field_id;
field.name = field_name;
field.className = field_class;
for (var project in values) {
option = document.createElement('option');
option.value = values[project]
if(values[project] == selected) { option.selected = 'selected'; }
option.innerHTML = project;
field.appendChild(option);
}
}
field.setAttribute('onChange', 'needReloadProjectData();');
target.appendChild(field);
},
create_form_hidden: function(target, field_id, field_name, field_class, value){
field = document.createElement('input');
field.type = 'hidden';
field.id = field_id;
field.name = field_name;
field.value = value;
field.className = field_class;
target.appendChild(field);
},
create_form_text: function(target, field_id, field_name, field_placeholder, field_class, value, required){
field = document.createElement('input');
field.type = 'text';
field.id = field_id;
field.name = field_name;
field.value = value;
field.placeholder = field_placeholder;
field.className = required ? field_class + ' required-field' : field_class;
target.appendChild(field);
},
create_form_area: function(target, field_id, field_name, field_placeholder, field_class, required){
field = document.createElement('textarea');
field.cols = 55;
field.rows = 10;
field.id = field_id;
field.name = field_name;
field.placeholder = field_placeholder;
field.className = required ? field_class + ' required-field' : field_class;
target.appendChild(field);
},
create_form_submit: function(target, label){
field = document.createElement('input');
field.id = 'form-submit-btn';
field.type = 'submit';
field.name = 'submit';
field.className = 'btn';
field.value = label;
field.title = this.translation('buttomLabel') || '';
if (RedmineHelpdeskWidget.configuration['color']) {
field.style.background = RedmineHelpdeskWidget.configuration['color'];
}
target.appendChild(field);
},
create_attch_link: function(target){
if (this.configuration['attachment'] != false ) {
attach_div = document.createElement('div');
attach_div.className = 'attach_div';
attach_link = document.createElement('a');
attach_link.className = 'attach_link';
attach_link.href = 'javascript:void(0)';
attach_link.innerHTML = this.translation('attachmentLinkLabel') || 'Attach a file';
attach_div.appendChild(attach_link);
attach_field = document.createElement('input');
attach_field.type = 'file';
attach_field.id = 'attachment';
attach_field.className = 'attach_field';
attach_field.name = 'attachment';
attach_field.attributes['data-max-size'] = <%= Setting[:attachment_max_size].to_i * 1024 %>;
attach_field.addEventListener('change', function(){ RedmineHelpdeskWidget.upload_file() });
attach_div.appendChild(attach_field);
this.attachment = attach_field;
target.appendChild(attach_div);
}
},
upload_file: function(){
if (this.attachment.attributes['data-max-size'] > this.attachment.files[0].size) {
this.read_file(this.attachment.files[0], function(e){
attach_field = RedmineHelpdeskWidget.form.getElementsByClassName('attach_field')[0]
attach_field.attributes['data-value'] = e.target.result;
displayed_name = (attach_field.files[0].name.length <= 20) ? attach_field.files[0].name : attach_field.files[0].name.substring(0, 20) + '...';
RedmineHelpdeskWidget.form.getElementsByClassName('attach_link')[0].innerHTML = displayed_name;
});
} else {
this.attachment.attributes['data-value'] = '';
RedmineHelpdeskWidget.form.getElementsByClassName('attach_link')[0].innerHTML = '<%= t(:label_helpdesk_widget_file_large) %>';
}
},
read_file: function(file, callback){
var reader = new FileReader();
reader.onload = callback
reader.readAsDataURL(file);
},
load_custom_fields: function(target, project_id, tracker_id){
var xmlhttp = getXmlHttp();
var params = 'project_id=' + encodeURIComponent(project_id) + '&tracker_id=' + encodeURIComponent(tracker_id);
custom_div = document.createElement('div');
xmlhttp.open('GET', this.base_url + '/helpdesk_widget/load_custom_fields?' + params, true);
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == 4) {
if (xmlhttp.status == 200 || xmlhttp.status == 304) {
custom_div.innerHTML = xmlhttp.responseText;
target.appendChild(custom_div);
RedmineHelpdeskWidget.set_custom_values();
RedmineHelpdeskWidget.positionate_iframe();
}
}
};
xmlhttp.send(null);
},
set_custom_values: function(){
if (this.configuration['identify'] && this.configuration['identify']['customFieldValues']){
for(var cf in this.configuration['identify']['customFieldValues']) {
custom_field = this.form.querySelector('#issue_custom_field_values_' + this.schema.custom_fields[cf])
if (custom_field){
switch (custom_field.tagName){
case 'INPUT':
custom_field.type = 'hidden';
custom_field.value = this.configuration['identify']['customFieldValues'][cf];
this.form.querySelector("[data-error-key='" + cf + "']").style.display = 'none';
break;
case 'SELECT':
options = custom_field.options;
for(var option, index = 0; option = options[index]; index++) {
if(option.value == this.configuration['identify']['customFieldValues'][cf]) {
this.create_form_hidden(custom_field.parentElement, custom_field.id, custom_field.name, custom_field.classList.toString(), this.configuration['identify']['customFieldValues'][cf]);
custom_field.remove();
this.form.querySelector("[data-error-key='" + cf + "']").style.display = 'none';
break;
}
}
break;
}
}
}
}
},
positionate_iframe: function(){
widget_height = this.form.offsetHeight > this.height ? this.height : this.form.offsetHeight;
this.iframe.setAttribute('height', this.margin + widget_height);
switch (this.configuration['position']) {
case 'topLeft':
this.iframe.style.top = (this.margin + this.widget_button.offsetWidth) + 'px';
iframe.style.left = (this.margin - this.widget_button.offsetWidth / 2) + 'px';
break;
case 'topRight':
this.iframe.style.top = (this.margin + this.widget_button.offsetWidth) + 'px';
iframe.style.left = (this.margin + this.widget_button.offsetWidth - this.width - 20) + 'px';
break;
case 'bottomLeft':
this.iframe.style.top = (- this.margin * 2 - widget_height) + 'px';
iframe.style.left = (this.margin - this.widget_button.offsetWidth / 2) + 'px';
break;
case 'bottomRight':
this.iframe.style.top = (- this.margin * 2 - widget_height) + 'px';
iframe.style.left = (this.margin + this.widget_button.offsetWidth - this.width - 20) + 'px';
break;
default:
this.iframe.style.top = (- this.margin * 2 - widget_height) + 'px';
iframe.style.left = (this.margin + this.widget_button.offsetWidth - this.width - 20) + 'px';
}
},
create_message_listener: function(){
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['reload'] == true) {
RedmineHelpdeskWidget.reload = true;
}
if (data['project_reload'] == true) {
RedmineHelpdeskWidget.reload_project_data();
}
},false);
},
reload_form: function(){
this.iframe.remove();
this.create_iframe();
this.fill_form();
this.decorate_iframe();
this.reload = false;
},
show: function() {
this.iframe.style.visibility = 'visible';
this.iframe.style.opacity = '1';
this.positionate_iframe();
switch (this.configuration['position']) {
case 'topLeft':
case 'topRight':
this.widget_button.style.borderRadius = '50% 50% 0%';
break;
case 'bottomLeft':
case 'bottomRight':
this.widget_button.style.borderRadius = '0 100% 100%';
break;
default:
this.widget_button.style.borderRadius = '0 100% 100%';
}
this.widget_button.style.webkitTransform = 'rotate(45deg)';
this.widget_button.style.mozTransform = 'rotate(45deg)';
this.widget_button.style.msTransform = 'rotate(45deg)';
this.widget_button.style.oTransform = 'rotate(45deg)';
},
hide: function() {
if (this.reload == true) {
this.reload_form();
}
body = this.iframe.contentWindow.document.body;
this.iframe.style.visibility = 'hidden';
this.iframe.style.opacity = '0';
this.widget_button.style.borderRadius = '30px';
this.widget_button.style.webkitTransform = '';
this.widget_button.style.mozTransform = '';
this.widget_button.style.msTransform = '';
this.widget_button.style.oTransform = '';
},
toggle: function() {
(this.iframe.style.visibility == 'visible') ? this.hide() : this.show();
}
}
RedmineHelpdeskWidget.load();
@@ -0,0 +1,2 @@
<%= contact_tag(contact, :id => "customer_send_tag") %>
(<%= contact_email %>)
@@ -0,0 +1,87 @@
<% if !@issue.blank? && User.current.allowed_to?(:view_helpdesk_tickets, @project) %>
<span id="cusomer_profile_and_issues">
<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 => @helpdesk_ticket},
: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>
<%= select_contact_tag('helpdesk_ticket[contact_id]', @helpdesk_ticket.customer, :is_select => Contact.visible.by_project(ContactsSetting.cross_project_contacts? ? nil : @project).count < 50, :include_blank => false, :add_contact => true, :display_field => @helpdesk_ticket.customer.blank?) %>
</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_date.to_s(: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 %>
</span>
<% end %>
@@ -0,0 +1,6 @@
<% if @project && @project.module_enabled?(:contacts_helpdesk)%>
<h3><%= l(:label_helpdesk_reports) %></h3>
<%= link_to l(:label_helpdesk_first_response_time), project_helpdesk_reports_path(@project, :report => 'first_response_time') %>
<br>
<%= link_to l(:label_helpdesk_busiest_time_of_day), project_helpdesk_reports_path(@project, :report => 'busiest_time_of_day') %>
<% end %>
@@ -0,0 +1,154 @@
<% if authorize_for(:issues, :send_helpdesk_response) && @issue.customer && @issue.customer.primary_email %>
<script type="text/javascript" charset="utf-8">
function emailTagResult (opt) {
if (opt.name){
var formated_tag = $('<span>' + opt.avatar + '&nbsp;' + opt.text + '</span>');;
} else {
var formated_tag = opt.text;
}
return formated_tag
};
function emailTagSelection (opt) {
if (opt.name){
var formated_tag = opt.name + ' <' + opt.email + '>';
} else {
var formated_tag = opt.text;
}
return formated_tag
};
$(document).ready(function() {
toggleSendMail($('#helpdesk_is_send_mail').get(0));
var select2_fields = ['#helpdesk_to', '#helpdesk_cc', '#helpdesk_bcc'];
$.each(select2_fields, function(index, field){
$(field).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){ elem.id = elem.email; return elem }) };
},
cache: true
},
tags: true,
placeholder: ' ',
minimumInputLength: 1,
width: '60%',
templateResult: emailTagResult,
templateSelection: emailTagSelection,
}).on('select2:open', function (e) {
$(field).closest('.cc-list-edit').find('.select2-search__field').val(' ').trigger($.Event('input', { which: 13 })).val('');
});
});
$('#email_footer').insertAfter($('#helpdesk_is_send_mail').parents().eq(1).find('.jstEditor'));
$('#email_header').insertBefore($('#helpdesk_is_send_mail').parents().eq(1).find('>legend'));
$('#email_canned_responses').insertBefore($('#helpdesk_is_send_mail').parents().eq(1).find('>legend'));
});
var issue_status = $('#issue_status_id').val();
function toggleSendMail(element) {
if (element.checked) {
$('.email-template').show();
<% unless HelpdeskSettings["helpdesk_answered_status", @project].blank? %>
issue_status = $('#issue_status_id').val();
$('#issue_status_id').val("<%= HelpdeskSettings["helpdesk_answered_status", @project] %>");
<% end %>
<% if @issue.assigned_to.blank? %>
$('#issue_assigned_to_id').val("<%= User.current.id %>");
<% end %>
} else {
$('.email-template').hide();
$('#cc_list_edit').hide();
$('#helpdesk_is_cc').val("");
<% unless HelpdeskSettings["helpdesk_answered_status", @project].blank? %>
$('#issue_status_id').val(issue_status);
<% end %>
}
}
function updateCannedResposeFrom(url, value) {
$.ajax({
url: url,
type: 'post',
data: {id: value}
});
}
</script>
<% canned_responses = CannedResponse.visible.in_project_or_public(@project) %>
<% if canned_responses.any? %>
<span id="email_canned_responses" style="display: none; float:right;" class="email-template">
<%= select_tag 'helpdesk[canned_response]', options_for_select([[ "--- #{l(:label_helpdesk_canned_response_plural)} ---", '' ]] + canned_responses.order("#{CannedResponse.table_name}.name").map{|cr| [cr.name, cr.id]}), :onchange => "updateCannedResposeFrom('#{escape_javascript add_canned_responses_path(:project_id => @project, :issue_id => @issue, :format => 'js')}', $(this).val())" %>
</span>
<% end %>
<% unless HelpdeskSettings["helpdesk_emails_header", @project].blank? %>
<div id="email_header" style="display: none;" class="email-template">
<%= textilizable(HelpdeskMailer.apply_macro(HelpdeskSettings["helpdesk_emails_header", @project], @issue.customer, @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(HelpdeskMailer.apply_macro(HelpdeskSettings["helpdesk_emails_footer", @project], @issue.customer, @issue, User.current)).html_safe %>
</div>
<% end %>
<p id="helpdesk_send_response">
<%= check_box_tag 'helpdesk[is_send_mail]', 1, HelpdeskSettings["send_note_by_default", @project], :onclick => "toggleSendMail(this);" %>
<%= label_tag :helpdesk_is_send_mail, l(:label_is_send_mail), :class => "icon icon-email-to", :style => "" %>
<span id="journal_contacts" style="display: none;" 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>
<a href="#" class="inline-edit email-template" onclick="$('#customer_to_email').hide(); $(this).hide(); $('#cc_list_edit').show(); return false;"><img alt="Edit" src="/images/edit.png" style="vertical-align:middle;" ></a>
</span>
</p>
<div id="cc_list_edit" style="display:none;">
<% contact_emails = ((@issue.helpdesk_ticket.cc_address.to_s.split(',') + @issue.helpdesk_ticket.response_addresses).uniq.map { |email| [email, email] } + (@issue.contacts + [@issue.customer]).uniq.map{ |contact| [contact.email_name, contact.primary_email] }).uniq(&:last) %>
<%= hidden_field_tag 'helpdesk[is_cc]' %>
<p class="cc-list-edit">
<span class="is-cc">
<%= label_tag l(:label_helpdesk_to) %>
</span>
<%= select_tag 'journal_message[to_address]', options_for_select(contact_emails, @issue.helpdesk_ticket.default_to_address), :id => "helpdesk_to", :multiple => true %>
</p>
<p class="cc-list-edit">
<span class="is-cc">
<%= label_tag l(:label_helpdesk_cc) %>
</span>
<%= select_tag 'journal_message[cc_address]', options_for_select(contact_emails, @issue.customer.primary_email != @issue.helpdesk_ticket.default_to_address ? @issue.customer.primary_email : @issue.helpdesk_ticket.cc_addresses), :id => "helpdesk_cc", :multiple => true %>
</p>
<div style="clear: both;"></div>
<p class="cc-list-edit">
<span class="is-cc">
<%= label_tag l(:label_helpdesk_bcc) %>
</span>
<%= select_tag 'journal_message[bcc_address]', nil, :id => "helpdesk_bcc", :multiple => true %>
</p>
<div style="clear: both;"></div>
</div>
<% end %>
<% content_for :header_tags do %>
<%= javascript_include_tag :redmine_helpdesk, :plugin => 'redmine_contacts_helpdesk' %>
<%= stylesheet_link_tag :helpdesk, :plugin => 'redmine_contacts_helpdesk' %>
<% end %>
<% if authorize_for(:issues, :send_helpdesk_response) && @issue.customer && @issue.customer.primary_email %>
<%= javascript_tag do %>
$('#content .contextual:first a:first').before('<%= helpdesk_reply_link %>')
$('#content .contextual:last a:first').before('<%= helpdesk_reply_link %>')
<% end %>
<% end %>
@@ -0,0 +1,56 @@
<% if User.current.allowed_to?(:view_helpdesk_tickets, @project) && @issue.is_ticket? %>
<div id="ticket_data">
<div class="contextual">
<%= link_to l(:label_helpdesk_public_link), public_ticket_path(@issue.helpdesk_ticket, :hash => @issue.helpdesk_ticket.token), :class => "icon icon-public-link" if RedmineHelpdesk.public_tickets? && !@issue.is_private %>
<%# link_to l(:label_helpdesk_spam),
{:controller => 'helpdesk',
:action => 'delete_spam',
:project_id => @project,
:issue_id => @issue},
:method => :delete,
:data => {:confirm => l(:text_are_you_sure)},
:class => "icon icon-email-spam" if @issue.helpdesk_ticket.source == HelpdeskTicket::HELPDESK_EMAIL_SOURCE && @issue.customer.primary_email && User.current.allowed_to?(:send_response, @project) && User.current.allowed_to?(:delete_issues, @project) && User.current.allowed_to?(:delete_contacts, @project) %>
</div>
<span class="icon <%= helpdesk_ticket_source_icon(@issue.helpdesk_ticket) %>", title="<%= l(:label_helpdesk_to_address) %>: <%= @issue.helpdesk_ticket.to_address %>">
<%= @issue.helpdesk_ticket.is_incoming? ? l(:label_helpdesk_from) : l(:label_sent_to) %>
</span>
<span class="ticket_customer" style="white-space: nowrap;display: inline-block;">
<%= contact_tag(@issue.customer, :type => "plain") %>
(<%= @issue.helpdesk_ticket.from_address %>)
</span>
<% if attachment = @issue.helpdesk_ticket.message_file %>
<span class="attachment" style="white-space: nowrap;display: inline-block;">
<%= link_to_attachment attachment, :text => l(:label_helpdesk_original), :download => true, :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 %>
<span class="helpdesk-message-date"><%= format_time(@issue.helpdesk_ticket.ticket_date) %></span>
<br>
<% unless @issue.helpdesk_ticket.cc_address.blank? %>
<span class="helpdesk-message-date"><%= l(:label_helpdesk_cc) %>: <%= @issue.helpdesk_ticket.cc_address.split(',').join(', ') %></span>
<% end %>
</div>
<% if Redmine::VERSION.to_s > '3.2' %>
<script type="text/javascript">
$(document).ready(function() {
$(".attributes").first().prepend($("#ticket_data"));
$("#ticket_data").after("<hr>");
});
</script>
<% else %>
<hr />
<% end %>
<%= issue_fields_rows do |rows|
rows.left l(:label_helpdesk_ticket_reaction_time), distance_of_time_in_words(@issue.helpdesk_ticket.reaction_time, 0, :include_seconds => true) if @issue.helpdesk_ticket.reaction_time
rows.left l(:label_helpdesk_ticket_resolve_time), distance_of_time_in_words(@issue.helpdesk_ticket.resolve_time, 0, :include_seconds => true) if @issue.helpdesk_ticket.resolve_time
rows.right l(:label_helpdesk_contact_vote), show_customer_vote(@issue.helpdesk_ticket.vote, @issue.helpdesk_ticket.vote_comment) if @issue.helpdesk_ticket.vote && @issue.helpdesk_ticket.vote >= 0
rows.right l(:label_helpdesk_ticket_first_response_time), distance_of_time_in_words(@issue.helpdesk_ticket.first_response_time, 0, :include_seconds => true) if @issue.helpdesk_ticket.first_response_time
rows.right l(:label_helpdesk_ticket_last_response_time), distance_of_time_in_words(@issue.helpdesk_ticket.last_response_time, Time.now.utc) if @issue.helpdesk_ticket.last_response_time
end %>
<% end %>
@@ -0,0 +1,26 @@
<% if @issue.new_record? && !@copy_from && User.current.allowed_to?(:edit_helpdesk_tickets, @project) && (@issue.tracker_id.to_s == HelpdeskSettings["helpdesk_tracker", @project.id] || HelpdeskSettings["helpdesk_tracker", @project.id] == 'all') %>
<div class="email-template">
<% @issue.build_helpdesk_ticket if @issue.helpdesk_ticket.blank? %>
<%= form.fields_for :helpdesk_ticket do |f| %>
<div class="splitcontentleft">
<p><%= f.label_for_field("issue_helpdesk_ticket_attributes_contact_id_selected_contact", :label => l(:label_helpdesk_contact), :required => true) %>
<%= select_contact_tag('issue[helpdesk_ticket_attributes][contact_id]', @issue.helpdesk_ticket.try(:customer), :is_select => Contact.visible.by_project(ContactsSetting.cross_project_contacts? ? nil : @project).count < 50, :include_blank => true, :add_contact => true, :display_field => @issue.helpdesk_ticket.try(:customer).blank?) %>
</p>
<p class="required">
<%= f.text_field :ticket_date, :label => l(:label_helpdesk_ticket_date), :size => 12, :value => @issue.helpdesk_ticket.ticket_date.to_date %>
<%= f.text_field :ticket_time, :size => 5, :no_label => true, :value => @issue.helpdesk_ticket.ticket_date.to_s(:time) %><%= calendar_for('issue_helpdesk_ticket_attributes_ticket_date') %>
</p>
</div>
<div class="splitcontentright">
<p><%= f.select :source, helpdesk_tickets_source_for_select, :label => l(:label_helpdesk_ticket_source) %></p>
<p><%= label_tag :helpdesk_send_as, l(:label_helpdesk_send_as)%>
<%= select_tag :helpdesk_send_as, options_for_select(helpdesk_send_as_for_select, params[:helpdesk_send_as]) %> </p>
</div>
<div style="clear:both;"></div>
<% end %>
</div>
<% end %>
@@ -0,0 +1,41 @@
<% if User.current.allowed_to?(:view_helpdesk_tickets, @project) && @issue.journal_messages && journal_message = @issue.journal_messages.detect{|j| j.journal_id == journal.id} %>
<p class="journal_message">
<span class="icon <%= journal_message.is_incoming? ? 'icon-email' : 'icon-email-to' %>">
<%= journal_message.is_incoming? ? l(:label_received_from) : l(:label_sent_to) %>
</span>
<span class="contact" style="white-space: nowrap;display: inline-block;">
<% if journal_message.is_incoming? %>
<%= "#{contact_tag(journal_message.contact)} (#{journal_message.from_address})".html_safe %>
<% else %>
<%= journal_message.contact.emails.include?(journal_message.to_address) ? "#{contact_tag(journal_message.contact)} (#{journal_message.to_address})".html_safe : journal_message.to_address %>
<% end %>
</span>
<% if attachment = journal_message.message_file %>
<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 %>
<span class="helpdesk-message-date"><%= format_time(journal_message.message_date) %></span>
<% unless journal_message.bcc_address.blank? && journal_message.cc_address.blank? %>
<br>
<span class="heldesk_cc helpdesk-message-date">
<%= "#{l(:label_helpdesk_cc)}: #{journal_message.cc_address}" unless journal_message.cc_address.blank? %><%= ", #{l(:label_helpdesk_bcc)}: #{journal_message.bcc_address}" unless journal_message.bcc_address.blank? %>
</span>
<% end %>
<% if false && journal_message.is_incoming? %>
<span class="actions" style="float: right;">
<%= link_to "Split", "", :class => "icon icon-split" %>
</span>
<% end %>
</p>
<% end %>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tag %>
<%= favicon %>
<%= stylesheet_link_tag 'jquery/jquery-ui-1.9.2', 'application', :media => 'all' %>
<%= stylesheet_link_tag 'rtl', :media => 'all' if l(:direction) == 'rtl' %>
<%= javascript_heads %>
<%= heads_for_theme %>
<%= call_hook :view_layouts_base_html_head %>
<!-- page specific tags -->
<%= yield :header_tags -%>
</head>
<body class="<%=h body_css_classes %>">
<div id="wrapper">
<div id="wrapper2">
<div id="wrapper3">
<div id="header">
<h1><%= RedmineHelpdesk.public_title.blank? ? Setting.app_title : RedmineHelpdesk.public_title %></h1>
</div>
<div id="main" class="<%= sidebar_content? ? '' : 'nosidebar' %>">
<div id="sidebar">
<%= yield :sidebar %>
<%= view_layouts_base_sidebar_hook_response %>
</div>
<div id="content">
<%= render_flash_messages %>
<%= yield %>
<%= call_hook :view_layouts_base_content %>
<div style="clear:both;"></div>
</div>
</div>
</div>
<div id="ajax-indicator" style="display:none;"><span><%= l(:label_loading) %></span></div>
<div id="ajax-modal" style="display:none;"></div>
<div id="footer">
<div class="bgl"><div class="bgr">
</div></div>
</div>
</div>
</div>
<%= call_hook :view_layouts_base_body_bottom %>
</body>
</html>
@@ -0,0 +1,56 @@
<% tickets_scope = Issue.visible.open.joins(:helpdesk_ticket).where(:assigned_to_id => User.current.id) %>
<% tickets = tickets_scope.limit(10) %>
<h3><%= l(:my_helpdesk_tickets) %> (<%= tickets_scope.count %>)</h3>
<% if tickets && tickets.any? %>
<%= form_tag({}, :data => {:cm_url => issues_context_menu_path}) do %>
<table class="list tickets">
<thead><tr>
<th><%=l(:field_subject)%></th>
<th><%=l(:field_project)%></th>
<th><%=l(:label_helpdesk_contact)%></th>
<th><%=l(:label_helpdesk_last_message)%></th>
</tr></thead>
<tbody>
<% for ticket in tickets %>
<tr id="ticket-<%= h(ticket.id) %>" class="issue hascontextmenu <%= cycle('odd', 'even') %> <%= ticket.css_classes %>">
<td class="subject">
<%= check_box_tag("ids[]", ticket.id, false, :style => 'display:none;', :id => nil) %>
<span class="icon <%= ticket.helpdesk_ticket.ticket_source_icon %>"></span>
<%= link_to "##{ticket.id} - #{truncate(ticket.subject, :length => 60)}", issue_path(ticket) %> (<%=h ticket.status %>)
</td>
<td class="project"><%= link_to_project(ticket.project) %></td>
<td class="customer"><%= contact_tag(ticket.customer) + (ticket.customer_company.blank? ? "" : " (#{ticket.customer_company})") if ticket.customer %></td>
<td class="last_message"><small>
<%= (ticket.last_message.blank? ? ticket.description : ticket.last_message).truncate(250) %>
</small></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
<% if tickets.length > 0 %>
<p class="small"><%= link_to l(:label_helpdesk_view_all_tickets), :controller => 'issues',
:action => 'index',
:set_filter => 1,
:assigned_to_id => 'me',
:customer => "*",
:status_id => "o",
:c => ["project", "tracker", "status", "subject", "customer", "customer_company", "last_message"],
# :op => {:assigned_to_id => "=", :customer => "*", :status_id => "o"},
:sort => 'priority:desc,updated_on:desc' %></p>
<% end %>
<% content_for :header_tags do %>
<%= auto_discovery_link_tag(:atom,
{:controller => 'issues', :action => 'index', :set_filter => 1,
:assigned_to_id => 'me', :format => 'atom', :key => User.current.rss_key},
{:title => l(:label_assigned_to_me_issues)}) %>
<% end %>
@@ -0,0 +1,10 @@
<% if User.current.allowed_to?(:view_helpdesk_tickets, @project) %>
<% if tickets = HelpdeskTicket.includes(:issue => [:project]).where(:projects => {:id => @project}) %>
<% customers = Contact.includes(:tickets => :project).where(:projects => {:id => @project}) %>
<h3><%= l(:label_helpdesk_ticket_plural) %></h3>
<p><span class="icon icon-helpdesk"><%= l(:text_helpdesk_ticket_count, :count => tickets.count) %></span></p>
<p><span class="icon icon-company-contact"><%= l(:text_helpdesk_customer_count, :count => customers.count) %> </span></p>
<p><%# link_to(l(:label_report), {:controller => "helpdesk_reports", :action => "tickets_report", :project_id => @project}) %></p>
<%= call_hook(:view_projects_show_helpdesk_sidebar_bottom, :project => @project) %>
<% end %>
<% end %>
@@ -0,0 +1,43 @@
<%= error_messages_for 'helpdesk_settings' %>
<% if @project.module_enabled?(:contacts) && @project.module_enabled?(:issue_tracking) %>
<% if canned_responses = CannedResponse.visible.in_project_or_public(@project).order("#{CannedResponse.table_name}.name") %>
<table class="list">
<thead><tr>
<th><%= l(:field_name) %></th>
<th><%= l(:field_content) %></th>
<th><%= l(:field_is_public) %></th>
<th><%= l(:field_is_for_all) %></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="tick"><%= checked_image canned_response.project.blank? %></td>
<td class="buttons">
<% if User.current.allowed_to?(:manage_canned_responses, @project) %>
<%= link_to l(:button_edit), edit_canned_response_path(canned_response), :class => 'icon icon-edit' %>
<%= delete_link canned_response_path(canned_response, :project_id => @project) %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
<p><%= link_to l(:label_helpdesk_new_canned_response), new_project_canned_response_path(@project), :class => 'icon icon-add' if User.current.allowed_to?(:manage_canned_responses, @project) %></p>
<% else %>
<p class="nodata"><%= l(:label_helpdesk_enable_modules) %></p>
<% end %>
@@ -0,0 +1,58 @@
<div class="box" >
<p>
<label><%= l(:field_mail_from) %></label>
<%= text_field_tag "helpdesk_answer_from", HelpdeskSettings["helpdesk_answer_from", @project.id], :size => "60", :placeholder => RedmineHelpdesk.settings["helpdesk_answer_from"] %>
<em class="info"><%= l(:text_helpdesk_answer_macros, :macro => HelpdeskSettings::FROM_MACRO_LIST.join(', ')) %></em>
</p>
<p>
<label><%= l(:label_helpdesk_answered_status) %></label>
<%= select_tag "helpdesk_answered_status", ("<option value=\"\">#{l(:label_no_change_option)}</option>" + options_for_select(IssueStatus.all.collect {|p| [p.name, p.id.to_s]}, HelpdeskSettings["helpdesk_answered_status", @project.id])).html_safe %>
</p>
<p>
<label><%= l(:label_helpdesk_reopen_status) %></label>
<%= select_tag "helpdesk_reopen_status", ("<option value=\"\">#{l(:label_no_change_option)}</option>" + options_for_select(IssueStatus.all.collect {|p| [p.name, p.id.to_s]}, HelpdeskSettings["helpdesk_reopen_status", @project.id])).html_safe %>
</p>
<p>
<label><%= l(:label_helpdesk_tracker) %></label>
<%= select_tag "helpdesk_tracker", options_for_select([[l(:label_all), "all"]] + @project.trackers.collect {|t| [t.name, t.id.to_s]}, HelpdeskSettings["helpdesk_tracker", @project.id]), :include_blank => true %>
</p>
<p>
<label><%= l(:label_helpdesk_assigned_to) %></label>
<%= select_tag "helpdesk_assigned_to", ("<option value=\"\">#{l(:label_no_change_option)}</option>" + options_for_select(@project.assignable_users.collect {|t| [t.name, t.id.to_s]}, HelpdeskSettings["helpdesk_assigned_to", @project.id])).html_safe %>
</p>
<p>
<label><%= l(:label_helpdesk_lifetime) %></label>
<%= text_field_tag "helpdesk_lifetime", HelpdeskSettings["helpdesk_lifetime", @project.id], :size => "5" %> <%= l(:label_day_plural) %>
</p>
<hr/>
<p>
<label><%= l(:label_not_create_contacts) %></label>
<%= hidden_field_tag("helpdesk_is_not_create_contacts", 0) %>
<%= check_box_tag "helpdesk_is_not_create_contacts", 1, HelpdeskSettings["helpdesk_is_not_create_contacts", @project.id].to_i > 0, :onclick => '$("#add_tags").toggle();' %>
</p>
<p>
<label><%= l(:label_helpdesk_blacklist) %></label>
<%= text_area_tag "helpdesk_blacklist", HelpdeskSettings["helpdesk_blacklist", @project.id].blank? ? '' : HelpdeskSettings["helpdesk_blacklist", @project.id].split("\n").map{|u| u.strip}.join("\n"), :rows => 10 %>
<br /><em class="info"><%= l(:text_custom_field_possible_values_info) %></em> </p>
<div id="add_tags" class="contacts-tags-edit" <%= "style=\"display: none;\"" if HelpdeskSettings["helpdesk_is_not_create_contacts", @project.id].to_i > 0 %>>
<p>
<label><%= l(:field_created_contact_tags) %></label>
<%= text_field_tag "helpdesk_created_contact_tag", HelpdeskSettings["helpdesk_created_contact_tag", @project.id], :size => 10, :class => 'hol' %><%= tagsedit_for('#helpdesk_created_contact_tag', Contact.available_tags(:project => @project).map(&:name).join("\',\'").html_safe ) %>
</p>
</div>
</div>
@@ -0,0 +1,184 @@
<fieldset class="box tabular"><legend><%= l(:label_helpdesk_incoming_mail_server) %></legend>
<script type="text/javascript" charset="utf-8">
function changeServerSettings(element) {
$('#helpdesk_use_ssl_field').show();
if (element.value == 'pop3') {
$('#server_settings').show();
$('#imap_settings').hide();
$('#pop3_settings').show();
$('#host_settings').show();
}
if (element.value == 'imap') {
$('#server_settings').show();
$('#pop3_settings').hide();
$('#imap_settings').show();
$('#host_settings').show();
}
if (element.value == '') {
$('#server_settings').hide();
}
if (element.value == 'gmail' || element.value == 'yahoo' || element.value == 'yandex' ) {
$('#server_settings').show();
$('#host_settings').hide();
$('#imap_settings').show();
$('#pop3_settings').hide();
$('#helpdesk_use_ssl_field').hide();
}
}
</script>
<p>
<label><%= l(:label_helpdesk_protocol) %></label>
<%= select_tag :helpdesk_protocol, options_for_select([['', ""], ["pop3", "pop3"], ["imap", "imap"], ["Gmail", "gmail"], ["Yahoo", "yahoo"], ["Yandex", "yandex"]] , HelpdeskSettings[:helpdesk_protocol, @project.id]), :onchange => "changeServerSettings(this)" %>
</p>
<span id="server_settings" <%= "style=\"display: none;\"".html_safe if HelpdeskSettings[:helpdesk_protocol, @project.id].blank? %>>
<span id="host_settings" <%= "style=\"display: none;\"".html_safe if ["gmail","yahoo", "yandex"].include?(HelpdeskSettings[:helpdesk_protocol, @project.id]) %>>
<p>
<label><%= l(:label_helpdesk_host) %></label>
<%= text_field_tag :helpdesk_host, HelpdeskSettings[:helpdesk_host, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_port) %></label>
<%= text_field_tag :helpdesk_port, HelpdeskSettings[:helpdesk_port, @project.id] %>
</p>
</span>
<p>
<label><%= l(:label_helpdesk_username) %></label>
<%= text_field_tag :helpdesk_username, HelpdeskSettings[:helpdesk_username, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_password) %></label>
<%= link_to_function image_tag('edit.png'), '$(this).hide(); $("#helpdesk_password_field").show()' unless HelpdeskSettings[:helpdesk_username, @project.id].blank? %>
<%= content_tag 'span', :id => "helpdesk_password_field", :style => (HelpdeskSettings[:helpdesk_username, @project.id].blank? ? nil : 'display:none') do %>
<%= password_field_tag :helpdesk_password, '' %>
<% end %>
</p>
<p <%= "style=\"display: none;\"".html_safe if ["gmail", "yahoo", "yandex"].include?(HelpdeskSettings[:helpdesk_protocol, @project.id]) %> id="helpdesk_use_ssl_field">
<label><%= l(:label_helpdesk_ssl) %></label>
<%= hidden_field_tag(:helpdesk_use_ssl, 0) %>
<%= check_box_tag :helpdesk_use_ssl, 1, HelpdeskSettings[:helpdesk_use_ssl, @project.id].to_i > 0 %>
</p>
<span id="imap_settings" <%= "style=\"display: none;\"".html_safe if !["gmail", "yahoo", "yandex", "imap"].include?(HelpdeskSettings[:helpdesk_protocol, @project.id]) %>>
<p>
<label><%= l(:label_helpdesk_imap_folder) %></label>
<%= text_field_tag :helpdesk_imap_folder, HelpdeskSettings[:helpdesk_imap_folder, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_move_on_success) %></label>
<%= text_field_tag :helpdesk_move_on_success, HelpdeskSettings[:helpdesk_move_on_success, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_move_on_failure) %></label>
<%= text_field_tag :helpdesk_move_on_failure, HelpdeskSettings[:helpdesk_move_on_failure, @project.id] %>
</p>
</span>
<span id="pop3_settings" <%= "style=\"display: none;\"".html_safe if HelpdeskSettings[:helpdesk_protocol, @project.id] != "pop3" %>>
<p>
<label><%= l(:label_helpdesk_apop) %></label>
<%= hidden_field_tag(:helpdesk_apop, 0) %>
<%= check_box_tag :helpdesk_apop, 1, HelpdeskSettings[:helpdesk_apop, @project.id].to_i > 0 %>
</p>
<p>
<label><%= l(:label_helpdesk_delete_unprocessed) %></label>
<%= hidden_field_tag(:helpdesk_delete_unprocessed, 0) %>
<%= check_box_tag :helpdesk_delete_unprocessed, 1, HelpdeskSettings[:helpdesk_delete_unprocessed, @project.id].to_i > 0 %>
</p>
</span>
<div id="test_connection_messages">
</div>
<%= link_to l(:label_helpdesk_get_mail),
{},
:remote => true,
:onclick => "updateCustomForm('#{url_for(:controller => 'helpdesk', :action => 'get_mail', :project_id => @project)}', $('#helpdesk_settings'))" %>
<br/>
</span> <!-- Server settings -->
</fieldset>
<fieldset class="box tabular"><legend><%= l(:label_helpdesk_outgoing_mail_server) %> (experimental)</legend>
<p>
<label><%= l(:label_helpdesk_smtp_use_default_settings) %></label>
<%= hidden_field_tag(:helpdesk_smtp_use_default_settings, 1) %>
<%= check_box_tag :helpdesk_smtp_use_default_settings, 0, HelpdeskSettings[:helpdesk_smtp_use_default_settings, @project.id].to_i == 0, :onchange => "$('.smtp-settings').toggle(); return false;" %>
</p>
<span class="smtp-settings" <%= "style=\"display: none;\"".html_safe unless HelpdeskSettings[:helpdesk_smtp_use_default_settings, @project.id].to_i > 0 %>>
<p>
<label><%= l(:label_helpdesk_smtp_server) %></label>
<%= text_field_tag :helpdesk_smtp_server, HelpdeskSettings[:helpdesk_smtp_server, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_port) %></label>
<%= text_field_tag :helpdesk_smtp_port, HelpdeskSettings[:helpdesk_smtp_port, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_smtp_domain) %></label>
<%= text_field_tag :helpdesk_smtp_domain, HelpdeskSettings[:helpdesk_smtp_domain, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_authentication) %></label>
<%= select_tag :helpdesk_smtp_authentication, options_for_select([[l(:label_helpdesk_authentication_plain), "plain"], [l(:label_helpdesk_authentication_login), "login"], [l(:label_helpdesk_authentication_cram_md5), "cram_md5"]] , HelpdeskSettings[:helpdesk_smtp_authentication, @project.id]) %>
</p>
<p>
<label><%= l(:label_helpdesk_username) %></label>
<%= text_field_tag :helpdesk_smtp_username, HelpdeskSettings[:helpdesk_smtp_username, @project.id] %>
</p>
<p>
<label><%= l(:label_helpdesk_password) %></label>
<%= link_to_function image_tag('edit.png'), '$(this).hide(); $("#helpdesk_smtp_password_field").show()' unless HelpdeskSettings[:helpdesk_smtp_username, @project.id].blank? %>
<%= content_tag 'span', :id => "helpdesk_smtp_password_field", :style => (HelpdeskSettings[:helpdesk_smtp_username, @project.id].blank? ? nil : 'display:none') do %>
<%= password_field_tag :helpdesk_smtp_password, '' %>
<% end %>
</p>
<p>
<label><%= l(:label_helpdesk_ssl) %></label>
<%= hidden_field_tag(:helpdesk_smtp_ssl, 0) %>
<%= check_box_tag :helpdesk_smtp_ssl, 1, HelpdeskSettings[:helpdesk_smtp_ssl, @project.id].to_i > 0 %>
</p>
<p>
<label><%= l(:label_helpdesk_smtp_tls) %></label>
<%= hidden_field_tag(:helpdesk_smtp_tls, 0) %>
<%= check_box_tag :helpdesk_smtp_tls, 1, HelpdeskSettings[:helpdesk_smtp_tls, @project.id].to_i > 0 %>
</p>
</span> <!-- Server settings -->
</fieldset>
@@ -0,0 +1,28 @@
<%= error_messages_for 'helpdesk_settings' %>
<% errors = [] %>
<% errors << l(:label_helpdesk_enable_modules) unless @project.module_enabled?(:contacts) && @project.module_enabled?(:issue_tracking) %>
<% if errors.empty? %>
<%= form_tag({:controller => :helpdesk, :action => :save_settings, :project_id => @project, :tab => 'helpdesk'}, :method => :put, :class => "tabular", :multipart => true, :id => 'helpdesk_settings') do %>
<div class="splitcontentleft">
<h3><%=l(:label_helpdesk)%></h3>
<%= render :partial => 'projects/settings/helpdesk_general' %>
</div>
<div class="splitcontentright">
<h3><%=l(:label_helpdesk_server_settings)%></h3>
<%= render :partial => 'projects/settings/helpdesk_server' %>
</div>
<div style="clear:both;"></div>
<%= submit_tag l(:button_save) %>
<% end %>
<% else %>
<p class="nodata"><%= errors.join("<br/>").html_safe %></p>
<% end %>
@@ -0,0 +1,64 @@
<%= error_messages_for 'helpdesk_settings' %>
<% if @project.module_enabled?(:contacts) && @project.module_enabled?(:issue_tracking) %>
<%= form_tag({:controller => :helpdesk, :action => :save_settings, :project_id => @project, :tab => 'helpdesk_template'}, :method => :put, :class => "tabular", :multipart => true, :id => 'helpdesk_template') do %>
<fieldset class="box tabular"><legend><%= l(:label_helpdesk_answer_template) %></legend>
<p>
<label><%= l(:field_subject) %></label>
<%= text_field_tag "helpdesk_answer_subject", HelpdeskSettings["helpdesk_answer_subject", @project.id], :style => "width:100%" %>
</p>
<p>
<label><%= l(:setting_emails_header) %></label>
<%= text_area_tag "helpdesk_emails_header", HelpdeskSettings["helpdesk_emails_header", @project.id], :class => 'wiki-edit', :rows => 5 %>
</p>
<p>
<label><%= l(:setting_emails_footer) %></label>
<%= text_area_tag "helpdesk_emails_footer", HelpdeskSettings["helpdesk_emails_footer", @project.id], :class => 'wiki-edit', :rows => 5 %>
</p>
</fieldset>
<fieldset class="box" style="background-color: #FFD;"><legend><%= l(:label_helpdesk_auto_answer_template) %></legend>
<p>
<label><%= l(:label_send_auto_answer) %></label>
<%= hidden_field_tag("helpdesk_send_notification", 0) %>
<%= check_box_tag "helpdesk_send_notification", 1, ContactsSetting["helpdesk_send_notification", @project.id].to_i > 0 %>
</p>
<p>
<label><%= l(:field_subject) %></label>
<%= text_field_tag "helpdesk_first_answer_subject", HelpdeskSettings["helpdesk_first_answer_subject", @project.id], :style => "width:100%" %>
</p>
<p>
<label><%= l(:label_helpdesk_first_answer_template) %></label>
<%= text_area_tag "helpdesk_first_answer_template", HelpdeskSettings["helpdesk_first_answer_template", @project.id], :class => 'wiki-edit', :rows => 15 %>
<%= wikitoolbar_for 'helpdesk_first_answer_template' %>
</p>
</fieldset>
<div> <em class="info"><%= l(:text_helpdesk_answer_macros, :macro => HelpdeskSettings::MACRO_LIST.join(', ')) %></em></div>
<br/>
<%= submit_tag l(:button_save) %>
<% end %>
<% else %>
<p class="nodata"><%= l(:label_helpdesk_enable_modules) %></p>
<% end %>
<% content_for :header_tags do %>
<%= javascript_include_tag :"tag-it", :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :"jquery.tagit.css", :plugin => 'redmine_contacts' %>
<%= javascript_include_tag :contacts, :plugin => 'redmine_contacts' %>
<%= stylesheet_link_tag :contacts, :plugin => 'redmine_contacts' %>
<% end %>
@@ -0,0 +1,8 @@
<%= labelled_form_for @journal, :url => public_ticket_add_comment_path(:id => @ticket.id, :hash => @ticket.token) , :html => {:id => 'add_comment_form', :multipart => true} do |f| %>
<%= error_messages_for 'journal', 'journal_message' %>
<div class="box">
<%= f.text_area 'notes', :cols => 60, :rows => 10, :class => 'wiki-edit' %>
<%= wikitoolbar_for 'journal_notes' %>
</div>
<%= submit_tag l(:button_submit) %>
<% end %>
@@ -0,0 +1,13 @@
<div class="attachments">
<% for attachment in attachments %>
<p><%= link_to_attachment_with_hash attachment, :class => 'icon icon-attachment', :download => true -%>
<% if attachment.is_text? %>
<%= link_to image_tag('magnifier.png'),
:controller => 'attachments', :action => 'show',
:id => attachment, :filename => attachment.filename %>
<% end %>
<%= h(" - #{attachment.description}") unless attachment.description.blank? %>
<span class="size">(<%= number_to_human_size attachment.filesize %>)</span>
</p>
<% end %>
</div>
@@ -0,0 +1,11 @@
<% for journal in journals %>
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %> <%= journal.journal_message.is_incoming? ? 'incoming' : 'outgoing' %>">
<div id="note-<%= journal.indice %>">
<h4><%= link_to "##{journal.indice}", {:anchor => "note-#{journal.indice}"}, :class => "journal-link" %>
<%= authoring_public journal, :label => :label_updated_time_by %></h4>
<div class="wiki" id="journal-<%= journal.id %>-notes">
<%= textilizable(journal, :notes) unless journal.notes.blank? %>
</div>
</div>
</div>
<% end %>
@@ -0,0 +1,19 @@
<% html_title "#{@issue.tracker.name} ##{@issue.id}: #{@issue.subject}" %>
<% if (RedmineHelpdesk.public_spent_time? && @total_spent_hours.to_i > 0) || !@previous_tickets.blank? %>
<% content_for :sidebar do %>
<% if RedmineHelpdesk.public_spent_time? && @total_spent_hours.to_i > 0 %>
<h3><%= l(:label_spent_time) %></h3>
<p><span class="icon icon-time"><%= l_hours(@total_spent_hours) %></span></p>
<% end %>
<% unless @previous_tickets.empty? %>
<h3>
<%= l(:label_helpdesk_previous_tickets) %>
</h3>
<% @previous_tickets.each do |previous_ticket| %>
<p><%= link_to "##{previous_ticket.id} - #{previous_ticket.subject} (#{previous_ticket.status.name})", public_ticket_path(previous_ticket.helpdesk_ticket, :hash => previous_ticket.helpdesk_ticket.token), :class => previous_ticket.css_classes %></p>
<% end %>
<% end %>
<% end %>
<% end %>
@@ -0,0 +1,79 @@
<h2><%= issue_heading(@issue) %></h2>
<div class="<%= @issue.css_classes %> details">
<%= avatar(@ticket.from_address, :size => "50") %>
<div class="subject">
<h3><%= @issue.subject %></h3>
</div>
<p class="author">
<%= l(:label_added_time_by, :author => mail_to(@ticket.from_address), :age => content_tag('acronym', distance_of_time_in_words(Time.now, @issue.created_on), :title => format_time(@issue.created_on))).html_safe %>.
<%# authoring @issue.created_on, mail_to(@ticket.from_address) %>
<% if @issue.created_on != @issue.updated_on %>
<%= l(:label_updated_time, ticket_time_tag(@issue.updated_on)).html_safe %>.
<% end %>
</p>
<<%= Redmine::VERSION.to_s > '3.2' ? 'div' : 'table' %> class="attributes">
<%= issue_fields_rows do |rows|
rows.left l(:field_status), h(@issue.status.name), :class => 'status'
unless @issue.disabled_core_fields.include?('assigned_to_id')
rows.left l(:field_assigned_to), (@issue.assigned_to ? @issue.assigned_to.name : "-"), :class => 'assigned-to'
end
unless @issue.disabled_core_fields.include?('done_ratio')
rows.right l(:field_done_ratio), progress_bar(@issue.done_ratio, :width => '80px', :legend => "#{@issue.done_ratio}%"), :class => 'progress'
end
if RedmineHelpdesk.public_spent_time?
unless @issue.disabled_core_fields.include?('estimated_hours')
if RedmineHelpdesk.public_spent_time? && !@issue.estimated_hours.blank?
rows.right l(:field_estimated_hours), l_hours(@issue.estimated_hours), :class => 'estimated-hours'
end
end
rows.right l(:label_spent_time), (@issue.total_spent_hours > 0 ? l_hours(@issue.total_spent_hours) : "-"), :class => 'spent-time'
end
end %>
<%# render_custom_fields_rows(@issue) %>
</<%= Redmine::VERSION.to_s > '3.2' ? 'div' : 'table' %>>
<% if @issue.description? || @issue.attachments.any? -%>
<hr />
<% if @issue.description? %>
<p><strong><%=l(:field_description)%></strong></p>
<div class="wiki">
<%= textilizable @issue, :description, :attachments => @issue.attachments %>
</div>
<% end %>
<% if @issue.attachments.any? %>
<fieldset class="collapsible collapsed">
<legend onclick="toggleFieldset(this);"><%= l(:label_attachment_plural) %></legend>
<div style="display: none;">
<%= link_to_attachments_with_hash @issue, :thumbnails => true %>
</div>
</fieldset>
<% end %>
<% end -%>
</div>
<% if @journals.present? %>
<div id="history" class="ticket-history">
<h3><%=l(:label_history)%></h3>
<%= render :partial => 'public_tickets/history', :locals => { :issue => @issue, :journals => @journals } %>
</div>
<% end %>
<div style="clear: both;"></div>
<% if RedmineHelpdesk.public_comments? %>
<p><%= toggle_link l(:label_comment_add), "update", :focus => "journal_notes" %></p>
<div id="update" style="display:none;">
<h3><%= l(:label_comment_add) %></h3>
<%= render :partial => 'add_comment' %>
</div>
<% end %>
<%= render :partial => 'sidebar_content' %>
@@ -0,0 +1,18 @@
<% helpdesk_tabs = [
{:name => 'general', :partial => 'settings/helpdesk_general', :label => :label_helpdesk_general},
{:name => 'public', :partial => 'settings/helpdesk_public', :label => :label_helpdesk_settings_public},
{:name => 'templates', :partial => 'settings/helpdesk_template', :label => :label_helpdesk_template},
{:name => 'votes', :partial => 'settings/helpdesk_vote', :label => :label_helpdesk_vote},
{:name => 'canned_responses', :partial => 'settings/helpdesk_canned_responses', :label => :label_helpdesk_canned_response_plural},
{:name => 'widget', :partial => 'settings/helpdesk_widget', :label => :label_helpdesk_widget}
] %>
<% helpdesk_tabs.push({:name => 'hidden', :partial => 'settings/helpdesk_hidden', :label => :label_crm_contacts_hidden}) if params[:hidden] %>
<%= render_tabs helpdesk_tabs %>
<% html_title(l(:label_settings), l(:label_helpdesk)) -%>
<% content_for(:header_tags) do %>
<%= javascript_include_tag :redmine_helpdesk, :plugin => 'redmine_contacts_helpdesk' %>
<% end %>
@@ -0,0 +1,2 @@
<% @canned_responses = CannedResponse.all %>
<%= render :partial => 'canned_responses/index' %>
@@ -0,0 +1,50 @@
<p>
<label><%= l(:label_helpdesk_from_address) %></label>
<%= text_field_tag 'settings[helpdesk_answer_from]', @settings["helpdesk_answer_from"], :size => "98%" %>
<em class="info"><%= l(:text_helpdesk_answer_macros, :macro => HelpdeskSettings::FROM_MACRO_LIST.join(', ')) %></em>
</p>
<p>
<label><%= l(:label_helpdesk_save_cc) %></label>
<%= check_box_tag 'settings[helpdesk_save_cc]', 1, @settings["helpdesk_save_cc"] %>
</p>
<p>
<label><%= l(:label_helpdesk_send_note_by_default) %></label>
<%= check_box_tag 'settings[send_note_by_default]', 1, @settings["send_note_by_default"] %>
</p>
<p>
<label><%= l(:label_helpdesk_add_contact_notes) %></label>
<%= check_box_tag 'settings[helpdesk_add_contact_notes]', 1, @settings["helpdesk_add_contact_notes"] %>
</p>
<p>
<label><%= l(:label_helpdesk_assign_contact_user) %></label>
<%= check_box_tag 'settings[helpdesk_assign_contact_user]', 1, @settings["helpdesk_assign_contact_user"], :class => 'assign_contact_user' %>
</p>
<p>
<label class='parent'><%= l(:label_helpdesk_create_private_tickets) %></label>
<%= check_box_tag 'settings[helpdesk_create_private_tickets]', 1, @settings["helpdesk_create_private_tickets"], :disabled => true, :class => 'private_tikets' %>
</p>
<p>
<label class='parent'><%= l(:label_helpdesk_autoclose_tickets_after) %></label>
<%= select_tag 'settings[helpdesk_autoclose_tickets_after]', options_for_select((1..24).map{ |h| [h, h] }, @settings["helpdesk_autoclose_tickets_after"]),
:onchange => "toggleStatusesForAutoclose(this); return false", :include_blank => true %>
<%= radio_button_tag 'settings[helpdesk_autoclose_tickets_time_unit]', 'day', RedmineHelpdesk.autoclose_time_unit_is?('day') || RedmineHelpdesk.autoclose_time_unit.nil? %>
<%= l(:label_helpdesk_days) %>
<%= radio_button_tag 'settings[helpdesk_autoclose_tickets_time_unit]', 'hour', RedmineHelpdesk.autoclose_time_unit_is?('hour') %>
<%= l(:label_helpdesk_hours) %>
</p>
<div id="statuses_autoclose" style="display:<%= !@settings["helpdesk_autoclose_tickets_after"].blank? ? 'block' : 'none' %>">
<p>
<label><%= l(:label_helpdesk_autoclose_from_status) %></label>
<%= select_tag 'settings[helpdesk_autoclose_from_status]', options_for_select(IssueStatus.all.map{|st| [st.name, st.id]}, @settings["helpdesk_autoclose_from_status"]), :style => "width:150px" %>
</p>
<p>
<label><%= l(:label_helpdesk_autoclose_to_status) %></label>
<%= select_tag 'settings[helpdesk_autoclose_to_status]', options_for_select(IssueStatus.all.map{|st| [st.name, st.id]}, @settings["helpdesk_autoclose_to_status"]), :style => "width:150px" %>
</p>
</div>
@@ -0,0 +1,9 @@
<p>
<label><%= l(:setting_plain_text_mail) %></label>
<%= check_box_tag 'settings[plain_text_mail]', 1, @settings["plain_text_mail"] %>
</p>
<p>
<label>Do not strip HTML tags</label>
<%= check_box_tag 'settings[helpdesk_do_not_strip_tags]', 1, @settings["helpdesk_do_not_strip_tags"] %>
</p>
@@ -0,0 +1,20 @@
<p>
<label><%= l(:label_helpdesk_public_tickets) %></label>
<%= check_box_tag 'settings[helpdesk_public_tickets]', 1, @settings["helpdesk_public_tickets"] %>
</p>
<p>
<label><%= l(:label_helpdesk_public_show_spent_time) %></label>
<%= check_box_tag 'settings[helpdesk_public_show_spent_time]', 1, @settings["helpdesk_public_show_spent_time"] %>
</p>
<p>
<label><%= l(:label_helpdesk_public_comments) %></label>
<%= check_box_tag 'settings[helpdesk_public_comments]', 1, @settings["helpdesk_public_comments"] %>
</p>
<p>
<label><%= l(:label_helpdesk_public_title) %></label>
<%= text_field_tag 'settings[helpdesk_public_title]', @settings["helpdesk_public_title"], :size => "98%" %>
</p>
@@ -0,0 +1,45 @@
<fieldset class="box tabular"><legend><%= l(:label_helpdesk_answer_template) %></legend>
<p>
<label><%= l(:field_subject) %></label>
<%= text_field_tag 'settings[helpdesk_answer_subject]', @settings["helpdesk_answer_subject"], :style => "width:100%" %>
</p>
<p>
<label><%= l(:setting_emails_header) %></label>
<%= text_area_tag 'settings[helpdesk_emails_header]', @settings["helpdesk_emails_header"], :class => 'wiki-edit', :rows => 5 %>
</p>
<p>
<label><%= l(:setting_emails_footer) %></label>
<%= text_area_tag 'settings[helpdesk_emails_footer]', @settings["helpdesk_emails_footer"], :class => 'wiki-edit', :rows => 5 %>
</p>
</fieldset>
<fieldset class="box" style="background-color: #FFD;"><legend><%= l(:label_helpdesk_auto_answer_template) %></legend>
<p>
<label><%= l(:field_subject) %></label>
<%= text_field_tag 'settings[helpdesk_first_answer_subject]', @settings["helpdesk_first_answer_subject"], :style => "width:100%" %>
</p>
<%= text_area_tag 'settings[helpdesk_first_answer_template]', @settings["helpdesk_first_answer_template"], :class => 'wiki-edit', :rows => 15 %>
</fieldset>
<fieldset class="box"><legend><%= l(:label_helpdesk_css) %></legend>
<%= text_area_tag 'settings[helpdesk_helpdesk_css]', @settings["helpdesk_helpdesk_css"], :class => 'wiki-edit', :rows => 10 %>
</fieldset>
<em class="info"><%= l(:text_helpdesk_answer_macros, :macro => HelpdeskSettings::MACRO_LIST.join(', ')) %></em>
<% if params[:show_hidden] %>
<fieldset id="hidden_settings">
<p>
<label>Show excerpt issues list</label>
<%= check_box_tag 'settings[show_excerpt_tickets_list]', 1, @settings["show_excerpt_tickets_list"] %>
</p>
</fieldset>
<% end %>
@@ -0,0 +1,9 @@
<p>
<label><%= l(:label_helpdesk_vote_settings) %></label>
<%= check_box_tag 'settings[helpdesk_vote_accept]', 1, @settings["helpdesk_vote_accept"] %>
</p>
<p>
<label><%= l(:label_helpdesk_vote_comment_settings) %></label>
<%= check_box_tag 'settings[helpdesk_vote_comment_accept]', 1, @settings["helpdesk_vote_comment_accept"] %>
</p>
@@ -0,0 +1,44 @@
<p>
<label><%= l(:label_helpdesk_widget_enable) %></label>
<%= check_box_tag 'settings[helpdesk_widget_enable]', 1, @settings["helpdesk_widget_enable"] %>
</p>
<% if @settings["helpdesk_widget_enable"].to_i > 0 %>
<p>
<label><%= l(:label_helpdesk_widget_available_projects) %></label>
<% @helpdesk_projects = Project.visible.has_module('contacts_helpdesk') %>
<% if @helpdesk_projects.count > 0 %>
<% @helpdesk_projects.each do |project| %>
<%= check_box_tag 'settings[helpdesk_widget_available_projects][]', project.id, @settings["helpdesk_widget_available_projects"].try(:include?, project.id.to_s) %>
<span><%= project.name %></span>
<br>
<% end %>
<% else %>
<span class="error-text"><%= l(:label_helpdesk_widget_no_available_projects) %></span>
<% end %>
</p>
<p>
<label><%= l(:label_helpdesk_widget_custom_fields) %></label>
<% IssueCustomField.visible.each do |cf| %>
<%= check_box_tag 'settings[helpdesk_widget_available_custom_fields][]', cf.id, @settings["helpdesk_widget_available_custom_fields"].try(:include?, cf.id.to_s) %>
<span><%= cf.name %></span>
<br>
<% end %>
</p>
<h4><%= l(:label_helpdesk_widget_activation_message) %></h4>
<pre style="background-color: #ddd;">
<code class="html syntaxhl">
<%= Redmine::SyntaxHighlighting.highlight_by_language(
"<span>
<div id=\"helpdesk_widget\"></div>
<script type=\"text/javascript\" src=\"#{Setting.protocol}://#{Setting.host_name}/helpdesk_widget/widget.js\"></script>
</span>", "html").html_safe %>
</code>
</pre>
<span>
<div id="helpdesk_widget"></div>
<script type="text/javascript" src="<%= "#{Setting.protocol}://#{Setting.host_name}" %>/helpdesk_widget/widget.js"></script>
</span>
<% end %>
Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

@@ -0,0 +1,59 @@
function togglePrivateTicketsOnChange() {
var checked = $(this).is(':checked');
$('.private_tikets').attr('disabled', !checked);
}
function togglePrivateTicketsInit() {
$('.assign_contact_user').each(togglePrivateTicketsOnChange);
}
function toggleStatusesForAutoclose(node) {
if ($(node).val() == ''){
$('#statuses_autoclose').hide();
} else {
$('#statuses_autoclose').show();
}
}
function ccEmailTagResult (opt) {
if (opt.name){
var formated_tag = $('<span>' + opt.avatar + '&nbsp;' + opt.text + '</span>');;
} else {
var formated_tag = opt.text;
}
return formated_tag
};
function ccEmailTagSelection (opt) {
if (opt.name){
var formated_tag = opt.name + ' <' + opt.email + '>';
} else {
var formated_tag = opt.text;
}
return formated_tag
};
function showWithSendAndScrollTo(id, focus) {
showAndScrollTo(id, focus);
send_mail_input = $('#helpdesk_is_send_mail');
send_mail_input.prop( "checked", true );
send_mail_input.checked = true;
toggleSendMail(send_mail_input);
}
$(document).ready(function(){
$('#content').on('change', '.assign_contact_user', togglePrivateTicketsOnChange);
togglePrivateTicketsInit();
$('#history .contextual a[title="Quote"]').click(function(){
if ($('#ticket_data').length > 0) {
var journal_id = this.href.match(/journal_id=(\d*)/)[1];
console.log(journal_id);
$.ajax({
method: 'GET',
url: '/helpdesk/update_customer_email',
data: { journal_id: journal_id }
});
}
});
});
@@ -0,0 +1,370 @@
span.attachment img {
vertical-align: middle;
}
div#contacts_previous_issues {
margin-bottom: 20px;
}
/**********************************************************************/
/* TICKETS DATA SHOW ISSUE
/**********************************************************************/
span.helpdesk-message-date {
font-size: 0.9em;
color: #888;
}
/**********************************************************************/
/* TICKETS DATA FORM
/**********************************************************************/
form#ticket_data_form {
background:
#ffffff;
display: block;
padding: 6px;
margin-bottom: 6px;
margin-right: 10px;
border: 1px solid
#d7d7d7;
}
.contact_auto_complete > span {
font-weight: bold;
}
/**********************************************************************/
/* TICKETS EXCERPT LIST
/**********************************************************************/
table.list.issues td.helpdesk_ticket {white-space: normal; text-align: left;}
table.list.issues td.helpdesk_ticket .gravatar {float: left;}
table.list.issues td.helpdesk_ticket .ticket-data {margin-left: 45px;}
table.list.issues td.helpdesk_ticket .ticket-data .ticket-name {margin: 0px;font-weight: bold;}
table.list.issues td.helpdesk_ticket .ticket-status {float: left;}
table.list.issues td.last_message {vertical-align: top}
/**********************************************************************/
/* PUBCLIV TICKETS
/**********************************************************************/
div.ticket-history div.journal {
overflow: visible;
}
div.ticket-history div.journal.incoming,
div.ticket-history div.journal.outgoing {
padding: 6px;
margin-bottom: 6px;
border: 1px solid #d7d7d7;
position: relative;
}
div.ticket-history div.journal.incoming {
background: #ececec;
margin-right: 30px;
margin-left: 4px;
}
div.ticket-history div.journal.outgoing {
background: #f1faff;
margin-left: 30px;
margin-right: 4px;
}
div.ticket-history div.journal.incoming:after {
content: '';
display: block;
position: absolute;
top: 10px;
left:-7px;
width: 10px;
height: 10px;
background: #ececec;
border-left:1px solid #d7d7d7;
border-bottom:1px solid #d7d7d7;
-moz-transform:rotate(45deg);
-webkit-transform:rotate(45deg);
}
div.ticket-history div.journal.outgoing:after {
content: '';
display: block;
position: absolute;
top: 10px;
right:-7px;
width: 10px;
height: 10px;
background: #f1faff;
border-right:1px solid #d7d7d7;
border-bottom:1px solid #d7d7d7;
-moz-transform:rotate(-45deg);
-webkit-transform:rotate(-45deg);
}
/**********************************************************************/
/* TICKET DATA
/**********************************************************************/
span.ticket-status {
padding: 3px 4px;
font-size: 10px;
white-space: nowrap;
margin-right: 4px;
color: white;
}
div#ticket_data > div.contextual {
margin-top: initial;
}
form.new_issue .email-template {
padding: 10px;
}
span.ticket-status.status-1 {background-color: #CD5C5C;}
span.ticket-status.status-2 {background-color: deepSkyBlue;}
span.ticket-status.status-3 {background-color: green;}
span.ticket-status.status-4 {background-color: #FF8C00;}
span.ticket-status.status-5 {background-color: #AAA;}
span.ticket-status.status-6 {background-color: #8A2BE2;}
/**********************************************************************/
/* SHOW TICKET
/**********************************************************************/
#ticket-history .ticket-avatar {float: left;}
#ticket-history .ticket-note-content {margin-left: 50px;}
#ticket-history .ticket-note {border-top: 1px solid #d7d7d7; padding-top: 8px;}
/**********************************************************************/
/* SEND RESPONSE
/**********************************************************************/
.cc-list-edit .is-cc {
float: left;
min-width: 25px;
margin-top: 4px;
}
.cc-list-edit {
margin-top: -8px;
}
.cc-list-edit .select2 {
margin-left: 60px;
width: 90% !important;
-moz-border-radius: 0px;
-webkit-border-radius: 0px;
-khtml-border-radius: 0px;
border-radius: 0px;
background: white;
padding: 0px;
}
.cc-list-edit .select2 .select2-selection__choice {
background-color: #D7E7F9;
color: #000;
}
.cc-list-edit ul.tagit li.tagit-choice .tagit-close .text-icon:hover {
color: black;
}
.cc-list-edit ul.tagit li.tagit-new input {
font-size: 11px;
background: white;
margin-bottom: 2px;
margin-left: 2px;
width: 200px;
}
.cc-list-edit ul.tagit li.tagit-new {
padding: 0px;
}*/
p#helpdesk_send_response {
margin-top: 5px;
}
/**********************************************************************/
/* ISSUES LIST
/**********************************************************************/
tr.issue td.customer, tr.issue td.customer_company {
white-space: normal;
text-align: left;
}
table.list.issue td.last_message img.gravatar{
vertical-align: middle;
margin: 0 4px 2px 0;
}
div.email-template {
background-color: #FFD;
border: 1px solid #E4E4E4;
padding-left: 10px;
padding-right: 10px;
margin-bottom: 5px;
}
tr.issue td.last_message {
text-align: left;
white-space: normal;
padding: 5px;
}
tr.issue td.last_message_date {
text-align: left;
white-space: normal;
}
tr.issue.context-menu-selection td.last_message span.description {
color: inherit;
}
/**********************************************************************/
/* ICONS
/**********************************************************************/
#admin-menu a.helpdesk { background-image: url(../images/support.png)}
.icon-public-link { background-image: url(../../../images/external.png);padding-left: 14px;}
.icon-email-spam { background-image: url(../images/email_error.png); }
.icon-email-to { background-image: url(../images/email_go.png); }
.icon-email-from { background-image: url(../images/email_from.png); }
.icon-helpdesk { background-image: url(../images/user_comment.png); }
.icon-split { background-image: url(../images/arrow_divide.png); }
.icon-web { background-image: url(../images/world.png); }
.icon-support { background-image: url(../images/support.png); }
.icon-reply { background-image: url(../images/reply.png); }
/**********************************************************************/
/* VOTE
/**********************************************************************/
.vote_form {
text-align: center;
}
.submit, #vote_comment {
margin-top: 5px;
width: 40%;
margin-left: auto;
margin-right: auto;
padding: 10px;
}
.vote-value .icon {
padding-left: 20px;
margin-left: 5px;
}
.icon-awesome { background-image: url(../images/awesome.png); }
.icon-justok { background-image: url(../images/just_ok.png); }
.icon-notgood { background-image: url(../images/not_good.png); }
.error-text { color: red; }
/**********************************************************************/
/* CHARTS
/**********************************************************************/
.helpdesk_chart { text-align: center; width: 100%; }
.center { text-align: center; }
.chart_table { margin: auto; border-collapse:collapse; text-align: center }
.chart_table .header { height: 50px; background-color: #eee }
.chart_table .header .column_data { border: 1px solid #c0c0c0; padding: 0px; }
.chart_table .header .column_data .issues_count { font-weight: bold; }
.chart_table .main_block { height: 200px }
.chart_table .main_block .column_data { vertical-align: bottom; width: 80px; border: 1px solid #c0c0c0; padding: 0px; }
.chart_table .main_block .column_data .percents { background-color: #BAE0BA; }
.chart_table .footer { height: 50px }
.chart_table .footer .column_data { font-weight: bold; padding: 0px; }
/* Metrics*/
table.metrics {
border-collapse: separate;
table-layout: fixed;
width: 100%;
}
.metrics.box {
background-color: #eee;
width: 250px;
margin: 15px;
border: 1px solid #c0c0c0;
display: inline-block;
padding: 20px 5px;
}
.metrics td {
padding: 25px 0;
border-top: 1px solid #dbdde1;
text-align: left;
}
.metrics .num {
color: #444;
font-size: 29px;
margin-right: 8px;
display: inline-block;
}
.metrics .change {
display: inline-block;
color: #999;
font-size: 13px;
text-align: center;
}
.metrics p {
color: #999;
margin: 2px;
line-height: 1.4em;
}
.change>.caret {
border-width: 6px;
display: block;
margin: 0 auto 4px
}
.change>.neg {
border-top-color: #ed5a5a
}
.change>.pos {
border-top: 0;
border-bottom: 6px solid #43ac6d
}
.change>.mirror_pos {
border-top: 0;
border-bottom: 6px solid #ed5a5a
}
.change>.mirror_neg {
border-top-color: #43ac6d
}
.caret {
display: inline-block;
width: 0;
height: 0;
vertical-align: top;
border-top: 4px solid #2b2b2b;
border-right: 4px solid transparent;
border-left: 4px solid transparent;
content: ""
}
.metrics .num>span {
font-size: 16px;
}
#customer_previous_issues ul {margin: 0; padding: 0;}
#customer_previous_issues ul li {position: relative; margin-bottom: 10px}
#customer_previous_issues ul li .ticket-meta {color: #888; font-size: 0.9em;display: block}

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