From 8bf6c1098bf08ff604618f4b0474bd299ebdca0f Mon Sep 17 00:00:00 2001 From: Felix Niklas Date: Thu, 18 May 2017 10:46:31 +0200 Subject: [PATCH 001/234] Unleash New Ticket Width see #1059 --- app/assets/stylesheets/zammad.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 616fff940..79e200af3 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -5529,7 +5529,7 @@ footer { } .box.box--newTicket { - max-width: 658px; + max-width: 1080px; margin-left: auto; margin-right: auto; } From 8836aa081963a8e7d0594d8a7e9be7836b52ee53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bauer?= Date: Sun, 21 May 2017 10:53:51 +0200 Subject: [PATCH 002/234] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 715e22d1f..1c4a36d4f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ with a team of agents? You're going to love Zammad! -## Statusbadges +## Status - Build: [![Build Status](https://travis-ci.org/zammad/zammad.svg?branch=develop)](https://travis-ci.org/zammad/zammad) - Code: [![Code Climate](https://codeclimate.com/github/zammad/zammad/badges/gpa.svg)](https://codeclimate.com/github/zammad/zammad) [![Coverage Status](https://coveralls.io/repos/github/zammad/zammad/badge.svg)](https://coveralls.io/github/zammad/zammad) From 95d0b5bc7bfbd046438cb4f59169e1610e6f5bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bauer?= Date: Sun, 21 May 2017 10:58:41 +0200 Subject: [PATCH 003/234] changed readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 715e22d1f..1c4a36d4f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ with a team of agents? You're going to love Zammad! -## Statusbadges +## Status - Build: [![Build Status](https://travis-ci.org/zammad/zammad.svg?branch=develop)](https://travis-ci.org/zammad/zammad) - Code: [![Code Climate](https://codeclimate.com/github/zammad/zammad/badges/gpa.svg)](https://codeclimate.com/github/zammad/zammad) [![Coverage Status](https://coveralls.io/repos/github/zammad/zammad/badge.svg)](https://coveralls.io/github/zammad/zammad) From 18a27f0d7a8892232a5f378bd7f0cd46d3736bba Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 22 May 2017 21:44:40 +0200 Subject: [PATCH 004/234] Fixed issue #990 - paste in not possible in contentediable fields. --- .../app/lib/base/jquery.contenteditable.js | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js index 32b336806..2a1994828 100644 --- a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js +++ b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js @@ -254,6 +254,9 @@ if (e.clipboardData) { // ie clipboardData = e.clipboardData } + else if (window.clipboardData) { // ie + clipboardData = window.clipboardData + } else if (e.originalEvent.clipboardData) { // other browsers clipboardData = e.originalEvent.clipboardData } @@ -307,15 +310,23 @@ } // check existing + paste text for limit - var text = clipboardData.getData('text/html') - var docType = 'html' - if (!text || text.length === 0) { - docType = 'text' - text = clipboardData.getData('text/plain') + var text, docType + try { + text = clipboardData.getData('text/html') + docType = 'html' + if (!text || text.length === 0) { + docType = 'text' + text = clipboardData.getData('text/plain') + } + if (!text || text.length === 0) { + docType = 'text2' + text = clipboardData.getData('text') + } } - if (!text || text.length === 0) { - docType = 'text2' - text = clipboardData.getData('text') + catch (e) { + console.log('Sorry, can\'t insert markup because browser is not supporting it.') + docType = 'text3' + text = clipboardData.getData('text') } _this.log('paste', docType, text) @@ -355,7 +366,14 @@ // cleanup text = App.Utils.removeEmptyLines(text) _this.log('insert', text) - document.execCommand('insertHTML', false, text) + + // as fallback, insert html via pasteHtmlAtCaret (for IE 11 and lower) + if (docType == 'text3') { + _this.pasteHtmlAtCaret(text) + } + else { + document.execCommand('insertHTML', false, text) + } return true }) @@ -515,6 +533,37 @@ return this.$element.html().trim() } + // taken from https://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div/6691294#6691294 + Plugin.prototype.pasteHtmlAtCaret = function(html) { + var sel, range; + if (window.getSelection) { + sel = window.getSelection() + if (sel.getRangeAt && sel.rangeCount) { + range = sel.getRangeAt(0) + range.deleteContents() + + var el = document.createElement('div') + el.innerHTML = html; + var frag = document.createDocumentFragment(), node, lastNode + while ( (node = el.firstChild) ) { + lastNode = frag.appendChild(node) + } + range.insertNode(frag) + + if (lastNode) { + range = range.cloneRange() + range.setStartAfter(lastNode) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + } + } + } + else if (document.selection && document.selection.type != 'Control') { + document.selection.createRange().pasteHTML(html) + } + } + // log method Plugin.prototype.log = function() { if (App && App.Log) { From aa37dc02ca72cb84049c8599bbe1c0e4edbc2d50 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 23 May 2017 08:02:59 +0200 Subject: [PATCH 005/234] Added expire_in to gitlab config. --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index adb7fb768..0fe78e959 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -409,6 +409,7 @@ browser:build: - rake assets:precompile - rake db:drop artifacts: + expire_in: 1 week paths: - public/assets/.sprockets-manifest* - public/assets/application-* From e00776ea64133f020ea4ec98b8f1aa15bf8a8358 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 23 May 2017 10:53:19 +0200 Subject: [PATCH 006/234] Fixed issue #764 - Not showing TO: / CC: fields on IE11 when answering a ticket. --- app/assets/javascripts/app/lib/app_post/utils.coffee | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/app/lib/app_post/utils.coffee b/app/assets/javascripts/app/lib/app_post/utils.coffee index 44e4ff560..8be20ad0e 100644 --- a/app/assets/javascripts/app/lib/app_post/utils.coffee +++ b/app/assets/javascripts/app/lib/app_post/utils.coffee @@ -125,6 +125,7 @@ class App.Utils child = el.firstChild break if !child break if child.nodeType isnt 1 || child.tagName isnt 'BR' + break if !child.remove child.remove() loop @@ -133,6 +134,7 @@ class App.Utils child = el.lastChild break if !child break if child.nodeType isnt 1 || child.tagName isnt 'BR' + break if !child.remove child.remove() # true|false = App.Utils.htmlLastLineEmpty(element) From a2a6309692d06b986e93da0e1137805f5f934987 Mon Sep 17 00:00:00 2001 From: Rolf Schmidt Date: Tue, 23 May 2017 11:26:46 +0200 Subject: [PATCH 007/234] Fixed issue #1034 - Update of object attribute leads to CSRF token verification failure. --- app/controllers/object_manager_attributes_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/object_manager_attributes_controller.rb b/app/controllers/object_manager_attributes_controller.rb index 5dbf0d281..8cd188023 100644 --- a/app/controllers/object_manager_attributes_controller.rb +++ b/app/controllers/object_manager_attributes_controller.rb @@ -1,7 +1,7 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class ObjectManagerAttributesController < ApplicationController - before_action { authentication_check(permission: 'admin.object') } + prepend_before_action { authentication_check(permission: 'admin.object') } # GET /object_manager_attributes_list def list From 586d47a41f8133ea2e55efede4ae5327ad3593a8 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 24 May 2017 12:57:00 +0200 Subject: [PATCH 008/234] Fixed issue #1131 - Email loop if default trigger for follow up is active and customer email address is invalid. --- app/models/ticket.rb | 39 +++- test/unit/ticket_trigger_test.rb | 385 +++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+), 1 deletion(-) diff --git a/app/models/ticket.rb b/app/models/ticket.rb index d34688823..ec000154e 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -838,11 +838,48 @@ perform changes on ticket if item && item[:article_id] article = Ticket::Article.lookup(id: item[:article_id]) if article && article.preferences['is-auto-response'] == true && article.from && article.from =~ /#{Regexp.quote(recipient_email)}/i - logger.info "Send not trigger based notification to #{recipient_email} because of auto response tagged incoming email" + logger.info "Send no trigger based notification to #{recipient_email} because of auto response tagged incoming email" next end end + # loop protection / check if maximal count of trigger mail has reached + map = { + 30 => 15, + 60 => 25, + 180 => 50, + } + skip = false + map.each { |minutes, count| + already_sent = Ticket::Article.where( + ticket_id: id, + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + ).where("ticket_articles.created_at > ? AND ticket_articles.to LIKE '%#{recipient_email.strip}%'", Time.zone.now - minutes.minutes).count + next if already_sent < count + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} for this ticket within last #{minutes} minutes (loop protection)" + skip = true + break + } + next if skip + map = { + 1 => 150, + 3 => 250, + 6 => 450, + } + skip = false + map.each { |hours, count| + already_sent = Ticket::Article.where( + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'email'), + ).where("ticket_articles.created_at > ? AND ticket_articles.to LIKE '%#{recipient_email.strip}%'", Time.zone.now - hours.hours).count + next if already_sent < count + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{hours} hour(s) (loop protection)" + skip = true + break + } + next if skip + email = recipient_email.downcase.strip next if recipients_checked.include?(email) recipients_checked.push(email) diff --git a/test/unit/ticket_trigger_test.rb b/test/unit/ticket_trigger_test.rb index 05343af10..c0c59836b 100644 --- a/test/unit/ticket_trigger_test.rb +++ b/test/unit/ticket_trigger_test.rb @@ -2845,4 +2845,389 @@ class TicketTriggerTest < ActiveSupport::TestCase assert_equal(1, ticket1.articles.count, 'ticket1.articles verify') end + test '2 loop check' do + trigger1 = Trigger.create_or_update( + name: 'aaa loop check', + condition: { + 'ticket.state_id' => { + 'operator' => 'is', + 'value' => Ticket::State.all.pluck(:id), + }, + 'article.sender_id' => { + 'operator' => 'is', + 'value' => Ticket::Article::Sender.lookup(name: 'Customer').id, + }, + 'article.type_id' => { + 'operator' => 'is', + 'value' => [ + Ticket::Article::Type.lookup(name: 'email').id, + Ticket::Article::Type.lookup(name: 'phone').id, + Ticket::Article::Type.lookup(name: 'web').id, + ], + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some lala', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your inquiry - loop check (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket1 = Ticket.create( + title: 'loop try 1', + group: Group.lookup(name: 'Users'), + customer: User.lookup(email: 'nicole.braun@zammad.org'), + updated_by_id: 1, + created_by_id: 1, + ) + assert(ticket1, 'ticket1 created') + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: "some message note\nnew line", + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + ticket1.reload + assert_equal(1, ticket1.articles.count) + + Observer::Transaction.commit + ticket1.reload + assert_equal(2, ticket1.articles.count) + + ticket1.priority = Ticket::Priority.lookup(name: '2 normal') + ticket1.save! + + Observer::Transaction.commit + ticket1.reload + assert_equal(2, ticket1.articles.count) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(4, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[2].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[3].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(6, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[4].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[5].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(8, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[6].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[7].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(10, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[8].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[9].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(12, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[10].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[11].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(14, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[12].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[13].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(16, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[14].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[15].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(18, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[16].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[17].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(20, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[18].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[19].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(22, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[20].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[21].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(24, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[22].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[23].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(26, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[24].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[25].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(28, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[26].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[27].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(30, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[28].from) + assert_equal('nicole.braun@zammad.org', ticket1.articles[29].to) + + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_loop_sender@example.com', + to: 'some_loop_recipient@example.com', + subject: 'some subject 1234', + message_id: 'some@id', + content_type: 'text/html', + body: 'some message note
new line', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + + Observer::Transaction.commit + ticket1.reload + assert_equal(31, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[30].from) + + end + end From 626c11667920ecc27a59fe8bc9cf6887b82c2046 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 24 May 2017 15:58:22 +0200 Subject: [PATCH 009/234] Also take origin_by_id for article assets. --- app/models/ticket/article/assets.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/ticket/article/assets.rb b/app/models/ticket/article/assets.rb index eef72101c..82b266648 100644 --- a/app/models/ticket/article/assets.rb +++ b/app/models/ticket/article/assets.rb @@ -42,7 +42,7 @@ returns data[ app_model_article ][ id ] = attributes_with_association_ids end - %w(created_by_id updated_by_id).each { |local_user_id| + %w(created_by_id updated_by_id origin_by_id).each { |local_user_id| next if !self[ local_user_id ] next if data[ app_model_user ] && data[ app_model_user ][ self[ local_user_id ] ] user = User.lookup(id: self[ local_user_id ]) From 0b54d60e4f607a3c035e0448e641fc450d28de8f Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Fri, 26 May 2017 09:54:00 +0200 Subject: [PATCH 010/234] Small code cleanup (added placeholder for overview error message) and improved filter description. --- app/assets/javascripts/app/controllers/_channel/email.coffee | 4 +--- .../javascripts/app/views/ticket_overview/index.jst.eco | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index 5eb14a75f..c22b4814d 100644 --- a/app/assets/javascripts/app/controllers/_channel/email.coffee +++ b/app/assets/javascripts/app/controllers/_channel/email.coffee @@ -45,9 +45,7 @@ class App.ChannelEmailFilter extends App.Controller template = $( '' ) - description = ''' -With Filters you can e. g. dispatch new Tickets into certain groups or set a certain priority for Tickets of an VIP customer. -''' + description = 'With filters you can e. g. dispatch new tickets into certain groups or set a certain priority for tickets of a VIP customer.' new App.ControllerTable( el: template.find('.overview') diff --git a/app/assets/javascripts/app/views/ticket_overview/index.jst.eco b/app/assets/javascripts/app/views/ticket_overview/index.jst.eco index 5f4329177..b38fc6a6f 100644 --- a/app/assets/javascripts/app/views/ticket_overview/index.jst.eco +++ b/app/assets/javascripts/app/views/ticket_overview/index.jst.eco @@ -1,3 +1,4 @@ +
From e9efae4b83f57bb9c941962b6f42702c5cde46b1 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Fri, 26 May 2017 15:32:37 +0200 Subject: [PATCH 011/234] Fixed elasticsearch index issue by replace Twitter::NullObject with nil. --- ...70525000001_fixed_twitter_ticket_article_preferences.rb} | 0 lib/tweet_base.rb | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename db/migrate/{20170314000002_fixed_twitter_ticket_article_preferences.rb => 20170525000001_fixed_twitter_ticket_article_preferences.rb} (100%) diff --git a/db/migrate/20170314000002_fixed_twitter_ticket_article_preferences.rb b/db/migrate/20170525000001_fixed_twitter_ticket_article_preferences.rb similarity index 100% rename from db/migrate/20170314000002_fixed_twitter_ticket_article_preferences.rb rename to db/migrate/20170525000001_fixed_twitter_ticket_article_preferences.rb diff --git a/lib/tweet_base.rb b/lib/tweet_base.rb index 3c1388bef..52c99d15c 100644 --- a/lib/tweet_base.rb +++ b/lib/tweet_base.rb @@ -10,11 +10,11 @@ class TweetBase def user(tweet) if tweet.class == Twitter::DirectMessage - Rails.logger.error "Twitter sender for dm (#{tweet.id}): found" + Rails.logger.debug "Twitter sender for dm (#{tweet.id}): found" Rails.logger.debug tweet.sender.inspect return tweet.sender elsif tweet.class == Twitter::Tweet - Rails.logger.error "Twitter sender for tweet (#{tweet.id}): found" + Rails.logger.debug "Twitter sender for tweet (#{tweet.id}): found" Rails.logger.debug tweet.user.inspect return tweet.user else @@ -377,7 +377,7 @@ class TweetBase # replace Twitter::NullObject with nill to prevent elasticsearch index issue preferences.each { |_key, value| - next if value.class != ActiveSupport::HashWithIndifferentAccess + next if value.class != ActiveSupport::HashWithIndifferentAccess && value.class != Hash value.each { |sub_key, sub_level| next if sub_level.class != Twitter::NullObject value[sub_key] = nil From 79c398becb20c74370cc44513444c84f0c4eb347 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Fri, 26 May 2017 15:34:32 +0200 Subject: [PATCH 012/234] Implemented reply_to header as sender/customer feature. --- app/models/channel/email_parser.rb | 60 +++++----- .../channel/filter/reply_to_based_sender.rb | 36 ++++++ .../20170525000001_reply_to_sender_feature.rb | 45 ++++++++ db/seeds/settings.rb | 36 ++++++ test/unit/email_process_reply_to_test.rb | 103 ++++++++++++++++++ 5 files changed, 254 insertions(+), 26 deletions(-) create mode 100644 app/models/channel/filter/reply_to_based_sender.rb create mode 100644 db/migrate/20170525000001_reply_to_sender_feature.rb create mode 100644 test/unit/email_process_reply_to_test.rb diff --git a/app/models/channel/email_parser.rb b/app/models/channel/email_parser.rb index 2ddfec7bc..e1a9baa99 100644 --- a/app/models/channel/email_parser.rb +++ b/app/models/channel/email_parser.rb @@ -117,32 +117,7 @@ class Channel::EmailParser } # set extra headers - begin - data[:from_email] = Mail::Address.new(from).address - data[:from_local] = Mail::Address.new(from).local - data[:from_domain] = Mail::Address.new(from).domain - data[:from_display_name] = Mail::Address.new(from).display_name || - (Mail::Address.new(from).comments && Mail::Address.new(from).comments[0]) - rescue - from.strip! - if from =~ /^(.+?)<(.+?)@(.+?)>$/ - data[:from_email] = "#{$2}@#{$3}" - data[:from_local] = $2 - data[:from_domain] = $3 - data[:from_display_name] = $1 - else - data[:from_email] = from - data[:from_local] = from - data[:from_domain] = from - end - end - - # do extra decoding because we needed to use field.value - data[:from_display_name] = Mail::Field.new('X-From', data[:from_display_name]).to_s - data[:from_display_name].delete!('"') - data[:from_display_name].strip! - data[:from_display_name].gsub!(/^'/, '') - data[:from_display_name].gsub!(/'$/, '') + data = data.merge(Channel::EmailParser.sender_properties(from)) # do extra encoding (see issue#1045) if data[:subject].present? @@ -638,6 +613,39 @@ returns true end + def self.sender_properties(from) + data = {} + + begin + data[:from_email] = Mail::Address.new(from).address + data[:from_local] = Mail::Address.new(from).local + data[:from_domain] = Mail::Address.new(from).domain + data[:from_display_name] = Mail::Address.new(from).display_name || + (Mail::Address.new(from).comments && Mail::Address.new(from).comments[0]) + rescue + from.strip! + if from =~ /^(.+?)<(.+?)@(.+?)>$/ + data[:from_email] = "#{$2}@#{$3}" + data[:from_local] = $2 + data[:from_domain] = $3 + data[:from_display_name] = $1 + else + data[:from_email] = from + data[:from_local] = from + data[:from_domain] = from + end + end + + # do extra decoding because we needed to use field.value + data[:from_display_name] = Mail::Field.new('X-From', data[:from_display_name]).to_s + data[:from_display_name].delete!('"') + data[:from_display_name].strip! + data[:from_display_name].gsub!(/^'/, '') + data[:from_display_name].gsub!(/'$/, '') + + data + end + def set_attributes_by_x_headers(item_object, header_name, mail, suffix = false) # loop all x-zammad-hedaer-* headers diff --git a/app/models/channel/filter/reply_to_based_sender.rb b/app/models/channel/filter/reply_to_based_sender.rb new file mode 100644 index 000000000..ed42eabf3 --- /dev/null +++ b/app/models/channel/filter/reply_to_based_sender.rb @@ -0,0 +1,36 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +module Channel::Filter::ReplyToBasedSender + + def self.run(_channel, mail) + + reply_to = mail['reply-to'.to_sym] + return if reply_to.blank? + + setting = Setting.get('postmaster_sender_based_on_reply_to') + return if setting.blank? + + # get properties of reply-to header + result = Channel::EmailParser.sender_properties(reply_to) + + if setting == 'as_sender_of_email' + mail[:from] = reply_to + mail[:from_email] = result[:from_email] + mail[:from_local] = result[:from_local] + mail[:from_domain] = result[:from_domain] + mail[:from_display_name] = result[:from_display_name] + return + end + + if setting == 'as_sender_of_email_use_from_realname' + mail[:from] = reply_to + mail[:from_email] = result[:from_email] + mail[:from_local] = result[:from_local] + mail[:from_domain] = result[:from_domain] + return + end + + Rails.logger.error "Invalid setting value for 'postmaster_sender_based_on_reply_to' -> #{setting.inspect}" + end + +end diff --git a/db/migrate/20170525000001_reply_to_sender_feature.rb b/db/migrate/20170525000001_reply_to_sender_feature.rb new file mode 100644 index 000000000..397dca48e --- /dev/null +++ b/db/migrate/20170525000001_reply_to_sender_feature.rb @@ -0,0 +1,45 @@ +class ReplyToSenderFeature < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + Setting.create_if_not_exists( + title: 'Sender based on Reply-To header', + name: 'postmaster_sender_based_on_reply_to', + area: 'Email::Base', + description: 'Set/overwrite sender/from of email based on reply-to header. Useful to set correct customer if email is received from a third party system on behalf of a customer.', + options: { + form: [ + { + display: '', + null: true, + name: 'postmaster_sender_based_on_reply_to', + tag: 'select', + options: { + '' => '-', + 'as_sender_of_email' => 'Take reply-to header as sender/from of email.', + 'as_sender_of_email_use_from_realname' => 'Take reply-to header as sender/from of email and use realname of origin from.', + }, + }, + ], + }, + state: [], + preferences: { + permission: ['admin.channel_email'], + }, + frontend: false + ) + + Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0011_postmaster_sender_based_on_reply_to', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to set the sender/from of emails based on reply-to header.', + options: {}, + state: 'Channel::Filter::ReplyToBasedSender', + frontend: false + ) + end + +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 99c5305c8..53ffb728a 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -1657,6 +1657,33 @@ Setting.create_if_not_exists( frontend: false ) +Setting.create_if_not_exists( + title: 'Sender based on Reply-To header', + name: 'postmaster_sender_based_on_reply_to', + area: 'Email::Base', + description: 'Set/overwrite sender/from of email based on reply-to header. Useful to set correct customer if email is received from a third party system on behalf of a customer.', + options: { + form: [ + { + display: '', + null: true, + name: 'postmaster_sender_based_on_reply_to', + tag: 'select', + options: { + '' => '-', + 'as_sender_of_email' => 'Take reply-to header as sender/from of email.', + 'as_sender_of_email_use_from_realname' => 'Take reply-to header as sender/from of email and use realname of origin from.', + }, + }, + ], + }, + state: [], + preferences: { + permission: ['admin.channel_email'], + }, + frontend: false +) + Setting.create_if_not_exists( title: 'Notification Sender', name: 'notification_sender', @@ -2217,6 +2244,15 @@ Setting.create_if_not_exists( state: 'Channel::Filter::Trusted', frontend: false ) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0011_postmaster_sender_based_on_reply_to', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to set the sender/from of emails based on reply-to header.', + options: {}, + state: 'Channel::Filter::ReplyToBasedSender', + frontend: false +) Setting.create_if_not_exists( title: 'Defines postmaster filter.', name: '0012_postmaster_filter_sender_is_system_address', diff --git a/test/unit/email_process_reply_to_test.rb b/test/unit/email_process_reply_to_test.rb new file mode 100644 index 000000000..dbef68207 --- /dev/null +++ b/test/unit/email_process_reply_to_test.rb @@ -0,0 +1,103 @@ +# encoding: utf-8 +require 'test_helper' + +class EmailProcessReplyToTest < ActiveSupport::TestCase + + test 'normal processing' do + + setting_orig = Setting.get('postmaster_sender_based_on_reply_to') + Setting.set('postmaster_sender_based_on_reply_to', '') + + email = "From: Bob Smith +To: zammad@example.com +Subject: some new subject +Reply-To: replay_to_customer_process1@example.com + +Some Text" + + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email) + assert_equal('Bob Smith ', article_p.from) + assert_equal('replay_to_customer_process1@example.com', article_p.reply_to) + assert_equal('marketing_tool@example.com', ticket_p.customer.email) + assert_equal('Bob', ticket_p.customer.firstname) + assert_equal('Smith', ticket_p.customer.lastname) + + Setting.set('postmaster_sender_based_on_reply_to', setting_orig) + + end + + test 'normal processing - take reply to as customer' do + + setting_orig = Setting.get('postmaster_sender_based_on_reply_to') + Setting.set('postmaster_sender_based_on_reply_to', 'as_sender_of_email') + + email = "From: Bob Smith +To: zammad@example.com +Subject: some new subject +Reply-To: replay_to_customer_process2@example.com + +Some Text" + + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email) + assert_equal('replay_to_customer_process2@example.com', article_p.from) + assert_equal('replay_to_customer_process2@example.com', article_p.reply_to) + assert_equal('replay_to_customer_process2@example.com', ticket_p.customer.email) + assert_equal('', ticket_p.customer.firstname) + assert_equal('', ticket_p.customer.lastname) + + email = "From: Bob Smith +To: zammad@example.com +Subject: some new subject +Reply-To: Some Name + +Some Text" + + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email) + assert_equal('Some Name ', article_p.from) + assert_equal('Some Name ', article_p.reply_to) + assert_equal('replay_to_customer_process2-1@example.com', ticket_p.customer.email) + assert_equal('Some', ticket_p.customer.firstname) + assert_equal('Name', ticket_p.customer.lastname) + + Setting.set('postmaster_sender_based_on_reply_to', setting_orig) + + end + + test 'normal processing - take reply to as customer and use from as realname' do + + setting_orig = Setting.get('postmaster_sender_based_on_reply_to') + Setting.set('postmaster_sender_based_on_reply_to', 'as_sender_of_email_use_from_realname') + + email = "From: Bob Smith +To: zammad@example.com +Subject: some new subject +Reply-To: replay_to_customer_process3@example.com + +Some Text" + + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email) + assert_equal('replay_to_customer_process3@example.com', article_p.from) + assert_equal('replay_to_customer_process3@example.com', article_p.reply_to) + assert_equal('replay_to_customer_process3@example.com', ticket_p.customer.email) + assert_equal('Bob', ticket_p.customer.firstname) + assert_equal('Smith', ticket_p.customer.lastname) + + email = "From: Bob Smith +To: zammad@example.com +Subject: some new subject +Reply-To: Some Name + +Some Text" + + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email) + assert_equal('Some Name ', article_p.from) + assert_equal('Some Name ', article_p.reply_to) + assert_equal('replay_to_customer_process3-1@example.com', ticket_p.customer.email) + assert_equal('Bob', ticket_p.customer.firstname) + assert_equal('Smith', ticket_p.customer.lastname) + + Setting.set('postmaster_sender_based_on_reply_to', setting_orig) + + end + +end From 2893910aba0140d52660da68c339887a02d82f59 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Fri, 26 May 2017 15:36:45 +0200 Subject: [PATCH 013/234] Fixed filename. --- ...=> 20170525000002_fixed_twitter_ticket_article_preferences.rb} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename db/migrate/{20170525000001_fixed_twitter_ticket_article_preferences.rb => 20170525000002_fixed_twitter_ticket_article_preferences.rb} (100%) diff --git a/db/migrate/20170525000001_fixed_twitter_ticket_article_preferences.rb b/db/migrate/20170525000002_fixed_twitter_ticket_article_preferences.rb similarity index 100% rename from db/migrate/20170525000001_fixed_twitter_ticket_article_preferences.rb rename to db/migrate/20170525000002_fixed_twitter_ticket_article_preferences.rb From 1a6c8ce0f3c5a25647b2465a830954904ef4345a Mon Sep 17 00:00:00 2001 From: Rolf Schmidt Date: Mon, 29 May 2017 13:49:17 +0200 Subject: [PATCH 014/234] Fixed issue #944 - Remove all Users from Overview does not work. --- .../_application_controller_form.coffee | 7 +++-- app/assets/javascripts/application.js | 28 ++++++++++++++----- public/assets/tests/form_column_select.js | 1 + 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/app/controllers/_application_controller_form.coffee b/app/assets/javascripts/app/controllers/_application_controller_form.coffee index 8115dcd24..499602a72 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_form.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_form.coffee @@ -426,8 +426,11 @@ class App.ControllerForm extends App.Controller delete param[item.name] continue - # collect all params, push it to an array if already exists - value = item.value.trim() + # collect all params, push it to an array item.value already exists + value = item.value + if item.value + value = item.value.trim() + if item.type is 'boolean' if value is '' value = undefined diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index dffd7ba39..7b907799e 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -246,13 +246,27 @@ jQuery.fn.extend( { var val = $elem.val(); var type = $elem.data('field-type'); - return val == null ? - null : - jQuery.isArray( val ) ? - jQuery.map( val, function( val ) { - return { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type }; - } ) : - { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type }; + var result; + if ( val == null ) { + + // be sure that also null values are transfered + // https://github.com/zammad/zammad/issues/944 + if ( $elem.prop('multiple') ) { + result = { name: elem.name, value: null, type: type }; + } + else { + result = null + } + } + else if ( jQuery.isArray( val ) ) { + result = jQuery.map( val, function( val ) { + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type }; + } ); + } + else { + result = { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type }; + } + return result; } ).get(); } } ); diff --git a/public/assets/tests/form_column_select.js b/public/assets/tests/form_column_select.js index 9670678ae..a81645e3e 100644 --- a/public/assets/tests/form_column_select.js +++ b/public/assets/tests/form_column_select.js @@ -28,6 +28,7 @@ test( "column_select check", function(assert) { var params = App.ControllerForm.params(el) var test_params = { + column_select1: null, column_select2: ['aaa', 'bbb'], column_select3: ['1', '2'], } From 1bf75622293e639023fe79e14c3952ab7ebdfdb3 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 30 May 2017 01:45:04 +0200 Subject: [PATCH 015/234] Serialise Twitter::Place to prevent search indexing issues. --- ...0170525000002_fixed_twitter_ticket_article_preferences.rb | 5 +++++ lib/tweet_base.rb | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/db/migrate/20170525000002_fixed_twitter_ticket_article_preferences.rb b/db/migrate/20170525000002_fixed_twitter_ticket_article_preferences.rb index ce2cff6d0..40e9e15ee 100644 --- a/db/migrate/20170525000002_fixed_twitter_ticket_article_preferences.rb +++ b/db/migrate/20170525000002_fixed_twitter_ticket_article_preferences.rb @@ -12,6 +12,11 @@ class FixedTwitterTicketArticlePreferences < ActiveRecord::Migration article.preferences.each { |_key, value| next if value.class != ActiveSupport::HashWithIndifferentAccess value.each { |sub_key, sub_level| + if sub_level.class == Twitter::Place + value[sub_key] = sub_level.attrs + changed = true + next + end next if sub_level.class != Twitter::NullObject value[sub_key] = nil changed = true diff --git a/lib/tweet_base.rb b/lib/tweet_base.rb index 52c99d15c..24a41465e 100644 --- a/lib/tweet_base.rb +++ b/lib/tweet_base.rb @@ -379,6 +379,10 @@ class TweetBase preferences.each { |_key, value| next if value.class != ActiveSupport::HashWithIndifferentAccess && value.class != Hash value.each { |sub_key, sub_level| + if sub_level.class == Twitter::Place + value[sub_key] = sub_level.attrs + next + end next if sub_level.class != Twitter::NullObject value[sub_key] = nil } From 0ebeb58a6cfc89459bf3af2f678f5ea619e89832 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Tue, 30 May 2017 08:26:45 +0200 Subject: [PATCH 016/234] Fixed issue #1097 - Can't map LDAP group to multiple Zammad roles. --- .../app/controllers/_integration/ldap.coffee | 18 ++++++++++---- .../app/views/integration/ldap.jst.eco | 8 +++---- ...20170529132120_ldap_multi_group_mapping.rb | 24 +++++++++++++++++++ lib/ldap/group.rb | 15 +++++++----- spec/lib/import/ldap/user_factory_spec.rb | 6 ++--- spec/lib/import/ldap/user_spec.rb | 7 +++--- 6 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 db/migrate/20170529132120_ldap_multi_group_mapping.rb diff --git a/app/assets/javascripts/app/controllers/_integration/ldap.coffee b/app/assets/javascripts/app/controllers/_integration/ldap.coffee index cda274205..b497b0f05 100644 --- a/app/assets/javascripts/app/controllers/_integration/ldap.coffee +++ b/app/assets/javascripts/app/controllers/_integration/ldap.coffee @@ -61,8 +61,15 @@ class Form extends App.Controller render: (top = false) => @config = @currentConfig() + group_role_map = {} + for source, dests of @config.group_role_map + group_role_map[source] = dests.map((dest) -> + App.Role.find(dest).displayName() + ).join ', ' + @html App.view('integration/ldap')( - config: @config + config: @config, + group_role_map: group_role_map ) if _.isEmpty(@config) @$('.js-notConfigured').removeClass('hide') @@ -419,7 +426,9 @@ class ConnectionWizard extends App.WizardModal length = group_role_map.source.length-1 for count in [0..length] if group_role_map.source[count] && group_role_map.dest[count] - group_role_map_local[group_role_map.source[count]] = group_role_map.dest[count] + if !_.isArray(group_role_map_local[group_role_map.source[count]]) + group_role_map_local[group_role_map.source[count]] = [] + group_role_map_local[group_role_map.source[count]].push group_role_map.dest[count] @wizardConfig.group_role_map = group_role_map_local expertSettings = @formParam(@expertForm) @@ -454,8 +463,9 @@ class ConnectionWizard extends App.WizardModal buildRowsGroupRole: (group_role_map) => el = [] - for source, dest of group_role_map - el.push @buildRowGroupRole(source, dest) + for source, dests of group_role_map + for dest in dests + el.push @buildRowGroupRole(source, dest) el buildRowGroupRole: (source, dest) => diff --git a/app/assets/javascripts/app/views/integration/ldap.jst.eco b/app/assets/javascripts/app/views/integration/ldap.jst.eco index 5cfed344f..b580e21fa 100644 --- a/app/assets/javascripts/app/views/integration/ldap.jst.eco +++ b/app/assets/javascripts/app/views/integration/ldap.jst.eco @@ -64,7 +64,7 @@ <% end %>

<%- @T('Role') %>

- <% if _.isEmpty(@config.group_role_map): %> + <% if _.isEmpty(@group_role_map): %>
<%- @T('No Entries') %>
@@ -75,10 +75,10 @@ <%- @T('LDAP') %> <%- @T('Zammad') %> - <% for key, value of @config.group_role_map: %> + <% for source, dests of @group_role_map: %> - <%= key %> - <%= App.Role.find(value).displayName() %> + <%= source %> + <%= dests %> <% end %> <% end %> diff --git a/db/migrate/20170529132120_ldap_multi_group_mapping.rb b/db/migrate/20170529132120_ldap_multi_group_mapping.rb new file mode 100644 index 000000000..3c277aa23 --- /dev/null +++ b/db/migrate/20170529132120_ldap_multi_group_mapping.rb @@ -0,0 +1,24 @@ +class LdapMultiGroupMapping < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + # load existing LDAP config + ldap_config = Setting.get('ldap_config') + + # exit early if no config is present + return if ldap_config.blank? + return if ldap_config['group_role_map'].blank? + + # loop over group role mapping and check + # if we need to migrate to new array structure + ldap_config['group_role_map'].each do |source, dest| + next if dest.is_a?(Array) + ldap_config['group_role_map'][source] = [dest] + end + + # store updated + Setting.set('ldap_config', ldap_config) + end +end diff --git a/lib/ldap/group.rb b/lib/ldap/group.rb index eb3a10cc7..0e63a8dc2 100644 --- a/lib/ldap/group.rb +++ b/lib/ldap/group.rb @@ -85,16 +85,19 @@ class Ldap members = entry[:member] next if members.blank? - role = mapping[entry.dn.downcase] - next if role.blank? - role = role.to_i + roles = mapping[entry.dn.downcase] + next if roles.blank? members.each do |user_dn| user_dn_key = user_dn.downcase - result[user_dn_key] ||= [] - next if result[user_dn_key].include?(role) - result[user_dn_key].push(role) + roles.each do |role| + role = role.to_i + + result[user_dn_key] ||= [] + next if result[user_dn_key].include?(role) + result[user_dn_key].push(role) + end end end diff --git a/spec/lib/import/ldap/user_factory_spec.rb b/spec/lib/import/ldap/user_factory_spec.rb index 80f1a4c31..05c34b0c0 100644 --- a/spec/lib/import/ldap/user_factory_spec.rb +++ b/spec/lib/import/ldap/user_factory_spec.rb @@ -35,7 +35,7 @@ RSpec.describe Import::Ldap::UserFactory do # group user role mapping expect(mocked_ldap).to receive(:search) # user counting - expect(mocked_ldap).to receive(:count).and_return(1) + allow(mocked_ldap).to receive(:count).and_return(1) # user search expect(mocked_ldap).to receive(:search).and_yield(mocked_entry) @@ -201,7 +201,7 @@ RSpec.describe Import::Ldap::UserFactory do config = { group_filter: '(objectClass=group)', group_role_map: { - group_dn => '1', + group_dn => %w(1 2), } } @@ -219,7 +219,7 @@ RSpec.describe Import::Ldap::UserFactory do ) expected = { - user_dn => [1] + user_dn => [1, 2] } expect(user_roles).to be_a(Hash) diff --git a/spec/lib/import/ldap/user_spec.rb b/spec/lib/import/ldap/user_spec.rb index 823218edc..f49140828 100644 --- a/spec/lib/import/ldap/user_spec.rb +++ b/spec/lib/import/ldap/user_spec.rb @@ -29,7 +29,8 @@ RSpec.describe Import::Ldap::User do let(:user_roles) do { user_entry.dn => [ - Role.find_by(name: 'Admin').id + Role.find_by(name: 'Admin').id, + Role.find_by(name: 'Agent').id ] } end @@ -90,8 +91,8 @@ RSpec.describe Import::Ldap::User do # gets called later it will get initialized # with the changed dn user_roles[ user_entry.dn ] = [ - Role.find_by(name: 'Agent').id, - Role.find_by(name: 'Admin').id + Role.find_by(name: 'Admin').id, + Role.find_by(name: 'Agent').id ] # change dn so no mapping will match From 48e3da57ee06227c9b57f96a5afb56f132325aa1 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 30 May 2017 08:55:51 +0200 Subject: [PATCH 017/234] Improved email loop protection. Remember permanent delivery failed on user to prevent email loops. --- .../bounce_delivery_permanent_failed.rb | 73 +++++ ...nce_check.rb => bounce_follow_up_check.rb} | 6 +- app/models/ticket.rb | 13 + ...00002_setting_delivery_permanent_failed.rb | 35 +++ db/seeds/settings.rb | 13 +- test/fixtures/mail55.box | 230 ++++++++++++++++ ...s_bounce_delivery_permanent_failed_test.rb | 220 +++++++++++++++ test/unit/email_process_bounce_follow_test.rb | 254 ++++++++++++++++++ test/unit/email_process_bounce_test.rb | 39 --- 9 files changed, 841 insertions(+), 42 deletions(-) create mode 100644 app/models/channel/filter/bounce_delivery_permanent_failed.rb rename app/models/channel/filter/{bounce_check.rb => bounce_follow_up_check.rb} (90%) create mode 100644 db/migrate/20170529000002_setting_delivery_permanent_failed.rb create mode 100644 test/fixtures/mail55.box create mode 100644 test/unit/email_process_bounce_delivery_permanent_failed_test.rb create mode 100644 test/unit/email_process_bounce_follow_test.rb delete mode 100644 test/unit/email_process_bounce_test.rb diff --git a/app/models/channel/filter/bounce_delivery_permanent_failed.rb b/app/models/channel/filter/bounce_delivery_permanent_failed.rb new file mode 100644 index 000000000..ecd75f39f --- /dev/null +++ b/app/models/channel/filter/bounce_delivery_permanent_failed.rb @@ -0,0 +1,73 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +module Channel::Filter::BounceDeliveryPermanentFailed + + def self.run(_channel, mail) + + return if !mail[:mail_instance] + return if !mail[:mail_instance].bounced? + return if !mail[:attachments] + + # remember, do not send notifications to certain recipients again if failed permanent + mail[:attachments].each { |attachment| + next if !attachment[:preferences] + next if attachment[:preferences]['Mime-Type'] != 'message/rfc822' + next if !attachment[:data] + + result = Channel::EmailParser.new.parse(attachment[:data]) + next if !result[:message_id] + message_id_md5 = Digest::MD5.hexdigest(result[:message_id]) + article = Ticket::Article.where(message_id_md5: message_id_md5).order('created_at DESC, id DESC').limit(1).first + next if !article + + # check user preferences + next if mail[:mail_instance].action != 'failed' + next if mail[:mail_instance].retryable? != false + next if mail[:mail_instance].error_status != '5.1.1' + + # get recipient of origin article, if only one - mark this user to not sent notifications anymore + recipients = [] + if article.sender.name == 'System' || article.sender.name == 'Agent' + %w(to cc).each { |line| + next if article[line].blank? + recipients = [] + begin + list = Mail::AddressList.new(article[line]) + list.addresses.each { |address| + next if address.address.blank? + recipients.push address.address.downcase + } + rescue + Rails.logger.info "Unable to parse email address in '#{article[line]}'" + end + } + if recipients.count > 1 + recipients = [] + end + end + + # get recipient bounce mail, mark this user to not sent notifications anymore + final_recipient = mail[:mail_instance].final_recipient + if final_recipient.present? + final_recipient.sub!(/rfc822;\s{0,10}/, '') + if final_recipient.present? + recipients.push final_recipient.downcase + end + end + + # set user preferences + recipients.each { |recipient| + users = User.where(email: recipient) + users.each { |user| + next if !user + user.preferences[:mail_delivery_failed] = true + user.preferences[:mail_delivery_failed_data] = Time.zone.now + user.save! + } + } + } + + true + + end +end diff --git a/app/models/channel/filter/bounce_check.rb b/app/models/channel/filter/bounce_follow_up_check.rb similarity index 90% rename from app/models/channel/filter/bounce_check.rb rename to app/models/channel/filter/bounce_follow_up_check.rb index 1835254b6..872053564 100644 --- a/app/models/channel/filter/bounce_check.rb +++ b/app/models/channel/filter/bounce_follow_up_check.rb @@ -1,6 +1,6 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ -module Channel::Filter::BounceCheck +module Channel::Filter::BounceFollowUpCheck def self.run(_channel, mail) @@ -13,13 +13,17 @@ module Channel::Filter::BounceCheck next if !attachment[:preferences] next if attachment[:preferences]['Mime-Type'] != 'message/rfc822' next if !attachment[:data] + result = Channel::EmailParser.new.parse(attachment[:data]) next if !result[:message_id] message_id_md5 = Digest::MD5.hexdigest(result[:message_id]) article = Ticket::Article.where(message_id_md5: message_id_md5).order('created_at DESC, id DESC').limit(1).first next if !article + Rails.logger.debug "Follow up for '##{article.ticket.number}' in bounce email." mail[ 'x-zammad-ticket-id'.to_sym ] = article.ticket_id + mail[ 'x-zammad-is-auto-response'.to_sym ] = true + return true } diff --git a/app/models/ticket.rb b/app/models/ticket.rb index ec000154e..668f43c86 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -813,6 +813,19 @@ perform changes on ticket recipients_checked = [] recipients_raw.each { |recipient_email| + skip_user = false + users = User.where(email: recipient_email) + users.each { |user| + next if user.preferences[:mail_delivery_failed] != true + next if !user.preferences[:mail_delivery_failed_data] + till_blocked = ((user.preferences[:mail_delivery_failed_data] - Time.zone.now - 60.days) / 60 / 60 / 24).round + next if till_blocked.positive? + logger.info "Send no trigger based notification to #{recipient_email} because email is marked as mail_delivery_failed for #{till_blocked} days" + skip_user = true + break + } + next if skip_user + # send notifications only to email adresses next if !recipient_email next if recipient_email !~ /@/ diff --git a/db/migrate/20170529000002_setting_delivery_permanent_failed.rb b/db/migrate/20170529000002_setting_delivery_permanent_failed.rb new file mode 100644 index 000000000..aa299bb42 --- /dev/null +++ b/db/migrate/20170529000002_setting_delivery_permanent_failed.rb @@ -0,0 +1,35 @@ +class SettingDeliveryPermanentFailed < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + setting = Setting.find_by(name: '0900_postmaster_filter_bounce_check') + if setting + setting.name = '0900_postmaster_filter_bounce_follow_up_check' + setting.state = 'Channel::Filter::BounceFollowUpCheck' + setting.save! + else + Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0900_postmaster_filter_bounce_follow_up_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify postmaster bounced - to handle it as follow-up of the original ticket.', + options: {}, + state: 'Channel::Filter::BounceFollowUpCheck', + frontend: false + ) + end + Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0950_postmaster_filter_bounce_delivery_permanent_failed', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify postmaster bounced - disable sending notification on permanent deleivery failed.', + options: {}, + state: 'Channel::Filter::BounceDeliveryPermanentFailed', + frontend: false + ) + + end + +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 53ffb728a..777dc1aa1 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -2327,11 +2327,20 @@ Setting.create_if_not_exists( ) Setting.create_if_not_exists( title: 'Defines postmaster filter.', - name: '0900_postmaster_filter_bounce_check', + name: '0900_postmaster_filter_bounce_follow_up_check', area: 'Postmaster::PreFilter', description: 'Defines postmaster filter to identify postmaster bounced - to handle it as follow-up of the original ticket.', options: {}, - state: 'Channel::Filter::BounceCheck', + state: 'Channel::Filter::BounceFollowUpCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '0950_postmaster_filter_bounce_delivery_permanent_failed', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify postmaster bounced - disable sending notification on permanent deleivery failed.', + options: {}, + state: 'Channel::Filter::BounceDeliveryPermanentFailed', frontend: false ) Setting.create_if_not_exists( diff --git a/test/fixtures/mail55.box b/test/fixtures/mail55.box new file mode 100644 index 000000000..de2b25785 --- /dev/null +++ b/test/fixtures/mail55.box @@ -0,0 +1,230 @@ +Return-Path: +Delivered-To: example@zammad.com +Received: by mx1.zammad.loc (Postfix) + id 738F920A13B2; Fri, 26 May 2017 17:01:45 +0200 (CEST) +Date: Fri, 26 May 2017 17:01:45 +0200 (CEST) +From: MAILER-DAEMON@mx1.zammad.loc (Mail Delivery System) +Subject: Undelivered Mail Returned to Sender +To: example@zammad.com +Auto-Submitted: auto-replied +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=delivery-status; + boundary="207FF20398ED.1495810905/mx1.zammad.loc" +Message-Id: <20170526150145.738F920A13B2@mx1.zammad.loc> + +This is a MIME-encapsulated message. + +--207FF20398ED.1495810905/mx1.zammad.loc +Content-Description: Notification +Content-Type: text/plain; charset=us-ascii + +This is the mail system at host mx1.zammad.loc. + +I'm sorry to have to inform you that your message could not +be delivered to one or more recipients. It's attached below. + +For further assistance, please send mail to postmaster. + +If you do so, please include this problem report. You can +delete your own text from the attached returned message. + + The mail system + + : host aspmx.l.example.com[108.177.96.26] said: + 550-5.1.1 The email account that you tried to reach does not exist. Please + try 550-5.1.1 double-checking the recipient's email address for typos or + 550-5.1.1 unnecessary spaces. Learn more at 550 5.1.1 + https://support.example.com/mail/?p=NoSuchUser l59si1635011edl.281 - gsmtp + (in reply to RCPT TO command) + +--207FF20398ED.1495810905/mx1.zammad.loc +Content-Description: Delivery report +Content-Type: message/delivery-status + +Reporting-MTA: dns; mx1.zammad.loc +X-Postfix-Queue-ID: 207FF20398ED +X-Postfix-Sender: rfc822; example@zammad.com +Arrival-Date: Fri, 26 May 2017 17:01:45 +0200 (CEST) + +Final-Recipient: rfc822; ticket-bounce-trigger2@example.com +Original-Recipient: rfc822;ticket-bounce-trigger2@example.com +Action: failed +Status: 5.1.1 +Remote-MTA: dns; aspmx.l.example.com +Diagnostic-Code: smtp; 550-5.1.1 The email account that you tried to reach does + not exist. Please try 550-5.1.1 double-checking the recipient's email + address for typos or 550-5.1.1 unnecessary spaces. Learn more at 550 5.1.1 + https://support.example.com/mail/?p=NoSuchUser l59si1635011edl.281 - gsmtp + +--207FF20398ED.1495810905/mx1.zammad.loc +Content-Description: Undelivered Message +Content-Type: message/rfc822 + +Return-Path: +Received: from apn0000.dc.zammad.com (apn0000.dc.zammad.com [88.0.0.0]) + by mx1.zammad.loc (Postfix) with ESMTP id 207FF20398ED + for ; Fri, 26 May 2017 17:01:45 +0200 (CEST) +Received: by apn0000.dc.zammad.com (Postfix, from userid 1050) + id 08443420973; Fri, 26 May 2017 17:01:45 +0200 (CEST) +Date: Fri, 26 May 2017 17:01:45 +0200 +From: Twelve SaaS GmbH Helpdesk +To: ticket-bounce-trigger2@example.com +Message-ID: <20170526150141.232.13312@example.zammad.loc> +In-Reply-To: +References: <20170526150142.232.819805@example.zammad.loc> + <20170526150119.6C5E520A13B2@mx1.zammad.loc> + <20170526150141.232.799457@example.zammad.loc> + <20170526150117.0560820A13B3@mx1.zammad.loc> + <20170526150115.232.175460@example.zammad.loc> + <20170526150108.232.482766@example.zammad.loc> + <20170526150041.F3D2C20A13B3@mx1.zammad.loc> + <20170526150036.232.513248@example.zammad.loc> + <20170526150008.6AE8A20A13B8@mx1.zammad.loc> + <20170526150004.232.103372@example.zammad.loc> + <20170526145940.D799220A13B3@mx1.zammad.loc> + <20170526145932.232.91897@example.zammad.loc> + <20170526145906.8FCA520A13B2@mx1.zammad.loc> + <20170526145901.232.269971@example.zammad.loc> + + Subject: =?UTF-8?Q?[Ticket#1705265400361]_RE:_Thanks_for_your_follow_up?= + =?UTF-8?Q?_=28G_Suite:_Benachrichtigung_=C3=BCber_Verl=C3=A4ngerung_in_30?= + =?UTF-8?Q?_Tagen=29?= + Mime-Version: 1.0 +Content-Type: multipart/mixed; + boundary="--==_mimepart_5928435950b1_22d42086504355bc"; + charset=UTF-8 + Content-Transfer-Encoding: 7bit +Organization: Twelve SaaS GmbH +X-Loop: yes +Precedence: bulk +Auto-Submitted: auto-generated +X-Auto-Response-Suppress: All +X-Powered-By: Zammad - Helpdesk/Support (https://zammad.org/) +X-Mailer: Zammad Mail Service + + +----==_mimepart_5928435950b1_22d42086504355bc +Content-Type: multipart/alternative; + boundary="--==_mimepart_592843594d35_22d4208650435394"; + charset=UTF-8 + Content-Transfer-Encoding: 7bit + + +----==_mimepart_592843594d35_22d4208650435394 +Content-Type: text/plain; + charset=UTF-8 + Content-Transfer-Encoding: 7bit + +Your follow up for (Ticket#1705265400361) has been received and will be reviewed by our support staff. + +To provide additional information, please reply to this email or click on the following link:[1] https://example.zammad.loc/#ticket/zoom/232 + +Your Twelve SaaS Helpdesk Team + +[2] Zammad, your customer support system + +[1] https://example.zammad.loc/#ticket/zoom/232 +[2] https://zammad.com +----==_mimepart_592843594d35_22d4208650435394 +Content-Type: multipart/related; + boundary="--==_mimepart_592843594e47_22d4208650435484"; + charset=UTF-8 + Content-Transfer-Encoding: 7bit + + +----==_mimepart_592843594e47_22d4208650435484 +Content-Type: text/html; + charset=UTF-8 + Content-Transfer-Encoding: 7bit + + + + + + + +
Your follow up for (Ticket#1705265400361) has been received and will be reviewed by our support staff.
+
+
To provide additional information, please reply to this email or click on the following link: +https://example.zammad.loc/#ticket/zoom/232 +
+
+
Your Twelve SaaS Helpdesk Team
+
+
Zammad, your customer support system
+ + +----==_mimepart_592843594e47_22d4208650435484-- + +----==_mimepart_592843594d35_22d4208650435394-- + +----==_mimepart_5928435950b1_22d42086504355bc-- + +--207FF20398ED.1495810905/mx1.zammad.loc-- diff --git a/test/unit/email_process_bounce_delivery_permanent_failed_test.rb b/test/unit/email_process_bounce_delivery_permanent_failed_test.rb new file mode 100644 index 000000000..f62210e55 --- /dev/null +++ b/test/unit/email_process_bounce_delivery_permanent_failed_test.rb @@ -0,0 +1,220 @@ +# encoding: utf-8 +require 'test_helper' + +class EmailProcessBounceDeliveryPermanentFailedTest < ActiveSupport::TestCase + + test 'process with bounce trigger email loop check - article based blocker' do + roles = Role.where(name: %w(Customer)) + customer1 = User.create_or_update( + login: 'ticket-bounce-trigger1@example.com', + firstname: 'Notification', + lastname: 'Customer1', + email: 'ticket-bounce-trigger1@example.com', + active: true, + roles: roles, + preferences: {}, + updated_by_id: 1, + created_by_id: 1, + ) + + Trigger.create_or_update( + name: 'auto reply new ticket', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your inquiry (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + Trigger.create_or_update( + name: 'auto reply followup', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your follow up (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket = Ticket.create( + title: 'bounce check', + group: Group.lookup(name: 'Users'), + customer: customer1, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'bounce check', + message_id: '<20150830145601.30.6088xx@edenhofer.zammad.com>', + body: 'some message bounce check', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal('new', ticket.state.name) + assert_equal(2, ticket.articles.count) + + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: customer1.email, + subject: 'bounce check 2', + message_id: '<20150830145601.30.608881@edenhofer.zammad.com>', + body: 'some message bounce check 2', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal(4, ticket.articles.count) + + travel 1.second + email_raw_string = IO.binread('test/fixtures/mail33-undelivered-mail-returned-to-sender.box') + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal(ticket.id, ticket_p.id) + assert_equal('open', ticket_p.state.name) + assert_equal(5, ticket_p.articles.count) + travel_back + ticket.destroy + end + + test 'process with bounce trigger email loop check - bounce based blocker' do + roles = Role.where(name: %w(Customer)) + customer2 = User.create_or_update( + login: 'ticket-bounce-trigger2@example.com', + firstname: 'Notification', + lastname: 'Customer2', + email: 'ticket-bounce-trigger2@example.com', + active: true, + roles: roles, + preferences: {}, + updated_by_id: 1, + created_by_id: 1, + ) + + Trigger.create_or_update( + name: 'auto reply new ticket', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your inquiry (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + Trigger.create_or_update( + name: 'auto reply followup', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your follow up (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket = Ticket.create( + title: 'bounce check', + group: Group.lookup(name: 'Users'), + customer: customer2, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'bounce check', + message_id: '<20150830145601.30.6088xx@edenhofer.zammad.com>', + body: 'some message bounce check', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal('new', ticket.state.name) + assert_equal(2, ticket.articles.count) + + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'bounce check 2', + message_id: '<20170526150141.232.13312@example.zammad.loc>', + body: 'some message bounce check 2', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal(4, ticket.articles.count) + + travel 1.second + email_raw_string = IO.binread('test/fixtures/mail55.box') + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal(ticket.id, ticket_p.id) + assert_equal('open', ticket_p.state.name) + assert_equal(5, ticket_p.articles.count) + travel_back + ticket.destroy + end + +end diff --git a/test/unit/email_process_bounce_follow_test.rb b/test/unit/email_process_bounce_follow_test.rb new file mode 100644 index 000000000..242c95845 --- /dev/null +++ b/test/unit/email_process_bounce_follow_test.rb @@ -0,0 +1,254 @@ +# encoding: utf-8 +require 'test_helper' + +class EmailProcessBounceFollowUpTest < ActiveSupport::TestCase + + test 'process with bounce follow up check' do + + ticket = Ticket.create( + title: 'bounce check', + group: Group.lookup(name: 'Users'), + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'bounce check', + message_id: '<20150830145601.30.608881@edenhofer.zammad.com>', + body: 'some message bounce check', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + travel 1.second + email_raw_string = IO.binread('test/fixtures/mail33-undelivered-mail-returned-to-sender.box') + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal(ticket.id, ticket_p.id) + assert_equal('new', ticket_p.state.name) + travel_back + ticket.destroy + + end + + test 'process with bounce trigger email loop check - article based blocker' do + roles = Role.where(name: %w(Customer)) + customer1 = User.create_or_update( + login: 'ticket-bounce-trigger1@example.com', + firstname: 'Notification', + lastname: 'Customer1', + email: 'ticket-bounce-trigger1@example.com', + active: true, + roles: roles, + preferences: {}, + updated_by_id: 1, + created_by_id: 1, + ) + + Trigger.create_or_update( + name: 'auto reply new ticket', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your inquiry (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + Trigger.create_or_update( + name: 'auto reply followup', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your follow up (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket = Ticket.create( + title: 'bounce check', + group: Group.lookup(name: 'Users'), + customer: customer1, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'bounce check', + message_id: '<20150830145601.30.6088xx@edenhofer.zammad.com>', + body: 'some message bounce check', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal('new', ticket.state.name) + assert_equal(2, ticket.articles.count) + + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: customer1.email, + subject: 'bounce check 2', + message_id: '<20150830145601.30.608881@edenhofer.zammad.com>', + body: 'some message bounce check 2', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal(4, ticket.articles.count) + + travel 1.second + email_raw_string = IO.binread('test/fixtures/mail33-undelivered-mail-returned-to-sender.box') + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal(ticket.id, ticket_p.id) + assert_equal('open', ticket_p.state.name) + assert_equal(5, ticket_p.articles.count) + travel_back + ticket.destroy + end + + test 'process with bounce trigger email loop check - bounce based blocker' do + roles = Role.where(name: %w(Customer)) + customer2 = User.create_or_update( + login: 'ticket-bounce-trigger2@example.com', + firstname: 'Notification', + lastname: 'Customer2', + email: 'ticket-bounce-trigger2@example.com', + active: true, + roles: roles, + preferences: {}, + updated_by_id: 1, + created_by_id: 1, + ) + + Trigger.create_or_update( + name: 'auto reply new ticket', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your inquiry (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + Trigger.create_or_update( + name: 'auto reply followup', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'notification.email' => { + 'body' => 'some text
#{ticket.customer.lastname}
#{ticket.title}
#{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your follow up (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket = Ticket.create( + title: 'bounce check', + group: Group.lookup(name: 'Users'), + customer: customer2, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'bounce check', + message_id: '<20150830145601.30.6088xx@edenhofer.zammad.com>', + body: 'some message bounce check', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal('new', ticket.state.name) + assert_equal(2, ticket.articles.count) + + article = Ticket::Article.create( + ticket_id: ticket.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'bounce check 2', + message_id: '<20170526150141.232.13312@example.zammad.loc>', + body: 'some message bounce check 2', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + assert_equal(4, ticket.articles.count) + + travel 1.second + email_raw_string = IO.binread('test/fixtures/mail55.box') + ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal(ticket.id, ticket_p.id) + assert_equal('open', ticket_p.state.name) + assert_equal(5, ticket_p.articles.count) + travel_back + ticket.destroy + end + +end diff --git a/test/unit/email_process_bounce_test.rb b/test/unit/email_process_bounce_test.rb deleted file mode 100644 index 048f16d01..000000000 --- a/test/unit/email_process_bounce_test.rb +++ /dev/null @@ -1,39 +0,0 @@ -# encoding: utf-8 -require 'test_helper' - -class EmailProcessBounceTest < ActiveSupport::TestCase - - test 'process with bounce check' do - - ticket = Ticket.create( - title: 'bounce check', - group: Group.lookup( name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup( name: 'new' ), - priority: Ticket::Priority.lookup( name: '2 normal' ), - updated_by_id: 1, - created_by_id: 1, - ) - article = Ticket::Article.create( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'bounce check', - message_id: '<20150830145601.30.608881@edenhofer.zammad.com>', - body: 'some message bounce check', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - ) - travel 1.second - email_raw_string = IO.binread('test/fixtures/mail33-undelivered-mail-returned-to-sender.box') - ticket_p, article_p, user_p = Channel::EmailParser.new.process({}, email_raw_string) - assert_equal(ticket.id, ticket_p.id) - assert_equal('new', ticket_p.state.name) - travel_back - ticket.destroy - end - -end From 329ab853a26b515da298811432bddc644fcfd369 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 30 May 2017 16:06:17 +0200 Subject: [PATCH 018/234] Fixed error log of issue #948 - recipient attribute isn't a hash. --- lib/sessions/event/broadcast.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sessions/event/broadcast.rb b/lib/sessions/event/broadcast.rb index 4e3e84409..4cacafce1 100644 --- a/lib/sessions/event/broadcast.rb +++ b/lib/sessions/event/broadcast.rb @@ -12,7 +12,7 @@ class Sessions::Event::Broadcast < Sessions::Event::Base # broadcast to recipient list if @payload['recipient'] - if @payload['recipient'].class != Hash && @payload['recipient'].class != ActiveSupport::HashWithIndifferentAccess + if @payload['recipient'].class != Hash && @payload['recipient'].class != ActiveSupport::HashWithIndifferentAccess && @payload['recipient'].class != ActionController::Parameters log 'error', "recipient attribute isn't a hash (#{@payload['recipient'].class}) '#{@payload['recipient'].inspect}'" elsif !@payload['recipient'].key?('user_id') log 'error', "need recipient.user_id attribute '#{@payload['recipient'].inspect}'" From 2bbb2c9519cbe1ad0aabcb176c02c4cda4225f5a Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Tue, 30 May 2017 18:32:28 +0200 Subject: [PATCH 019/234] Fixed issue #1095 - Zendesk import: Error while importing ticket field with dash in name. --- lib/import/zendesk/object_field.rb | 5 +- spec/lib/import/zendesk/ticket_field_spec.rb | 28 +++++++++++ test/integration/zendesk_import_test.rb | 53 ++++++++++++++------ 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/lib/import/zendesk/object_field.rb b/lib/import/zendesk/object_field.rb index ac0024271..7457cf1c2 100644 --- a/lib/import/zendesk/object_field.rb +++ b/lib/import/zendesk/object_field.rb @@ -15,8 +15,7 @@ module Import private def local_name(object_field) - return @local_name if @local_name - @local_name = remote_name(object_field).gsub(/\s/, '_').downcase + @local_name ||= remote_name(object_field).gsub(%r{[\s\/]}, '_').underscore.gsub(/_{2,}/, '_') end def remote_name(object_field) @@ -28,7 +27,7 @@ module Import end def backend_class(object_field) - "Import::Zendesk::ObjectAttribute::#{object_field.type .capitalize}".constantize + "Import::Zendesk::ObjectAttribute::#{object_field.type.capitalize}".constantize end def object_name diff --git a/spec/lib/import/zendesk/ticket_field_spec.rb b/spec/lib/import/zendesk/ticket_field_spec.rb index 71c5975fa..327acc058 100644 --- a/spec/lib/import/zendesk/ticket_field_spec.rb +++ b/spec/lib/import/zendesk/ticket_field_spec.rb @@ -3,4 +3,32 @@ require 'lib/import/zendesk/object_field_examples' RSpec.describe Import::Zendesk::TicketField do it_behaves_like 'Import::Zendesk::ObjectField' + + it 'handles fields with dashes in title' do + + zendesk_object = double( + id: 1337, + title: 'Priority - Simple', + key: 'priority_simple', + type: 'text', + removable: true, + active: true, + position: 1, + required_in_portal: true, + visible_in_portal: true, + required: true, + description: 'Example field', + ) + + expect(ObjectManager::Attribute).to receive(:migration_execute).and_return(true) + + expect do + described_class.new(zendesk_object) + end.not_to raise_error + + ObjectManager::Attribute.remove( + object: 'Ticket', + name: zendesk_object.key, + ) + end end diff --git a/test/integration/zendesk_import_test.rb b/test/integration/zendesk_import_test.rb index 709ff1f2f..3e59fa1de 100644 --- a/test/integration/zendesk_import_test.rb +++ b/test/integration/zendesk_import_test.rb @@ -136,7 +136,14 @@ class ZendeskImportTest < ActiveSupport::TestCase checks.each { |check| user = User.find(check[:id]) check[:data].each { |key, value| - assert_equal(value, user[key], "user.#{key} for user_id #{check[:id]}") + user_value = user[key] + text = "user.#{key} for user_id #{check[:id]}" + + if value.nil? + assert_nil(user_value, text) + else + assert_equal(value, user_value, text) + end } assert_equal(check[:roles], user.roles.sort.to_a, "#{user.login} roles") assert_equal(check[:groups], user.groups.sort.to_a, "#{user.login} groups") @@ -247,7 +254,14 @@ class ZendeskImportTest < ActiveSupport::TestCase checks.each { |check| organization = Organization.find(check[:id]) check[:data].each { |key, value| - assert_equal(value, organization[key], "organization.#{key} for organization_id #{check[:id]}") + organization_value = organization[key] + text = "organization.#{key} for organization_id #{check[:id]}" + + if value.nil? + assert_nil(organization_value, text) + else + assert_equal(value, organization_value, text) + end } } end @@ -282,7 +296,7 @@ class ZendeskImportTest < ActiveSupport::TestCase id: 2, data: { title: 'test', - #note: 'This is the first comment. Feel free to delete this sample ticket.', + #note: 'This is the first comment. Feel free to delete this sample ticket.', note: 'test email', create_article_type_id: 1, create_article_sender_id: 2, @@ -293,11 +307,11 @@ class ZendeskImportTest < ActiveSupport::TestCase owner_id: 1, customer_id: 6, organization_id: 2, - test_checkbox: true, - custom_integer: 999, - custom_dropdown: 'key2', - custom_decimal: '1.6', - not_existing: nil, + test_checkbox: true, + custom_integer: 999, + custom_drop_down: 'key2', + custom_decimal: '1.6', + not_existing: nil, }, }, { @@ -315,12 +329,12 @@ If you\'re reading this message in your email, click the ticket number link that priority_id: 1, owner_id: 1, customer_id: 7, - organization_id: nil, - test_checkbox: false, - custom_integer: nil, - custom_dropdown: '', - custom_decimal: nil, - not_existing: nil, + organization_id: nil, + test_checkbox: false, + custom_integer: nil, + custom_drop_down: '', + custom_decimal: nil, + not_existing: nil, }, }, { @@ -376,7 +390,14 @@ If you\'re reading this message in your email, click the ticket number link that checks.each { |check| ticket = Ticket.find(check[:id]) check[:data].each { |key, value| - assert_equal(value, ticket[key], "ticket.#{key} for ticket_id #{check[:id]}") + ticket_value = ticket[key] + text = "ticket.#{key} for ticket_id #{check[:id]}" + + if value.nil? + assert_nil(ticket_value, text) + else + assert_equal(value, ticket_value, text) + end } } end @@ -471,7 +492,7 @@ If you\'re reading this message in your email, click the ticket number link that custom_date custom_integer custom_regex - custom_dropdown + custom_drop_down ) assert_equal(copmare_fields, local_fields, 'ticket fields') From d77e73bb12562f30b1bf93f89f27cf996682e6fc Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Tue, 30 May 2017 12:04:10 +0200 Subject: [PATCH 020/234] Zendesk import: Improved exception text for ObjectManager Attribute errors and removed dependency of init_callback implementation. --- lib/import/zendesk/object_attribute.rb | 4 +++- .../lib/import/zendesk/object_attribute_spec.rb | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/import/zendesk/object_attribute.rb b/lib/import/zendesk/object_attribute.rb index 4c8824487..ae8a7fd01 100644 --- a/lib/import/zendesk/object_attribute.rb +++ b/lib/import/zendesk/object_attribute.rb @@ -13,12 +13,14 @@ module Import private def init_callback(_attribute) - raise 'Missing init_callback method implementation for this object attribute' end def add(object, name, attribute) ObjectManager::Attribute.add( attribute_config(object, name, attribute) ) ObjectManager::Attribute.migration_execute(false) + rescue => e + # rubocop:disable Style/SpecialGlobalVars + raise $!, "Problem with ObjectManager Attribute '#{name}': #{$!}", $!.backtrace end def attribute_config(object, name, attribute) diff --git a/spec/lib/import/zendesk/object_attribute_spec.rb b/spec/lib/import/zendesk/object_attribute_spec.rb index 4fb38161d..0539fa263 100644 --- a/spec/lib/import/zendesk/object_attribute_spec.rb +++ b/spec/lib/import/zendesk/object_attribute_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe Import::Zendesk::ObjectAttribute do - it 'throws an exception if no init_callback is implemented' do + it 'extends ObjectManager Attribute exception text' do attribute = double( title: 'Example attribute', @@ -16,6 +16,19 @@ RSpec.describe Import::Zendesk::ObjectAttribute do type: 'input', ) - expect { described_class.new('Ticket', 'example_field', attribute) }.to raise_error(RuntimeError) + error_text = 'some error' + expect(ObjectManager::Attribute).to receive(:add).and_raise(RuntimeError, error_text) + + exception = nil + begin + described_class.new('Ticket', 'example_field', attribute) + rescue => e + exception = e + end + + expect(exception).not_to be nil + expect(exception).to be_a(RuntimeError) + expect(exception.message).to include(error_text) + expect(exception.message).not_to eq(error_text) end end From 8cef58b4da9aae9d501da17368b5e992dc47f227 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 1 Jun 2017 08:23:53 +0200 Subject: [PATCH 021/234] Improved streaming handling (fetch parent tweets via REST). Improved auto reconnect to stream if channel config has changed. Improved max import for streaming (already reached max import on initial config - took 15 min. to import first tweet). Changed fallback REST tweet search from 30 to 20 minutes. --- app/models/channel.rb | 66 ++++++++++++--------- app/models/channel/driver/twitter.rb | 54 +++++++++++++++-- lib/tweet_base.rb | 51 ++++++++-------- lib/tweet_stream.rb | 1 + test/integration/twitter_browser_test.rb | 3 +- test/integration/twitter_test.rb | 75 ++++++++++++++++++++---- 6 files changed, 181 insertions(+), 69 deletions(-) diff --git a/app/models/channel.rb b/app/models/channel.rb index b56a8e68e..2decd642e 100644 --- a/app/models/channel.rb +++ b/app/models/channel.rb @@ -132,45 +132,57 @@ stream all accounts def self.stream Thread.abort_on_exception = true + auto_reconnect_after = 25 last_channels = [] loop do logger.debug 'stream controll loop' + current_channels = [] channels = Channel.where('active = ? AND area LIKE ?', true, '%::Account') channels.each { |channel| next if channel.options[:adapter] != 'twitter' + channel_id = channel.id.to_s + current_channels.push channel_id - current_channels.push channel.id - - # exit it channel has changed - if @@channel_stream[channel.id] && @@channel_stream[channel.id][:updated_at] != channel.updated_at - logger.debug "channel (#{channel.id}) has changed, restart thread" - @@channel_stream[channel.id][:thread].exit - @@channel_stream[channel.id][:thread].join - @@channel_stream[channel.id][:stream_instance].disconnect - @@channel_stream[channel.id] = false + # exit it channel has changed or connection is older then 25 min. + if @@channel_stream[channel_id] + if @@channel_stream[channel_id][:updated_at] != channel.updated_at + logger.info "channel (#{channel.id}) has changed, stop thread" + @@channel_stream[channel_id][:thread].exit + @@channel_stream[channel_id][:thread].join + @@channel_stream[channel_id][:stream_instance].disconnect + @@channel_stream[channel_id] = false + elsif @@channel_stream[channel_id][:started_at] && @@channel_stream[channel_id][:started_at] < Time.zone.now - auto_reconnect_after.minutes + logger.info "channel (#{channel.id}) reconnect - thread is older then #{auto_reconnect_after} minutes, restart thread" + @@channel_stream[channel_id][:thread].exit + @@channel_stream[channel_id][:thread].join + @@channel_stream[channel_id][:stream_instance].disconnect + @@channel_stream[channel_id] = false + end end - #logger.debug "thread for channel (#{channel.id}) already running" if @@channel_stream[channel.id] - next if @@channel_stream[channel.id] + #logger.debug "thread for channel (#{channel.id}) already running" if channel_stream + next if @@channel_stream[channel_id] - @@channel_stream[channel.id] = { - updated_at: channel.updated_at + @@channel_stream[channel_id] = { + updated_at: channel.updated_at, + started_at: Time.zone.now, } # start channels with delay sleep @@channel_stream.count # start threads for each channel - @@channel_stream[channel.id][:thread] = Thread.new { + @@channel_stream[channel_id][:thread] = Thread.new { begin logger.info "Started stream channel for '#{channel.id}' (#{channel.area})..." - @@channel_stream[channel.id][:stream_instance] = channel.stream_instance - @@channel_stream[channel.id][:stream_instance].stream - @@channel_stream[channel.id][:stream_instance].disconnect - @@channel_stream[channel.id] = false - logger.debug " ...stopped thread for '#{channel.id}'" + @@channel_stream[channel_id] ||= {} + @@channel_stream[channel_id][:stream_instance] = channel.stream_instance + @@channel_stream[channel_id][:stream_instance].stream + @@channel_stream[channel_id][:stream_instance].disconnect + @@channel_stream[channel_id] = false + logger.info " ...stopped thread for '#{channel.id}'" rescue => e error = "Can't use channel (#{channel.id}): #{e.inspect}" logger.error error @@ -178,24 +190,24 @@ stream all accounts channel.status_in = 'error' channel.last_log_in = error channel.save - @@channel_stream[channel.id] = false + @@channel_stream[channel_id] = false end } } # cleanup deleted channels last_channels.each { |channel_id| - next if !@@channel_stream[channel_id] + next if !@@channel_stream[channel_id.to_s] next if current_channels.include?(channel_id) - logger.debug "channel (#{channel_id}) not longer active, stop thread" - @@channel_stream[channel_id][:thread].exit - @@channel_stream[channel_id][:thread].join - @@channel_stream[channel_id][:stream_instance].disconnect - @@channel_stream[channel_id] = false + logger.info "channel (#{channel_id}) not longer active, stop thread" + @@channel_stream[channel_id.to_s][:thread].exit + @@channel_stream[channel_id.to_s][:thread].join + @@channel_stream[channel_id.to_s][:stream_instance].disconnect + @@channel_stream[channel_id.to_s] = false } last_channels = current_channels - sleep 30 + sleep 20 end end diff --git a/app/models/channel/driver/twitter.rb b/app/models/channel/driver/twitter.rb index 349b76031..56d7146bb 100644 --- a/app/models/channel/driver/twitter.rb +++ b/app/models/channel/driver/twitter.rb @@ -82,7 +82,7 @@ returns # only fetch once in 30 minutes return true if !channel.preferences return true if !channel.preferences[:last_fetch] - return false if channel.preferences[:last_fetch] > Time.zone.now - 30.minutes + return false if channel.preferences[:last_fetch] > Time.zone.now - 20.minutes true end @@ -183,6 +183,24 @@ returns =end def stream + sleep_on_unauthorized = 61 + 2.times { |loop_count| + begin + stream_start + rescue Twitter::Error::Unauthorized => e + Rails.logger.info "Unable to stream, try #{loop_count}, error #{e.inspect}" + if loop_count < 2 + Rails.logger.info "wait for #{sleep_on_unauthorized} sec. and try it again" + sleep sleep_on_unauthorized + else + raise "Unable to stream, try #{loop_count}, error #{e.inspect}" + end + end + } + end + + def stream_start + sync = @channel.options['sync'] raise 'Need channel.options[\'sync\'] for account, but no params found' if !sync @@ -204,20 +222,21 @@ returns next if tweet.class != Twitter::Tweet && tweet.class != Twitter::DirectMessage # wait until own posts are stored in local database to prevent importing own tweets - sleep 4 + next if @stream_client.locale_sender?(tweet) && own_tweet_already_imported?(tweet) + next if Ticket::Article.find_by(message_id: tweet.id) # check direct message if tweet.class == Twitter::DirectMessage if sync['direct_messages'] && sync['direct_messages']['group_id'] != '' - next if @stream_client.direct_message_limit_reached(tweet) + next if @stream_client.direct_message_limit_reached(tweet, 2) @stream_client.to_group(tweet, sync['direct_messages']['group_id'], @channel) end next end next if !track_retweets? && tweet.retweet? - next if @stream_client.tweet_limit_reached(tweet) + next if @stream_client.tweet_limit_reached(tweet, 2) # check if it's mention if sync['mentions'] && sync['mentions']['group_id'] != '' @@ -290,6 +309,9 @@ returns Rails.logger.debug "tweet to old: #{tweet.id}/#{tweet.created_at}" next end + + next if @rest_client.locale_sender?(tweet) && own_tweet_already_imported?(tweet) + next if Ticket::Article.find_by(message_id: tweet.id) break if @rest_client.tweet_limit_reached(tweet) @rest_client.to_group(tweet, search[:group_id], @channel) @@ -351,4 +373,28 @@ returns def track_retweets? @channel.options && @channel.options['sync'] && @channel.options['sync']['track_retweets'] end + + def own_tweet_already_imported?(tweet) + event_time = Time.zone.now + sleep 4 + 12.times { |loop_count| + if Ticket::Article.find_by(message_id: tweet.id) + Rails.logger.debug "Own tweet already imported, skipping tweet #{tweet.id}" + return true + end + count = Delayed::Job.where('created_at < ?', event_time).count + break if count.zero? + sleep_time = 2 * count + sleep_time = 5 if sleep_time > 5 + Rails.logger.debug "Delay importing own tweets - sleep #{sleep_time} (loop #{loop_count})" + sleep sleep_time + } + + if Ticket::Article.find_by(message_id: tweet.id) + Rails.logger.debug "Own tweet already imported, skipping tweet #{tweet.id}" + return true + end + false + end + end diff --git a/lib/tweet_base.rb b/lib/tweet_base.rb index 24a41465e..25cae7f01 100644 --- a/lib/tweet_base.rb +++ b/lib/tweet_base.rb @@ -62,7 +62,7 @@ class TweetBase user_data[:active] = true user_data[:role_ids] = Role.signup_role_ids - user = User.create(user_data) + user = User.create!(user_data) end if user_data[:image_source] @@ -93,7 +93,7 @@ class TweetBase if auth auth.update_attributes(auth_data) else - Authorization.create(auth_data) + Authorization.create!(auth_data) end user @@ -128,10 +128,10 @@ class TweetBase state = get_state(channel, tweet) - Ticket.create( + Ticket.create!( customer_id: user.id, title: title, - group_id: group_id, + group_id: group_id || Group.first.id, state: state, priority: Ticket::Priority.find_by(name: '2 normal'), preferences: { @@ -235,29 +235,12 @@ class TweetBase Rails.logger.debug 'import tweet' - ticket = nil # use transaction if @connection_type == 'stream' ActiveRecord::Base.connection.reconnect! - - # if sender is a system account, wait until twitter message id is stored - # on article to prevent two (own created & twitter created) articles - tweet_user = user(tweet) - Channel.where(area: 'Twitter::Account').each { |local_channel| - next if !local_channel.options - next if !local_channel.options[:user] - next if !local_channel.options[:user][:id] - next if local_channel.options[:user][:id].to_s != tweet_user.id.to_s - sleep 5 - - # return if tweet already exists (send via system) - if Ticket::Article.find_by(message_id: tweet.id) - Rails.logger.debug "Do not import tweet.id #{tweet.id}, article already exists" - return nil - end - } end + ticket = nil Transaction.execute(reset_user_id: true) do # check if parent exists @@ -272,6 +255,11 @@ class TweetBase ticket = existing_article.ticket else begin + + # in case of streaming mode, get parent tweet via REST client + if !@client && @auth + @client = TweetRest.new(@auth) + end parent_tweet = @client.status(tweet.in_reply_to_status_id) ticket = to_group(parent_tweet, group_id, channel) rescue Twitter::Error::NotFound, Twitter::Error::Forbidden => e @@ -343,11 +331,12 @@ class TweetBase Ticket::State.find_by(default_follow_up: true) end - def tweet_limit_reached(tweet) + def tweet_limit_reached(tweet, factor = 1) max_count = 120 if @connection_type == 'stream' max_count = 30 end + max_count = max_count * factor type_id = Ticket::Article::Type.lookup(name: 'twitter status').id created_at = Time.zone.now - 15.minutes created_count = Ticket::Article.where('created_at > ? AND type_id = ?', created_at, type_id).count @@ -358,11 +347,12 @@ class TweetBase false end - def direct_message_limit_reached(tweet) + def direct_message_limit_reached(tweet, factor = 1) max_count = 100 if @connection_type == 'stream' max_count = 40 end + max_count = max_count * factor type_id = Ticket::Article::Type.lookup(name: 'twitter direct-message').id created_at = Time.zone.now - 15.minutes created_count = Ticket::Article.where('created_at > ? AND type_id = ?', created_at, type_id).count @@ -390,4 +380,17 @@ class TweetBase preferences end + def locale_sender?(tweet) + tweet_user = user(tweet) + Channel.where(area: 'Twitter::Account').each { |local_channel| + next if !local_channel.options + next if !local_channel.options[:user] + next if !local_channel.options[:user][:id] + next if local_channel.options[:user][:id].to_s != tweet_user.id.to_s + Rails.logger.debug "Tweet is sent by local account with user id #{tweet_user.id} and tweet.id #{tweet.id}" + return true + } + false + end + end diff --git a/lib/tweet_stream.rb b/lib/tweet_stream.rb index 1abf1d15f..dc270932c 100644 --- a/lib/tweet_stream.rb +++ b/lib/tweet_stream.rb @@ -6,6 +6,7 @@ class TweetStream < TweetBase def initialize(auth) @connection_type = 'stream' + @auth = auth @client = Twitter::Streaming::ClientCustom.new do |config| config.consumer_key = auth[:consumer_key] config.consumer_secret = auth[:consumer_secret] diff --git a/test/integration/twitter_browser_test.rb b/test/integration/twitter_browser_test.rb index abd763ae8..8be58b1b3 100644 --- a/test/integration/twitter_browser_test.rb +++ b/test/integration/twitter_browser_test.rb @@ -187,7 +187,7 @@ class TwitterBrowserTest < TestCase ) # wait till new streaming of channel is active - sleep 60 + sleep 80 # start tweet from customer client = Twitter::REST::Client.new do |config| @@ -211,7 +211,6 @@ class TwitterBrowserTest < TestCase ) click(text: 'Unassigned & Open') - sleep 6 # till overview is rendered watch_for( css: '.content.active', diff --git a/test/integration/twitter_test.rb b/test/integration/twitter_test.rb index 786cc2773..53f640191 100644 --- a/test/integration/twitter_test.rb +++ b/test/integration/twitter_test.rb @@ -526,14 +526,15 @@ class TwitterTest < ActiveSupport::TestCase tweet = client.update( text, ) - sleep 10 + article = nil - 2.times { + 5.times { + Scheduler.worker(true) article = Ticket::Article.find_by(message_id: tweet.id) break if article ActiveRecord::Base.clear_all_connections! ActiveRecord::Base.connection.query_cache.clear - sleep 15 + sleep 10 } assert(article, "article from customer with text '#{text}' message_id '#{tweet.id}' created") assert_equal(customer_login, article.from, 'ticket article from') @@ -551,9 +552,10 @@ class TwitterTest < ActiveSupport::TestCase tweet = client.update( text, ) - sleep 10 + article = nil - 2.times { + 5.times { + Scheduler.worker(true) article = Ticket::Article.find_by(message_id: tweet.id) break if article ActiveRecord::Base.clear_all_connections! @@ -594,7 +596,7 @@ class TwitterTest < ActiveSupport::TestCase assert(tweet_found, "found outbound '#{reply_text}' tweet '#{article.message_id}'") count = Ticket::Article.where(message_id: article.message_id).count - assert_equal(1, count) + assert_equal(1, count, "tweet #{article.message_id}") channel_id = article.ticket.preferences[:channel_id] assert(channel_id) @@ -616,13 +618,12 @@ class TwitterTest < ActiveSupport::TestCase text, ) assert(dm, "dm with ##{hash} created") - sleep 10 + article = nil - 2.times { + 5.times { + Scheduler.worker(true) article = Ticket::Article.find_by(message_id: dm.id) break if article - ActiveRecord::Base.clear_all_connections! - ActiveRecord::Base.connection.query_cache.clear sleep 10 } assert(article, "inbound article '#{text}' message_id '#{dm.id}' created") @@ -719,9 +720,8 @@ class TwitterTest < ActiveSupport::TestCase retweet = client.retweet(tweet).first # fetch check system account - sleep 15 article = nil - 2.times { + 4.times { # check if ticket and article has been created article = Ticket::Article.find_by(message_id: retweet.id) break if article @@ -734,6 +734,57 @@ class TwitterTest < ActiveSupport::TestCase thread.join end + test 'i restart stream after config of channel has changed' do + hash = "#citheo#{rand(999)}" + + thread = Thread.new { + Channel.stream + sleep 10 + item = { + term: hash, + group_id: group.id, + } + channel_thread = Channel.find(channel.id) + channel_thread[:options]['sync']['search'].push item + channel_thread.save! + } + + sleep 60 + + # new tweet - by me_bauer + client = Twitter::REST::Client.new do |config| + config.consumer_key = consumer_key + config.consumer_secret = consumer_secret + config.access_token = customer_token + config.access_token_secret = customer_token_secret + end + + hash = "#{hash_tag1} ##{hash_gen}" + text = "Today... #{rand_word} #{hash}" + tweet = client.update( + text, + ) + article = nil + 5.times { + Scheduler.worker(true) + article = Ticket::Article.find_by(message_id: tweet.id) + break if article + ActiveRecord::Base.clear_all_connections! + ActiveRecord::Base.connection.query_cache.clear + sleep 10 + } + assert(article, "article from customer with text '#{text}' message_id '#{tweet.id}' created") + assert_equal(customer_login, article.from, 'ticket article from') + assert_nil(article.to, 'ticket article to') + + thread.exit + thread.join + + channel_thread = Channel.find(channel.id) + channel_thread[:options]['sync']['search'].pop + channel_thread.save! + end + def hash_gen rand(999).to_s + (0...10).map { ('a'..'z').to_a[rand(26)] }.join end From 118ed9d58b5f9e1dd0c9a88fc8affbdf4cb7e145 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Thu, 1 Jun 2017 11:43:19 +0200 Subject: [PATCH 022/234] Fixed issue #1155 - Group and User filter detection for freeIPA LDAP fails. --- lib/ldap.rb | 1 + lib/ldap/group.rb | 2 +- lib/ldap/user.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/ldap.rb b/lib/ldap.rb index ac07fdb4d..4bd2a06ab 100644 --- a/lib/ldap.rb +++ b/lib/ldap.rb @@ -137,6 +137,7 @@ class Ldap result = ldap.get_operation_result raise Exceptions::UnprocessableEntity, "Can't bind to '#{@host}', #{result.code}, #{result.message}" rescue => e + Rails.logger.error e raise Exceptions::UnprocessableEntity, "Can't connect to '#{@host}' on port '#{@port}', #{e}" end diff --git a/lib/ldap/group.rb b/lib/ldap/group.rb index 0e63a8dc2..bec592c70 100644 --- a/lib/ldap/group.rb +++ b/lib/ldap/group.rb @@ -112,7 +112,7 @@ class Ldap # # @return [String, nil] The active or found filter or nil if none could be found. def filter - @filter ||= lookup_filter(['(objectClass=group)']) + @filter ||= lookup_filter(['(objectClass=group)', '(objectClass=posixgroup)']) end # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed. diff --git a/lib/ldap/user.rb b/lib/ldap/user.rb index 9cb25bdbd..ca000dbfc 100644 --- a/lib/ldap/user.rb +++ b/lib/ldap/user.rb @@ -162,7 +162,7 @@ class Ldap # # @return [String, nil] The active or found filter or nil if none could be found. def filter - @filter ||= lookup_filter(['(&(objectClass=user)(samaccountname=*)(!(samaccountname=*$)))', '(objectClass=user)']) + @filter ||= lookup_filter(['(&(objectClass=user)(samaccountname=*)(!(samaccountname=*$)))', '(objectClass=user)', '(objectClass=posixaccount)']) end # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed. From 017022eae9f74c662acd048418b105717c604c43 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Wed, 31 May 2017 13:36:25 +0200 Subject: [PATCH 023/234] Fixed issue #1114 - Configuration of LDAP with disabled anonymous bind fails. --- .../app/controllers/_integration/ldap.coffee | 13 +++++++------ .../integration/ldap_controller.rb | 19 ++++++++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/app/controllers/_integration/ldap.coffee b/app/assets/javascripts/app/controllers/_integration/ldap.coffee index b497b0f05..dc0bbbdef 100644 --- a/app/assets/javascripts/app/controllers/_integration/ldap.coffee +++ b/app/assets/javascripts/app/controllers/_integration/ldap.coffee @@ -287,12 +287,13 @@ class ConnectionWizard extends App.WizardModal option = '' options = {} - for dn in data.attributes.namingcontexts - options[dn] = dn - if option is '' - option = dn - if option.length > dn.length - option = dn + if !_.isEmpty data.attributes + for dn in data.attributes.namingcontexts + options[dn] = dn + if option is '' + option = dn + if option.length > dn.length + option = dn @wizardConfig.options = options @wizardConfig.option = option diff --git a/app/controllers/integration/ldap_controller.rb b/app/controllers/integration/ldap_controller.rb index ff91b292f..16d2393fb 100644 --- a/app/controllers/integration/ldap_controller.rb +++ b/app/controllers/integration/ldap_controller.rb @@ -14,12 +14,21 @@ class Integration::LdapController < ApplicationController attributes: ldap.preferences, } rescue => e - logger.error e + # workaround for issue #1114 + if e.message.end_with?(', 48, Inappropriate Authentication') + result = { + result: 'ok', + attributes: {}, + } + else + logger.error e + result = { + result: 'failed', + message: e.message, + } + end - render json: { - result: 'failed', - message: e.message, - } + render json: result end def bind From 6a98b5a41dfe1ebc669c02b0df4b0328555e073c Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Fri, 2 Jun 2017 11:30:53 +0200 Subject: [PATCH 024/234] Fixed issue #1153 - Using Zendesk basic priority feature breakes import. --- lib/import/zendesk/ticket_field_factory.rb | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/import/zendesk/ticket_field_factory.rb b/lib/import/zendesk/ticket_field_factory.rb index 9b3d0606c..de787c54f 100644 --- a/lib/import/zendesk/ticket_field_factory.rb +++ b/lib/import/zendesk/ticket_field_factory.rb @@ -5,13 +5,14 @@ module Import extend Import::Zendesk::LocalIDMapperHook MAPPING = { - 'subject' => 'title', - 'description' => 'note', - 'status' => 'state_id', - 'tickettype' => 'type', - 'priority' => 'priority_id', - 'group' => 'group_id', - 'assignee' => 'owner_id', + 'subject' => 'title', + 'description' => 'note', + 'status' => 'state_id', + 'tickettype' => 'type', + 'priority' => 'priority_id', + 'basic_priority' => 'priority_id', + 'group' => 'group_id', + 'assignee' => 'owner_id', }.freeze # rubocop:disable Style/ModuleFunction From f4495dc126bacdc4571f2b08bc6abca6e36cae21 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Fri, 2 Jun 2017 17:41:02 +0200 Subject: [PATCH 025/234] Improved email parsing of invalid email filename headers. --- app/models/channel/email_parser.rb | 9 +- test/fixtures/mail56.box | 933 +++++++++++++++++++++++++++++ test/unit/email_parser_test.rb | 24 +- 3 files changed, 962 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/mail56.box diff --git a/app/models/channel/email_parser.rb b/app/models/channel/email_parser.rb index e1a9baa99..df4a6b57b 100644 --- a/app/models/channel/email_parser.rb +++ b/app/models/channel/email_parser.rb @@ -314,9 +314,12 @@ class Channel::EmailParser filename = file.header[:content_disposition].filename rescue begin - result = file.header[:content_disposition].to_s.scan( /filename=("|)(.+?)("|);/i ) - if result && result[0] && result[0][1] - filename = result[0][1] + if file.header[:content_disposition].to_s =~ /filename="(.+?)"/i + filename = $1 + elsif file.header[:content_disposition].to_s =~ /filename='(.+?)'/i + filename = $1 + elsif file.header[:content_disposition].to_s =~ /filename=(.+?);/i + filename = $1 end rescue Rails.logger.debug 'Unable to get filename' diff --git a/test/fixtures/mail56.box b/test/fixtures/mail56.box new file mode 100644 index 000000000..fcf7f886f --- /dev/null +++ b/test/fixtures/mail56.box @@ -0,0 +1,933 @@ +Return-Path: +X-Original-To: me@example.de +Delivered-To: martin@samba.example.de +Received: from me.home (93-82-123-230.adsl.highway.telekom.at [93.82.123.230]) + by samba.example.de (Postfix) with ESMTPSA id B3F2D500D3D + for ; Mon, 2 Jul 2012 16:14:33 +0100 (BST) +From: Martin Edenhofer +Content-Type: multipart/alternative; boundary="Apple-Mail=_A3A84DDB-B242-4521-AD6F-24FAEC28042F" +Subject: =?utf-8?B?QVc6IE9UUlMgLyBBbmZyYWdlIE9UUlMgRWluZsO8aHJ1bmcvUHLDpHNlbnRh?= + =?utf-8?Q?tion_[Ticket#11545]?= +Date: Mon, 2 Jul 2012 17:14:37 +0200 +Message-Id: <4C4ECFBF-BA12-46D9-A407-8E873F20DEF3@example.de> +To: me@example.de +Mime-Version: 1.0 (Apple Message framework v1278) +X-Mailer: Apple Mail (2.1278) + + +--Apple-Mail=_A3A84DDB-B242-4521-AD6F-24FAEC28042F +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; + charset=us-ascii + +Enjoy! + +--Apple-Mail=_A3A84DDB-B242-4521-AD6F-24FAEC28042F +Content-Type: multipart/related; + type="text/html"; + boundary="Apple-Mail=_5FA1E6E6-1C40-4E5D-8231-1F0EF0E45CCF" + + +--Apple-Mail=_5FA1E6E6-1C40-4E5D-8231-1F0EF0E45CCF +Content-Transfer-Encoding: 7bit +Content-Type: text/html; + charset=us-ascii + +Enjoy! +--Apple-Mail=_5FA1E6E6-1C40-4E5D-8231-1F0EF0E45CCF +Content-Type: image/jpg +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Hofjägeralle Wasserschaden.jpg" + +/9j/4AAQSkZJRgABAQEASABIAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdC +IFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAA +AADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFj +cHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAA +ABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAAD +TAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJD +AAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5 +OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEA +AAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAA +AAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAA +AA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBo +dHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcg +Q29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENv +bmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAA +ABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAA +AAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAK +AA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUA +mgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEy +ATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMC +DAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh +Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4E +jASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3 +BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDII +RghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqY +Cq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUAN +Wg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBh +EH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT +5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReu +F9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9oc +AhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCY +IMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZcl +xyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2 +K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIx +SjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDec +N9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+ +oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXe +RiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN +3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYP +VlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1f +D19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/ +aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfBy +S3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyB +fOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuH +n4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLj +k02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6f +HZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1 +q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm4 +0blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZG +xsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnU +y9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj +4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozz +GfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////4QMmRXhpZgAA +TU0AKgAAAAgACgEPAAIAAAASAAAAhgEQAAIAAAAKAAAAmAESAAMAAAABAAEAAAEaAAUAAAABAAAA +ogEbAAUAAAABAAAAqgEoAAMAAAABAAIAAAExAAIAAAAeAAAAsgEyAAIAAAAUAAAA0AE8AAIAAAAQ +AAAA5IdpAAQAAAABAAAA9AAAAABOSUtPTiBDT1JQT1JBVElPTgBOSUtPTiBEOTAAAAAASAAAAAEA +AABIAAAAAUFkb2JlIFBob3Rvc2hvcCBDUzQgTWFjaW50b3NoADIwMTI6MDU6MTcgMjE6MjU6MTUA +TWFjIE9TIFggMTAuNi44AAAigpoABQAAAAEAAAKSgp0ABQAAAAEAAAKaiCIAAwAAAAEAAwAAiCcA +AwAAAAEAyAAAkAAABwAAAAQwMjIwkAMAAgAAABQAAAKikAQAAgAAABQAAAK2kQEABwAAAAQAAAAB +kQIABQAAAAEAAALKkgQACgAAAAEAAALSkgUABQAAAAEAAALakgcAAwAAAAEAAgAAkggAAwAAAAEA +AAAAkgkAAwAAAAEAAAAAkgoABQAAAAEAAALikoYABwAAACwAAALqkpAAAgAAAAMwMAAAkpEAAgAA +AAMwMAAAkpIAAgAAAAMwMAAAoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIABAAAAAEAAAKAoAMA +BAAAAAEAAAGpohcAAwAAAAEAAgAApAEAAwAAAAEAAAAApAIAAwAAAAEAAAAApAMAAwAAAAEAAAAA +pAQABQAAAAEAAAMWpAUAAwAAAAEANAAApAYAAwAAAAEAAAAApAgAAwAAAAEAAAAApAkAAwAAAAEA +AAAApAoAAwAAAAEAAAAApAwAAwAAAAEAAAAAAAAAAAAAAAEAAA+gAAAACQAAAAUyMDEyOjA1OjE3 +IDE4OjEwOjMzADIwMTI6MDU6MTcgMTg6MTA6MzMAAAAABAAAAAEAAAAAAAAAAQAAAAgAAAAFAAAA +IwAAAAFBU0NJSQAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAAAAAEAAAAB +/+EA5Gh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8APHg6eG1wbWV0YSB4bWxuczp4PSJhZG9i +ZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNC40LjAiPgogICA8cmRmOlJERiB4bWxuczpy +ZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8 +cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+ +CgD/2wBDAAICAgICAQICAgICAgIDAwYEAwMDAwcFBQQGCAcICAgHCAgJCg0LCQkMCggICw8LDA0O +Dg4OCQsQEQ8OEQ0ODg7/2wBDAQICAgMDAwYEBAYOCQgJDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4O +Dg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg7/wAARCAGpAoADASIAAhEBAxEB/8QAHwAAAQUBAQEB +AQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1Fh +ByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZ +WmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXG +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 + +--Apple-Mail=_5FA1E6E6-1C40-4E5D-8231-1F0EF0E45CCF-- + +--Apple-Mail=_A3A84DDB-B242-4521-AD6F-24FAEC28042F-- diff --git a/test/unit/email_parser_test.rb b/test/unit/email_parser_test.rb index c2d0e5be6..f3044ca83 100644 --- a/test/unit/email_parser_test.rb +++ b/test/unit/email_parser_test.rb @@ -1099,6 +1099,28 @@ end body: 'no visible content' }, }, + { + data: IO.binread('test/fixtures/mail56.box'), + body_md5: 'ee40e852b9fa18652ea66e2eda1ecbd3', + attachments: [ + { + md5: 'cd82962457892d2e2f2d6914da3a88ed', + filename: 'message.html', + }, + { + md5: 'ddbdf67aa2f5c60c294008a54d57082b', + filename: 'Hofjägeralle Wasserschaden.jpg', + }, + ], + params: { + from: 'Martin Edenhofer ', + from_email: 'martin@example.de', + from_display_name: 'Martin Edenhofer', + subject: 'AW: OTRS / Anfrage OTRS Einführung/Präsentation [Ticket#11545]', + content_type: 'text/html', + body: 'Enjoy!', + }, + }, ] count = 0 @@ -1139,7 +1161,7 @@ end found = false data[:attachments].each { |attachment_parser| next if found - file_md5 = Digest::MD5.hexdigest( attachment_parser[:data] ) + file_md5 = Digest::MD5.hexdigest(attachment_parser[:data]) #puts 'Attachment:' + attachment_parser.inspect + '-' + file_md5 if attachment[:md5] == file_md5 found = true From 0c29a596400dfd4791a77dfe8e1739ce17b63128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bauer?= Date: Sat, 3 Jun 2017 16:32:15 +0200 Subject: [PATCH 026/234] set postgresql db user to fix rake db:drop --- contrib/packager.io/functions | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/packager.io/functions b/contrib/packager.io/functions index 48d1b3378..b650fcdc5 100644 --- a/contrib/packager.io/functions +++ b/contrib/packager.io/functions @@ -140,12 +140,12 @@ function create_postgresql_db () { echo "# Restarting postgresql server" ${INIT_CMD} restart postgresql - echo "# Creating zammad postgresql db" - su - postgres -c "createdb -E UTF8 ${DB}" - echo "# Creating zammad postgresql user" echo "CREATE USER \"${DB_USER}\" WITH PASSWORD '${DB_PASS}';" | su - postgres -c psql + echo "# Creating zammad postgresql db" + su - postgres -c "createdb -E UTF8 ${DB} -O ${DB_USER}" + echo "# Grant privileges to new postgresql user" echo "GRANT ALL PRIVILEGES ON DATABASE \"${DB}\" TO \"${DB_USER}\";" | su - postgres -c psql } From 805199880a2487975b4c3d9cf17f5a414ef82a03 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 6 Jun 2017 14:09:50 +0200 Subject: [PATCH 027/234] Improved mem usage. --- app/models/concerns/has_search_index_backend.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/has_search_index_backend.rb b/app/models/concerns/has_search_index_backend.rb index 4ab81a4ab..42b14a822 100644 --- a/app/models/concerns/has_search_index_backend.rb +++ b/app/models/concerns/has_search_index_backend.rb @@ -123,7 +123,9 @@ reload search index with full data def search_index_reload tolerance = 5 tolerance_count = 0 - all.order('created_at DESC').each { |item| + ids = all.order('created_at DESC').pluck(:id) + ids.each { |item_id| + item = find(item_id) next if item.ignore_search_indexing?(:destroy) begin item.search_index_update_backend From f3a7b5415d1afd4b7d37a10e48a1dac0994727f7 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 6 Jun 2017 14:10:29 +0200 Subject: [PATCH 028/234] Improved test. --- test/integration/twitter_browser_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/twitter_browser_test.rb b/test/integration/twitter_browser_test.rb index 8be58b1b3..32bef3329 100644 --- a/test/integration/twitter_browser_test.rb +++ b/test/integration/twitter_browser_test.rb @@ -140,7 +140,7 @@ class TwitterBrowserTest < TestCase set(css: '.content.active .modal [name="search::term"]', value: hash) select(css: '.content.active .modal [name="search::group_id"]', value: 'Users') click(css: '.content.active .modal .js-submit') - sleep 5 + modal_disappear watch_for( css: '.content.active', From e3c90174eed1fb5ac6d3cd3be8adc6e023632a37 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 6 Jun 2017 14:11:28 +0200 Subject: [PATCH 029/234] Added save_to_file as helper to get attachments for testing assets. --- app/models/store.rb | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/models/store.rb b/app/models/store.rb index a5ea0520c..d423b90bc 100644 --- a/app/models/store.rb +++ b/app/models/store.rb @@ -139,6 +139,19 @@ returns true end +=begin + +get content of file + + store = Store.find(store_id) + content_as_string = store.content + +returns + + content_as_string + +=end + def content file = Store::File.find_by(id: store_file_id) if !file @@ -147,6 +160,34 @@ returns file.content end +=begin + +get content of file + + store = Store.find(store_id) + location_of_file = store.save_to_file + +returns + + location_of_file + +=end + + def save_to_file(path = nil) + content + file = Store::File.find_by(id: store_file_id) + if !file + raise "No such file #{store_file_id}!" + end + if !path + path = "#{Rails.root}/tmp/#{filename}" + end + ::File.open(path, 'wb') { |handle| + handle.write file.content + } + path + end + def provider file = Store::File.find_by(id: store_file_id) if !file From cb919312b03e46f69eabd05b9426959dfdebf13f Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Tue, 6 Jun 2017 17:49:49 +0200 Subject: [PATCH 030/234] Introduced foreign keys to ensure consistent data state. --- app/models/email_address.rb | 4 +- app/models/history.rb | 68 +----- app/models/object_lookup.rb | 12 -- app/models/store.rb | 23 +-- app/models/type_lookup.rb | 14 -- db/migrate/20120101000001_create_base.rb | 173 +++++++++++----- db/migrate/20120101000010_create_ticket.rb | 156 ++++++++++---- db/migrate/20170531144425_foreign_keys.rb | 227 +++++++++++++++++++++ spec/support/cache.rb | 8 + test/unit/assets_test.rb | 8 +- test/unit/cti_caller_id_test.rb | 10 +- test/unit/email_address_test.rb | 2 +- test/unit/user_test.rb | 10 +- 13 files changed, 511 insertions(+), 204 deletions(-) create mode 100644 db/migrate/20170531144425_foreign_keys.rb create mode 100644 spec/support/cache.rb diff --git a/app/models/email_address.rb b/app/models/email_address.rb index b2918ebbc..6c36238e9 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -12,7 +12,7 @@ class EmailAddress < ApplicationModel before_update :check_if_channel_exists_set_inactive after_create :update_email_address_id after_update :update_email_address_id - after_destroy :delete_group_reference + before_destroy :delete_group_reference =begin @@ -59,7 +59,7 @@ check and if channel not exists reset configured channels for email addresses # delete group.email_address_id reference if email address get's deleted def delete_group_reference Group.where(email_address_id: id).each { |group| - group.email_address_id = nil + group.update_attributes!(email_address_id: nil) } end diff --git a/app/models/history.rb b/app/models/history.rb index 6b7c72046..80ef771c6 100644 --- a/app/models/history.rb +++ b/app/models/history.rb @@ -5,17 +5,9 @@ class History < ApplicationModel include History::Assets self.table_name = 'histories' - belongs_to :history_type, class_name: 'History::Type' - belongs_to :history_object, class_name: 'History::Object' - belongs_to :history_attribute, class_name: 'History::Attribute' - # before_validation :check_type, :check_object - # attr_writer :history_type, :history_object - - # rubocop:disable Style/ClassVars - @@cache_type = {} - @@cache_object = {} - @@cache_attribute = {} -# rubocop:enable Style/ClassVars + belongs_to :history_type, class_name: 'History::Type' + belongs_to :history_object, class_name: 'History::Object' + belongs_to :history_attribute, class_name: 'History::Attribute' =begin @@ -216,96 +208,54 @@ returns end def self.type_lookup_id(id) - - # use cache - return @@cache_type[ id ] if @@cache_type[ id ] - - # lookup - history_type = History::Type.lookup(id: id) - @@cache_type[ id ] = history_type - history_type + History::Type.lookup(id: id) end def self.type_lookup(name) - - # use cache - return @@cache_type[ name ] if @@cache_type[ name ] - # lookup history_type = History::Type.lookup(name: name) if history_type - @@cache_type[ name ] = history_type return history_type end # create - history_type = History::Type.create( + History::Type.create( name: name ) - @@cache_type[ name ] = history_type - history_type end def self.object_lookup_id(id) - - # use cache - return @@cache_object[ id ] if @@cache_object[ id ] - - # lookup - history_object = History::Object.lookup(id: id) - @@cache_object[ id ] = history_object - history_object + History::Object.lookup(id: id) end def self.object_lookup(name) - - # use cache - return @@cache_object[ name ] if @@cache_object[ name ] - # lookup history_object = History::Object.lookup(name: name) if history_object - @@cache_object[ name ] = history_object return history_object end # create - history_object = History::Object.create( + History::Object.create( name: name ) - @@cache_object[ name ] = history_object - history_object end def self.attribute_lookup_id(id) - - # use cache - return @@cache_attribute[ id ] if @@cache_attribute[ id ] - - # lookup - history_attribute = History::Attribute.lookup(id: id) - @@cache_attribute[ id ] = history_attribute - history_attribute + History::Attribute.lookup(id: id) end def self.attribute_lookup(name) - - # use cache - return @@cache_attribute[ name ] if @@cache_attribute[ name ] - # lookup history_attribute = History::Attribute.lookup(name: name) if history_attribute - @@cache_attribute[ name ] = history_attribute return history_attribute end # create - history_attribute = History::Attribute.create( + History::Attribute.create( name: name ) - @@cache_attribute[ name ] = history_attribute - history_attribute end class Object < ApplicationModel diff --git a/app/models/object_lookup.rb b/app/models/object_lookup.rb index a20194dba..5ada6c174 100644 --- a/app/models/object_lookup.rb +++ b/app/models/object_lookup.rb @@ -1,35 +1,23 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class ObjectLookup < ApplicationModel - @@cache_object = {} # rubocop:disable Style/ClassVars def self.by_id(id) - - # use cache - return @@cache_object[ id ] if @@cache_object[ id ] - # lookup lookup = self.lookup(id: id) return if !lookup - @@cache_object[ id ] = lookup.name lookup.name end def self.by_name(name) - - # use cache - return @@cache_object[ name ] if @@cache_object[ name ] - # lookup lookup = self.lookup(name: name) if lookup - @@cache_object[ name ] = lookup.id return lookup.id end # create lookup = create(name: name) - @@cache_object[ name ] = lookup.id lookup.id end diff --git a/app/models/store.rb b/app/models/store.rb index d423b90bc..51a84ed5b 100644 --- a/app/models/store.rb +++ b/app/models/store.rb @@ -118,25 +118,22 @@ returns remove one attachment from storage - result = Store.remove_item(store_id) - -returns - - result = true + Store.remove_item(store_id) =end def self.remove_item(store_id) - # check backend for references - store = Store.find(store_id) - files = Store.where(store_file_id: store.store_file_id) - if files.count == 1 && files.first.id == store.id - Store::File.find(store.store_file_id).destroy - end - + store = Store.find(store_id) + file_id = store.store_file_id store.destroy - true + + # check backend for references + files = Store.where(store_file_id: file_id) + return if files.count != 1 + return if files.first.id != store.id + + Store::File.find(file_id).destroy end =begin diff --git a/app/models/type_lookup.rb b/app/models/type_lookup.rb index 095db3762..5934498d6 100644 --- a/app/models/type_lookup.rb +++ b/app/models/type_lookup.rb @@ -1,29 +1,17 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class TypeLookup < ApplicationModel - @@cache_object = {} # rubocop:disable Style/ClassVars def self.by_id( id ) - - # use cache - return @@cache_object[ id ] if @@cache_object[ id ] - - # lookup lookup = self.lookup( id: id ) return if !lookup - @@cache_object[ id ] = lookup.name lookup.name end def self.by_name( name ) - - # use cache - return @@cache_object[ name ] if @@cache_object[ name ] - # lookup lookup = self.lookup( name: name ) if lookup - @@cache_object[ name ] = lookup.id return lookup.id end @@ -31,8 +19,6 @@ class TypeLookup < ApplicationModel lookup = create( name: name ) - @@cache_object[ name ] = lookup.id lookup.id end - end diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb index 722e5c517..bc746804a 100644 --- a/db/migrate/20120101000001_create_base.rb +++ b/db/migrate/20120101000001_create_base.rb @@ -56,6 +56,8 @@ class CreateBase < ActiveRecord::Migration add_index :users, [:mobile] add_index :users, [:source] add_index :users, [:created_by_id] + add_foreign_key :users, :users, column: :created_by_id + add_foreign_key :users, :users, column: :updated_by_id create_table :signatures do |t| t.string :name, limit: 100, null: false @@ -67,6 +69,8 @@ class CreateBase < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :signatures, [:name], unique: true + add_foreign_key :signatures, :users, column: :created_by_id + add_foreign_key :signatures, :users, column: :updated_by_id create_table :email_addresses do |t| t.integer :channel_id, null: true @@ -80,6 +84,8 @@ class CreateBase < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :email_addresses, [:email], unique: true + add_foreign_key :email_addresses, :users, column: :created_by_id + add_foreign_key :email_addresses, :users, column: :updated_by_id create_table :groups do |t| t.references :signature, null: true @@ -95,6 +101,10 @@ class CreateBase < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :groups, [:name], unique: true + add_foreign_key :groups, :signatures + add_foreign_key :groups, :email_addresses + add_foreign_key :groups, :users, column: :created_by_id + add_foreign_key :groups, :users, column: :updated_by_id create_table :roles do |t| t.string :name, limit: 100, null: false @@ -107,6 +117,8 @@ class CreateBase < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :roles, [:name], unique: true + add_foreign_key :roles, :users, column: :created_by_id + add_foreign_key :roles, :users, column: :updated_by_id create_table :permissions do |t| t.string :name, limit: 255, null: false @@ -135,27 +147,36 @@ class CreateBase < ActiveRecord::Migration end add_index :organizations, [:name], unique: true add_index :organizations, [:domain] + add_foreign_key :users, :organizations + add_foreign_key :organizations, :users, column: :created_by_id + add_foreign_key :organizations, :users, column: :updated_by_id create_table :roles_users, id: false do |t| - t.integer :user_id - t.integer :role_id + t.references :user + t.references :role end add_index :roles_users, [:user_id] add_index :roles_users, [:role_id] + add_foreign_key :roles_users, :users + add_foreign_key :roles_users, :roles create_table :groups_users, id: false do |t| - t.integer :user_id - t.integer :group_id + t.references :user + t.references :group end add_index :groups_users, [:user_id] add_index :groups_users, [:group_id] + add_foreign_key :groups_users, :users + add_foreign_key :groups_users, :groups create_table :organizations_users, id: false do |t| - t.integer :user_id - t.integer :organization_id + t.references :user + t.references :organization end add_index :organizations_users, [:user_id] add_index :organizations_users, [:organization_id] + add_foreign_key :organizations_users, :users + add_foreign_key :organizations_users, :organizations create_table :authorizations do |t| t.string :provider, limit: 250, null: false @@ -169,6 +190,7 @@ class CreateBase < ActiveRecord::Migration add_index :authorizations, [:uid, :provider] add_index :authorizations, [:user_id] add_index :authorizations, [:username] + add_foreign_key :authorizations, :users create_table :locales do |t| t.string :locale, limit: 20, null: false @@ -192,6 +214,8 @@ class CreateBase < ActiveRecord::Migration end add_index :translations, [:source], length: 255 add_index :translations, [:locale] + add_foreign_key :translations, :users, column: :created_by_id + add_foreign_key :translations, :users, column: :updated_by_id create_table :object_lookups do |t| t.string :name, limit: 250, null: false @@ -220,6 +244,7 @@ class CreateBase < ActiveRecord::Migration add_index :tokens, [:name, :action], unique: true add_index :tokens, :created_at add_index :tokens, :persistent + add_foreign_key :tokens, :users create_table :packages do |t| t.string :name, limit: 250, null: false @@ -230,6 +255,9 @@ class CreateBase < ActiveRecord::Migration t.integer :created_by_id, null: false t.timestamps limit: 3, null: false end + add_foreign_key :packages, :users, column: :created_by_id + add_foreign_key :packages, :users, column: :updated_by_id + create_table :package_migrations do |t| t.string :name, limit: 250, null: false t.string :version, limit: 250, null: false @@ -237,7 +265,7 @@ class CreateBase < ActiveRecord::Migration end create_table :taskbars do |t| - t.integer :user_id, null: false + t.references :user, null: false t.datetime :last_contact, null: false t.string :client_id, null: false t.string :key, limit: 100, null: false @@ -253,16 +281,7 @@ class CreateBase < ActiveRecord::Migration add_index :taskbars, [:user_id] add_index :taskbars, [:client_id] add_index :taskbars, [:key] - - create_table :tags do |t| - t.references :tag_item, null: false - t.references :tag_object, null: false - t.integer :o_id, null: false - t.integer :created_by_id, null: false - t.timestamps limit: 3, null: false - end - add_index :tags, [:o_id] - add_index :tags, [:tag_object_id] + add_foreign_key :taskbars, :users create_table :tag_objects do |t| t.string :name, limit: 250, null: false @@ -277,6 +296,19 @@ class CreateBase < ActiveRecord::Migration end add_index :tag_items, [:name_downcase] + create_table :tags do |t| + t.references :tag_item, null: false + t.references :tag_object, null: false + t.integer :o_id, null: false + t.integer :created_by_id, null: false + t.timestamps limit: 3, null: false + end + add_index :tags, [:o_id] + add_index :tags, [:tag_object_id] + add_foreign_key :tags, :tag_items + add_foreign_key :tags, :tag_objects + add_foreign_key :tags, :users, column: :created_by_id + create_table :recent_views do |t| t.references :recent_view_object, null: false t.integer :o_id, null: false @@ -287,6 +319,8 @@ class CreateBase < ActiveRecord::Migration add_index :recent_views, [:created_by_id] add_index :recent_views, [:created_at] add_index :recent_views, [:recent_view_object_id] + add_foreign_key :recent_views, :object_lookups, column: :recent_view_object_id + add_foreign_key :recent_views, :users, column: :created_by_id create_table :activity_streams do |t| t.references :activity_stream_type, null: false @@ -304,6 +338,30 @@ class CreateBase < ActiveRecord::Migration add_index :activity_streams, [:created_at] add_index :activity_streams, [:activity_stream_object_id] add_index :activity_streams, [:activity_stream_type_id] + add_foreign_key :activity_streams, :type_lookups, column: :activity_stream_type_id + add_foreign_key :activity_streams, :object_lookups, column: :activity_stream_object_id + add_foreign_key :activity_streams, :permissions + add_foreign_key :activity_streams, :groups + add_foreign_key :activity_streams, :users, column: :created_by_id + + create_table :history_types do |t| + t.string :name, limit: 250, null: false + t.timestamps limit: 3, null: false + end + add_index :history_types, [:name], unique: true + + create_table :history_objects do |t| + t.string :name, limit: 250, null: false + t.string :note, limit: 250, null: true + t.timestamps limit: 3, null: false + end + add_index :history_objects, [:name], unique: true + + create_table :history_attributes do |t| + t.string :name, limit: 250, null: false + t.timestamps limit: 3, null: false + end + add_index :history_attributes, [:name], unique: true create_table :histories do |t| t.references :history_type, null: false @@ -329,25 +387,10 @@ class CreateBase < ActiveRecord::Migration add_index :histories, [:id_from] add_index :histories, [:value_from], length: 255 add_index :histories, [:value_to], length: 255 - - create_table :history_types do |t| - t.string :name, limit: 250, null: false - t.timestamps limit: 3, null: false - end - add_index :history_types, [:name], unique: true - - create_table :history_objects do |t| - t.string :name, limit: 250, null: false - t.string :note, limit: 250, null: true - t.timestamps limit: 3, null: false - end - add_index :history_objects, [:name], unique: true - - create_table :history_attributes do |t| - t.string :name, limit: 250, null: false - t.timestamps limit: 3, null: false - end - add_index :history_attributes, [:name], unique: true + add_foreign_key :histories, :history_types + add_foreign_key :histories, :history_objects + add_foreign_key :histories, :history_attributes + add_foreign_key :histories, :users, column: :created_by_id create_table :settings do |t| t.string :title, limit: 200, null: false @@ -365,18 +408,6 @@ class CreateBase < ActiveRecord::Migration add_index :settings, [:area] add_index :settings, [:frontend] - create_table :stores do |t| - t.references :store_object, null: false - t.references :store_file, null: false - t.integer :o_id, limit: 8, null: false - t.string :preferences, limit: 2500, null: true - t.string :size, limit: 50, null: true - t.string :filename, limit: 250, null: false - t.integer :created_by_id, null: false - t.timestamps limit: 3, null: false - end - add_index :stores, [:store_object_id, :o_id] - create_table :store_objects do |t| t.string :name, limit: 250, null: false t.string :note, limit: 250, null: true @@ -392,6 +423,21 @@ class CreateBase < ActiveRecord::Migration add_index :store_files, [:sha], unique: true add_index :store_files, [:provider] + create_table :stores do |t| + t.references :store_object, null: false + t.references :store_file, null: false + t.integer :o_id, limit: 8, null: false + t.string :preferences, limit: 2500, null: true + t.string :size, limit: 50, null: true + t.string :filename, limit: 250, null: false + t.integer :created_by_id, null: false + t.timestamps limit: 3, null: false + end + add_index :stores, [:store_object_id, :o_id] + add_foreign_key :stores, :store_objects + add_foreign_key :stores, :store_files + add_foreign_key :stores, :users, column: :created_by_id + create_table :store_provider_dbs do |t| t.string :sha, limit: 128, null: false t.binary :data, limit: 200.megabytes, null: true @@ -418,6 +464,8 @@ class CreateBase < ActiveRecord::Migration add_index :avatars, [:store_hash] add_index :avatars, [:source] add_index :avatars, [:default] + add_foreign_key :avatars, :users, column: :created_by_id + add_foreign_key :avatars, :users, column: :updated_by_id create_table :online_notifications do |t| t.integer :o_id, null: false @@ -433,6 +481,8 @@ class CreateBase < ActiveRecord::Migration add_index :online_notifications, [:seen] add_index :online_notifications, [:created_at] add_index :online_notifications, [:updated_at] + add_foreign_key :online_notifications, :users, column: :created_by_id + add_foreign_key :online_notifications, :users, column: :updated_by_id create_table :schedulers do |t| t.string :name, limit: 250, null: false @@ -449,6 +499,8 @@ class CreateBase < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :schedulers, [:name], unique: true + add_foreign_key :schedulers, :users, column: :created_by_id + add_foreign_key :schedulers, :users, column: :updated_by_id create_table :calendars do |t| t.string :name, limit: 250, null: true @@ -464,6 +516,8 @@ class CreateBase < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :calendars, [:name], unique: true + add_foreign_key :calendars, :users, column: :created_by_id + add_foreign_key :calendars, :users, column: :updated_by_id create_table :user_devices do |t| t.references :user, null: false @@ -483,6 +537,7 @@ class CreateBase < ActiveRecord::Migration add_index :user_devices, [:fingerprint] add_index :user_devices, [:updated_at] add_index :user_devices, [:created_at] + add_foreign_key :user_devices, :users create_table :external_credentials do |t| t.string :name @@ -511,6 +566,9 @@ class CreateBase < ActiveRecord::Migration end add_index :object_manager_attributes, [:object_lookup_id, :name], unique: true add_index :object_manager_attributes, [:object_lookup_id] + add_foreign_key :object_manager_attributes, :object_lookups + add_foreign_key :object_manager_attributes, :users, column: :created_by_id + add_foreign_key :object_manager_attributes, :users, column: :updated_by_id create_table :delayed_jobs, force: true do |t| t.integer :priority, default: 0 # Allows some jobs to jump to the front of the queue @@ -573,19 +631,20 @@ class CreateBase < ActiveRecord::Migration add_index :cti_logs, [:from] create_table :cti_caller_ids do |t| - t.string :caller_id, limit: 100, null: false - t.string :comment, limit: 500, null: true - t.string :level, limit: 100, null: false - t.string :object, limit: 100, null: false - t.integer :o_id, null: false - t.integer :user_id, null: true - t.text :preferences, limit: 500.kilobytes + 1, null: true + t.string :caller_id, limit: 100, null: false + t.string :comment, limit: 500, null: true + t.string :level, limit: 100, null: false + t.string :object, limit: 100, null: false + t.integer :o_id, null: false + t.references :user, null: true + t.text :preferences, limit: 500.kilobytes + 1, null: true t.timestamps limit: 3, null: false end add_index :cti_caller_ids, [:caller_id] add_index :cti_caller_ids, [:caller_id, :level] add_index :cti_caller_ids, [:caller_id, :user_id] add_index :cti_caller_ids, [:object, :o_id] + add_foreign_key :cti_caller_ids, :users create_table :stats_stores do |t| t.references :stats_store_object, null: false @@ -602,6 +661,7 @@ class CreateBase < ActiveRecord::Migration add_index :stats_stores, [:stats_store_object_id] add_index :stats_stores, [:created_by_id] add_index :stats_stores, [:created_at] + add_foreign_key :stats_stores, :users, column: :created_by_id create_table :http_logs do |t| t.column :direction, :string, limit: 20, null: false @@ -619,6 +679,7 @@ class CreateBase < ActiveRecord::Migration add_index :http_logs, [:facility] add_index :http_logs, [:created_by_id] add_index :http_logs, [:created_at] - + add_foreign_key :http_logs, :users, column: :created_by_id + add_foreign_key :http_logs, :users, column: :updated_by_id end end diff --git a/db/migrate/20120101000010_create_ticket.rb b/db/migrate/20120101000010_create_ticket.rb index bbf3b3723..586b5cca7 100644 --- a/db/migrate/20120101000010_create_ticket.rb +++ b/db/migrate/20120101000010_create_ticket.rb @@ -8,6 +8,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :ticket_state_types, [:name], unique: true + add_foreign_key :ticket_state_types, :users, column: :created_by_id + add_foreign_key :ticket_state_types, :users, column: :updated_by_id create_table :ticket_states do |t| t.references :state_type, null: false @@ -25,6 +27,9 @@ class CreateTicket < ActiveRecord::Migration add_index :ticket_states, [:name], unique: true add_index :ticket_states, [:default_create] add_index :ticket_states, [:default_follow_up] + add_foreign_key :ticket_states, :ticket_state_types, column: :state_type_id + add_foreign_key :ticket_states, :users, column: :created_by_id + add_foreign_key :ticket_states, :users, column: :updated_by_id create_table :ticket_priorities do |t| t.column :name, :string, limit: 250, null: false @@ -37,6 +42,8 @@ class CreateTicket < ActiveRecord::Migration end add_index :ticket_priorities, [:name], unique: true add_index :ticket_priorities, [:default_create] + add_foreign_key :ticket_priorities, :users, column: :created_by_id + add_foreign_key :ticket_priorities, :users, column: :updated_by_id create_table :tickets do |t| t.references :group, null: false @@ -102,30 +109,28 @@ class CreateTicket < ActiveRecord::Migration add_index :tickets, [:pending_time] add_index :tickets, [:type] add_index :tickets, [:time_unit] + add_foreign_key :tickets, :groups + add_foreign_key :tickets, :users, column: :owner_id + add_foreign_key :tickets, :users, column: :customer_id + add_foreign_key :tickets, :ticket_priorities, column: :priority_id + add_foreign_key :tickets, :ticket_states, column: :state_id + add_foreign_key :tickets, :organizations + add_foreign_key :tickets, :users, column: :created_by_id + add_foreign_key :tickets, :users, column: :updated_by_id create_table :ticket_flags do |t| - t.references :tickets, null: false + t.references :ticket, null: false t.column :key, :string, limit: 50, null: false t.column :value, :string, limit: 50, null: true t.column :created_by_id, :integer, null: false t.timestamps limit: 3, null: false end - add_index :ticket_flags, [:tickets_id, :created_by_id] - add_index :ticket_flags, [:tickets_id, :key] - add_index :ticket_flags, [:tickets_id] + add_index :ticket_flags, [:ticket_id, :created_by_id] + add_index :ticket_flags, [:ticket_id, :key] + add_index :ticket_flags, [:ticket_id] add_index :ticket_flags, [:created_by_id] - - create_table :ticket_time_accountings do |t| - t.references :ticket, null: false - t.references :ticket_article, null: true - t.column :time_unit, :decimal, precision: 6, scale: 2, null: false - t.column :created_by_id, :integer, null: false - t.timestamps limit: 3, null: false - end - add_index :ticket_time_accountings, [:ticket_id] - add_index :ticket_time_accountings, [:ticket_article_id] - add_index :ticket_time_accountings, [:created_by_id] - add_index :ticket_time_accountings, [:time_unit] + add_foreign_key :ticket_flags, :tickets, column: :ticket_id + add_foreign_key :ticket_flags, :users, column: :created_by_id create_table :ticket_article_types do |t| t.column :name, :string, limit: 250, null: false @@ -137,6 +142,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :ticket_article_types, [:name], unique: true + add_foreign_key :ticket_article_types, :users, column: :created_by_id + add_foreign_key :ticket_article_types, :users, column: :updated_by_id create_table :ticket_article_senders do |t| t.column :name, :string, limit: 250, null: false @@ -146,6 +153,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :ticket_article_senders, [:name], unique: true + add_foreign_key :ticket_article_senders, :users, column: :created_by_id + add_foreign_key :ticket_article_senders, :users, column: :updated_by_id create_table :ticket_articles do |t| t.references :ticket, null: false @@ -177,18 +186,41 @@ class CreateTicket < ActiveRecord::Migration add_index :ticket_articles, [:internal] add_index :ticket_articles, [:type_id] add_index :ticket_articles, [:sender_id] + add_foreign_key :ticket_articles, :tickets + add_foreign_key :ticket_articles, :ticket_article_types, column: :type_id + add_foreign_key :ticket_articles, :ticket_article_senders, column: :sender_id + add_foreign_key :ticket_articles, :users, column: :created_by_id + add_foreign_key :ticket_articles, :users, column: :updated_by_id + add_foreign_key :ticket_articles, :users, column: :origin_by_id create_table :ticket_article_flags do |t| - t.references :ticket_articles, null: false + t.references :ticket_article, null: false t.column :key, :string, limit: 50, null: false t.column :value, :string, limit: 50, null: true t.column :created_by_id, :integer, null: false t.timestamps limit: 3, null: false end - add_index :ticket_article_flags, [:ticket_articles_id, :created_by_id], name: 'index_ticket_article_flags_on_articles_id_and_created_by_id' - add_index :ticket_article_flags, [:ticket_articles_id, :key] - add_index :ticket_article_flags, [:ticket_articles_id] + add_index :ticket_article_flags, [:ticket_article_id, :created_by_id], name: 'index_ticket_article_flags_on_articles_id_and_created_by_id' + add_index :ticket_article_flags, [:ticket_article_id, :key] + add_index :ticket_article_flags, [:ticket_article_id] add_index :ticket_article_flags, [:created_by_id] + add_foreign_key :ticket_article_flags, :ticket_articles, column: :ticket_article_id + add_foreign_key :ticket_article_flags, :users, column: :created_by_id + + create_table :ticket_time_accountings do |t| + t.references :ticket, null: false + t.references :ticket_article, null: true + t.column :time_unit, :decimal, precision: 6, scale: 2, null: false + t.column :created_by_id, :integer, null: false + t.timestamps limit: 3, null: false + end + add_index :ticket_time_accountings, [:ticket_id] + add_index :ticket_time_accountings, [:ticket_article_id] + add_index :ticket_time_accountings, [:created_by_id] + add_index :ticket_time_accountings, [:time_unit] + add_foreign_key :ticket_time_accountings, :tickets + add_foreign_key :ticket_time_accountings, :ticket_articles + add_foreign_key :ticket_time_accountings, :users, column: :created_by_id create_table :ticket_counters do |t| t.column :content, :string, limit: 100, null: false @@ -211,27 +243,35 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :overviews, [:name] + add_foreign_key :overviews, :users, column: :created_by_id + add_foreign_key :overviews, :users, column: :updated_by_id create_table :overviews_roles, id: false do |t| - t.integer :overview_id - t.integer :role_id + t.references :overview + t.references :role end add_index :overviews_roles, [:overview_id] add_index :overviews_roles, [:role_id] + add_foreign_key :overviews_roles, :overviews + add_foreign_key :overviews_roles, :roles create_table :overviews_users, id: false do |t| - t.integer :overview_id - t.integer :user_id + t.references :overview + t.references :user end add_index :overviews_users, [:overview_id] add_index :overviews_users, [:user_id] + add_foreign_key :overviews_users, :overviews + add_foreign_key :overviews_users, :users create_table :overviews_groups, id: false do |t| - t.integer :overview_id - t.integer :group_id + t.references :overview + t.references :group end add_index :overviews_groups, [:overview_id] add_index :overviews_groups, [:group_id] + add_foreign_key :overviews_groups, :overviews + add_foreign_key :overviews_groups, :groups create_table :triggers do |t| t.column :name, :string, limit: 250, null: false @@ -245,6 +285,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :triggers, [:name], unique: true + add_foreign_key :triggers, :users, column: :created_by_id + add_foreign_key :triggers, :users, column: :updated_by_id create_table :jobs do |t| t.column :name, :string, limit: 250, null: false @@ -265,6 +307,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :jobs, [:name], unique: true + add_foreign_key :jobs, :users, column: :created_by_id + add_foreign_key :jobs, :users, column: :updated_by_id create_table :notifications do |t| t.column :subject, :string, limit: 250, null: false @@ -281,7 +325,7 @@ class CreateTicket < ActiveRecord::Migration t.column :active, :boolean, null: false, default: true t.timestamps limit: 3, null: false end - add_index :link_types, [:name], unique: true + add_index :link_types, [:name], unique: true create_table :link_objects do |t| t.column :name, :string, limit: 250, null: false @@ -300,6 +344,7 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :links, [:link_object_source_id, :link_object_source_value, :link_object_target_id, :link_object_target_value, :link_type_id], unique: true, name: 'links_uniq_total' + add_foreign_key :links, :link_types create_table :postmaster_filters do |t| t.column :name, :string, limit: 250, null: false @@ -313,6 +358,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :postmaster_filters, [:channel] + add_foreign_key :postmaster_filters, :users, column: :created_by_id + add_foreign_key :postmaster_filters, :users, column: :updated_by_id create_table :text_modules do |t| t.references :user, null: true @@ -328,13 +375,18 @@ class CreateTicket < ActiveRecord::Migration end add_index :text_modules, [:user_id] add_index :text_modules, [:name] + add_foreign_key :text_modules, :users + add_foreign_key :text_modules, :users, column: :created_by_id + add_foreign_key :text_modules, :users, column: :updated_by_id create_table :text_modules_groups, id: false do |t| - t.integer :text_module_id - t.integer :group_id + t.references :text_module + t.references :group end add_index :text_modules_groups, [:text_module_id] add_index :text_modules_groups, [:group_id] + add_foreign_key :text_modules_groups, :text_modules + add_foreign_key :text_modules_groups, :groups create_table :templates do |t| t.references :user, null: true @@ -346,13 +398,18 @@ class CreateTicket < ActiveRecord::Migration end add_index :templates, [:user_id] add_index :templates, [:name] + add_foreign_key :templates, :users + add_foreign_key :templates, :users, column: :created_by_id + add_foreign_key :templates, :users, column: :updated_by_id create_table :templates_groups, id: false do |t| - t.integer :template_id - t.integer :group_id + t.references :template + t.references :group end add_index :templates_groups, [:template_id] add_index :templates_groups, [:group_id] + add_foreign_key :templates_groups, :templates + add_foreign_key :templates_groups, :groups create_table :channels do |t| t.references :group, null: true @@ -369,10 +426,13 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :channels, [:area] + add_foreign_key :channels, :groups + add_foreign_key :channels, :users, column: :created_by_id + add_foreign_key :channels, :users, column: :updated_by_id create_table :slas do |t| + t.references :calendar, null: false t.column :name, :string, limit: 150, null: true - t.column :calendar_id, :integer, null: false t.column :first_response_time, :integer, null: true t.column :update_time, :integer, null: true t.column :solution_time, :integer, null: true @@ -382,6 +442,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :slas, [:name], unique: true + add_foreign_key :slas, :users, column: :created_by_id + add_foreign_key :slas, :users, column: :updated_by_id create_table :macros do |t| t.string :name, limit: 250, null: true @@ -393,6 +455,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :macros, [:name], unique: true + add_foreign_key :macros, :users, column: :created_by_id + add_foreign_key :macros, :users, column: :updated_by_id create_table :chats do |t| t.string :name, limit: 250, null: true @@ -406,6 +470,8 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :chats, [:name], unique: true + add_foreign_key :chats, :users, column: :created_by_id + add_foreign_key :chats, :users, column: :updated_by_id create_table :chat_topics do |t| t.integer :chat_id, null: false @@ -416,13 +482,15 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :chat_topics, [:name], unique: true + add_foreign_key :chat_topics, :users, column: :created_by_id + add_foreign_key :chat_topics, :users, column: :updated_by_id create_table :chat_sessions do |t| - t.integer :chat_id, null: false + t.references :chat, null: false t.string :session_id, null: false t.string :name, limit: 250, null: true t.string :state, limit: 50, null: false, default: 'waiting' # running, closed - t.integer :user_id, null: true + t.references :user, null: true t.text :preferences, limit: 100.kilobytes + 1, null: true t.integer :updated_by_id, null: true t.integer :created_by_id, null: true @@ -432,14 +500,20 @@ class CreateTicket < ActiveRecord::Migration add_index :chat_sessions, [:state] add_index :chat_sessions, [:user_id] add_index :chat_sessions, [:chat_id] + add_foreign_key :chat_sessions, :chats + add_foreign_key :chat_sessions, :users + add_foreign_key :chat_sessions, :users, column: :created_by_id + add_foreign_key :chat_sessions, :users, column: :updated_by_id create_table :chat_messages do |t| - t.integer :chat_session_id, null: false + t.references :chat_session, null: false t.text :content, limit: 20.megabytes + 1, null: false t.integer :created_by_id, null: true t.timestamps limit: 3, null: false end add_index :chat_messages, [:chat_session_id] + add_foreign_key :chat_messages, :chat_sessions + add_foreign_key :chat_messages, :users, column: :created_by_id create_table :chat_agents do |t| t.boolean :active, null: false, default: true @@ -451,6 +525,8 @@ class CreateTicket < ActiveRecord::Migration add_index :chat_agents, [:active] add_index :chat_agents, [:updated_by_id], unique: true add_index :chat_agents, [:created_by_id], unique: true + add_foreign_key :chat_agents, :users, column: :created_by_id + add_foreign_key :chat_agents, :users, column: :updated_by_id create_table :report_profiles do |t| t.column :name, :string, limit: 150, null: true @@ -461,14 +537,17 @@ class CreateTicket < ActiveRecord::Migration t.timestamps limit: 3, null: false end add_index :report_profiles, [:name], unique: true + add_foreign_key :report_profiles, :users, column: :created_by_id + add_foreign_key :report_profiles, :users, column: :updated_by_id create_table :karma_users do |t| - t.integer :user_id, null: false + t.references :user, null: false t.integer :score, null: false t.string :level, limit: 200, null: false t.timestamps limit: 3, null: false end add_index :karma_users, [:user_id], unique: true + add_foreign_key :karma_users, :users create_table :karma_activities do |t| t.string :name, limit: 200, null: false @@ -482,7 +561,7 @@ class CreateTicket < ActiveRecord::Migration create_table :karma_activity_logs do |t| t.integer :o_id, null: false t.integer :object_lookup_id, null: false - t.integer :user_id, null: false + t.references :user, null: false t.integer :activity_id, null: false t.integer :score, null: false t.integer :score_total, null: false @@ -491,7 +570,8 @@ class CreateTicket < ActiveRecord::Migration add_index :karma_activity_logs, [:user_id] add_index :karma_activity_logs, [:created_at] add_index :karma_activity_logs, [:o_id, :object_lookup_id] - + add_foreign_key :karma_activity_logs, :users + add_foreign_key :karma_activity_logs, :karma_activities, column: :activity_id end def self.down diff --git a/db/migrate/20170531144425_foreign_keys.rb b/db/migrate/20170531144425_foreign_keys.rb new file mode 100644 index 000000000..b38e3ab71 --- /dev/null +++ b/db/migrate/20170531144425_foreign_keys.rb @@ -0,0 +1,227 @@ +class ForeignKeys < ActiveRecord::Migration + def change + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + # remove wrong plural of ID columns + ActiveRecord::Migration.rename_column :ticket_flags, :tickets_id, :ticket_id + ActiveRecord::Migration.rename_column :ticket_article_flags, :ticket_articles_id, :ticket_article_id + + # add missing foreign keys + foreign_keys = [ + # Base + [:users, :organizations], + [:users, :users, column: :created_by_id], + [:users, :users, column: :updated_by_id], + + [:signatures, :users, column: :created_by_id], + [:signatures, :users, column: :updated_by_id], + + [:email_addresses, :users, column: :created_by_id], + [:email_addresses, :users, column: :updated_by_id], + + [:groups, :signatures], + [:groups, :email_addresses], + [:groups, :users, column: :created_by_id], + [:groups, :users, column: :updated_by_id], + + [:roles, :users, column: :created_by_id], + [:roles, :users, column: :updated_by_id], + + [:organizations, :users, column: :created_by_id], + [:organizations, :users, column: :updated_by_id], + + [:roles_users, :users], + [:roles_users, :roles], + + [:groups_users, :users], + [:groups_users, :groups], + + [:organizations_users, :users], + [:organizations_users, :organizations], + + [:authorizations, :users], + + [:translations, :users, column: :created_by_id], + [:translations, :users, column: :updated_by_id], + + [:tokens, :users], + + [:packages, :users, column: :created_by_id], + [:packages, :users, column: :updated_by_id], + + [:taskbars, :users], + + [:tags, :tag_items], + [:tags, :tag_objects], + [:tags, :users, column: :created_by_id], + + [:recent_views, :object_lookups, column: :recent_view_object_id], + [:recent_views, :users, column: :created_by_id], + + [:activity_streams, :type_lookups, column: :activity_stream_type_id], + [:activity_streams, :object_lookups, column: :activity_stream_object_id], + [:activity_streams, :permissions], + [:activity_streams, :groups], + [:activity_streams, :users, column: :created_by_id], + + [:histories, :history_types], + [:histories, :history_objects], + [:histories, :history_attributes], + [:histories, :users, column: :created_by_id], + + [:stores, :store_objects], + [:stores, :store_files], + [:stores, :users, column: :created_by_id], + + [:avatars, :users, column: :created_by_id], + [:avatars, :users, column: :updated_by_id], + + [:online_notifications, :users, column: :created_by_id], + [:online_notifications, :users, column: :updated_by_id], + + [:schedulers, :users, column: :created_by_id], + [:schedulers, :users, column: :updated_by_id], + + [:calendars, :users, column: :created_by_id], + [:calendars, :users, column: :updated_by_id], + + [:user_devices, :users], + + [:object_manager_attributes, :object_lookups], + [:object_manager_attributes, :users, column: :created_by_id], + [:object_manager_attributes, :users, column: :updated_by_id], + + [:cti_caller_ids, :users], + + [:stats_stores, :users, column: :created_by_id], + + [:http_logs, :users, column: :created_by_id], + [:http_logs, :users, column: :updated_by_id], + + # Ticket + [:ticket_state_types, :users, column: :created_by_id], + [:ticket_state_types, :users, column: :updated_by_id], + + [:ticket_states, :ticket_state_types, column: :state_type_id], + [:ticket_states, :users, column: :created_by_id], + [:ticket_states, :users, column: :updated_by_id], + + [:ticket_priorities, :users, column: :created_by_id], + [:ticket_priorities, :users, column: :updated_by_id], + + [:tickets, :groups], + [:tickets, :users, column: :owner_id], + [:tickets, :users, column: :customer_id], + [:tickets, :ticket_priorities, column: :priority_id], + [:tickets, :ticket_states, column: :state_id], + [:tickets, :organizations], + [:tickets, :users, column: :created_by_id], + [:tickets, :users, column: :updated_by_id], + + [:ticket_flags, :tickets, column: :ticket_id], + [:ticket_flags, :users, column: :created_by_id], + + [:ticket_article_types, :users, column: :created_by_id], + [:ticket_article_types, :users, column: :updated_by_id], + + [:ticket_article_senders, :users, column: :created_by_id], + [:ticket_article_senders, :users, column: :updated_by_id], + + [:ticket_articles, :tickets], + [:ticket_articles, :ticket_article_types, column: :type_id], + [:ticket_articles, :ticket_article_senders, column: :sender_id], + [:ticket_articles, :users, column: :created_by_id], + [:ticket_articles, :users, column: :updated_by_id], + [:ticket_articles, :users, column: :origin_by_id], + + [:ticket_article_flags, :ticket_articles, column: :ticket_article_id], + [:ticket_article_flags, :users, column: :created_by_id], + + [:ticket_time_accountings, :tickets], + [:ticket_time_accountings, :ticket_articles], + [:ticket_time_accountings, :users, column: :created_by_id], + + [:overviews, :users, column: :created_by_id], + [:overviews, :users, column: :updated_by_id], + + [:overviews_roles, :overviews], + [:overviews_roles, :roles], + + [:overviews_users, :overviews], + [:overviews_users, :users], + + [:overviews_groups, :overviews], + [:overviews_groups, :groups], + + [:triggers, :users, column: :created_by_id], + [:triggers, :users, column: :updated_by_id], + + [:jobs, :users, column: :created_by_id], + [:jobs, :users, column: :updated_by_id], + + [:links, :link_types], + + [:postmaster_filters, :users, column: :created_by_id], + [:postmaster_filters, :users, column: :updated_by_id], + + [:text_modules, :users], + [:text_modules, :users, column: :created_by_id], + [:text_modules, :users, column: :updated_by_id], + + [:text_modules_groups, :text_modules], + [:text_modules_groups, :groups], + + [:templates, :users], + [:templates, :users, column: :created_by_id], + [:templates, :users, column: :updated_by_id], + + [:templates_groups, :templates], + [:templates_groups, :groups], + + [:channels, :groups], + [:channels, :users, column: :created_by_id], + [:channels, :users, column: :updated_by_id], + + [:slas, :users, column: :created_by_id], + [:slas, :users, column: :updated_by_id], + + [:macros, :users, column: :created_by_id], + [:macros, :users, column: :updated_by_id], + + [:chats, :users, column: :created_by_id], + [:chats, :users, column: :updated_by_id], + + [:chat_topics, :users, column: :created_by_id], + [:chat_topics, :users, column: :updated_by_id], + + [:chat_sessions, :chats], + [:chat_sessions, :users], + [:chat_sessions, :users, column: :created_by_id], + [:chat_sessions, :users, column: :updated_by_id], + + [:chat_messages, :chat_sessions], + [:chat_messages, :users, column: :created_by_id], + + [:chat_agents, :users, column: :created_by_id], + [:chat_agents, :users, column: :updated_by_id], + + [:report_profiles, :users, column: :created_by_id], + [:report_profiles, :users, column: :updated_by_id], + + [:karma_users, :users], + + [:karma_activity_logs, :users], + [:karma_activity_logs, :karma_activities, column: :activity_id], + ] + + foreign_keys.each do |foreign_key| + begin + ActiveRecord::Migration.add_foreign_key(*foreign_key) + rescue => e + Rails.logger.error "Inconsistent data status detected while adding foreign key '#{foreign_key.inspect}': #{e.message}" + end + end + end +end diff --git a/spec/support/cache.rb b/spec/support/cache.rb new file mode 100644 index 000000000..4a6b30119 --- /dev/null +++ b/spec/support/cache.rb @@ -0,0 +1,8 @@ +RSpec.configure do |config| + config.before(:all) do + # clear the cache otherwise it won't + # be able to recognize the rollbacks + # done by RSpec + Cache.clear + end +end diff --git a/test/unit/assets_test.rb b/test/unit/assets_test.rb index 6f042ab9e..95ed76cfa 100644 --- a/test/unit/assets_test.rb +++ b/test/unit/assets_test.rb @@ -132,9 +132,9 @@ class AssetsTest < ActiveSupport::TestCase assert( diff(attributes, assets[:User][user3.id]), 'check assets' ) travel_back - user1.destroy - user2.destroy user3.destroy + user2.destroy + user1.destroy org1.destroy org2.destroy @@ -270,9 +270,9 @@ class AssetsTest < ActiveSupport::TestCase assert( diff(attributes, assets[:User][user_new_2.id]), 'check assets' ) travel_back - user1.destroy - user2.destroy user3.destroy + user2.destroy + user1.destroy org.destroy org_new.destroy diff --git a/test/unit/cti_caller_id_test.rb b/test/unit/cti_caller_id_test.rb index a5dea0a81..8619fd9b5 100644 --- a/test/unit/cti_caller_id_test.rb +++ b/test/unit/cti_caller_id_test.rb @@ -210,10 +210,12 @@ Mob: +49 333 1112222", assert_equal(2, caller_ids[0].user_id) assert_nil(caller_ids[0].comment) + user_id = User.find_by(login: 'ticket-caller_id-customer1@example.com').id + Cti::CallerId.maybe_add( caller_id: '4912345678901', level: 'maybe', - user_id: 3, + user_id: user_id, object: 'Ticket', o_id: 2, ) @@ -221,7 +223,7 @@ Mob: +49 333 1112222", caller_ids = Cti::CallerId.lookup('4912345678901') assert_equal(2, caller_ids.length) assert_equal('maybe', caller_ids[0].level) - assert_equal(3, caller_ids[0].user_id) + assert_equal(user_id, caller_ids[0].user_id) assert_nil(caller_ids[0].comment) assert_equal('maybe', caller_ids[1].level) assert_equal(2, caller_ids[1].user_id) @@ -230,7 +232,7 @@ Mob: +49 333 1112222", Cti::CallerId.maybe_add( caller_id: '4912345678901', level: 'known', - user_id: 3, + user_id: user_id, object: 'User', o_id: 2, ) @@ -238,7 +240,7 @@ Mob: +49 333 1112222", caller_ids = Cti::CallerId.lookup('4912345678901') assert_equal(1, caller_ids.length) assert_equal('known', caller_ids[0].level) - assert_equal(3, caller_ids[0].user_id) + assert_equal(user_id, caller_ids[0].user_id) assert_nil(caller_ids[0].comment) end diff --git a/test/unit/email_address_test.rb b/test/unit/email_address_test.rb index 9741b00d2..261cd033e 100644 --- a/test/unit/email_address_test.rb +++ b/test/unit/email_address_test.rb @@ -40,7 +40,7 @@ class EmailAddressTest < ActiveSupport::TestCase email_address1.destroy group1 = Group.find(group1.id) - assert(group1.email_address_id) + assert_nil(group1.email_address_id, 'References to groups are deleted') end test 'channel tests' do diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index 31919b5d0..799294515 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -550,7 +550,15 @@ class UserTest < ActiveSupport::TestCase end test 'min admin permission check' do - User.with_permissions('admin').each(&:destroy) + # workaround: + # - We need to get rid of all admin users but can't delete them + # because we have foreign keys pointing at them since the tests are not isolated yet :( + # - We can't just remove the roles since then our check would take place + # So we need to merge them with the User Nr 1 and destroy them afterwards + User.with_permissions('admin').each do |user| + Models.merge('User', 1, user.id) + user.destroy + end # store current admin count admin_count_inital = User.with_permissions('admin').count From e1611ee5090cc1cb391e36c6c3729058113a98d3 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 6 Jun 2017 20:20:37 +0200 Subject: [PATCH 031/234] Improved error handling/parsing of invalid filenames for attachments. --- app/models/channel/email_parser.rb | 23 +- test/fixtures/mail57.box | 988 +++++++++++++++++++++++++++++ test/unit/email_parser_test.rb | 38 +- 3 files changed, 1047 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/mail57.box diff --git a/app/models/channel/email_parser.rb b/app/models/channel/email_parser.rb index df4a6b57b..300a2b791 100644 --- a/app/models/channel/email_parser.rb +++ b/app/models/channel/email_parser.rb @@ -303,7 +303,17 @@ class Channel::EmailParser # get file preferences headers_store = {} file.header.fields.each { |field| - headers_store[field.name.to_s] = field.value.to_s + + # full line, encode, ready for storage + begin + value = Encode.conv('utf8', field.to_s) + if value.blank? + value = field.raw_value + end + headers_store[field.name.to_s] = value + rescue => e + headers_store[field.name.to_s] = field.raw_value + end } # get filename from content-disposition @@ -326,6 +336,17 @@ class Channel::EmailParser end end + # as fallback, use raw values + if filename.blank? + if headers_store['Content-Disposition'].to_s =~ /filename="(.+?)"/i + filename = $1 + elsif headers_store['Content-Disposition'].to_s =~ /filename='(.+?)'/i + filename = $1 + elsif headers_store['Content-Disposition'].to_s =~ /filename=(.+?);/i + filename = $1 + end + end + # for some broken sm mail clients (X-MimeOLE: Produced By Microsoft Exchange V6.5) filename ||= file.header[:content_location].to_s diff --git a/test/fixtures/mail57.box b/test/fixtures/mail57.box new file mode 100644 index 000000000..9b4fa14c6 --- /dev/null +++ b/test/fixtures/mail57.box @@ -0,0 +1,988 @@ +Received: from EX132MBOX2C.de2.local (10.1.1.1) by EX132MBOX2C.de2.local + (10.1.1.1) with Microsoft SMTP Server (TLS) id 15.0.1263.5 via Mailbox + Transport; Wed, 31 May 2017 09:25:57 +0200 +Received: from mx-gate29.example.com (46.235.240.148) by + EX132MBOX2C.de2.local (10.1.1.1) with Microsoft SMTP Server (TLS) id + 15.0.1263.5; Wed, 31 May 2017 09:25:57 +0200 +Received: from mail-in-12.example.net (1.1.1.1) by mx-gate29.example.com; + Wed, 31 May 2017 09:25:50 +0200 +Received: from mail-in-16-z2.example.net (mail-in-16-z2.example.net [2.2.2.2]) + by mx.example.com (Postfix) with ESMTP id 3wd27r1Xl2z8Rhy + for ; Wed, 31 May 2017 09:25:44 +0200 (CEST) +Received: from mail-in-09.example.net (mail-in-09.example.net [3.3.3.3]) + by mail-in-16-z2.example.net (Postfix) with ESMTP id 272C021EE0B + for ; Wed, 31 May 2017 09:25:44 +0200 (CEST) +Received: from webmail13.example.net (webmail13.example.net [4.4.4.4]) + by mail-in-09.example.net (Postfix) with ESMTP id 3wd27q62k3zB2gs + for ; Wed, 31 May 2017 09:25:43 +0200 (CEST) +X-DKIM: Sendmail DKIM Filter v2.8.2 mail-in-09.example.net 3wd27q62k3zB2gs +DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=mail-in; + t=1496215544; bh=/hKv3UznRqMiOfLdlAP2PSvg0FyuagsojQaKnDVfL+M=; + h=Date:From:To:Message-ID:References:Subject:MIME-Version: + Content-Type; + b=UHRKApCnlTeAagyi/h1F1sXwOJ52tc941+5/05Q/iVS8DMN0ycKkyROwaIU1ShNXH + YdyMvQj/5yPIjgleCwz4I+ei1cplzmpH+TVrQ//+cUkDc9ErdEQxH+g2eIszzd9ahJ + N6dn5oZCEEplZdOqEd33AnxiLlcKtizusEUpawdo= +Received: from [5.5.5.5] by webmail13.example.net (6.6.6.6) with HTTP (AAABBOORR Webmail); Wed, 31 May 2017 09:25:42 +0200 (CEST) +Date: Wed, 31 May 2017 09:25:43 +0200 (CEST) +From: example@example.com +To: bob@example.com +Message-ID: <5775856.182062.1496215543824.JavaMail.ngmail@webmail13.example.net> +References: <311454489.454089.1496153842814.JavaMail.ngmail@webmail12.example.net> <760691b300a147099e8bee4b696d200f@EX132MBOX2C.de2.local> + , + <20170426093400.4804.697272@dwertmann-hausverwaltung.zammad.com> + +Subject: W.: Invoice +Content-Type: multipart/mixed; + boundary="----=_Part_182060_213452753.1496215543130" +X-ngMessageSubType: MessageSubType_MAIL +X-WebmailclientIP: 7.7.7.7 +X-example-sender: example@example.com +X-example-recipient: bob@example.com +X-example-MSGID: 111222333444555ff32a579581b5910b-11111222223333355555666669ee8eabf0 +X-example-Virusscan: CLEAN +X-example-disclaimer: This E-Mail was scanned by www.example.com E-Mailservice on mx-gate29 with 6385F70000 +X-example-date: 1496215545 +X-example: INCOMING: +X-example-Connect: mail-in-12.example.net[8.8.8.8],TLS=1;EMIG=0 +X-example-WC: 2:241:4:376572:0:200:0:0:0:0:0:0:0:0:0:6:0:33:158:191:0:0:0:2:0:13:0:0:0:0:0:1:0:0:0:3:1:0:0:0:0:0 +X-example-Spamstatus: CLEAN +X-example-REASON: Score:-14.5 + * -4.3 BAYES_00 BODY: Bayesian spam probability is 0 to 1% + * [score: 0.0000] + * -3.5 ASE_NEG_FP_2011 No description available. + * -5.0 IS_RESPONSE Answer to or Forward of a real Mail + * -1.7 ASE_FP_2008_02 ASE 2008 negativer Score +Return-Path: example@example.com +X-MS-Exchange-Organization-Network-Message-Id: 11112222333-fe4f-45b2-3e6a-111222333444 +X-MS-Exchange-Organization-AVStamp-Enterprise: 1.0 +X-EXCLAIMER-MD-CONFIG: 111222333-e5c9-454a-b62b-111222333444 +X-MS-Exchange-Organization-SCL: 0 +X-MS-Exchange-Organization-AuthSource: EX132MBOX2C.de2.local +X-MS-Exchange-Organization-AuthAs: Anonymous +MIME-Version: 1.0 + +------=_Part_182060_213452753.1496215543130 +Content-Type: multipart/alternative; + boundary="----=_Part_182059_1383285025.1496215543130" + +------=_Part_182059_1383285025.1496215543130 +Content-Type: text/plain; charset=ISO-8859-1 +Content-Transfer-Encoding: quoted-printable + +=20 + + +----- Original Nachricht ---- +Von: example@example.com +An: bob@example.com +Datum: 30.05.2017 16:17 +Betreff: Invoice + +Dear Mrs.Weber + +anbei mal wieder ein paar Invoice. + +W=FCnsche Ihnen noch einen sch=F6nen Arbeitstag. + +Mit freundlichen Gr=FC=DFen + +Bob Smith + +------=_Part_182059_1383285025.1496215543130-- + +------=_Part_182060_213452753.1496215543130 +Content-Type: image/jpg +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Hofjgeralle Wasserschaden.jpg" + +/9j/4AAQSkZJRgABAQEASABIAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdC +IFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAA +AADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFj +cHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAA +ABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAAD +TAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJD +AAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5 +OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEA +AAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAA +AAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAA +AA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBo +dHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcg +Q29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENv +bmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAA +ABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAA +AAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAK +AA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUA +mgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEy +ATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMC +DAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh +Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4E +jASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3 +BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDII +RghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqY +Cq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUAN +Wg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBh +EH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT +5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReu +F9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9oc +AhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCY +IMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZcl +xyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2 +K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIx +SjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDec +N9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+ +oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXe +RiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN +3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYP +VlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1f +D19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/ +aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfBy +S3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyB +fOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuH +n4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLj +k02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6f +HZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1 +q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm4 +0blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZG +xsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnU +y9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj +4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozz +GfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////4QMmRXhpZgAA +TU0AKgAAAAgACgEPAAIAAAASAAAAhgEQAAIAAAAKAAAAmAESAAMAAAABAAEAAAEaAAUAAAABAAAA +ogEbAAUAAAABAAAAqgEoAAMAAAABAAIAAAExAAIAAAAeAAAAsgEyAAIAAAAUAAAA0AE8AAIAAAAQ +AAAA5IdpAAQAAAABAAAA9AAAAABOSUtPTiBDT1JQT1JBVElPTgBOSUtPTiBEOTAAAAAASAAAAAEA +AABIAAAAAUFkb2JlIFBob3Rvc2hvcCBDUzQgTWFjaW50b3NoADIwMTI6MDU6MTcgMjE6MjU6MTUA +TWFjIE9TIFggMTAuNi44AAAigpoABQAAAAEAAAKSgp0ABQAAAAEAAAKaiCIAAwAAAAEAAwAAiCcA +AwAAAAEAyAAAkAAABwAAAAQwMjIwkAMAAgAAABQAAAKikAQAAgAAABQAAAK2kQEABwAAAAQAAAAB +kQIABQAAAAEAAALKkgQACgAAAAEAAALSkgUABQAAAAEAAALakgcAAwAAAAEAAgAAkggAAwAAAAEA +AAAAkgkAAwAAAAEAAAAAkgoABQAAAAEAAALikoYABwAAACwAAALqkpAAAgAAAAMwMAAAkpEAAgAA +AAMwMAAAkpIAAgAAAAMwMAAAoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIABAAAAAEAAAKAoAMA +BAAAAAEAAAGpohcAAwAAAAEAAgAApAEAAwAAAAEAAAAApAIAAwAAAAEAAAAApAMAAwAAAAEAAAAA +pAQABQAAAAEAAAMWpAUAAwAAAAEANAAApAYAAwAAAAEAAAAApAgAAwAAAAEAAAAApAkAAwAAAAEA +AAAApAoAAwAAAAEAAAAApAwAAwAAAAEAAAAAAAAAAAAAAAEAAA+gAAAACQAAAAUyMDEyOjA1OjE3 +IDE4OjEwOjMzADIwMTI6MDU6MTcgMTg6MTA6MzMAAAAABAAAAAEAAAAAAAAAAQAAAAgAAAAFAAAA +IwAAAAFBU0NJSQAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAAAAAEAAAAB +/+EA5Gh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8APHg6eG1wbWV0YSB4bWxuczp4PSJhZG9i +ZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNC40LjAiPgogICA8cmRmOlJERiB4bWxuczpy +ZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8 +cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+ +CgD/2wBDAAICAgICAQICAgICAgIDAwYEAwMDAwcFBQQGCAcICAgHCAgJCg0LCQkMCggICw8LDA0O +Dg4OCQsQEQ8OEQ0ODg7/2wBDAQICAgMDAwYEBAYOCQgJDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4O +Dg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg7/wAARCAGpAoADASIAAhEBAxEB/8QAHwAAAQUBAQEB +AQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1Fh +ByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZ +WmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXG +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 + +------=_Part_182060_213452753.1496215543130-- diff --git a/test/unit/email_parser_test.rb b/test/unit/email_parser_test.rb index f3044ca83..63f478b7f 100644 --- a/test/unit/email_parser_test.rb +++ b/test/unit/email_parser_test.rb @@ -447,7 +447,7 @@ Managing Director: Martin Edenhofer # mutt c1abb5fb77a9d2ab2017749a7987c074 { md5: '2ef81e47872d42efce7ef34bfa2de043', - filename: 'file-1', + filename: '¼¨Ð§¹ÜÀí,¾¿¾¹Ë­´íÁË.xls', }, ], params: { @@ -1121,6 +1121,42 @@ end body: 'Enjoy!', }, }, + { + data: IO.binread('test/fixtures/mail57.box'), + body_md5: '3c5e4cf2d2a9bc572f10cd6222556027', + attachments: [ + { + md5: 'ddbdf67aa2f5c60c294008a54d57082b', + filename: 'Hofjägeralle Wasserschaden.jpg', + }, + ], + params: { + from: 'example@example.com', + from_email: 'example@example.com', + from_display_name: '', + subject: 'W.: Invoice', + content_type: 'text/plain', + body: ' + + +----- Original Nachricht ---- +Von: example@example.com +An: bob@example.com +Datum: 30.05.2017 16:17 +Betreff: Invoice + +Dear Mrs.Weber + +anbei mal wieder ein paar Invoice. + +Wünsche Ihnen noch einen schönen Arbeitstag. + +Mit freundlichen Grüßen + +Bob Smith +', + }, + }, ] count = 0 From 08940fbb069b9f1c1a526f15f3fab3c6dd11ca65 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Wed, 7 Jun 2017 10:32:50 +0200 Subject: [PATCH 032/234] Fixed bug: Reports raise error if (history) attribute or type couldn't be found. --- lib/report/base.rb | 125 +++++++++++++++++++++++---------------------- 1 file changed, 63 insertions(+), 62 deletions(-) diff --git a/lib/report/base.rb b/lib/report/base.rb index b5654a2a1..98ffe2e1e 100644 --- a/lib/report/base.rb +++ b/lib/report/base.rb @@ -14,9 +14,6 @@ class Report::Base query, bind_params, tables = Ticket.selector2sql(params[:selector]) - count = 0 - ticket_ids = [] - # created if params[:type] == 'created' history_type = History::Type.lookup( name: 'created' ) @@ -31,73 +28,77 @@ class Report::Base if params[:type] == 'updated' history_type = History::Type.lookup( name: 'updated' ) history_attribute = History::Attribute.lookup( name: params[:attribute] ) + + result = nil if !history_attribute || !history_type - count = 0 + result = 0 elsif params[:id_not_from] && params[:id_to] - return History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') - .where(query, *bind_params).joins(tables) - .where( - 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.id_from NOT IN (?) AND histories.id_to IN (?)', - params[:start], - params[:end], - history_object.id, - history_type.id, - history_attribute.id, - params[:id_not_from], - params[:id_to], - ).count + result = History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') + .where(query, *bind_params).joins(tables) + .where( + 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.id_from NOT IN (?) AND histories.id_to IN (?)', + params[:start], + params[:end], + history_object.id, + history_type.id, + history_attribute.id, + params[:id_not_from], + params[:id_to], + ).count elsif params[:id_from] && params[:id_not_to] - return History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') - .where(query, *bind_params).joins(tables) - .where( - 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.id_from IN (?) AND histories.id_to NOT IN (?)', - params[:start], - params[:end], - history_object.id, - history_type.id, - history_attribute.id, - params[:id_from], - params[:id_not_to], - ).count + result = History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') + .where(query, *bind_params).joins(tables) + .where( + 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.id_from IN (?) AND histories.id_to NOT IN (?)', + params[:start], + params[:end], + history_object.id, + history_type.id, + history_attribute.id, + params[:id_from], + params[:id_not_to], + ).count elsif params[:value_from] && params[:value_not_to] - return History.joins('INNER JOIN tickets ON tickets.id = histories.o_id') - .where(query, *bind_params).joins(tables) - .where( - 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.value_from IN (?) AND histories.value_to NOT IN (?)', - params[:start], - params[:end], - history_object.id, - history_type.id, - history_attribute.id, - params[:value_from], - params[:value_not_to], - ).count + result = History.joins('INNER JOIN tickets ON tickets.id = histories.o_id') + .where(query, *bind_params).joins(tables) + .where( + 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.value_from IN (?) AND histories.value_to NOT IN (?)', + params[:start], + params[:end], + history_object.id, + history_type.id, + history_attribute.id, + params[:value_from], + params[:value_not_to], + ).count elsif params[:value_to] - return History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') - .where(query, *bind_params).joins(tables) - .where( - 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.value_to IN (?)', - params[:start], - params[:end], - history_object.id, - history_type.id, - history_attribute.id, - params[:value_to], - ).count + result = History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') + .where(query, *bind_params).joins(tables) + .where( + 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.value_to IN (?)', + params[:start], + params[:end], + history_object.id, + history_type.id, + history_attribute.id, + params[:value_to], + ).count elsif params[:id_to] - return History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') - .where(query, *bind_params).joins(tables) - .where( - 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.id_to IN (?)', - params[:start], - params[:end], - history_object.id, - history_type.id, - history_attribute.id, - params[:id_to], - ).count + result = History.select('histories.o_id').joins('INNER JOIN tickets ON tickets.id = histories.o_id') + .where(query, *bind_params).joins(tables) + .where( + 'histories.created_at >= ? AND histories.created_at <= ? AND histories.history_object_id = ? AND histories.history_type_id = ? AND histories.history_attribute_id IN (?) AND histories.id_to IN (?)', + params[:start], + params[:end], + history_object.id, + history_type.id, + history_attribute.id, + params[:id_to], + ).count end + return result if !result.nil? + raise "UNKOWN params (#{params.inspect})!" end raise "UNKOWN :type (#{params[:type]})!" From 6bf970b85d038b5b8187f6c44a2a3a9e38fef29a Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Wed, 7 Jun 2017 10:34:38 +0200 Subject: [PATCH 033/234] Added report columns: Owner, Organization, Create Channel, Sender and Tags. --- app/controllers/reports_controller.rb | 35 ++++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 2c9bbbf8e..ccfd83d26 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -180,7 +180,7 @@ class ReportsController < ApplicationController worksheet.set_column(4, 8, 20) # Add and define a format - format = workbook.add_format # Add a format + format = workbook.add_format format.set_bold format.set_size(14) format.set_color('black') @@ -189,22 +189,28 @@ class ReportsController < ApplicationController # Write a formatted and unformatted string, row and column notation. worksheet.write(0, 0, "Tickets: #{profile.name} (#{title})", format) - format_header = workbook.add_format # Add a format + format_header = workbook.add_format format_header.set_italic format_header.set_bg_color('gray') format_header.set_color('white') + worksheet.write(2, 0, '#', format_header) worksheet.write(2, 1, 'Title', format_header) worksheet.write(2, 2, 'State', format_header) worksheet.write(2, 3, 'Priority', format_header) worksheet.write(2, 4, 'Group', format_header) - worksheet.write(2, 5, 'Customer', format_header) - worksheet.write(2, 6, 'Created at', format_header) - worksheet.write(2, 7, 'Updated at', format_header) - worksheet.write(2, 8, 'Closed at', format_header) + worksheet.write(2, 5, 'Owner', format_header) + worksheet.write(2, 6, 'Customer', format_header) + worksheet.write(2, 7, 'Organization', format_header) + worksheet.write(2, 8, 'Create Channel', format_header) + worksheet.write(2, 9, 'Sender', format_header) + worksheet.write(2, 10, 'Tags', format_header) + worksheet.write(2, 11, 'Created at', format_header) + worksheet.write(2, 12, 'Updated at', format_header) + worksheet.write(2, 13, 'Closed at', format_header) row = 2 - result[:ticket_ids].each { |ticket_id| + result[:ticket_ids].each do |ticket_id| ticket = Ticket.lookup(id: ticket_id) row += 1 worksheet.write(row, 0, ticket.number) @@ -212,11 +218,16 @@ class ReportsController < ApplicationController worksheet.write(row, 2, ticket.state.name) worksheet.write(row, 3, ticket.priority.name) worksheet.write(row, 4, ticket.group.name) - worksheet.write(row, 5, ticket.customer.fullname) - worksheet.write(row, 6, ticket.created_at) - worksheet.write(row, 7, ticket.updated_at) - worksheet.write(row, 8, ticket.close_at) - } + worksheet.write(row, 5, ticket.owner.fullname) + worksheet.write(row, 6, ticket.customer.fullname) + worksheet.write(row, 7, ticket.try(:organization).try(:name)) + worksheet.write(row, 8, ticket.create_article_type.name) + worksheet.write(row, 9, ticket.create_article_sender.name) + worksheet.write(row, 10, ticket.tag_list.join(',')) + worksheet.write(row, 11, ticket.created_at) + worksheet.write(row, 12, ticket.updated_at) + worksheet.write(row, 13, ticket.close_at) + end workbook.close From 1eaf46f466db1f1248482bebc2d4e13a7e1d3524 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 8 Jun 2017 06:49:25 +0200 Subject: [PATCH 034/234] Improved Setting.get and Setting.set - reduced database access and moved from 2 minutes to 15 seconds ttl for reading. --- app/models/setting.rb | 105 ++++++++++---------- test/integration/sipgate_controller_test.rb | 105 +++++++------------- 2 files changed, 88 insertions(+), 122 deletions(-) diff --git a/app/models/setting.rb b/app/models/setting.rb index 8dfaeffb0..1997c6ebb 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -7,22 +7,21 @@ class Setting < ApplicationModel store :preferences before_create :state_check, :set_initial, :check_broadcast before_update :state_check, :check_broadcast - after_create :reset_cache - after_update :reset_cache - after_destroy :reset_cache + after_create :reset_change_id + after_update :reset_change_id attr_accessor :state - @@current = {} # rubocop:disable Style/ClassVars - @@change_id = nil # rubocop:disable Style/ClassVars - @@lookup_at = nil # rubocop:disable Style/ClassVars - @@lookup_timeout = if ENV['ZAMMAD_SETTING_TTL'] # rubocop:disable Style/ClassVars - ENV['ZAMMAD_SETTING_TTL'].to_i.seconds - elsif Rails.env.production? - 2.minutes - else - 15.seconds - end + @@current = {} # rubocop:disable Style/ClassVars + @@raw = {} # rubocop:disable Style/ClassVars + @@change_id = nil # rubocop:disable Style/ClassVars + @@last_changed_at = nil # rubocop:disable Style/ClassVars + @@lookup_at = nil # rubocop:disable Style/ClassVars + @@lookup_timeout = if ENV['ZAMMAD_SETTING_TTL'] # rubocop:disable Style/ClassVars + ENV['ZAMMAD_SETTING_TTL'].to_i.seconds + else + 15.seconds + end =begin @@ -38,7 +37,7 @@ set config setting raise "Can't find config setting '#{name}'" end setting.state_current = { value: value } - setting.save + setting.save! logger.info "Setting.set(#{name}, #{value.inspect})" end @@ -52,7 +51,7 @@ get config setting def self.get(name) load - @@current[:settings_config][name] + @@current[name] end =begin @@ -69,10 +68,8 @@ reset config setting to default raise "Can't find config setting '#{name}'" end setting.state_current = setting.state_initial - setting.save + setting.save! logger.info "Setting.reset(#{name}, #{setting.state_current.inspect})" - load - @@current[:settings_config][name] end =begin @@ -84,6 +81,7 @@ reload config settings =end def self.reload + @@last_changed_at = nil # rubocop:disable Style/ClassVars load(true) end @@ -93,27 +91,36 @@ reload config settings def self.load(force = false) # check if config is already generated - if !force && @@current[:settings_config] - return false if cache_valid? + return false if !force && @@current.present? && cache_valid? + + # read all or only changed since last read + latest = Setting.order(updated_at: :desc).limit(1).pluck(:updated_at) + settings = if @@last_changed_at && @@current.present? + Setting.where('updated_at > ?', @@last_changed_at).order(:id).pluck(:name, :state_current) + else + Setting.order(:id).pluck(:name, :state_current) + end + if latest + @@last_changed_at = latest[0] # rubocop:disable Style/ClassVars end - # read all config settings - config = {} - Setting.select('name, state_current').order(:id).each { |setting| - config[setting.name] = setting.state_current[:value] - } - - # config lookups - config.each { |key, value| - next if value.class.to_s != 'String' - - config[key].gsub!(/\#\{config\.(.+?)\}/) { - config[$1].to_s + if settings.present? + settings.each { |setting| + @@raw[setting[0]] = setting[1]['value'] } - } + @@raw.each { |key, value| + if value.class != String + @@current[key] = value + next + end + @@current[key] = value.gsub(/\#\{config\.(.+?)\}/) { + @@raw[$1].to_s + } + } + end - # store for class requests - cache(config) + @@change_id = Cache.get('Setting::ChangeId') # rubocop:disable Style/ClassVars + @@lookup_at = Time.zone.now # rubocop:disable Style/ClassVars true end private_class_method :load @@ -123,37 +130,27 @@ reload config settings self.state_initial = state_current end - # set new cache - def self.cache(config) - @@change_id = Cache.get('Setting::ChangeId') # rubocop:disable Style/ClassVars - @@current[:settings_config] = config - logger.debug "Setting.cache: set cache, #{@@change_id}" - @@lookup_at = Time.zone.now # rubocop:disable Style/ClassVars - end - private_class_method :cache - - # reset cache - def reset_cache - @@change_id = rand(999_999_999).to_s # rubocop:disable Style/ClassVars - logger.debug "Setting.reset_cache: set new cache, #{@@change_id}" - - Cache.write('Setting::ChangeId', @@change_id, { expires_in: 24.hours }) - @@current[:settings_config] = nil + def reset_change_id + @@current[name] = state_current[:value] + change_id = rand(999_999_999).to_s + logger.debug "Setting.reset_change_id: set new cache, #{change_id}" + Cache.write('Setting::ChangeId', change_id, { expires_in: 24.hours }) + @@lookup_at = nil # rubocop:disable Style/ClassVars end # check if cache is still valid def self.cache_valid? if @@lookup_at && @@lookup_at > Time.zone.now - @@lookup_timeout - #logger.debug 'Setting.cache_valid?: cache_id has beed set within last 2 minutes' + #logger.debug "Setting.cache_valid?: cache_id has been set within last #{@@lookup_timeout} seconds" return true end change_id = Cache.get('Setting::ChangeId') if change_id == @@change_id @@lookup_at = Time.zone.now # rubocop:disable Style/ClassVars - logger.debug "Setting.cache_valid?: cache still valid, #{@@change_id}/#{change_id}" + #logger.debug "Setting.cache_valid?: cache still valid, #{@@change_id}/#{change_id}" return true end - logger.debug "Setting.cache_valid?: cache has changed, #{@@change_id}/#{change_id}" + #logger.debug "Setting.cache_valid?: cache has changed, #{@@change_id}/#{change_id}" false end private_class_method :cache_valid? diff --git a/test/integration/sipgate_controller_test.rb b/test/integration/sipgate_controller_test.rb index c79d90d45..4eca490a4 100644 --- a/test/integration/sipgate_controller_test.rb +++ b/test/integration/sipgate_controller_test.rb @@ -7,65 +7,34 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest Cti::Log.destroy_all - Setting.create_or_update( - title: 'sipgate.io integration', - name: 'sipgate_integration', - area: 'Integration::Switch', - description: 'Define if sipgate.io (http://www.sipgate.io) is enabled or not.', - options: { - form: [ - { - display: '', - null: true, - name: 'sipgate_integration', - tag: 'boolean', - options: { - true => 'yes', - false => 'no', - }, - }, - ], - }, - state: true, - preferences: { prio: 1 }, - frontend: false - ) - Setting.create_or_update( - title: 'sipgate.io config', - name: 'sipgate_config', - area: 'Integration::Sipgate', - description: 'Define the sipgate.io config.', - options: {}, - state: { - outbound: { - routing_table: [ - { - dest: '41*', - caller_id: '41715880339000', - }, - { - dest: '491714000000', - caller_id: '41715880339000', - }, - ], - default_caller_id: '4930777000000', - }, - inbound: { - block_caller_ids: [ - { - caller_id: '491715000000', - note: 'some note', - } - ], - notify_user_ids: { - 2 => true, - 4 => false, - }, - } - }, - frontend: false, - preferences: { prio: 2 }, - ) + Setting.set('sipgate_integration', true) + Setting.set('sipgate_config', { + outbound: { + routing_table: [ + { + dest: '41*', + caller_id: '41715880339000', + }, + { + dest: '491714000000', + caller_id: '41715880339000', + }, + ], + default_caller_id: '4930777000000', + }, + inbound: { + block_caller_ids: [ + { + caller_id: '491715000000', + note: 'some note', + } + ], + notify_user_ids: { + 2 => true, + 4 => false, + }, + } + },) groups = Group.where(name: 'Users') roles = Role.where(name: %w(Agent)) @@ -262,7 +231,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('out', log.direction) assert_equal('user 1', log.from_comment) assert_equal('CallerId Customer1', log.to_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('newCall', log.state) assert_equal(true, log.done) @@ -292,7 +261,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('out', log.direction) assert_equal('user 1', log.from_comment) assert_equal('CallerId Customer1', log.to_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('newCall', log.state) assert_equal(true, log.done) @@ -307,7 +276,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('out', log.direction) assert_equal('user 1', log.from_comment) assert_equal('CallerId Customer1', log.to_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('answer', log.state) assert_equal(true, log.done) @@ -337,7 +306,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('in', log.direction) assert_equal('user 1', log.to_comment) assert_equal('CallerId Customer1', log.from_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('newCall', log.state) assert_equal(true, log.done) @@ -352,7 +321,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('in', log.direction) assert_equal('user 1', log.to_comment) assert_equal('CallerId Customer1', log.from_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('answer', log.state) assert_equal(true, log.done) @@ -382,7 +351,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('in', log.direction) assert_equal('user 1,user 2', log.to_comment) assert_equal('CallerId Customer1', log.from_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('newCall', log.state) assert_equal(true, log.done) @@ -397,7 +366,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('in', log.direction) assert_equal('voicemail', log.to_comment) assert_equal('CallerId Customer1', log.from_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('answer', log.state) assert_equal(true, log.done) @@ -427,7 +396,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('in', log.direction) assert_equal('user 1,user 2', log.to_comment) assert_equal('CallerId Customer1', log.from_comment) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('newCall', log.state) assert_equal(true, log.done) @@ -459,7 +428,7 @@ class SipgateControllerTest < ActionDispatch::IntegrationTest assert_equal('CallerId Customer3,CallerId Customer2', log.from_comment) assert_not(log.preferences['to']) assert(log.preferences['from']) - assert_equal(nil, log.comment) + assert_nil(log.comment) assert_equal('newCall', log.state) assert_equal(true, log.done) From 44e41dcf10561fabdffcca7b497b6dab353c8d72 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Fri, 9 Jun 2017 10:06:04 +0200 Subject: [PATCH 035/234] Fixed issue #1176 - Generation of weekly report fails with internal server error. --- app/controllers/reports_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index ccfd83d26..7abb9ccf8 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -143,7 +143,7 @@ class ReportsController < ApplicationController stop = "#{date}T23:59:59Z" range = 'hour' elsif params[:timeRange] == 'week' - start = Date.commercial(params[:year], params[:week]).iso8601 + start = Date.commercial(params[:year].to_i, params[:week].to_i).iso8601 stop = Date.parse(start).end_of_week.iso8601 range = 'week' elsif params[:timeRange] == 'month' From 8ab56ef23545186c47711738ee615046edf73306 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 12 Jun 2017 15:24:10 +0200 Subject: [PATCH 036/234] If user organization relation/featrue is used for different propose, do not touch references. --- .../observer/organization/ref_object_touch.rb | 5 +- app/models/observer/user/ref_object_touch.rb | 18 ++- app/models/organization/assets.rb | 7 +- .../organization_ref_object_touch_test.rb | 132 ++++++++++++++++- test/unit/user_ref_object_touch_test.rb | 137 ++++++++++++++++-- 5 files changed, 274 insertions(+), 25 deletions(-) diff --git a/app/models/observer/organization/ref_object_touch.rb b/app/models/observer/organization/ref_object_touch.rb index 8f97798db..043113b28 100644 --- a/app/models/observer/organization/ref_object_touch.rb +++ b/app/models/observer/organization/ref_object_touch.rb @@ -20,8 +20,11 @@ class Observer::Organization::RefObjectTouch < ActiveRecord::Observer # return if we run import mode return if Setting.get('import_mode') + # featrue used for different propose, do not touch references + return if User.where(organization_id: record.id).count > 100 + # touch organizations tickets - Ticket.select('id').where( organization_id: record.id ).each(&:touch) + Ticket.select('id').where(organization_id: record.id).each(&:touch) # touch current members record.member_ids.uniq.each { |user_id| diff --git a/app/models/observer/user/ref_object_touch.rb b/app/models/observer/user/ref_object_touch.rb index 9c2a76233..b63f043dd 100644 --- a/app/models/observer/user/ref_object_touch.rb +++ b/app/models/observer/user/ref_object_touch.rb @@ -25,16 +25,24 @@ class Observer::User::RefObjectTouch < ActiveRecord::Observer organization_id_changed = record.changes['organization_id'] if organization_id_changed && organization_id_changed[0] != organization_id_changed[1] if organization_id_changed[0] - organization = Organization.find(organization_id_changed[0]) - organization.touch - member_ids = organization.member_ids + + # featrue used for different propose, do not touch references + if User.where(organization_id: organization_id_changed[0]).count < 100 + organization = Organization.find(organization_id_changed[0]) + organization.touch + member_ids = organization.member_ids + end end end # touch new/current organization if record.organization - record.organization.touch - member_ids += record.organization.member_ids + + # featrue used for different propose, do not touch references + if User.where(organization_id: record.organization_id).count < 100 + record.organization.touch + member_ids += record.organization.member_ids + end end # touch old/current customer diff --git a/app/models/organization/assets.rb b/app/models/organization/assets.rb index 86840cf20..d0108676f 100644 --- a/app/models/organization/assets.rb +++ b/app/models/organization/assets.rb @@ -39,7 +39,12 @@ returns # loops, will be updated with lookup attributes later data[ app_model_organization ][ id ] = local_attributes - if local_attributes['member_ids'] + if local_attributes['member_ids'].present? + + # featrue used for different propose, do limit refernces + if local_attributes['member_ids'].count > 100 + local_attributes['member_ids'] = local_attributes['member_ids'].sort[0, 100] + end local_attributes['member_ids'].each { |local_user_id| next if data[ app_model_user ][ local_user_id ] user = User.lookup(id: local_user_id) diff --git a/test/unit/organization_ref_object_touch_test.rb b/test/unit/organization_ref_object_touch_test.rb index c9cb26f97..04a7c9ebe 100644 --- a/test/unit/organization_ref_object_touch_test.rb +++ b/test/unit/organization_ref_object_touch_test.rb @@ -2,11 +2,8 @@ require 'test_helper' class OrganizationRefObjectTouchTest < ActiveSupport::TestCase - agent1 = nil - organization1 = nil - customer1 = nil - customer2 = nil - test 'aaa - setup' do + + test 'check if ticket and customer has been updated' do # create base groups = Group.where(name: 'Users') @@ -63,9 +60,6 @@ class OrganizationRefObjectTouchTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - end - - test 'b - check if ticket and customer has been updated' do ticket = Ticket.create( title: "some title1\n äöüß", @@ -125,4 +119,126 @@ class OrganizationRefObjectTouchTest < ActiveSupport::TestCase assert(delete, 'ticket destroy') travel_back end + + test 'check if ticket and customer has not been updated (different featrue propose)' do + + # create base + groups = Group.where(name: 'Users') + roles = Role.where(name: 'Agent') + agent1 = User.create_or_update( + login: 'organization-ref-object-not-update-agent1@example.com', + firstname: 'Notification', + lastname: 'Agent1', + email: 'organization-ref-object-not-update-agent1@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + roles = Role.where(name: 'Customer') + organization1 = Organization.create_if_not_exists( + name: 'Ref Object Update Org 1 (no update)', + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + organization2 = Organization.create_if_not_exists( + name: 'Ref Object Update Org 2 (no update)', + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + customer1 = User.create_or_update( + login: 'organization-ref-object-not-update-customer1@example.com', + firstname: 'Notification', + lastname: 'Agent1', + email: 'organization-ref-object-not-update-customer1@example.com', + password: 'customerpw', + active: true, + organization_id: organization1.id, + roles: roles, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + customer2 = User.create_or_update( + login: 'organization-ref-object-not-update-customer2@example.com', + firstname: 'Notification', + lastname: 'Agent2', + email: 'organization-ref-object-not-update-customer2@example.com', + password: 'customerpw', + active: true, + organization_id: organization2.id, + roles: roles, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + (1..100).each { |count| + User.create_or_update( + login: "organization-ref-object-update-customer3-#{count}@example.com", + firstname: 'Notification', + lastname: 'Agent2', + email: "organization-ref-object-update-customer3-#{count}@example.com", + password: 'customerpw', + active: true, + organization_id: organization1.id, + roles: roles, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + } + + ticket = Ticket.create( + title: "some title1\n äöüß", + group: Group.lookup(name: 'Users'), + customer_id: customer1.id, + owner_id: agent1.id, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_at: '2015-02-05 16:39:00', + updated_by_id: 1, + created_by_id: 1, + ) + assert(ticket, 'ticket created') + assert_equal(ticket.customer.id, customer1.id) + assert_equal(ticket.organization.id, organization1.id) + + customer1 = User.find(customer1.id) + assert_not_equal('2015-02-05 16:37:00 UTC', customer1.updated_at.to_s) + customer1_updated_at = customer1.updated_at + + travel 4.seconds + organization1.name = 'Ref Object Update Org 1 (no update)/1' + organization1.save + organization1_updated_at = organization1.updated_at + + # check if ticket and customer has been touched + ticket = Ticket.find(ticket.id) + assert_equal('2015-02-05 16:39:00 UTC', ticket.updated_at.to_s) + + customer1 = User.find(customer1.id) + assert_equal(customer1_updated_at.to_s, customer1.updated_at.to_s) + + travel 4.seconds + + customer2.organization_id = organization1.id + customer2.save + + # check if customer1 and organization has been touched + customer1 = User.find(customer1.id) + assert_equal(customer1_updated_at.to_s, customer1.updated_at.to_s) + + organization1 = Organization.find(organization1.id) + assert_equal(organization1_updated_at.to_s, organization1.updated_at.to_s) + + delete = ticket.destroy + assert(delete, 'ticket destroy') + travel_back + end + end diff --git a/test/unit/user_ref_object_touch_test.rb b/test/unit/user_ref_object_touch_test.rb index cf8117485..f4d949c04 100644 --- a/test/unit/user_ref_object_touch_test.rb +++ b/test/unit/user_ref_object_touch_test.rb @@ -2,11 +2,7 @@ require 'test_helper' class UserRefObjectTouchTest < ActiveSupport::TestCase - agent1 = nil - organization1 = nil - customer1 = nil - customer2 = nil - test 'aaa - setup' do + test 'check if ticket and organization has been updated' do # create base groups = Group.where(name: 'Users') @@ -57,9 +53,6 @@ class UserRefObjectTouchTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - end - - test 'b - check if ticket and organization has been updated' do ticket = Ticket.create( title: "some title1\n äöüß", @@ -75,7 +68,7 @@ class UserRefObjectTouchTest < ActiveSupport::TestCase assert_equal(ticket.customer.id, customer1.id) assert_equal(ticket.organization.id, organization1.id) - sleep 4 + travel 4.seconds customer1.firstname = 'firstname customer1' customer1.save @@ -88,7 +81,131 @@ class UserRefObjectTouchTest < ActiveSupport::TestCase assert(false, 'organization1.updated_at has not been updated') end - sleep 4 + travel 4.seconds + + ticket.customer_id = customer2.id + ticket.save + + # check if customer1, customer2 and organization has been touched + customer1 = User.find(customer1.id) + if customer1.updated_at > 3.seconds.ago + assert(true, 'customer1.updated_at has been updated') + else + assert(false, 'customer1.updated_at has not been updated') + end + + customer2 = User.find(customer2.id) + if customer2.updated_at > 3.seconds.ago + assert(true, 'customer2.updated_at has been updated') + else + assert(false, 'customer2.updated_at has not been updated') + end + + organization1 = Organization.find(organization1.id) + if organization1.updated_at > 3.seconds.ago + assert(true, 'organization1.updated_at has been updated') + else + assert(false, 'organization1.updated_at has not been updated') + end + + delete = ticket.destroy + assert(delete, 'ticket destroy') + end + + test 'check if ticket and organization has not been updated (different featrue propose)' do + + # create base + groups = Group.where(name: 'Users') + roles = Role.where(name: 'Agent') + agent1 = User.create_or_update( + login: 'user-ref-object-update-agent1@example.com', + firstname: 'Notification', + lastname: 'Agent1', + email: 'user-ref-object-update-agent1@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + roles = Role.where(name: 'Customer') + organization1 = Organization.create_if_not_exists( + name: 'Ref Object Update Org (not updated)', + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + customer1 = User.create_or_update( + login: 'user-ref-object-update-customer1@example.com', + firstname: 'Notification', + lastname: 'Agent1', + email: 'user-ref-object-update-customer1@example.com', + password: 'customerpw', + active: true, + organization_id: organization1.id, + roles: roles, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + customer2 = User.create_or_update( + login: 'user-ref-object-update-customer2@example.com', + firstname: 'Notification', + lastname: 'Agent2', + email: 'user-ref-object-update-customer2@example.com', + password: 'customerpw', + active: true, + organization_id: nil, + roles: roles, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + + (1..100).each { |count| + User.create_or_update( + login: "user-ref-object-update-customer3-#{count}@example.com", + firstname: 'Notification', + lastname: 'Agent2', + email: "user-ref-object-update-customer3-#{count}@example.com", + password: 'customerpw', + active: true, + organization_id: organization1.id, + roles: roles, + updated_at: '2015-02-05 16:37:00', + updated_by_id: 1, + created_by_id: 1, + ) + } + + ticket = Ticket.create( + title: "some title1\n äöüß", + group: Group.lookup(name: 'Users'), + customer_id: customer1.id, + owner_id: agent1.id, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: 1, + created_by_id: 1, + ) + assert(ticket, 'ticket created') + assert_equal(ticket.customer.id, customer1.id) + assert_equal(ticket.organization.id, organization1.id) + organization1_updated_at = ticket.organization.updated_at + + travel 4.seconds + + customer1.firstname = 'firstname customer1' + customer1.save + customer1_updated_at = customer1.updated_at + + # check if organization has been touched + organization1 = Organization.find(organization1.id) + assert_equal(organization1_updated_at.to_s, ticket.updated_at.to_s) + + travel 4.seconds ticket.customer_id = customer2.id ticket.save From c1aa57fd9b24697ee10529a94fc334592543c861 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 13 Jun 2017 01:49:28 +0200 Subject: [PATCH 037/234] Updated nokogiri to 1.8.0 (see https://github.com/sparklemotion/nokogiri/issues/1634). --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4efc085b7..138fb1c44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -193,7 +193,7 @@ GEM mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) - mini_portile2 (2.1.0) + mini_portile2 (2.2.0) minitest (5.10.1) multi_json (1.12.1) multi_xml (0.5.5) @@ -204,8 +204,8 @@ GEM nestful (1.1.1) net-ldap (0.15.0) netrc (0.11.0) - nokogiri (1.7.1) - mini_portile2 (~> 2.1.0) + nokogiri (1.8.0) + mini_portile2 (~> 2.2.0) notiffany (0.1.1) nenv (~> 0.1) shellany (~> 0.0) From 898e00ef37a5c370eb6c627bca2642c5133d802b Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 13 Jun 2017 02:01:08 +0200 Subject: [PATCH 038/234] Fixed typo. --- db/migrate/20170525000001_reply_to_sender_feature.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20170525000001_reply_to_sender_feature.rb b/db/migrate/20170525000001_reply_to_sender_feature.rb index 397dca48e..ace1d8d5f 100644 --- a/db/migrate/20170525000001_reply_to_sender_feature.rb +++ b/db/migrate/20170525000001_reply_to_sender_feature.rb @@ -24,7 +24,7 @@ class ReplyToSenderFeature < ActiveRecord::Migration }, ], }, - state: [], + state: '', preferences: { permission: ['admin.channel_email'], }, From 71980d6cf5c08651b30dcef71ddd363fff94ae88 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 13 Jun 2017 02:01:49 +0200 Subject: [PATCH 039/234] Fixed typo. --- .../controllers/_ui_element/object_manager_attribute.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee index c960fc2a9..050a0fa8e 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee @@ -83,7 +83,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi invite_customer: show: false required: false - 'admin.group': + 'admin.user': create: shown: true required: false @@ -111,7 +111,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi required: false view: shown: true - 'admin.group': + 'admin.organization': create: shown: true required: false From 1a43d9bd5c0bdd66db5327c185e330576a64e89a Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 14 Jun 2017 17:25:45 +0200 Subject: [PATCH 040/234] Improved tests. --- app/models/setting.rb | 5 +- app/models/user.rb | 14 +- db/seeds/community_user_resources.rb | 4 +- test/controllers/search_controller_test.rb | 57 +- test/integration/elasticsearch_test.rb | 302 ++++---- test/integration/report_test.rb | 613 ++++++++-------- test/integration_test_helper.rb | 9 + test/test_helper.rb | 20 +- test/unit/activity_stream_test.rb | 70 +- test/unit/cache_test.rb | 2 +- test/unit/chat_test.rb | 74 +- test/unit/online_notifiaction_test.rb | 295 ++++---- test/unit/session_basic_test.rb | 4 +- test/unit/session_collections_test.rb | 8 +- test/unit/session_enhanced_test.rb | 60 +- ...icket_customer_organization_update_test.rb | 36 +- test/unit/ticket_notification_test.rb | 673 +++++++++--------- test/unit/ticket_overview_test.rb | 219 +++--- test/unit/ticket_ref_object_touch_test.rb | 63 +- test/unit/ticket_selector_test.rb | 455 ++++++------ 20 files changed, 1481 insertions(+), 1502 deletions(-) diff --git a/app/models/setting.rb b/app/models/setting.rb index 1997c6ebb..17ff09e71 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -60,13 +60,16 @@ reset config setting to default Setting.reset('some_config_name') + Setting.reset('some_config_name', force) # true|false - force it false per default + =end - def self.reset(name) + def self.reset(name, force = false) setting = Setting.find_by(name: name) if !setting raise "Can't find config setting '#{name}'" end + return true if !force && setting.state_current == setting.state_initial setting.state_current = setting.state_initial setting.save! logger.info "Setting.reset(#{name}, #{setting.state_current.inspect})" diff --git a/app/models/user.rb b/app/models/user.rb index 8b01e0afa..6941fb1b4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -746,7 +746,7 @@ returns true end - def check_notifications(o) + def check_notifications(o, shouldSave = true) default = Rails.configuration.preferences_default_by_permission return if !default default.deep_stringify_keys! @@ -762,7 +762,7 @@ returns return true if !has_changed - if id + if id && shouldSave save! return true end @@ -772,8 +772,14 @@ returns end def check_preferences_default + if @preferences_default.blank? + if id + roles.each { |role| + check_notifications(role, false) + } + end + end return if @preferences_default.blank? - preferences_tmp = @preferences_default.merge(preferences) self.preferences = preferences_tmp @preferences_default = nil @@ -945,7 +951,7 @@ raise 'Minimum one user need to have admin permissions' # set the user's locale to the one of the "executing" user return if !UserInfo.current_user_id - user = User.find_by( id: UserInfo.current_user_id ) + user = User.find_by(id: UserInfo.current_user_id) return if !user return if !user.preferences[:locale] diff --git a/db/seeds/community_user_resources.rb b/db/seeds/community_user_resources.rb index e57a7c4a6..a8c4aff12 100644 --- a/db/seeds/community_user_resources.rb +++ b/db/seeds/community_user_resources.rb @@ -16,12 +16,12 @@ user_community = User.create_or_update( UserInfo.current_user_id = user_community.id -ticket = Ticket.create( +ticket = Ticket.create!( group_id: Group.find_by(name: 'Users').id, customer_id: User.find_by(login: 'nicole.braun@zammad.org').id, title: 'Welcome to Zammad!', ) -Ticket::Article.create( +Ticket::Article.create!( ticket_id: ticket.id, type_id: Ticket::Article::Type.find_by(name: 'phone').id, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb index 5ba3aa1de..dffe4e693 100644 --- a/test/controllers/search_controller_test.rb +++ b/test/controllers/search_controller_test.rb @@ -1,14 +1,9 @@ # encoding: utf-8 require 'test_helper' +require 'rake' class SearchControllerTest < ActionDispatch::IntegrationTest - def base_data - - # clear cache - Cache.clear - - # remove background jobs - Delayed::Job.destroy_all + setup do # set current user UserInfo.current_user_id = 1 @@ -92,16 +87,14 @@ class SearchControllerTest < ActionDispatch::IntegrationTest Ticket.all.destroy_all - @ticket1 = Ticket.create( + @ticket1 = Ticket.create!( title: 'test 1234-1', group: Group.lookup(name: 'Users'), customer_id: @customer_without_org.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: 1, - created_by_id: 1, ) - @article1 = Ticket::Article.create( + @article1 = Ticket::Article.create!( ticket_id: @ticket1.id, from: 'some_sender1@example.com', to: 'some_recipient1@example.com', @@ -111,20 +104,16 @@ class SearchControllerTest < ActionDispatch::IntegrationTest internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, ) sleep 1 - @ticket2 = Ticket.create( + @ticket2 = Ticket.create!( title: 'test 1234-2', group: Group.lookup(name: 'Users'), customer_id: @customer_with_org2.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: 1, - created_by_id: 1, ) - @article2 = Ticket::Article.create( + @article2 = Ticket::Article.create!( ticket_id: @ticket2.id, from: 'some_sender2@example.com', to: 'some_recipient2@example.com', @@ -134,20 +123,16 @@ class SearchControllerTest < ActionDispatch::IntegrationTest internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, ) sleep 1 - @ticket3 = Ticket.create( + @ticket3 = Ticket.create!( title: 'test 1234-2', group: Group.lookup(name: 'Users'), customer_id: @customer_with_org3.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: 1, - created_by_id: 1, ) - @article3 = Ticket::Article.create( + @article3 = Ticket::Article.create!( ticket_id: @ticket3.id, from: 'some_sender3@example.com', to: 'some_recipient3@example.com', @@ -157,12 +142,10 @@ class SearchControllerTest < ActionDispatch::IntegrationTest internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, ) # configure es - if ENV['ES_URL'] + if ENV['ES_URL'].present? #fail "ERROR: Need ES_URL - hint ES_URL='http://127.0.0.1:9200'" Setting.set('es_url', ENV['ES_URL']) @@ -174,15 +157,17 @@ class SearchControllerTest < ActionDispatch::IntegrationTest # set max attachment size in mb Setting.set('es_attachment_max_size_in_mb', 1) - if ENV['ES_INDEX'] + if ENV['ES_INDEX'].present? #fail "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" Setting.set('es_index', ENV['ES_INDEX']) end # drop/create indexes + Rake::Task.clear + Zammad::Application.load_tasks #Rake::Task["searchindex:drop"].execute #Rake::Task["searchindex:create"].execute - system('rake searchindex:rebuild') + Rake::Task['searchindex:rebuild'].execute # execute background jobs Scheduler.worker(true) @@ -192,8 +177,6 @@ class SearchControllerTest < ActionDispatch::IntegrationTest end test 'settings index with nobody' do - base_data - params = { query: 'test 1234', limit: 2, @@ -219,19 +202,15 @@ class SearchControllerTest < ActionDispatch::IntegrationTest assert_equal(Hash, result.class) assert_not(result.empty?) assert_equal('authentication failed', result['error']) - end test 'settings index with admin' do - base_data - credentials = ActionController::HttpAuthentication::Basic.encode_credentials('search-admin@example.com', 'adminpw') params = { query: '1234*', limit: 1, } - post '/api/v1/search', params.to_json, @headers.merge('Authorization' => credentials) assert_response(200) result = JSON.parse(@response.body) @@ -293,12 +272,9 @@ class SearchControllerTest < ActionDispatch::IntegrationTest assert_equal('User', result['result'][0]['type']) assert_equal(@agent.id, result['result'][0]['id']) assert_not(result['result'][1]) - end test 'settings index with agent' do - base_data - credentials = ActionController::HttpAuthentication::Basic.encode_credentials('search-agent@example.com', 'agentpw') params = { @@ -367,12 +343,9 @@ class SearchControllerTest < ActionDispatch::IntegrationTest assert_equal('User', result['result'][0]['type']) assert_equal(@agent.id, result['result'][0]['id']) assert_not(result['result'][1]) - end test 'settings index with customer 1' do - base_data - credentials = ActionController::HttpAuthentication::Basic.encode_credentials('search-customer1@example.com', 'customer1pw') params = { @@ -413,12 +386,9 @@ class SearchControllerTest < ActionDispatch::IntegrationTest result = JSON.parse(@response.body) assert_equal(Hash, result.class) assert_not(result['result'][0]) - end test 'settings index with customer 2' do - base_data - credentials = ActionController::HttpAuthentication::Basic.encode_credentials('search-customer2@example.com', 'customer2pw') params = { @@ -463,7 +433,6 @@ class SearchControllerTest < ActionDispatch::IntegrationTest result = JSON.parse(@response.body) assert_equal(Hash, result.class) assert_not(result['result'][0]) - end end diff --git a/test/integration/elasticsearch_test.rb b/test/integration/elasticsearch_test.rb index d58771423..1df367b88 100644 --- a/test/integration/elasticsearch_test.rb +++ b/test/integration/elasticsearch_test.rb @@ -1,111 +1,117 @@ # encoding: utf-8 require 'integration_test_helper' +require 'rake' class ElasticsearchTest < ActiveSupport::TestCase - # set config - if !ENV['ES_URL'] - raise "ERROR: Need ES_URL - hint ES_URL='http://127.0.0.1:9200'" + setup do + + # set config + if ENV['ES_URL'].blank? + raise "ERROR: Need ES_URL - hint ES_URL='http://127.0.0.1:9200'" + end + Setting.set('es_url', ENV['ES_URL']) + if ENV['ES_INDEX_RAND'].present? + ENV['ES_INDEX'] = "es_index_#{rand(999_999_999)}" + end + if ENV['ES_INDEX'].blank? + raise "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" + end + Setting.set('es_index', ENV['ES_INDEX']) + + # Setting.set('es_url', 'http://127.0.0.1:9200') + # Setting.set('es_index', 'estest.local_zammad') + # Setting.set('es_user', 'elasticsearch') + # Setting.set('es_password', 'zammad') + + # set max attachment size in mb + Setting.set('es_attachment_max_size_in_mb', 1) + + # drop/create indexes + Rake::Task.clear + Zammad::Application.load_tasks + #Rake::Task["searchindex:drop"].execute + #Rake::Task["searchindex:create"].execute + Rake::Task['searchindex:rebuild'].execute + + groups = Group.where(name: 'Users') + roles = Role.where(name: 'Agent') + @agent = User.create_or_update( + login: 'es-agent@example.com', + firstname: 'E', + lastname: 'S', + email: 'es-agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + updated_by_id: 1, + created_by_id: 1, + ) + group_without_access = Group.create_if_not_exists( + name: 'WithoutAccess', + note: 'Test for not access check.', + updated_by_id: 1, + created_by_id: 1 + ) + roles = Role.where(name: 'Customer') + @organization1 = Organization.create_if_not_exists( + name: 'Customer Organization Update', + note: 'some note', + updated_by_id: 1, + created_by_id: 1, + ) + @customer1 = User.create_or_update( + login: 'es-customer1@example.com', + firstname: 'ES', + lastname: 'Customer1', + email: 'es-customer1@example.com', + password: 'customerpw', + active: true, + organization_id: @organization1.id, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + sleep 1 + @customer2 = User.create_or_update( + login: 'es-customer2@example.com', + firstname: 'ES', + lastname: 'Customer2', + email: 'es-customer2@example.com', + password: 'customerpw', + active: true, + organization_id: @organization1.id, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + sleep 1 + @customer3 = User.create_or_update( + login: 'es-customer3@example.com', + firstname: 'ES', + lastname: 'Customer3', + email: 'es-customer3@example.com', + password: 'customerpw', + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) end - Setting.set('es_url', ENV['ES_URL']) - if !ENV['ES_INDEX'] && !ENV['ES_INDEX_RAND'] - raise "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" - end - if ENV['ES_INDEX_RAND'] - ENV['ES_INDEX'] = "es_index_#{rand(999_999_999)}" - end - Setting.set('es_index', ENV['ES_INDEX']) - - # Setting.set('es_url', 'http://127.0.0.1:9200') - # Setting.set('es_index', 'estest.local_zammad') - # Setting.set('es_user', 'elasticsearch') - # Setting.set('es_password', 'zammad') - - # set max attachment size in mb - Setting.set('es_attachment_max_size_in_mb', 1) - - # drop/create indexes - #Rake::Task["searchindex:drop"].execute - #Rake::Task["searchindex:create"].execute - system('rake searchindex:rebuild') - - groups = Group.where(name: 'Users') - roles = Role.where(name: 'Agent') - agent = User.create_or_update( - login: 'es-agent@example.com', - firstname: 'E', - lastname: 'S', - email: 'es-agent@example.com', - password: 'agentpw', - active: true, - roles: roles, - groups: groups, - updated_by_id: 1, - created_by_id: 1, - ) - group_without_access = Group.create_if_not_exists( - name: 'WithoutAccess', - note: 'Test for not access check.', - updated_by_id: 1, - created_by_id: 1 - ) - roles = Role.where(name: 'Customer') - organization1 = Organization.create_if_not_exists( - name: 'Customer Organization Update', - note: 'some note', - updated_by_id: 1, - created_by_id: 1, - ) - customer1 = User.create_or_update( - login: 'es-customer1@example.com', - firstname: 'ES', - lastname: 'Customer1', - email: 'es-customer1@example.com', - password: 'customerpw', - active: true, - organization_id: organization1.id, - roles: roles, - updated_by_id: 1, - created_by_id: 1, - ) - sleep 1 - customer2 = User.create_or_update( - login: 'es-customer2@example.com', - firstname: 'ES', - lastname: 'Customer2', - email: 'es-customer2@example.com', - password: 'customerpw', - active: true, - organization_id: organization1.id, - roles: roles, - updated_by_id: 1, - created_by_id: 1, - ) - sleep 1 - customer3 = User.create_or_update( - login: 'es-customer3@example.com', - firstname: 'ES', - lastname: 'Customer3', - email: 'es-customer3@example.com', - password: 'customerpw', - active: true, - roles: roles, - updated_by_id: 1, - created_by_id: 1, - ) # check search attributes test 'a - objects' do # user - attributes = agent.search_index_data + attributes = @agent.search_index_data assert_equal('E', attributes['firstname']) assert_equal('S', attributes['lastname']) assert_equal('es-agent@example.com', attributes['email']) assert_not(attributes['password']) assert_not(attributes['organization']) - attributes = agent.search_index_attribute_lookup + attributes = @agent.search_index_attribute_lookup assert_equal('E', attributes['firstname']) assert_equal('S', attributes['lastname']) assert_equal('es-agent@example.com', attributes['email']) @@ -113,27 +119,27 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_not(attributes['organization']) # organization - attributes = organization1.search_index_data + attributes = @organization1.search_index_data assert_equal('Customer Organization Update', attributes['name']) assert_equal('some note', attributes['note']) assert_not(attributes['members']) - attributes = organization1.search_index_attribute_lookup + attributes = @organization1.search_index_attribute_lookup assert_equal('Customer Organization Update', attributes['name']) assert_equal('some note', attributes['note']) assert(attributes['members']) # ticket/article - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some title äöüß', group: Group.lookup(name: 'Users'), - customer_id: customer1.id, + customer_id: @customer1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, created_by_id: 1, ) - article1 = Ticket::Article.create( + article1 = Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -146,6 +152,14 @@ class ElasticsearchTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) + Store.add( + object: 'Ticket::Article', + o_id: article1.id, + data: IO.binread("#{Rails.root}/test/fixtures/es-normal.txt"), + filename: 'es-normal.txt', + preferences: {}, + created_by_id: 1, + ) attributes = ticket1.search_index_attribute_lookup assert_equal('Users', attributes['group']) @@ -163,28 +177,27 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_not(attributes['owner']['password']) assert_not(attributes['owner']['organization']) + assert(attributes['article'][0]['attachment']) + assert(attributes['article'][0]['attachment'][0]) + assert_not(attributes['article'][0]['attachment'][1]) + assert_equal('es-normal.txt', attributes['article'][0]['attachment'][0]['_name']) + assert_equal("c29tZSBub3JtYWwgdGV4dDY2Cg==\n", attributes['article'][0]['attachment'][0]['_content']) + ticket1.destroy # execute background jobs Scheduler.worker(true) - end - - # check tickets and search it - test 'b - tickets' do - - system('rake searchindex:rebuild') - - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: "some title\n äöüß", group: Group.lookup(name: 'Users'), - customer_id: customer1.id, + customer_id: @customer1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, created_by_id: 1, ) - article1 = Ticket::Article.create( + article1 = Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -199,7 +212,7 @@ class ElasticsearchTest < ActiveSupport::TestCase ) # add attachments which should get index / .txt - # "some normal text" + # "some normal text66" Store.add( object: 'Ticket::Article', o_id: article1.id, @@ -244,16 +257,16 @@ class ElasticsearchTest < ActiveSupport::TestCase ticket1.tag_add('someTagA', 1) sleep 1 - ticket2 = Ticket.create( + ticket2 = Ticket.create!( title: 'something else', group: Group.lookup(name: 'Users'), - customer_id: customer2.id, + customer_id: @customer2.id, state: Ticket::State.lookup(name: 'open'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, created_by_id: 1, ) - article2 = Ticket::Article.create( + article2 = Ticket::Article.create!( ticket_id: ticket2.id, from: 'some_sender@example.org', to: 'some_recipient@example.org', @@ -270,16 +283,16 @@ class ElasticsearchTest < ActiveSupport::TestCase ticket2.tag_add('someTagB', 1) sleep 1 - ticket3 = Ticket.create( + ticket3 = Ticket.create!( title: 'something else', group: Group.lookup(name: 'WithoutAccess'), - customer_id: customer3.id, + customer_id: @customer3.id, state: Ticket::State.lookup(name: 'open'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, created_by_id: 1, ) - article3 = Ticket::Article.create( + article3 = Ticket::Article.create!( ticket_id: ticket3.id, from: 'some_sender@example.org', to: 'some_recipient@example.org', @@ -295,14 +308,13 @@ class ElasticsearchTest < ActiveSupport::TestCase # execute background jobs Scheduler.worker(true) - sleep 4 - # search as agent + # search as @agent # search for article data result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'autobahn', limit: 15, ) @@ -314,7 +326,7 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for html content result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'strong', limit: 15, ) @@ -326,7 +338,7 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for indexed attachment result = Ticket.search( - current_user: agent, + current_user: @agent, query: '"some normal text66"', limit: 15, ) @@ -334,7 +346,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket1.id) result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'test77', limit: 15, ) @@ -343,14 +355,14 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for not indexed attachment result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'test88', limit: 15, ) assert(!result[0], 'record 1') result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'test99', limit: 15, ) @@ -358,16 +370,16 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for ticket with no permissions result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'kindergarden', limit: 15, ) assert(result.empty?, 'result should be empty') assert(!result[0], 'record 1') - # search as customer1 + # search as @customer1 result = Ticket.search( - current_user: customer1, + current_user: @customer1, query: 'title OR else', limit: 15, ) @@ -379,9 +391,9 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket2.id) assert_equal(result[1].id, ticket1.id) - # search as customer2 + # search as @customer2 result = Ticket.search( - current_user: customer2, + current_user: @customer2, query: 'title OR else', limit: 15, ) @@ -393,9 +405,9 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket2.id) assert_equal(result[1].id, ticket1.id) - # search as customer3 + # search as @customer3 result = Ticket.search( - current_user: customer3, + current_user: @customer3, query: 'title OR else', limit: 15, ) @@ -407,7 +419,7 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for tags result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'tag:someTagA', limit: 15, ) @@ -416,7 +428,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket1.id) result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'tag:someTagB', limit: 15, ) @@ -439,7 +451,7 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for tags result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'tag:someTagA', limit: 15, ) @@ -447,7 +459,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert(!result[1], 'record 1') result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'tag:someTagB', limit: 15, ) @@ -456,7 +468,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket2.id) result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'tag:someTagC', limit: 15, ) @@ -465,7 +477,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket1.id) result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'state:open', limit: 15, ) @@ -474,7 +486,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket2.id) result = Ticket.search( - current_user: agent, + current_user: @agent, query: '"some_sender@example.com"', limit: 15, ) @@ -483,7 +495,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert_equal(result[0].id, ticket1.id) result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'article.from:"some_sender@example.com"', limit: 15, ) @@ -491,25 +503,21 @@ class ElasticsearchTest < ActiveSupport::TestCase assert(!result[1], 'record 2') assert_equal(result[0].id, ticket1.id) - end - - # check users and search it - test 'c - users' do - - # search as agent + # check users and search it + # search as @agent result = User.search( - current_user: agent, + current_user: @agent, query: 'customer1', limit: 15, ) assert(!result.empty?, 'result should not be empty') assert(result[0], 'record 1') assert(!result[1], 'record 2') - assert_equal(result[0].id, customer1.id) + assert_equal(result[0].id, @customer1.id) - # search as customer1 + # search as @customer1 result = User.search( - current_user: customer1, + current_user: @customer1, query: 'customer1', limit: 15, ) @@ -517,7 +525,7 @@ class ElasticsearchTest < ActiveSupport::TestCase assert(!result[0], 'record 1') # cleanup - system('rake searchindex:drop') + Rake::Task['searchindex:drop'].execute end end diff --git a/test/integration/report_test.rb b/test/integration/report_test.rb index adc5db302..c90f7a561 100644 --- a/test/integration/report_test.rb +++ b/test/integration/report_test.rb @@ -1,262 +1,258 @@ # encoding: utf-8 require 'integration_test_helper' +require 'rake' class ReportTest < ActiveSupport::TestCase - # set config - if !ENV['ES_URL'] - raise "ERROR: Need ES_URL - hint ES_URL='http://127.0.0.1:9200'" + setup do + + # set config + if !ENV['ES_URL'] + raise "ERROR: Need ES_URL - hint ES_URL='http://127.0.0.1:9200'" + end + Setting.set('es_url', ENV['ES_URL']) + if ENV['ES_INDEX_RAND'].present? + ENV['ES_INDEX'] = "es_index_#{rand(999_999_999)}" + end + if ENV['ES_INDEX'].blank? + raise "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" + end + + # Setting.set('es_url', 'http://127.0.0.1:9200') + # Setting.set('es_index', 'estest.local_zammad') + # Setting.set('es_user', 'elasticsearch') + # Setting.set('es_password', 'zammad') + # Setting.set('es_attachment_max_size_in_mb', 1) + + Ticket.destroy_all + + # drop/create indexes + Rake::Task.clear + Zammad::Application.load_tasks + #Rake::Task["searchindex:drop"].execute + #Rake::Task["searchindex:create"].execute + Rake::Task['searchindex:rebuild'].execute + + group1 = Group.lookup(name: 'Users') + group2 = Group.create_if_not_exists( + name: 'Report Test', + updated_by_id: 1, + created_by_id: 1 + ) + + @ticket1 = Ticket.create!( + title: 'test 1', + group: group2, + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + created_at: '2015-10-28 09:30:00 UTC', + updated_at: '2015-10-28 09:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create!( + ticket_id: @ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message article_inbound', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + created_at: '2015-10-28 09:30:00 UTC', + updated_at: '2015-10-28 09:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + @ticket1.update_attributes( + group: Group.lookup(name: 'Users'), + updated_at: '2015-10-28 14:30:00 UTC', + ) + + @ticket2 = Ticket.create!( + title: 'test 2', + group: group1, + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + created_at: '2015-10-28 09:30:01 UTC', + updated_at: '2015-10-28 09:30:01 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create!( + ticket_id: @ticket2.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message article_inbound', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + created_at: '2015-10-28 09:30:01 UTC', + updated_at: '2015-10-28 09:30:01 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + @ticket2.update_attributes( + group_id: group2.id, + updated_at: '2015-10-28 14:30:00 UTC', + ) + + @ticket3 = Ticket.create!( + title: 'test 3', + group: group1, + customer_id: 2, + state: Ticket::State.lookup(name: 'open'), + priority: Ticket::Priority.lookup(name: '3 high'), + created_at: '2015-10-28 10:30:00 UTC', + updated_at: '2015-10-28 10:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create!( + ticket_id: @ticket3.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message article_inbound', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + created_at: '2015-10-28 10:30:00 UTC', + updated_at: '2015-10-28 10:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + + @ticket4 = Ticket.create!( + title: 'test 4', + group: group1, + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + priority: Ticket::Priority.lookup(name: '2 normal'), + close_at: '2015-10-28 11:30:00 UTC', + created_at: '2015-10-28 10:30:00 UTC', + updated_at: '2015-10-28 10:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create!( + ticket_id: @ticket4.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message article_inbound', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + created_at: '2015-10-28 10:30:00 UTC', + updated_at: '2015-10-28 10:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + + @ticket5 = Ticket.create!( + title: 'test 5', + group: group1, + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + priority: Ticket::Priority.lookup(name: '3 high'), + close_at: '2015-10-28 11:40:00 UTC', + created_at: '2015-10-28 11:30:00 UTC', + updated_at: '2015-10-28 11:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create!( + ticket_id: @ticket5.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message article_outbound', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + created_at: '2015-10-28 11:30:00 UTC', + updated_at: '2015-10-28 11:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + @ticket5.update_attributes( + state: Ticket::State.lookup(name: 'open'), + updated_at: '2015-10-28 14:30:00 UTC', + ) + + @ticket6 = Ticket.create!( + title: 'test 6', + group: group1, + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + priority: Ticket::Priority.lookup(name: '2 normal'), + close_at: '2015-10-31 12:35:00 UTC', + created_at: '2015-10-31 12:30:00 UTC', + updated_at: '2015-10-31 12:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create!( + ticket_id: @ticket6.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message article_outbound', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + created_at: '2015-10-31 12:30:00 UTC', + updated_at: '2015-10-31 12:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + + @ticket7 = Ticket.create!( + title: 'test 7', + group: group1, + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + priority: Ticket::Priority.lookup(name: '2 normal'), + close_at: '2015-11-01 12:30:00 UTC', + created_at: '2015-11-01 12:30:00 UTC', + updated_at: '2015-11-01 12:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + Ticket::Article.create!( + ticket_id: @ticket7.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'some subject', + message_id: 'some@id', + body: 'some message article_outbound', + internal: false, + sender: Ticket::Article::Sender.where(name: 'Agent').first, + type: Ticket::Article::Type.where(name: 'email').first, + created_at: '2015-11-01 12:30:00 UTC', + updated_at: '2015-11-01 12:30:00 UTC', + updated_by_id: 1, + created_by_id: 1, + ) + + # execute background jobs + Scheduler.worker(true) + end - Setting.set('es_url', ENV['ES_URL']) - if !ENV['ES_INDEX'] && !ENV['ES_INDEX_RAND'] - raise "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" - end - if ENV['ES_INDEX_RAND'] - ENV['ES_INDEX'] = "es_index_#{rand(999_999_999)}" - end - Setting.set('es_index', ENV['ES_INDEX']) - # Setting.set('es_url', 'http://127.0.0.1:9200') - # Setting.set('es_index', 'estest.local_zammad') - # Setting.set('es_user', 'elasticsearch') - # Setting.set('es_password', 'zammad') - # Setting.set('es_attachment_max_size_in_mb', 1) + test 'compare' do - # clear cache - Cache.clear - - # remove background jobs - Delayed::Job.destroy_all - - Ticket.destroy_all - - # drop/create indexes - #Rake::Task["searchindex:drop"].execute - #Rake::Task["searchindex:create"].execute - system('rake searchindex:rebuild') - - group1 = Group.lookup(name: 'Users') - group2 = Group.create_if_not_exists( - name: 'Report Test', - updated_by_id: 1, - created_by_id: 1 - ) - - load "#{Rails.root}/test/fixtures/seeds.rb" - - ticket1 = Ticket.create( - title: 'test 1', - group: group2, - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2015-10-28 09:30:00 UTC', - updated_at: '2015-10-28 09:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - article1 = Ticket::Article.create( - ticket_id: ticket1.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message article_inbound', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - created_at: '2015-10-28 09:30:00 UTC', - updated_at: '2015-10-28 09:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - ticket1.update_attributes( - group: Group.lookup(name: 'Users'), - updated_at: '2015-10-28 14:30:00 UTC', - ) - - ticket2 = Ticket.create( - title: 'test 2', - group: group1, - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2015-10-28 09:30:01 UTC', - updated_at: '2015-10-28 09:30:01 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - article2 = Ticket::Article.create( - ticket_id: ticket2.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message article_inbound', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - created_at: '2015-10-28 09:30:01 UTC', - updated_at: '2015-10-28 09:30:01 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - ticket2.update_attributes( - group_id: group2.id, - updated_at: '2015-10-28 14:30:00 UTC', - ) - - ticket3 = Ticket.create( - title: 'test 3', - group: group1, - customer_id: 2, - state: Ticket::State.lookup(name: 'open'), - priority: Ticket::Priority.lookup(name: '3 high'), - created_at: '2015-10-28 10:30:00 UTC', - updated_at: '2015-10-28 10:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - article3 = Ticket::Article.create( - ticket_id: ticket3.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message article_inbound', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - created_at: '2015-10-28 10:30:00 UTC', - updated_at: '2015-10-28 10:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - - ticket4 = Ticket.create( - title: 'test 4', - group: group1, - customer_id: 2, - state: Ticket::State.lookup(name: 'closed'), - priority: Ticket::Priority.lookup(name: '2 normal'), - close_at: '2015-10-28 11:30:00 UTC', - created_at: '2015-10-28 10:30:00 UTC', - updated_at: '2015-10-28 10:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - article4 = Ticket::Article.create( - ticket_id: ticket4.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message article_inbound', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - created_at: '2015-10-28 10:30:00 UTC', - updated_at: '2015-10-28 10:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - - ticket5 = Ticket.create( - title: 'test 5', - group: group1, - customer_id: 2, - state: Ticket::State.lookup(name: 'closed'), - priority: Ticket::Priority.lookup(name: '3 high'), - close_at: '2015-10-28 11:40:00 UTC', - created_at: '2015-10-28 11:30:00 UTC', - updated_at: '2015-10-28 11:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - article5 = Ticket::Article.create( - ticket_id: ticket5.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message article_outbound', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - created_at: '2015-10-28 11:30:00 UTC', - updated_at: '2015-10-28 11:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - ticket5.update_attributes( - state: Ticket::State.lookup(name: 'open'), - updated_at: '2015-10-28 14:30:00 UTC', - ) - - ticket6 = Ticket.create( - title: 'test 6', - group: group1, - customer_id: 2, - state: Ticket::State.lookup(name: 'closed'), - priority: Ticket::Priority.lookup(name: '2 normal'), - close_at: '2015-10-31 12:35:00 UTC', - created_at: '2015-10-31 12:30:00 UTC', - updated_at: '2015-10-31 12:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - article6 = Ticket::Article.create( - ticket_id: ticket6.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message article_outbound', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - created_at: '2015-10-31 12:30:00 UTC', - updated_at: '2015-10-31 12:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - - ticket7 = Ticket.create( - title: 'test 7', - group: group1, - customer_id: 2, - state: Ticket::State.lookup(name: 'closed'), - priority: Ticket::Priority.lookup(name: '2 normal'), - close_at: '2015-11-01 12:30:00 UTC', - created_at: '2015-11-01 12:30:00 UTC', - updated_at: '2015-11-01 12:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - article7 = Ticket::Article.create( - ticket_id: ticket7.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message article_outbound', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - created_at: '2015-11-01 12:30:00 UTC', - updated_at: '2015-11-01 12:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - - # execute background jobs - Scheduler.worker(true) - - sleep 6 - - test 'a - first solution' do - - # month + # first solution result = Report::TicketFirstSolution.aggs( range_start: '2015-01-01T00:00:00Z', range_end: '2015-12-31T23:59:59Z', @@ -276,7 +272,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(2, result[9]) assert_equal(1, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketFirstSolution.items( range_start: '2015-01-01T00:00:00Z', @@ -284,10 +280,10 @@ class ReportTest < ActiveSupport::TestCase selector: {}, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket5.id, result[:ticket_ids][0]) - assert_equal(ticket6.id, result[:ticket_ids][1]) - assert_equal(ticket7.id, result[:ticket_ids][2]) - assert_equal(nil, result[:ticket_ids][3]) + assert_equal(@ticket5.id, result[:ticket_ids][0]) + assert_equal(@ticket6.id, result[:ticket_ids][1]) + assert_equal(@ticket7.id, result[:ticket_ids][2]) + assert_nil(result[:ticket_ids][3]) # month - with selector #1 result = Report::TicketFirstSolution.aggs( @@ -314,7 +310,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(1, result[9]) assert_equal(0, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketFirstSolution.items( range_start: '2015-01-01T00:00:00Z', @@ -327,8 +323,8 @@ class ReportTest < ActiveSupport::TestCase }, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket5.id, result[:ticket_ids][0]) - assert_equal(nil, result[:ticket_ids][1]) + assert_equal(@ticket5.id, result[:ticket_ids][0]) + assert_nil(result[:ticket_ids][1]) # month - with selector #2 result = Report::TicketFirstSolution.aggs( @@ -355,7 +351,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(1, result[9]) assert_equal(1, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketFirstSolution.items( range_start: '2015-01-01T00:00:00Z', @@ -368,9 +364,9 @@ class ReportTest < ActiveSupport::TestCase }, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket6.id, result[:ticket_ids][0]) - assert_equal(ticket7.id, result[:ticket_ids][1]) - assert_equal(nil, result[:ticket_ids][2]) + assert_equal(@ticket6.id, result[:ticket_ids][0]) + assert_equal(@ticket7.id, result[:ticket_ids][1]) + assert_nil(result[:ticket_ids][2]) # week result = Report::TicketFirstSolution.aggs( @@ -387,7 +383,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(0, result[4]) assert_equal(1, result[5]) assert_equal(1, result[6]) - assert_equal(nil, result[7]) + assert_nil(result[7]) result = Report::TicketFirstSolution.items( range_start: '2015-10-26T00:00:00Z', @@ -396,10 +392,10 @@ class ReportTest < ActiveSupport::TestCase selector: {}, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket5.id, result[:ticket_ids][0]) - assert_equal(ticket6.id, result[:ticket_ids][1]) - assert_equal(ticket7.id, result[:ticket_ids][2]) - assert_equal(nil, result[:ticket_ids][3]) + assert_equal(@ticket5.id, result[:ticket_ids][0]) + assert_equal(@ticket6.id, result[:ticket_ids][1]) + assert_equal(@ticket7.id, result[:ticket_ids][2]) + assert_nil(result[:ticket_ids][3]) # day result = Report::TicketFirstSolution.aggs( @@ -440,7 +436,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(0, result[28]) assert_equal(0, result[29]) assert_equal(1, result[30]) - assert_equal(nil, result[31]) + assert_nil(result[31]) result = Report::TicketFirstSolution.items( range_start: '2015-10-01T00:00:00Z', @@ -449,9 +445,9 @@ class ReportTest < ActiveSupport::TestCase selector: {}, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket5.id, result[:ticket_ids][0]) - assert_equal(ticket6.id, result[:ticket_ids][1]) - assert_equal(nil, result[:ticket_ids][2]) + assert_equal(@ticket5.id, result[:ticket_ids][0]) + assert_equal(@ticket6.id, result[:ticket_ids][1]) + assert_nil(result[:ticket_ids][2]) # hour result = Report::TicketFirstSolution.aggs( @@ -485,7 +481,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(0, result[21]) assert_equal(0, result[22]) assert_equal(0, result[23]) - assert_equal(nil, result[24]) + assert_nil(result[24]) result = Report::TicketFirstSolution.items( range_start: '2015-10-28T00:00:00Z', @@ -494,15 +490,10 @@ class ReportTest < ActiveSupport::TestCase selector: {}, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket5.id, result[:ticket_ids][0]) - assert_equal(nil, result[:ticket_ids][1]) + assert_equal(@ticket5.id, result[:ticket_ids][0]) + assert_nil(result[:ticket_ids][1]) - # created by channel and direction - end - - test 'b - reopen' do - - # month + # reopen result = Report::TicketReopened.aggs( range_start: '2015-01-01T00:00:00Z', range_end: '2015-12-31T23:59:59Z', @@ -522,7 +513,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(1, result[9]) assert_equal(0, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketReopened.items( range_start: '2015-01-01T00:00:00Z', @@ -530,8 +521,8 @@ class ReportTest < ActiveSupport::TestCase selector: {}, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket5.id, result[:ticket_ids][0]) - assert_equal(nil, result[:ticket_ids][1]) + assert_equal(@ticket5.id, result[:ticket_ids][0]) + assert_nil(result[:ticket_ids][1]) # month - with selector #1 result = Report::TicketReopened.aggs( @@ -558,7 +549,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(1, result[9]) assert_equal(0, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketReopened.items( range_start: '2015-01-01T00:00:00Z', @@ -571,8 +562,8 @@ class ReportTest < ActiveSupport::TestCase }, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(ticket5.id, result[:ticket_ids][0]) - assert_equal(nil, result[:ticket_ids][1]) + assert_equal(@ticket5.id, result[:ticket_ids][0]) + assert_nil(result[:ticket_ids][1]) # month - with selector #2 result = Report::TicketReopened.aggs( @@ -599,7 +590,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(0, result[9]) assert_equal(0, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketReopened.items( range_start: '2015-01-01T00:00:00Z', @@ -612,13 +603,9 @@ class ReportTest < ActiveSupport::TestCase }, # ticket selector to get only a collection of tickets ) assert(result) - assert_equal(nil, result[:ticket_ids][0]) + assert_nil(result[:ticket_ids][0]) - end - - test 'c - move in/out' do - - # month + # move in/out result = Report::TicketMoved.aggs( range_start: '2015-01-01T00:00:00Z', range_end: '2015-12-31T23:59:59Z', @@ -646,7 +633,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(1, result[9]) assert_equal(0, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketMoved.items( range_start: '2015-01-01T00:00:00Z', @@ -662,8 +649,8 @@ class ReportTest < ActiveSupport::TestCase }, ) assert(result) - assert_equal(ticket1.id, result[:ticket_ids][0]) - assert_equal(nil, result[:ticket_ids][1]) + assert_equal(@ticket1.id, result[:ticket_ids][0]) + assert_nil(result[:ticket_ids][1]) # out result = Report::TicketMoved.aggs( @@ -693,7 +680,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(1, result[9]) assert_equal(0, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketMoved.items( range_start: '2015-01-01T00:00:00Z', @@ -709,14 +696,10 @@ class ReportTest < ActiveSupport::TestCase }, ) assert(result) - assert_equal(ticket2.id, result[:ticket_ids][0]) - assert_equal(nil, result[:ticket_ids][1]) + assert_equal(@ticket2.id, result[:ticket_ids][0]) + assert_nil(result[:ticket_ids][1]) - end - - test 'd - created at' do - - # month + # create at result = Report::TicketGenericTime.aggs( range_start: '2015-01-01T00:00:00Z', range_end: '2015-12-31T23:59:59Z', @@ -737,7 +720,7 @@ class ReportTest < ActiveSupport::TestCase assert_equal(6, result[9]) assert_equal(1, result[10]) assert_equal(0, result[11]) - assert_equal(nil, result[12]) + assert_nil(result[12]) result = Report::TicketGenericTime.items( range_start: '2015-01-01T00:00:00Z', @@ -747,17 +730,17 @@ class ReportTest < ActiveSupport::TestCase ) assert(result) - assert_equal(ticket7.id, result[:ticket_ids][0].to_i) - assert_equal(ticket6.id, result[:ticket_ids][1].to_i) - assert_equal(ticket5.id, result[:ticket_ids][2].to_i) - assert_equal(ticket3.id, result[:ticket_ids][3].to_i) - assert_equal(ticket4.id, result[:ticket_ids][4].to_i) - assert_equal(ticket2.id, result[:ticket_ids][5].to_i) - assert_equal(ticket1.id, result[:ticket_ids][6].to_i) - assert_equal(nil, result[:ticket_ids][7]) + assert_equal(@ticket7.id, result[:ticket_ids][0].to_i) + assert_equal(@ticket6.id, result[:ticket_ids][1].to_i) + assert_equal(@ticket5.id, result[:ticket_ids][2].to_i) + assert_equal(@ticket3.id, result[:ticket_ids][3].to_i) + assert_equal(@ticket4.id, result[:ticket_ids][4].to_i) + assert_equal(@ticket2.id, result[:ticket_ids][5].to_i) + assert_equal(@ticket1.id, result[:ticket_ids][6].to_i) + assert_nil(result[:ticket_ids][7]) # cleanup - system('rake searchindex:drop') + Rake::Task['searchindex:drop'].execute end end diff --git a/test/integration_test_helper.rb b/test/integration_test_helper.rb index d04873d37..37e183bbe 100644 --- a/test/integration_test_helper.rb +++ b/test/integration_test_helper.rb @@ -7,6 +7,9 @@ class ActiveSupport::TestCase # disable transactions #self.use_transactional_fixtures = false + ActiveRecord::Base.logger = Rails.logger.clone + ActiveRecord::Base.logger.level = Logger::INFO + # clear cache Cache.clear @@ -19,8 +22,14 @@ class ActiveSupport::TestCase # clear cache Cache.clear + # remove all session messages + Sessions.cleanup + # remove background jobs Delayed::Job.destroy_all + Trigger.destroy_all + ActivityStream.destroy_all + PostmasterFilter.destroy_all # set current user UserInfo.current_user_id = nil diff --git a/test/test_helper.rb b/test/test_helper.rb index a003338b7..24f283f45 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -12,6 +12,9 @@ Coveralls.wear! class ActiveSupport::TestCase self.test_order = :sorted + ActiveRecord::Base.logger = Rails.logger.clone + ActiveRecord::Base.logger.level = Logger::INFO + # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order. # # Note: You'll currently still have to declare fixtures explicitly in integration tests @@ -37,7 +40,14 @@ class ActiveSupport::TestCase # set system mode to done / to activate Setting.set('system_init_done', true) - def setup + setup do + + # exit all threads + Thread.list.each do |thread| + next if thread == Thread.current + thread.exit + thread.join + end # clear cache Cache.clear @@ -52,6 +62,14 @@ class ActiveSupport::TestCase PostmasterFilter.destroy_all Ticket.destroy_all + # reset settings + Setting.all.pluck(:name).each { |name| + next if name == 'models_searchable' # skip setting + Setting.reset(name, false) + } + Setting.set('system_init_done', true) + Setting.reload + # set current user UserInfo.current_user_id = nil diff --git a/test/unit/activity_stream_test.rb b/test/unit/activity_stream_test.rb index 47cef8b35..27b905ad1 100644 --- a/test/unit/activity_stream_test.rb +++ b/test/unit/activity_stream_test.rb @@ -2,12 +2,11 @@ require 'test_helper' class ActivityStreamTest < ActiveSupport::TestCase - admin_user = nil - current_user = nil - test 'aaa - setup' do + + setup do roles = Role.where(name: %w(Admin Agent)) - group = Group.lookup(name: 'Users') - admin_user = User.create_or_update( + groups = Group.where(name: 'Users') + @admin_user = User.create_or_update( login: 'admin', firstname: 'Bob', lastname: 'Smith', @@ -15,30 +14,31 @@ class ActivityStreamTest < ActiveSupport::TestCase password: 'some_pass', active: true, roles: roles, - group_ids: [group.id], + groups: groups, updated_by_id: 1, created_by_id: 1 ) - current_user = User.lookup(email: 'nicole.braun@zammad.org') + @current_user = User.lookup(email: 'nicole.braun@zammad.org') + ActivityStream.delete_all end test 'ticket+user' do ticket = Ticket.create( group_id: Group.lookup(name: 'Users').id, - customer_id: current_user.id, + customer_id: @current_user.id, owner_id: User.lookup(login: '-').id, title: 'Unit Test 1 (äöüß)!', state_id: Ticket::State.lookup(name: 'new').id, priority_id: Ticket::Priority.lookup(name: '2 normal').id, - updated_by_id: current_user.id, - created_by_id: current_user.id, + updated_by_id: @current_user.id, + created_by_id: @current_user.id, ) travel 2.seconds article = Ticket::Article.create( ticket_id: ticket.id, - updated_by_id: current_user.id, - created_by_id: current_user.id, + updated_by_id: @current_user.id, + created_by_id: @current_user.id, type_id: Ticket::Article::Type.lookup(name: 'phone').id, sender_id: Ticket::Article::Sender.lookup(name: 'Customer').id, from: 'Unit Test ', @@ -61,28 +61,28 @@ class ActivityStreamTest < ActiveSupport::TestCase ) # check activity_stream - stream = admin_user.activity_stream(4) + stream = @admin_user.activity_stream(4) assert_equal(stream[0]['group_id'], ticket.group_id) assert_equal(stream[0]['o_id'], ticket.id) - assert_equal(stream[0]['created_by_id'], current_user.id) + assert_equal(stream[0]['created_by_id'], @current_user.id) assert_equal(stream[0]['created_at'].to_s, updated_at.to_s) assert_equal(stream[0]['object'], 'Ticket') assert_equal(stream[0]['type'], 'update') assert_equal(stream[1]['group_id'], ticket.group_id) assert_equal(stream[1]['o_id'], article.id) - assert_equal(stream[1]['created_by_id'], current_user.id) + assert_equal(stream[1]['created_by_id'], @current_user.id) assert_equal(stream[1]['created_at'].to_s, article.created_at.to_s) assert_equal(stream[1]['object'], 'Ticket::Article') assert_equal(stream[1]['type'], 'create') assert_equal(stream[2]['group_id'], ticket.group_id) assert_equal(stream[2]['o_id'], ticket.id) - assert_equal(stream[2]['created_by_id'], current_user.id) + assert_equal(stream[2]['created_by_id'], @current_user.id) assert_equal(stream[2]['created_at'].to_s, ticket.created_at.to_s) assert_equal(stream[2]['object'], 'Ticket') assert_equal(stream[2]['type'], 'create') assert_not(stream[3]) - stream = current_user.activity_stream(4) + stream = @current_user.activity_stream(4) assert(stream.empty?) # cleanup @@ -91,11 +91,10 @@ class ActivityStreamTest < ActiveSupport::TestCase end test 'organization' do - organization = Organization.create( name: 'some name', - updated_by_id: current_user.id, - created_by_id: current_user.id, + updated_by_id: @current_user.id, + created_by_id: @current_user.id, ) travel 100.seconds assert_equal(organization.class, Organization) @@ -107,22 +106,22 @@ class ActivityStreamTest < ActiveSupport::TestCase organization.update_attributes(name: 'some name 2 (äöüß)') # check activity_stream - stream = admin_user.activity_stream(3) + stream = @admin_user.activity_stream(3) assert_not(stream[0]['group_id']) assert_equal(stream[0]['o_id'], organization.id) - assert_equal(stream[0]['created_by_id'], current_user.id) + assert_equal(stream[0]['created_by_id'], @current_user.id) assert_equal(stream[0]['created_at'].to_s, updated_at.to_s) assert_equal(stream[0]['object'], 'Organization') assert_equal(stream[0]['type'], 'update') assert_not(stream[1]['group_id']) assert_equal(stream[1]['o_id'], organization.id) - assert_equal(stream[1]['created_by_id'], current_user.id) + assert_equal(stream[1]['created_by_id'], @current_user.id) assert_equal(stream[1]['created_at'].to_s, organization.created_at.to_s) assert_equal(stream[1]['object'], 'Organization') assert_equal(stream[1]['type'], 'create') assert_not(stream[2]) - stream = current_user.activity_stream(4) + stream = @current_user.activity_stream(4) assert(stream.empty?) # cleanup @@ -135,8 +134,8 @@ class ActivityStreamTest < ActiveSupport::TestCase login: 'someemail@example.com', email: 'someemail@example.com', firstname: 'Bob Smith II', - updated_by_id: current_user.id, - created_by_id: current_user.id, + updated_by_id: @current_user.id, + created_by_id: @current_user.id, ) assert_equal(user.class, User) user.update_attributes( @@ -145,16 +144,16 @@ class ActivityStreamTest < ActiveSupport::TestCase ) # check activity_stream - stream = admin_user.activity_stream(3) + stream = @admin_user.activity_stream(3) assert_not(stream[0]['group_id']) assert_equal(stream[0]['o_id'], user.id) - assert_equal(stream[0]['created_by_id'], current_user.id) + assert_equal(stream[0]['created_by_id'], @current_user.id) assert_equal(stream[0]['created_at'].to_s, user.created_at.to_s) assert_equal(stream[0]['object'], 'User') assert_equal(stream[0]['type'], 'create') assert_not(stream[1]) - stream = current_user.activity_stream(4) + stream = @current_user.activity_stream(4) assert(stream.empty?) # cleanup @@ -163,13 +162,12 @@ class ActivityStreamTest < ActiveSupport::TestCase end test 'user with update check true' do - user = User.create( login: 'someemail@example.com', email: 'someemail@example.com', firstname: 'Bob Smith II', - updated_by_id: current_user.id, - created_by_id: current_user.id, + updated_by_id: @current_user.id, + created_by_id: @current_user.id, ) travel 100.seconds assert_equal(user.class, User) @@ -187,22 +185,22 @@ class ActivityStreamTest < ActiveSupport::TestCase ) # check activity_stream - stream = admin_user.activity_stream(3) + stream = @admin_user.activity_stream(3) assert_not(stream[0]['group_id']) assert_equal(stream[0]['o_id'], user.id) - assert_equal(stream[0]['created_by_id'], current_user.id) + assert_equal(stream[0]['created_by_id'], @current_user.id) assert_equal(stream[0]['created_at'].to_s, updated_at.to_s) assert_equal(stream[0]['object'], 'User') assert_equal(stream[0]['type'], 'update') assert_not(stream[1]['group_id']) assert_equal(stream[1]['o_id'], user.id) - assert_equal(stream[1]['created_by_id'], current_user.id) + assert_equal(stream[1]['created_by_id'], @current_user.id) assert_equal(stream[1]['created_at'].to_s, user.created_at.to_s) assert_equal(stream[1]['object'], 'User') assert_equal(stream[1]['type'], 'create') assert_not(stream[2]) - stream = current_user.activity_stream(4) + stream = @current_user.activity_stream(4) assert(stream.empty?) # cleanup diff --git a/test/unit/cache_test.rb b/test/unit/cache_test.rb index 07152f081..37992bdaf 100644 --- a/test/unit/cache_test.rb +++ b/test/unit/cache_test.rb @@ -43,7 +43,7 @@ class CacheTest < ActiveSupport::TestCase # test 6 Cache.write('123', { key: 'some valueöäüß2' }, expires_in: 3.seconds) - sleep 5 + travel 5.seconds cache = Cache.get('123') assert_nil(cache) end diff --git a/test/unit/chat_test.rb b/test/unit/chat_test.rb index 927c921bd..fe406d23b 100644 --- a/test/unit/chat_test.rb +++ b/test/unit/chat_test.rb @@ -2,14 +2,11 @@ require 'test_helper' class ChatTest < ActiveSupport::TestCase - agent1 = nil - agent2 = nil - test 'aaa - setup' do - # create base + setup do groups = Group.all roles = Role.where( name: %w(Agent) ) - agent1 = User.create_or_update( + @agent1 = User.create_or_update( login: 'ticket-chat-agent1@example.com', firstname: 'Notification', lastname: 'Agent1', @@ -22,7 +19,7 @@ class ChatTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - agent2 = User.create_or_update( + @agent2 = User.create_or_update( login: 'ticket-chat-agent2@example.com', firstname: 'Notification', lastname: 'Agent2', @@ -35,15 +32,16 @@ class ChatTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - end - - test 'default test' do Chat.delete_all Chat::Session.delete_all Chat::Message.delete_all Chat::Agent.delete_all Setting.set('chat', false) + end + + test 'default test' do + chat = Chat.create_or_update( name: 'default', max_queue: 5, @@ -55,14 +53,14 @@ class ChatTest < ActiveSupport::TestCase # check if feature is disabled assert_equal('chat_disabled', chat.customer_state[:state]) - assert_equal('chat_disabled', Chat.agent_state(agent1.id)[:state]) + assert_equal('chat_disabled', Chat.agent_state(@agent1.id)[:state]) Setting.set('chat', true) # check customer state assert_equal('offline', chat.customer_state[:state]) # check agent state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(0, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -74,15 +72,15 @@ class ChatTest < ActiveSupport::TestCase chat_agent1 = Chat::Agent.create_or_update( active: true, concurrent: 4, - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) # check customer state assert_equal('online', chat.customer_state[:state]) # check agent state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(0, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -93,7 +91,7 @@ class ChatTest < ActiveSupport::TestCase # start session chat_session1 = Chat::Session.create( chat_id: chat.id, - user_id: agent1.id, + user_id: @agent1.id, ) assert(chat_session1.session_id) @@ -101,7 +99,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal('online', chat.customer_state[:state]) # check agent state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(1, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -113,15 +111,15 @@ class ChatTest < ActiveSupport::TestCase chat_agent2 = Chat::Agent.create_or_update( active: true, concurrent: 2, - updated_by_id: agent2.id, - created_by_id: agent2.id, + updated_by_id: @agent2.id, + created_by_id: @agent2.id, ) # check customer state assert_equal('online', chat.customer_state[:state]) # check agent1 state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(1, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -130,7 +128,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(true, agent_state[:active]) # check agent2 state - agent_state = Chat.agent_state_with_sessions(agent2.id) + agent_state = Chat.agent_state_with_sessions(@agent2.id) assert_equal(1, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -147,7 +145,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal('online', chat.customer_state[:state]) # check agent1 state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(2, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -156,7 +154,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(true, agent_state[:active]) # check agent2 state - agent_state = Chat.agent_state_with_sessions(agent2.id) + agent_state = Chat.agent_state_with_sessions(@agent2.id) assert_equal(2, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -182,7 +180,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal('no_seats_available', chat.customer_state[:state]) # check agent1 state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(6, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -191,7 +189,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(true, agent_state[:active]) # check agent2 state - agent_state = Chat.agent_state_with_sessions(agent2.id) + agent_state = Chat.agent_state_with_sessions(@agent2.id) assert_equal(6, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -199,26 +197,26 @@ class ChatTest < ActiveSupport::TestCase assert_equal(6, agent_state[:seads_total]) assert_equal(true, agent_state[:active]) - chat_session6.user_id = agent1.id + chat_session6.user_id = @agent1.id chat_session6.state = 'running' chat_session6.save Chat::Message.create( chat_session_id: chat_session6.id, content: 'message 1', - created_by_id: agent1.id, + created_by_id: @agent1.id, ) travel 1.second Chat::Message.create( chat_session_id: chat_session6.id, content: 'message 2', - created_by_id: agent1.id, + created_by_id: @agent1.id, ) travel 1.second Chat::Message.create( chat_session_id: chat_session6.id, content: 'message 3', - created_by_id: agent1.id, + created_by_id: @agent1.id, ) travel 1.second Chat::Message.create( @@ -245,12 +243,12 @@ class ChatTest < ActiveSupport::TestCase assert_nil(customer_state[:agent][:avatar]) # check agent1 state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(5, agent_state[:waiting_chat_count]) assert_equal(1, agent_state[:running_chat_count]) assert_equal(Array, agent_state[:active_sessions].class) assert_equal(chat.id, agent_state[:active_sessions][0]['chat_id']) - assert_equal(agent1.id, agent_state[:active_sessions][0]['user_id']) + assert_equal(@agent1.id, agent_state[:active_sessions][0]['user_id']) assert(agent_state[:active_sessions][0]['messages']) assert_equal(Array, agent_state[:active_sessions][0]['messages'].class) assert_equal('message 1', agent_state[:active_sessions][0]['messages'][0]['content']) @@ -262,7 +260,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(true, agent_state[:active]) # check agent2 state - agent_state = Chat.agent_state_with_sessions(agent2.id) + agent_state = Chat.agent_state_with_sessions(@agent2.id) assert_equal(5, agent_state[:waiting_chat_count]) assert_equal(1, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -278,12 +276,12 @@ class ChatTest < ActiveSupport::TestCase assert_equal(5, chat.customer_state[:queue]) # check agent1 state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(5, agent_state[:waiting_chat_count]) assert_equal(1, agent_state[:running_chat_count]) assert_equal(Array, agent_state[:active_sessions].class) assert_equal(chat.id, agent_state[:active_sessions][0]['chat_id']) - assert_equal(agent1.id, agent_state[:active_sessions][0]['user_id']) + assert_equal(@agent1.id, agent_state[:active_sessions][0]['user_id']) assert(agent_state[:active_sessions][0]['messages']) assert_equal(Array, agent_state[:active_sessions][0]['messages'].class) assert_equal('message 1', agent_state[:active_sessions][0]['messages'][0]['content']) @@ -295,7 +293,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(true, agent_state[:active]) # check agent2 state - agent_state = Chat.agent_state_with_sessions(agent2.id) + agent_state = Chat.agent_state_with_sessions(@agent2.id) assert_equal(5, agent_state[:waiting_chat_count]) assert_equal(1, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -311,7 +309,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(5, chat.customer_state[:queue]) # check agent1 state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(5, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -320,7 +318,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(true, agent_state[:active]) # check agent2 state - agent_state = Chat.agent_state_with_sessions(agent2.id) + agent_state = Chat.agent_state_with_sessions(@agent2.id) assert_equal(5, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -335,7 +333,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal('online', chat.customer_state[:state]) # check agent1 state - agent_state = Chat.agent_state_with_sessions(agent1.id) + agent_state = Chat.agent_state_with_sessions(@agent1.id) assert_equal(3, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) @@ -344,7 +342,7 @@ class ChatTest < ActiveSupport::TestCase assert_equal(true, agent_state[:active]) # check agent2 state - agent_state = Chat.agent_state_with_sessions(agent2.id) + agent_state = Chat.agent_state_with_sessions(@agent2.id) assert_equal(3, agent_state[:waiting_chat_count]) assert_equal(0, agent_state[:running_chat_count]) assert_equal([], agent_state[:active_sessions]) diff --git a/test/unit/online_notifiaction_test.rb b/test/unit/online_notifiaction_test.rb index b5106c40b..f30c37588 100644 --- a/test/unit/online_notifiaction_test.rb +++ b/test/unit/online_notifiaction_test.rb @@ -2,18 +2,15 @@ require 'test_helper' class OnlineNotificationTest < ActiveSupport::TestCase - group = nil - agent_user1 = nil - agent_user2 = nil - customer_user = nil - test 'aaa - setup' do - role = Role.lookup(name: 'Agent') - group = Group.create_or_update( + + setup do + role = Role.lookup(name: 'Agent') + @group = Group.create_or_update( name: 'OnlineNotificationTest', updated_by_id: 1, created_by_id: 1 ) - agent_user1 = User.create_or_update( + @agent_user1 = User.create_or_update( login: 'agent_online_notify1', firstname: 'Bob', lastname: 'Smith', @@ -21,11 +18,11 @@ class OnlineNotificationTest < ActiveSupport::TestCase password: 'some_pass', active: true, role_ids: [role.id], - group_ids: [group.id], + group_ids: [@group.id], updated_by_id: 1, created_by_id: 1 ) - agent_user2 = User.create_or_update( + @agent_user2 = User.create_or_update( login: 'agent_online_notify2', firstname: 'Bob', lastname: 'Smith', @@ -33,11 +30,11 @@ class OnlineNotificationTest < ActiveSupport::TestCase password: 'some_pass', active: true, role_ids: [role.id], - group_ids: [group.id], + group_ids: [@group.id], updated_by_id: 1, created_by_id: 1 ) - customer_user = User.lookup(email: 'nicole.braun@zammad.org') + @customer_user = User.lookup(email: 'nicole.braun@zammad.org') end test 'ticket notification' do @@ -46,19 +43,19 @@ class OnlineNotificationTest < ActiveSupport::TestCase # case #1 ticket1 = Ticket.create( - group: group, - customer_id: customer_user.id, + group: @group, + customer_id: @customer_user.id, owner_id: User.lookup(login: '-').id, title: 'Unit Test 1 (äöüß)!', state_id: Ticket::State.lookup(name: 'closed').id, priority_id: Ticket::Priority.lookup(name: '2 normal').id, - updated_by_id: agent_user1.id, - created_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, + created_by_id: @agent_user1.id, ) article1 = Ticket::Article.create( ticket_id: ticket1.id, - updated_by_id: agent_user1.id, - created_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, + created_by_id: @agent_user1.id, type_id: Ticket::Article::Type.lookup(name: 'phone').id, sender_id: Ticket::Article::Sender.lookup(name: 'Customer').id, from: 'Unit Test ', @@ -76,16 +73,16 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already closed assert(OnlineNotification.all_seen?('Ticket', ticket1.id)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket1.id, 'create', agent_user1, false)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket1.id, 'create', agent_user1, true)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket1.id, 'create', agent_user1, false)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket1.id, 'create', agent_user1, true)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket1.id, 'create', @agent_user1, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket1.id, 'create', @agent_user1, true)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket1.id, 'create', @agent_user1, false)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket1.id, 'create', @agent_user1, true)) ticket1.update_attributes( title: 'Unit Test 1 (äöüß) - update!', state_id: Ticket::State.lookup(name: 'open').id, priority_id: Ticket::Priority.lookup(name: '1 low').id, - updated_by_id: customer_user.id, + updated_by_id: @customer_user.id, ) # execute object transaction @@ -94,26 +91,26 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already open assert(!OnlineNotification.all_seen?('Ticket', ticket1.id)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket1.id, 'update', customer_user, true)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket1.id, 'update', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket1.id, 'update', customer_user, true)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket1.id, 'update', customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket1.id, 'update', @customer_user, true)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket1.id, 'update', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket1.id, 'update', @customer_user, true)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket1.id, 'update', @customer_user, false)) # case #2 ticket2 = Ticket.create( - group: group, - customer_id: customer_user.id, - owner_id: agent_user1.id, + group: @group, + customer_id: @customer_user.id, + owner_id: @agent_user1.id, title: 'Unit Test 1 (äöüß)!', state_id: Ticket::State.lookup(name: 'closed').id, priority_id: Ticket::Priority.lookup(name: '2 normal').id, - updated_by_id: customer_user.id, - created_by_id: customer_user.id, + updated_by_id: @customer_user.id, + created_by_id: @customer_user.id, ) article2 = Ticket::Article.create( ticket_id: ticket2.id, - updated_by_id: customer_user.id, - created_by_id: customer_user.id, + updated_by_id: @customer_user.id, + created_by_id: @customer_user.id, type_id: Ticket::Article::Type.lookup(name: 'phone').id, sender_id: Ticket::Article::Sender.lookup(name: 'Customer').id, from: 'Unit Test ', @@ -131,16 +128,16 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already closed assert(!OnlineNotification.all_seen?('Ticket', ticket2.id)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket2.id, 'create', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket2.id, 'create', customer_user, true)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket2.id, 'create', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket2.id, 'create', customer_user, true)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket2.id, 'create', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket2.id, 'create', @customer_user, true)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket2.id, 'create', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket2.id, 'create', @customer_user, true)) ticket2.update_attributes( title: 'Unit Test 1 (äöüß) - update!', state_id: Ticket::State.lookup(name: 'open').id, priority_id: Ticket::Priority.lookup(name: '1 low').id, - updated_by_id: customer_user.id, + updated_by_id: @customer_user.id, ) # execute object transaction @@ -149,26 +146,26 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already open assert(!OnlineNotification.all_seen?('Ticket', ticket2.id)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket2.id, 'update', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket2.id, 'update', customer_user, true)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket2.id, 'update', customer_user, true)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket2.id, 'update', customer_user, false)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket2.id, 'update', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket2.id, 'update', @customer_user, true)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket2.id, 'update', @customer_user, true)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket2.id, 'update', @customer_user, false)) # case #3 ticket3 = Ticket.create( - group: group, - customer_id: customer_user.id, + group: @group, + customer_id: @customer_user.id, owner_id: User.lookup(login: '-').id, title: 'Unit Test 2 (äöüß)!', state_id: Ticket::State.lookup(name: 'new').id, priority_id: Ticket::Priority.lookup(name: '2 normal').id, - updated_by_id: agent_user1.id, - created_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, + created_by_id: @agent_user1.id, ) article3 = Ticket::Article.create( ticket_id: ticket3.id, - updated_by_id: agent_user1.id, - created_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, + created_by_id: @agent_user1.id, type_id: Ticket::Article::Type.lookup(name: 'phone').id, sender_id: Ticket::Article::Sender.lookup(name: 'Customer').id, from: 'Unit Test ', @@ -185,16 +182,16 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already new assert(!OnlineNotification.all_seen?('Ticket', ticket3.id)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket3.id, 'create', agent_user1, false)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket3.id, 'create', agent_user1, true)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket3.id, 'create', agent_user1, false)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket3.id, 'create', agent_user1, true)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket3.id, 'create', @agent_user1, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket3.id, 'create', @agent_user1, true)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket3.id, 'create', @agent_user1, false)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket3.id, 'create', @agent_user1, true)) ticket3.update_attributes( title: 'Unit Test 2 (äöüß) - update!', state_id: Ticket::State.lookup(name: 'closed').id, priority_id: Ticket::Priority.lookup(name: '1 low').id, - updated_by_id: customer_user.id, + updated_by_id: @customer_user.id, ) # execute object transaction @@ -203,17 +200,17 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already closed assert(OnlineNotification.all_seen?('Ticket', ticket3.id)) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, agent_user1, 'update')) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, agent_user2, 'update')) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket3.id, 'update', customer_user, false)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket3.id, 'update', customer_user, true)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket3.id, 'update', customer_user, false)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket3.id, 'update', customer_user, true)) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, @agent_user1, 'update')) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, @agent_user2, 'update')) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket3.id, 'update', @customer_user, false)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket3.id, 'update', @customer_user, true)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket3.id, 'update', @customer_user, false)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket3.id, 'update', @customer_user, true)) article3 = Ticket::Article.create( ticket_id: ticket3.id, - updated_by_id: customer_user.id, - created_by_id: customer_user.id, + updated_by_id: @customer_user.id, + created_by_id: @customer_user.id, type_id: Ticket::Article::Type.lookup(name: 'phone').id, sender_id: Ticket::Article::Sender.lookup(name: 'Customer').id, from: 'Unit Test ', @@ -227,28 +224,28 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already closed but an follow up arrived later assert(!OnlineNotification.all_seen?('Ticket', ticket3.id)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket3.id, 'update', customer_user, false)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket3.id, 'update', customer_user, true)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket3.id, 'update', customer_user, false)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket3.id, 'update', customer_user, true)) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, agent_user1, 'update')) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, agent_user2, 'update')) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket3.id, 'update', @customer_user, false)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket3.id, 'update', @customer_user, true)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket3.id, 'update', @customer_user, false)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket3.id, 'update', @customer_user, true)) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, @agent_user1, 'update')) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, @agent_user2, 'update')) # case #4 ticket4 = Ticket.create( - group: group, - customer_id: customer_user.id, - owner_id: agent_user1.id, + group: @group, + customer_id: @customer_user.id, + owner_id: @agent_user1.id, title: 'Unit Test 3 (äöüß)!', state_id: Ticket::State.lookup(name: 'new').id, priority_id: Ticket::Priority.lookup(name: '2 normal').id, - updated_by_id: customer_user.id, - created_by_id: customer_user.id, + updated_by_id: @customer_user.id, + created_by_id: @customer_user.id, ) article4 = Ticket::Article.create( ticket_id: ticket4.id, - updated_by_id: customer_user.id, - created_by_id: customer_user.id, + updated_by_id: @customer_user.id, + created_by_id: @customer_user.id, type_id: Ticket::Article::Type.lookup(name: 'phone').id, sender_id: Ticket::Article::Sender.lookup(name: 'Customer').id, from: 'Unit Test ', @@ -265,16 +262,16 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already new assert(!OnlineNotification.all_seen?('Ticket', ticket4.id)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket4.id, 'create', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket4.id, 'create', customer_user, true)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket4.id, 'create', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket4.id, 'create', customer_user, true)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket4.id, 'create', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket4.id, 'create', @customer_user, true)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket4.id, 'create', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket4.id, 'create', @customer_user, true)) ticket4.update_attributes( title: 'Unit Test 3 (äöüß) - update!', state_id: Ticket::State.lookup(name: 'open').id, priority_id: Ticket::Priority.lookup(name: '1 low').id, - updated_by_id: customer_user.id, + updated_by_id: @customer_user.id, ) # execute object transaction @@ -283,26 +280,26 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already open assert(!OnlineNotification.all_seen?('Ticket', ticket4.id)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket4.id, 'update', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket4.id, 'update', customer_user, true)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket4.id, 'update', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket4.id, 'update', customer_user, true)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket4.id, 'update', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket4.id, 'update', @customer_user, true)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket4.id, 'update', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket4.id, 'update', @customer_user, true)) # case #5 ticket5 = Ticket.create( - group: group, - customer_id: customer_user.id, + group: @group, + customer_id: @customer_user.id, owner_id: User.lookup(login: '-').id, title: 'Unit Test 4 (äöüß)!', state_id: Ticket::State.lookup(name: 'new').id, priority_id: Ticket::Priority.lookup( name: '2 normal').id, - updated_by_id: agent_user1.id, - created_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, + created_by_id: @agent_user1.id, ) article5 = Ticket::Article.create( ticket_id: ticket5.id, - updated_by_id: agent_user1.id, - created_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, + created_by_id: @agent_user1.id, type_id: Ticket::Article::Type.lookup(name: 'phone').id, sender_id: Ticket::Article::Sender.lookup(name: 'Customer').id, from: 'Unit Test ', @@ -319,16 +316,16 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already new assert(!OnlineNotification.all_seen?('Ticket', ticket5.id)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket5.id, 'create', agent_user1, true)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket5.id, 'create', agent_user1, false)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket5.id, 'create', agent_user1, false)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket5.id, 'create', agent_user1, true)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket5.id, 'create', @agent_user1, true)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket5.id, 'create', @agent_user1, false)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket5.id, 'create', @agent_user1, false)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket5.id, 'create', @agent_user1, true)) ticket5.update_attributes( title: 'Unit Test 4 (äöüß) - update!', state_id: Ticket::State.lookup(name: 'open').id, priority_id: Ticket::Priority.lookup(name: '1 low').id, - updated_by_id: customer_user.id, + updated_by_id: @customer_user.id, ) # execute object transaction @@ -337,10 +334,10 @@ class OnlineNotificationTest < ActiveSupport::TestCase # because it's already open assert(!OnlineNotification.all_seen?('Ticket', ticket5.id)) - assert(OnlineNotification.exists?(agent_user1, 'Ticket', ticket5.id, 'update', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user1, 'Ticket', ticket5.id, 'update', customer_user, true)) - assert(OnlineNotification.exists?(agent_user2, 'Ticket', ticket5.id, 'update', customer_user, false)) - assert(!OnlineNotification.exists?(agent_user2, 'Ticket', ticket5.id, 'update', customer_user, true)) + assert(OnlineNotification.exists?(@agent_user1, 'Ticket', ticket5.id, 'update', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user1, 'Ticket', ticket5.id, 'update', @customer_user, true)) + assert(OnlineNotification.exists?(@agent_user2, 'Ticket', ticket5.id, 'update', @customer_user, false)) + assert(!OnlineNotification.exists?(@agent_user2, 'Ticket', ticket5.id, 'update', @customer_user, true)) # merge tickets - also remove notifications of merged tickets tickets[0].merge_to( @@ -374,8 +371,8 @@ class OnlineNotificationTest < ActiveSupport::TestCase test 'ticket notification item check' do ticket1 = Ticket.create( title: 'some title', - group: group, - customer_id: customer_user.id, + group: @group, + customer_id: @customer_user.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, @@ -397,96 +394,96 @@ class OnlineNotificationTest < ActiveSupport::TestCase ) assert_equal(ticket1.online_notification_seen_state, false) - assert_equal(ticket1.online_notification_seen_state(agent_user1), false) - assert_equal(ticket1.online_notification_seen_state(agent_user2), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user1), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user2), false) # pending reminder, just let new owner to unseed ticket1.update_attributes( - owner_id: agent_user1.id, + owner_id: @agent_user1.id, state: Ticket::State.lookup(name: 'pending reminder'), - updated_by_id: agent_user2.id, + updated_by_id: @agent_user2.id, ) assert_equal(ticket1.online_notification_seen_state, true) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), false) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), true) # pending reminder, just let new owner to unseed ticket1.update_attributes( owner_id: 1, state: Ticket::State.lookup(name: 'pending reminder'), - updated_by_id: agent_user2.id, + updated_by_id: @agent_user2.id, ) assert_equal(ticket1.online_notification_seen_state, true) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), false) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), false) # pending reminder, self done, all to unseed ticket1.update_attributes( - owner_id: agent_user1.id, + owner_id: @agent_user1.id, state: Ticket::State.lookup(name: 'pending reminder'), - updated_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, ) assert_equal(ticket1.online_notification_seen_state, true) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), true) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), true) # pending close, all to unseen ticket1.update_attributes( - owner_id: agent_user1.id, + owner_id: @agent_user1.id, state: Ticket::State.lookup(name: 'pending close'), - updated_by_id: agent_user2.id, + updated_by_id: @agent_user2.id, ) assert_equal(ticket1.online_notification_seen_state, true) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), false) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), true) # to open, all to seen ticket1.update_attributes( - owner_id: agent_user1.id, + owner_id: @agent_user1.id, state: Ticket::State.lookup(name: 'open'), - updated_by_id: agent_user2.id, + updated_by_id: @agent_user2.id, ) assert_equal(ticket1.online_notification_seen_state, false) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), false) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), false) # to closed, all only others to seen ticket1.update_attributes( - owner_id: agent_user1.id, + owner_id: @agent_user1.id, state: Ticket::State.lookup(name: 'closed'), - updated_by_id: agent_user2.id, + updated_by_id: @agent_user2.id, ) assert_equal(ticket1.online_notification_seen_state, true) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), false) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), false) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), true) # to closed by owner self, all to seen ticket1.update_attributes( - owner_id: agent_user1.id, + owner_id: @agent_user1.id, state: Ticket::State.lookup(name: 'closed'), - updated_by_id: agent_user1.id, + updated_by_id: @agent_user1.id, ) assert_equal(ticket1.online_notification_seen_state, true) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), true) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), true) # to closed by owner self, all to seen ticket1.update_attributes( - owner_id: agent_user1.id, + owner_id: @agent_user1.id, state: Ticket::State.lookup(name: 'merged'), - updated_by_id: agent_user2.id, + updated_by_id: @agent_user2.id, ) assert_equal(ticket1.online_notification_seen_state, true) - assert_equal(ticket1.online_notification_seen_state(agent_user1.id), true) - assert_equal(ticket1.online_notification_seen_state(agent_user2.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user1.id), true) + assert_equal(ticket1.online_notification_seen_state(@agent_user2.id), true) end @@ -496,7 +493,7 @@ class OnlineNotificationTest < ActiveSupport::TestCase object: 'Ticket', o_id: 123, seen: false, - user_id: agent_user1.id, + user_id: @agent_user1.id, created_by_id: 1, updated_by_id: 1, created_at: Time.zone.now - 10.months, @@ -507,7 +504,7 @@ class OnlineNotificationTest < ActiveSupport::TestCase object: 'Ticket', o_id: 123, seen: true, - user_id: agent_user1.id, + user_id: @agent_user1.id, created_by_id: 1, updated_by_id: 1, created_at: Time.zone.now - 10.months, @@ -518,7 +515,7 @@ class OnlineNotificationTest < ActiveSupport::TestCase object: 'Ticket', o_id: 123, seen: false, - user_id: agent_user1.id, + user_id: @agent_user1.id, created_by_id: 1, updated_by_id: 1, created_at: Time.zone.now - 2.days, @@ -529,9 +526,9 @@ class OnlineNotificationTest < ActiveSupport::TestCase object: 'Ticket', o_id: 123, seen: true, - user_id: agent_user1.id, - created_by_id: agent_user1.id, - updated_by_id: agent_user1.id, + user_id: @agent_user1.id, + created_by_id: @agent_user1.id, + updated_by_id: @agent_user1.id, created_at: Time.zone.now - 2.days, updated_at: Time.zone.now - 2.days, ) @@ -540,9 +537,9 @@ class OnlineNotificationTest < ActiveSupport::TestCase object: 'Ticket', o_id: 123, seen: true, - user_id: agent_user1.id, - created_by_id: agent_user2.id, - updated_by_id: agent_user2.id, + user_id: @agent_user1.id, + created_by_id: @agent_user2.id, + updated_by_id: @agent_user2.id, created_at: Time.zone.now - 2.days, updated_at: Time.zone.now - 2.days, ) @@ -551,9 +548,9 @@ class OnlineNotificationTest < ActiveSupport::TestCase object: 'Ticket', o_id: 123, seen: true, - user_id: agent_user1.id, - created_by_id: agent_user1.id, - updated_by_id: agent_user1.id, + user_id: @agent_user1.id, + created_by_id: @agent_user1.id, + updated_by_id: @agent_user1.id, created_at: Time.zone.now - 5.minutes, updated_at: Time.zone.now - 5.minutes, ) @@ -562,9 +559,9 @@ class OnlineNotificationTest < ActiveSupport::TestCase object: 'Ticket', o_id: 123, seen: true, - user_id: agent_user1.id, - created_by_id: agent_user2.id, - updated_by_id: agent_user2.id, + user_id: @agent_user1.id, + created_by_id: @agent_user2.id, + updated_by_id: @agent_user2.id, created_at: Time.zone.now - 5.minutes, updated_at: Time.zone.now - 5.minutes, ) diff --git a/test/unit/session_basic_test.rb b/test/unit/session_basic_test.rb index 58d314716..3e239a2df 100644 --- a/test/unit/session_basic_test.rb +++ b/test/unit/session_basic_test.rb @@ -2,11 +2,11 @@ require 'test_helper' class SessionBasicTest < ActiveSupport::TestCase - test 'aaa - setup' do + setup do user = User.lookup(id: 1) roles = Role.where(name: %w(Agent Admin)) user.roles = roles - user.save + user.save! end test 'b cache' do diff --git a/test/unit/session_collections_test.rb b/test/unit/session_collections_test.rb index d6883d715..08b5b61d5 100644 --- a/test/unit/session_collections_test.rb +++ b/test/unit/session_collections_test.rb @@ -99,7 +99,7 @@ class SessionCollectionsTest < ActiveSupport::TestCase # next check should be empty result1 = collection_client1.push assert(result1.empty?, 'check collections - recall') - sleep 0.4 + travel 0.4.seconds result2 = collection_client2.push assert(result2.empty?, 'check collections - recall') result3 = collection_client3.push @@ -108,13 +108,13 @@ class SessionCollectionsTest < ActiveSupport::TestCase # change collection group = Group.first group.touch - travel 3.seconds + travel 4.seconds # get whole collections result1 = collection_client1.push assert(result1, 'check collections - after touch') assert(check_if_collection_exists(result1, :Group), 'check collections - after touch') - sleep 0.1 + travel 0.1.seconds result2 = collection_client2.push assert(result2, 'check collections - after touch') assert(check_if_collection_exists(result2, :Group), 'check collections - after touch') @@ -123,7 +123,7 @@ class SessionCollectionsTest < ActiveSupport::TestCase assert(check_if_collection_exists(result3, :Group), 'check collections - after touch') # next check should be empty - sleep 0.5 + travel 0.5.seconds result1 = collection_client1.push assert(result1.empty?, 'check collections - recall') result2 = collection_client2.push diff --git a/test/unit/session_enhanced_test.rb b/test/unit/session_enhanced_test.rb index f191b5f40..9ce8c0ece 100644 --- a/test/unit/session_enhanced_test.rb +++ b/test/unit/session_enhanced_test.rb @@ -47,9 +47,9 @@ class SessionEnhancedTest < ActiveSupport::TestCase agent3.save # create sessions - client_id1 = '1234' - client_id2 = '123456' - client_id3 = 'abc' + client_id1 = 'a1234' + client_id2 = 'a123456' + client_id3 = 'aabc' Sessions.destroy(client_id1) Sessions.destroy(client_id2) Sessions.destroy(client_id3) @@ -145,8 +145,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase jobs = Thread.new { Sessions.jobs } - sleep 3 - #jobs.join + sleep 6 # check client threads assert(Sessions.thread_client_exists?(client_id1), 'check if client is running') @@ -154,8 +153,9 @@ class SessionEnhancedTest < ActiveSupport::TestCase assert(Sessions.thread_client_exists?(client_id3), 'check if client is running') # check if session still exists after idle cleanup - sleep 4 + travel 10.seconds client_ids = Sessions.destroy_idle_sessions(2) + travel 2.seconds # check client sessions assert(!Sessions.session_exists?(client_id1), 'check if session is removed') @@ -171,7 +171,8 @@ class SessionEnhancedTest < ActiveSupport::TestCase # exit jobs jobs.exit - + jobs.join + travel_back end test 'b check client and backends' do @@ -196,7 +197,6 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent1.roles = roles agent1.save agent2 = User.create_or_update( login: 'session-agent-2', @@ -209,16 +209,29 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent2.roles = roles agent2.save + agent3 = User.create_or_update( + login: 'session-agent-3', + firstname: 'Session', + lastname: 'Agent 3', + email: 'session-agent3@example.com', + password: 'agentpw', + active: true, + organization: organization, + roles: roles, + groups: groups, + ) + agent3.save # create sessions - client_id1_0 = '1234-1' - client_id1_1 = '1234-2' - client_id2 = '123456' + client_id1_0 = 'b1234-1' + client_id1_1 = 'b1234-2' + client_id2 = 'b123456' + client_id3 = 'c123456' Sessions.destroy(client_id1_0) Sessions.destroy(client_id1_1) Sessions.destroy(client_id2) + Sessions.destroy(client_id3) # start jobs jobs = Thread.new { @@ -230,11 +243,16 @@ class SessionEnhancedTest < ActiveSupport::TestCase Sessions.create(client_id1_1, agent1.attributes, { type: 'websocket' }) sleep 3.2 Sessions.create(client_id2, agent2.attributes, { type: 'ajax' }) + sleep 3.2 + Sessions.create(client_id3, agent3.attributes, { type: 'websocket' }) # check if session exists assert(Sessions.session_exists?(client_id1_0), 'check if session exists') assert(Sessions.session_exists?(client_id1_1), 'check if session exists') assert(Sessions.session_exists?(client_id2), 'check if session exists') + assert(Sessions.session_exists?(client_id3), 'check if session exists') + + travel 8.seconds sleep 8 # check collections @@ -245,6 +263,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase assert_if_collection_reset_message_exists(client_id1_0, collections, 'init') assert_if_collection_reset_message_exists(client_id1_1, collections, 'init') assert_if_collection_reset_message_exists(client_id2, collections, 'init') + assert_if_collection_reset_message_exists(client_id3, collections, 'init') collections = { 'Group' => nil, @@ -253,7 +272,9 @@ class SessionEnhancedTest < ActiveSupport::TestCase assert_if_collection_reset_message_exists(client_id1_0, collections, 'init2') assert_if_collection_reset_message_exists(client_id1_1, collections, 'init2') assert_if_collection_reset_message_exists(client_id2, collections, 'init2') + assert_if_collection_reset_message_exists(client_id3, collections, 'init2') + travel 8.seconds sleep 8 collections = { @@ -263,12 +284,14 @@ class SessionEnhancedTest < ActiveSupport::TestCase assert_if_collection_reset_message_exists(client_id1_0, collections, 'init3') assert_if_collection_reset_message_exists(client_id1_1, collections, 'init3') assert_if_collection_reset_message_exists(client_id2, collections, 'init3') + assert_if_collection_reset_message_exists(client_id3, collections, 'init3') # change collection group = Group.first group.touch - sleep 10 + travel 12.seconds + sleep 12 # check collections collections = { @@ -278,16 +301,23 @@ class SessionEnhancedTest < ActiveSupport::TestCase assert_if_collection_reset_message_exists(client_id1_0, collections, 'update') assert_if_collection_reset_message_exists(client_id1_1, collections, 'update') assert_if_collection_reset_message_exists(client_id2, collections, 'update') + assert_if_collection_reset_message_exists(client_id3, collections, 'update') # check if session still exists after idle cleanup - sleep 4 - client_ids = Sessions.destroy_idle_sessions(3) + travel 10.seconds + client_ids = Sessions.destroy_idle_sessions(2) + travel 2.seconds # check client sessions assert(!Sessions.session_exists?(client_id1_0), 'check if session is removed') assert(!Sessions.session_exists?(client_id1_1), 'check if session is removed') assert(!Sessions.session_exists?(client_id2), 'check if session is removed') + assert(!Sessions.session_exists?(client_id3), 'check if session is removed') + # exit jobs + jobs.exit + jobs.join + travel_back end def assert_if_collection_reset_message_exists(client_id, collections_orig, type) diff --git a/test/unit/ticket_customer_organization_update_test.rb b/test/unit/ticket_customer_organization_update_test.rb index a13af3b34..8327048e9 100644 --- a/test/unit/ticket_customer_organization_update_test.rb +++ b/test/unit/ticket_customer_organization_update_test.rb @@ -2,15 +2,11 @@ require 'test_helper' class TicketCustomerOrganizationUpdateTest < ActiveSupport::TestCase - agent1 = nil - organization1 = nil - customer1 = nil - test 'aaa - setup' do - # create base + setup do groups = Group.where(name: 'Users') roles = Role.where(name: 'Agent') - agent1 = User.create_or_update( + @agent1 = User.create_or_update( login: 'ticket-customer-organization-update-agent1@example.com', firstname: 'Notification', lastname: 'Agent1', @@ -24,20 +20,20 @@ class TicketCustomerOrganizationUpdateTest < ActiveSupport::TestCase created_by_id: 1, ) roles = Role.where(name: 'Customer') - organization1 = Organization.create_if_not_exists( + @organization1 = Organization.create_if_not_exists( name: 'Customer Organization Update', updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - customer1 = User.create_or_update( + @customer1 = User.create_or_update( login: 'ticket-customer-organization-update-customer1@example.com', firstname: 'Notification', lastname: 'Customer1', email: 'ticket-customer-organization-update-customer1@example.com', password: 'customerpw', active: true, - organization_id: organization1.id, + organization_id: @organization1.id, roles: roles, updated_at: '2015-02-05 16:37:00', updated_by_id: 1, @@ -50,32 +46,32 @@ class TicketCustomerOrganizationUpdateTest < ActiveSupport::TestCase ticket = Ticket.create( title: "some title1\n äöüß", group: Group.lookup(name: 'Users'), - customer_id: customer1.id, - owner_id: agent1.id, + customer_id: @customer1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, created_by_id: 1, ) assert(ticket, 'ticket created') - assert_equal(customer1.id, ticket.customer.id) - assert_equal(organization1.id, ticket.organization.id) + assert_equal(@customer1.id, ticket.customer.id) + assert_equal(@organization1.id, ticket.organization.id) # update customer organization - customer1.organization_id = nil - customer1.save + @customer1.organization_id = nil + @customer1.save! # verify ticket - ticket = Ticket.find(ticket.id) + ticket.reload assert_nil(ticket.organization_id) # update customer organization - customer1.organization_id = organization1.id - customer1.save + @customer1.organization_id = @organization1.id + @customer1.save! # verify ticket - ticket = Ticket.find(ticket.id) - assert_equal(organization1.id, ticket.organization_id) + ticket.reload + assert_equal(@organization1.id, ticket.organization_id) ticket.destroy end diff --git a/test/unit/ticket_notification_test.rb b/test/unit/ticket_notification_test.rb index 108e45de7..9d6ae786c 100644 --- a/test/unit/ticket_notification_test.rb +++ b/test/unit/ticket_notification_test.rb @@ -2,10 +2,7 @@ require 'test_helper' class TicketNotificationTest < ActiveSupport::TestCase - agent1 = nil - agent2 = nil - customer = nil - test 'aaa - setup' do + setup do Trigger.create_or_update( name: 'auto reply - new ticket', condition: { @@ -45,7 +42,7 @@ class TicketNotificationTest < ActiveSupport::TestCase updated_by_id: 1, ) - # create agent1 & agent2 + # create @agent1 & @agent2 Group.create_or_update( name: 'TicketNotificationTest', updated_by_id: 1, @@ -53,7 +50,7 @@ class TicketNotificationTest < ActiveSupport::TestCase ) groups = Group.where(name: 'TicketNotificationTest') roles = Role.where(name: 'Agent') - agent1 = User.create_or_update( + @agent1 = User.create_or_update( login: 'ticket-notification-agent1@example.com', firstname: 'Notification', lastname: 'Agent1', @@ -68,7 +65,7 @@ class TicketNotificationTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - agent2 = User.create_or_update( + @agent2 = User.create_or_update( login: 'ticket-notification-agent2@example.com', firstname: 'Notification', lastname: 'Agent2', @@ -90,9 +87,9 @@ class TicketNotificationTest < ActiveSupport::TestCase created_by_id: 1 ) - # create customer + # create @customer roles = Role.where(name: 'Customer') - customer = User.create_or_update( + @customer = User.create_or_update( login: 'ticket-notification-customer@example.com', firstname: 'Notification', lastname: 'Customer', @@ -110,16 +107,16 @@ class TicketNotificationTest < ActiveSupport::TestCase # create ticket in group ApplicationHandleInfo.current = 'scheduler.postmaster' - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some notification test 1', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -129,8 +126,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) assert(ticket1) @@ -138,22 +135,22 @@ class TicketNotificationTest < ActiveSupport::TestCase Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to @agent1 + @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) # create ticket in group ApplicationHandleInfo.current = 'application_server' - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some notification test 2', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -163,8 +160,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) assert(ticket1) @@ -172,25 +169,25 @@ class TicketNotificationTest < ActiveSupport::TestCase Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) end test 'ticket notification - simple' do # create ticket in group ApplicationHandleInfo.current = 'application_server' - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some notification test 3', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -200,8 +197,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) assert(ticket1, 'ticket created - ticket notification simple') @@ -209,25 +206,25 @@ class TicketNotificationTest < ActiveSupport::TestCase Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to @agent1 + @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) # update ticket attributes ticket1.title = "#{ticket1.title} - #2" ticket1.priority = Ticket::Priority.lookup(name: '3 high') - ticket1.save + ticket1.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to @agent1 + @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) # add article to ticket - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some person', subject: 'some note', @@ -235,23 +232,23 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: true, sender: Ticket::Article::Sender.where(name: 'Agent').first, type: Ticket::Article::Type.where(name: 'note').first, - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to not to agent1 but to agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to not to @agent1 but to @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) # update ticket by user - ticket1.owner_id = agent1.id - ticket1.updated_by_id = agent1.id - ticket1.save - Ticket::Article.create( + ticket1.owner_id = @agent1.id + ticket1.updated_by_id = @agent1.id + ticket1.save! + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some person', subject: 'some note', @@ -259,30 +256,30 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: true, sender: Ticket::Article::Sender.where(name: 'Agent').first, type: Ticket::Article::Type.where(name: 'note').first, - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to not to agent1 but to agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to not to @agent1 but to @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) - # create ticket with agent1 as owner - ticket2 = Ticket.create( + # create ticket with @agent1 as owner + ticket2 = Ticket.create!( title: 'some notification test 4', group: Group.lookup(name: 'TicketNotificationTest'), customer_id: 2, - owner_id: agent1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket2.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -292,8 +289,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Agent').first, type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: agent1.id, - created_by_id: agent1.id, + updated_by_id: @agent1.id, + created_by_id: @agent1.id, ) # execute object transaction @@ -302,49 +299,49 @@ class TicketNotificationTest < ActiveSupport::TestCase assert(ticket2, 'ticket created') # verify notifications to no one - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, agent1, 'email'), ticket2.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, agent2, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) # update ticket ticket2.title = "#{ticket2.title} - #2" - ticket2.updated_by_id = agent1.id + ticket2.updated_by_id = @agent1.id ticket2.priority = Ticket::Priority.lookup(name: '3 high') - ticket2.save + ticket2.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) # verify notifications to none - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, agent1, 'email'), ticket2.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, agent2, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) # update ticket ticket2.title = "#{ticket2.title} - #3" - ticket2.updated_by_id = agent2.id + ticket2.updated_by_id = @agent2.id ticket2.priority = Ticket::Priority.lookup(name: '2 normal') - ticket2.save + ticket2.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 and not to agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, agent1, 'email'), ticket2.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, agent2, 'email'), ticket2.id) + # verify notifications to @agent1 and not to @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) - # create ticket with agent2 and agent1 as owner - ticket3 = Ticket.create( + # create ticket with @agent2 and @agent1 as owner + ticket3 = Ticket.create!( title: 'some notification test 5', group: Group.lookup(name: 'TicketNotificationTest'), customer_id: 2, - owner_id: agent1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: agent2.id, - created_by_id: agent2.id, + updated_by_id: @agent2.id, + created_by_id: @agent2.id, ) - article_inbound = Ticket::Article.create( + article_inbound = Ticket::Article.create!( ticket_id: ticket3.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -354,8 +351,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Agent').first, type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: agent2.id, - created_by_id: agent2.id, + updated_by_id: @agent2.id, + created_by_id: @agent2.id, ) # execute object transaction @@ -363,49 +360,49 @@ class TicketNotificationTest < ActiveSupport::TestCase Scheduler.worker(true) assert(ticket3, 'ticket created') - # verify notifications to agent1 and not to agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, agent1, 'email'), ticket3.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, agent2, 'email'), ticket3.id) + # verify notifications to @agent1 and not to @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, @agent1, 'email'), ticket3.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, @agent2, 'email'), ticket3.id) # update ticket ticket3.title = "#{ticket3.title} - #2" - ticket3.updated_by_id = agent1.id + ticket3.updated_by_id = @agent1.id ticket3.priority = Ticket::Priority.lookup(name: '3 high') - ticket3.save + ticket3.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) # verify notifications to no one - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, agent1, 'email'), ticket3.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, agent2, 'email'), ticket3.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, @agent1, 'email'), ticket3.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, @agent2, 'email'), ticket3.id) # update ticket ticket3.title = "#{ticket3.title} - #3" - ticket3.updated_by_id = agent2.id + ticket3.updated_by_id = @agent2.id ticket3.priority = Ticket::Priority.lookup(name: '2 normal') - ticket3.save + ticket3.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 and not to agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, agent1, 'email'), ticket3.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, agent2, 'email'), ticket3.id) + # verify notifications to @agent1 and not to @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, @agent1, 'email'), ticket3.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, @agent2, 'email'), ticket3.id) # update article / not notification should be sent article_inbound.internal = true - article_inbound.save + article_inbound.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications not to agent1 and not to agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, agent1, 'email'), ticket3.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, agent2, 'email'), ticket3.id) + # verify notifications not to @agent1 and not to @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, @agent1, 'email'), ticket3.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, @agent2, 'email'), ticket3.id) delete = ticket1.destroy assert(delete, 'ticket1 destroy') @@ -421,16 +418,16 @@ class TicketNotificationTest < ActiveSupport::TestCase test 'ticket notification - no notification' do # create ticket in group - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some notification test 1 - no notification', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -440,8 +437,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) assert(ticket1, 'ticket created - ticket no notification') @@ -449,42 +446,42 @@ class TicketNotificationTest < ActiveSupport::TestCase Observer::Transaction.commit(disable_notification: true) Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) end test 'ticket notification - z preferences tests' do - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = false - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = false - agent1.save + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = false + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = false + @agent1.save! - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent2.save + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent2.save! # create ticket in group ApplicationHandleInfo.current = 'scheduler.postmaster' - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some notification test - z preferences tests 1', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -494,43 +491,43 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) # update ticket attributes ticket1.title = "#{ticket1.title} - #2" ticket1.priority = Ticket::Priority.lookup(name: '3 high') - ticket1.save + ticket1.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, agent1, 'email'), ticket1.id) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, agent2, 'email'), ticket1.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent1, 'email'), ticket1.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket1, @agent2, 'email'), ticket1.id) # create ticket in group - ticket2 = Ticket.create( + ticket2 = Ticket.create!( title: 'some notification test - z preferences tests 2', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, - owner: agent1, + customer: @customer, + owner: @agent1, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket2.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -540,43 +537,43 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, agent1, 'email'), ticket2.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, agent2, 'email'), ticket2.id) + # verify notifications to @agent1 + @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) # update ticket attributes ticket2.title = "#{ticket2.title} - #2" ticket2.priority = Ticket::Priority.lookup(name: '3 high') - ticket2.save + ticket2.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, agent1, 'email'), ticket2.id) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, agent2, 'email'), ticket2.id) + # verify notifications to @agent1 + @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) # create ticket in group - ticket3 = Ticket.create( + ticket3 = Ticket.create!( title: 'some notification test - z preferences tests 3', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, - owner: agent2, + customer: @customer, + owner: @agent2, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket3.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -586,61 +583,61 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, agent1, 'email'), ticket3.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, agent2, 'email'), ticket3.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, @agent1, 'email'), ticket3.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket3, @agent2, 'email'), ticket3.id) # update ticket attributes ticket3.title = "#{ticket3.title} - #2" ticket3.priority = Ticket::Priority.lookup(name: '3 high') - ticket3.save + ticket3.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, agent1, 'email'), ticket3.id) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, agent2, 'email'), ticket3.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket3, @agent1, 'email'), ticket3.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket3, @agent2, 'email'), ticket3.id) - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent1.preferences['notification_config']['group_ids'] = [Group.lookup(name: 'TicketNotificationTest').id.to_s] - agent1.save + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent1.preferences['notification_config']['group_ids'] = [Group.lookup(name: 'TicketNotificationTest').id.to_s] + @agent1.save! - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent1.preferences['notification_config']['group_ids'] = ['-'] - agent2.save + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent1.preferences['notification_config']['group_ids'] = ['-'] + @agent2.save! # create ticket in group ApplicationHandleInfo.current = 'scheduler.postmaster' - ticket4 = Ticket.create( + ticket4 = Ticket.create!( title: 'some notification test - z preferences tests 4', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket4.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -650,61 +647,61 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket4, agent1, 'email'), ticket4.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket4, agent2, 'email'), ticket4.id) + # verify notifications to @agent1 + @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket4, @agent1, 'email'), ticket4.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket4, @agent2, 'email'), ticket4.id) # update ticket attributes ticket4.title = "#{ticket4.title} - #2" ticket4.priority = Ticket::Priority.lookup(name: '3 high') - ticket4.save + ticket4.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket4, agent1, 'email'), ticket4.id) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket4, agent2, 'email'), ticket4.id) + # verify notifications to @agent1 + @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket4, @agent1, 'email'), ticket4.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket4, @agent2, 'email'), ticket4.id) - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent1.preferences['notification_config']['group_ids'] = [Group.lookup(name: 'TicketNotificationTest').id.to_s] - agent1.save + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent1.preferences['notification_config']['group_ids'] = [Group.lookup(name: 'TicketNotificationTest').id.to_s] + @agent1.save! - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent2.preferences['notification_config']['group_ids'] = [99] - agent2.save + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent2.preferences['notification_config']['group_ids'] = [99] + @agent2.save! # create ticket in group ApplicationHandleInfo.current = 'scheduler.postmaster' - ticket5 = Ticket.create( + ticket5 = Ticket.create!( title: 'some notification test - z preferences tests 5', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket5.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -714,62 +711,62 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket5, agent1, 'email'), ticket5.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket5, agent2, 'email'), ticket5.id) + # verify notifications to @agent1 + @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket5, @agent1, 'email'), ticket5.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket5, @agent2, 'email'), ticket5.id) # update ticket attributes ticket5.title = "#{ticket5.title} - #2" ticket5.priority = Ticket::Priority.lookup(name: '3 high') - ticket5.save + ticket5.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket5, agent1, 'email'), ticket5.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket5, agent2, 'email'), ticket5.id) + # verify notifications to @agent1 + @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket5, @agent1, 'email'), ticket5.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket5, @agent2, 'email'), ticket5.id) - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent1.preferences['notification_config']['group_ids'] = [999] - agent1.save + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent1.preferences['notification_config']['group_ids'] = [999] + @agent1.save! - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent2.preferences['notification_config']['group_ids'] = [999] - agent2.save + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent2.preferences['notification_config']['group_ids'] = [999] + @agent2.save! # create ticket in group ApplicationHandleInfo.current = 'scheduler.postmaster' - ticket6 = Ticket.create( + ticket6 = Ticket.create!( title: 'some notification test - z preferences tests 6', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, - owner: agent1, + customer: @customer, + owner: @agent1, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket6.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -779,74 +776,74 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket6, agent1, 'email'), ticket6.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket6, agent1, 'online'), ticket6.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, agent2, 'email'), ticket6.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, agent2, 'online'), ticket6.id) + # verify notifications to @agent1 + @agent2 + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket6, @agent1, 'email'), ticket6.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket6, @agent1, 'online'), ticket6.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, @agent2, 'email'), ticket6.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, @agent2, 'online'), ticket6.id) # update ticket attributes ticket6.title = "#{ticket6.title} - #2" ticket6.priority = Ticket::Priority.lookup(name: '3 high') - ticket6.save + ticket6.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket6, agent1, 'email'), ticket6.id) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket6, agent1, 'online'), ticket6.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, agent2, 'email'), ticket6.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, agent2, 'online'), ticket6.id) + # verify notifications to @agent1 + @agent2 + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket6, @agent1, 'email'), ticket6.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket6, @agent1, 'online'), ticket6.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, @agent2, 'email'), ticket6.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket6, @agent2, 'online'), ticket6.id) - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent1.preferences['notification_config']['matrix']['create']['channel']['email'] = false - agent1.preferences['notification_config']['matrix']['create']['channel']['online'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true - agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent1.preferences['notification_config']['matrix']['update']['channel']['email'] = false - agent1.preferences['notification_config']['matrix']['update']['channel']['online'] = true - agent1.preferences['notification_config']['group_ids'] = [999] - agent1.save + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent1.preferences['notification_config']['matrix']['create']['channel']['email'] = false + @agent1.preferences['notification_config']['matrix']['create']['channel']['online'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true + @agent1.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent1.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent1.preferences['notification_config']['matrix']['update']['channel']['email'] = false + @agent1.preferences['notification_config']['matrix']['update']['channel']['online'] = true + @agent1.preferences['notification_config']['group_ids'] = [999] + @agent1.save! - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true - agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true - agent2.preferences['notification_config']['matrix']['create']['channel']['email'] = false - agent2.preferences['notification_config']['matrix']['create']['channel']['online'] = true - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true - agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false - agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true - agent2.preferences['notification_config']['matrix']['update']['channel']['email'] = false - agent2.preferences['notification_config']['matrix']['update']['channel']['online'] = true - agent2.preferences['notification_config']['group_ids'] = [999] - agent2.save + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_me'] = true + @agent2.preferences['notification_config']['matrix']['create']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['create']['criteria']['no'] = true + @agent2.preferences['notification_config']['matrix']['create']['channel']['email'] = false + @agent2.preferences['notification_config']['matrix']['create']['channel']['online'] = true + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_me'] = true + @agent2.preferences['notification_config']['matrix']['update']['criteria']['owned_by_nobody'] = false + @agent2.preferences['notification_config']['matrix']['update']['criteria']['no'] = true + @agent2.preferences['notification_config']['matrix']['update']['channel']['email'] = false + @agent2.preferences['notification_config']['matrix']['update']['channel']['online'] = true + @agent2.preferences['notification_config']['group_ids'] = [999] + @agent2.save! # create ticket in group ApplicationHandleInfo.current = 'scheduler.postmaster' - ticket7 = Ticket.create( + ticket7 = Ticket.create!( title: 'some notification test - z preferences tests 7', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, - owner: agent1, + customer: @customer, + owner: @agent1, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket7.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -856,50 +853,50 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, agent1, 'email'), ticket7.id) - assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket7, agent1, 'online'), ticket7.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, agent2, 'email'), ticket7.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, agent2, 'online'), ticket7.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, @agent1, 'email'), ticket7.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket7, @agent1, 'online'), ticket7.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, @agent2, 'email'), ticket7.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, @agent2, 'online'), ticket7.id) # update ticket attributes ticket7.title = "#{ticket7.title} - #2" ticket7.priority = Ticket::Priority.lookup(name: '3 high') - ticket7.save + ticket7.save! # execute object transaction Observer::Transaction.commit Scheduler.worker(true) - # verify notifications to agent1 + agent2 - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, agent1, 'email'), ticket7.id) - assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket7, agent1, 'online'), ticket7.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, agent2, 'email'), ticket7.id) - assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, agent2, 'online'), ticket7.id) + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, @agent1, 'email'), ticket7.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket7, @agent1, 'online'), ticket7.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, @agent2, 'email'), ticket7.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket7, @agent2, 'online'), ticket7.id) end test 'ticket notification events' do # create ticket in group - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some notification event test 1', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - Ticket::Article.create( + Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -909,8 +906,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) assert(ticket1, 'ticket created') @@ -920,7 +917,7 @@ class TicketNotificationTest < ActiveSupport::TestCase # update ticket attributes ticket1.title = "#{ticket1.title} - #2" ticket1.priority = Ticket::Priority.lookup(name: '3 high') - ticket1.save + ticket1.save! list = EventBuffer.list('transaction') list_objects = Observer::Transaction.get_uniq_changes(list) @@ -934,7 +931,7 @@ class TicketNotificationTest < ActiveSupport::TestCase # update ticket attributes ticket1.title = "#{ticket1.title} - #3" ticket1.priority = Ticket::Priority.lookup(name: '1 low') - ticket1.save + ticket1.save! list = EventBuffer.list('transaction') list_objects = Observer::Transaction.get_uniq_changes(list) @@ -950,16 +947,16 @@ class TicketNotificationTest < ActiveSupport::TestCase test 'ticket notification template' do # create ticket in group - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some notification template test 1 Bobs\'s resumé', group: Group.lookup(name: 'TicketNotificationTest'), - customer: customer, + customer: @customer, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) - article = Ticket::Article.create( + article = Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -969,8 +966,8 @@ class TicketNotificationTest < ActiveSupport::TestCase internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer.id, - created_by_id: customer.id, + updated_by_id: @customer.id, + created_by_id: @customer.id, ) assert(ticket1, 'ticket created - ticket notification template') @@ -986,7 +983,7 @@ class TicketNotificationTest < ActiveSupport::TestCase ) # check changed attributes - human_changes = bg.human_changes(agent2, ticket1) + human_changes = bg.human_changes(@agent2, ticket1) assert(human_changes['Priority'], 'Check if attributes translated based on ObjectManager::Attribute') assert(human_changes['Pending till'], 'Check if attributes translated based on ObjectManager::Attribute') assert_equal('1 low', human_changes['Priority'][0]) @@ -999,12 +996,12 @@ class TicketNotificationTest < ActiveSupport::TestCase # en notification result = NotificationFactory::Mailer.template( - locale: agent2.preferences[:locale], + locale: @agent2.preferences[:locale], template: 'ticket_update', objects: { ticket: ticket1, article: article, - recipient: agent2, + recipient: @agent2, changes: human_changes, }, ) @@ -1018,7 +1015,7 @@ class TicketNotificationTest < ActiveSupport::TestCase assert_no_match(/pending_till/, result[:body]) assert_no_match(/i18n/, result[:body]) - human_changes = bg.human_changes(agent1, ticket1) + human_changes = bg.human_changes(@agent1, ticket1) assert(human_changes['Priority'], 'Check if attributes translated based on ObjectManager::Attribute') assert(human_changes['Pending till'], 'Check if attributes translated based on ObjectManager::Attribute') assert_equal('1 niedrig', human_changes['Priority'][0]) @@ -1031,12 +1028,12 @@ class TicketNotificationTest < ActiveSupport::TestCase # de notification result = NotificationFactory::Mailer.template( - locale: agent1.preferences[:locale], + locale: @agent1.preferences[:locale], template: 'ticket_update', objects: { ticket: ticket1, article: article, - recipient: agent1, + recipient: @agent1, changes: human_changes, }, ) @@ -1059,11 +1056,11 @@ class TicketNotificationTest < ActiveSupport::TestCase title: ['some notification template test old 1', 'some notification template test 1 #2'], priority_id: [2, 3], }, - user_id: customer.id, + user_id: @customer.id, ) # check changed attributes - human_changes = bg.human_changes(agent1, ticket1) + human_changes = bg.human_changes(@agent1, ticket1) assert(human_changes['Title'], 'Check if attributes translated based on ObjectManager::Attribute') assert(human_changes['Priority'], 'Check if attributes translated based on ObjectManager::Attribute') assert_equal('2 normal', human_changes['Priority'][0]) @@ -1076,12 +1073,12 @@ class TicketNotificationTest < ActiveSupport::TestCase # de notification result = NotificationFactory::Mailer.template( - locale: agent1.preferences[:locale], + locale: @agent1.preferences[:locale], template: 'ticket_update', objects: { ticket: ticket1, article: article, - recipient: agent1, + recipient: @agent1, changes: human_changes, } ) @@ -1097,16 +1094,16 @@ class TicketNotificationTest < ActiveSupport::TestCase assert_match(/2 normal/, result[:body]) assert_match(/aktualisier/, result[:body]) - human_changes = bg.human_changes(agent2, ticket1) + human_changes = bg.human_changes(@agent2, ticket1) # en notification result = NotificationFactory::Mailer.template( - locale: agent2.preferences[:locale], + locale: @agent2.preferences[:locale], template: 'ticket_update', objects: { ticket: ticket1, article: article, - recipient: agent2, + recipient: @agent2, changes: human_changes, } ) @@ -1126,8 +1123,4 @@ class TicketNotificationTest < ActiveSupport::TestCase end - test 'zzz - cleanup' do - Trigger.destroy_all - end - end diff --git a/test/unit/ticket_overview_test.rb b/test/unit/ticket_overview_test.rb index 555ebf023..8c1acc94d 100644 --- a/test/unit/ticket_overview_test.rb +++ b/test/unit/ticket_overview_test.rb @@ -2,31 +2,16 @@ require 'test_helper' class TicketOverviewTest < ActiveSupport::TestCase - agent1 = nil - agent2 = nil - organization_id = nil - customer1 = nil - customer2 = nil - customer3 = nil - overview1 = nil - overview2 = nil - overview3 = nil - overview4 = nil - overview5 = nil - overview6 = nil - overview7 = nil - overview8 = nil - test 'aaa - setup' do - # create base + setup do group = Group.create_or_update( name: 'OverviewTest', updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - roles = Role.where(name: 'Agent') - agent1 = User.create_or_update( + roles = Role.where(name: 'Agent') + @agent1 = User.create_or_update( login: 'ticket-overview-agent1@example.com', firstname: 'Overview', lastname: 'Agent1', @@ -39,7 +24,7 @@ class TicketOverviewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - agent2 = User.create_or_update( + @agent2 = User.create_or_update( login: 'ticket-overview-agent2@example.com', firstname: 'Overview', lastname: 'Agent2', @@ -59,7 +44,7 @@ class TicketOverviewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - customer1 = User.create_or_update( + @customer1 = User.create_or_update( login: 'ticket-overview-customer1@example.com', firstname: 'Overview', lastname: 'Customer1', @@ -72,7 +57,7 @@ class TicketOverviewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - customer2 = User.create_or_update( + @customer2 = User.create_or_update( login: 'ticket-overview-customer2@example.com', firstname: 'Overview', lastname: 'Customer2', @@ -85,7 +70,7 @@ class TicketOverviewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - customer3 = User.create_or_update( + @customer3 = User.create_or_update( login: 'ticket-overview-customer3@example.com', firstname: 'Overview', lastname: 'Customer3', @@ -101,7 +86,7 @@ class TicketOverviewTest < ActiveSupport::TestCase Overview.destroy_all UserInfo.current_user_id = 1 overview_role = Role.find_by(name: 'Agent') - overview1 = Overview.create_or_update( + @overview1 = Overview.create_or_update( name: 'My assigned Tickets', link: 'my_assigned', prio: 1000, @@ -109,7 +94,7 @@ class TicketOverviewTest < ActiveSupport::TestCase condition: { 'ticket.state_id' => { operator: 'is', - value: [ 1, 2, 3, 7 ], + value: [1, 2, 3, 7], }, 'ticket.owner_id' => { operator: 'is', @@ -128,7 +113,7 @@ class TicketOverviewTest < ActiveSupport::TestCase }, ) - overview2 = Overview.create_or_update( + @overview2 = Overview.create_or_update( name: 'Unassigned & Open', link: 'all_unassigned', prio: 1010, @@ -154,16 +139,16 @@ class TicketOverviewTest < ActiveSupport::TestCase view_mode_default: 's', }, ) - overview3 = Overview.create_or_update( + @overview3 = Overview.create_or_update( name: 'My Tickets 2', link: 'my_tickets_2', prio: 1020, role_ids: [overview_role.id], - user_ids: [agent2.id], + user_ids: [@agent2.id], condition: { 'ticket.state_id' => { operator: 'is', - value: [ 1, 2, 3, 7 ], + value: [1, 2, 3, 7], }, 'ticket.owner_id' => { operator: 'is', @@ -181,12 +166,12 @@ class TicketOverviewTest < ActiveSupport::TestCase view_mode_default: 's', }, ) - overview4 = Overview.create_or_update( + @overview4 = Overview.create_or_update( name: 'My Tickets only with Note', link: 'my_tickets_onyl_with_note', prio: 1030, role_ids: [overview_role.id], - user_ids: [agent1.id], + user_ids: [@agent1.id], condition: { 'article.type_id' => { operator: 'is', @@ -210,7 +195,7 @@ class TicketOverviewTest < ActiveSupport::TestCase ) overview_role = Role.find_by(name: 'Customer') - overview5 = Overview.create_or_update( + @overview5 = Overview.create_or_update( name: 'My Tickets', link: 'my_tickets', prio: 1100, @@ -218,7 +203,7 @@ class TicketOverviewTest < ActiveSupport::TestCase condition: { 'ticket.state_id' => { operator: 'is', - value: [ 1, 2, 3, 4, 6, 7 ], + value: [1, 2, 3, 4, 6, 7], }, 'ticket.customer_id' => { operator: 'is', @@ -236,7 +221,7 @@ class TicketOverviewTest < ActiveSupport::TestCase view_mode_default: 's', }, ) - overview6 = Overview.create_or_update( + @overview6 = Overview.create_or_update( name: 'My Organization Tickets', link: 'my_organization_tickets', prio: 1200, @@ -245,7 +230,7 @@ class TicketOverviewTest < ActiveSupport::TestCase condition: { 'ticket.state_id' => { operator: 'is', - value: [ 1, 2, 3, 4, 6, 7 ], + value: [1, 2, 3, 4, 6, 7], }, 'ticket.organization_id' => { operator: 'is', @@ -263,17 +248,17 @@ class TicketOverviewTest < ActiveSupport::TestCase view_mode_default: 's', }, ) - overview7 = Overview.create_or_update( + @overview7 = Overview.create_or_update( name: 'My Organization Tickets (open)', link: 'my_organization_tickets_open', prio: 1200, role_ids: [overview_role.id], - user_ids: [customer2.id], + user_ids: [@customer2.id], organization_shared: true, condition: { 'ticket.state_id' => { operator: 'is', - value: [ 1, 2, 3 ], + value: [1, 2, 3], }, 'ticket.organization_id' => { operator: 'is', @@ -293,7 +278,7 @@ class TicketOverviewTest < ActiveSupport::TestCase ) overview_role = Role.find_by(name: 'Admin') - overview8 = Overview.create_or_update( + @overview8 = Overview.create_or_update( name: 'Not Shown Admin', link: 'not_shown_admin', prio: 9900, @@ -301,7 +286,7 @@ class TicketOverviewTest < ActiveSupport::TestCase condition: { 'ticket.state_id' => { operator: 'is', - value: [ 1, 2, 3 ], + value: [1, 2, 3], }, }, order: { @@ -320,7 +305,7 @@ class TicketOverviewTest < ActiveSupport::TestCase test 'bbb overiview index' do result = Ticket::Overviews.all( - current_user: agent1, + current_user: @agent1, ) assert_equal(3, result.count) assert_equal('My assigned Tickets', result[0].name) @@ -328,7 +313,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal('My Tickets only with Note', result[2].name) result = Ticket::Overviews.all( - current_user: agent2, + current_user: @agent2, ) assert_equal(3, result.count) assert_equal('My assigned Tickets', result[0].name) @@ -336,14 +321,14 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal('My Tickets 2', result[2].name) result = Ticket::Overviews.all( - current_user: customer1, + current_user: @customer1, ) assert_equal(2, result.count) assert_equal('My Tickets', result[0].name) assert_equal('My Organization Tickets', result[1].name) result = Ticket::Overviews.all( - current_user: customer2, + current_user: @customer2, ) assert_equal(3, result.count) assert_equal('My Tickets', result[0].name) @@ -351,7 +336,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal('My Organization Tickets (open)', result[2].name) result = Ticket::Overviews.all( - current_user: customer3, + current_user: @customer3, ) assert_equal(1, result.count) assert_equal('My Tickets', result[0].name) @@ -362,7 +347,7 @@ class TicketOverviewTest < ActiveSupport::TestCase Ticket.destroy_all - result = Ticket::Overviews.index(agent1) + result = Ticket::Overviews.index(@agent1) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) @@ -379,7 +364,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert(result[2][:tickets].empty?) assert_equal(result[2][:count], 0) - result = Ticket::Overviews.index(agent2) + result = Ticket::Overviews.index(@agent2) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) @@ -395,7 +380,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[2][:tickets].class, Array) assert(result[2][:tickets].empty?) - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'overview test 1', group: Group.lookup(name: 'OverviewTest'), customer_id: 2, @@ -404,7 +389,7 @@ class TicketOverviewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - article1 = Ticket::Article.create( + article1 = Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -418,7 +403,7 @@ class TicketOverviewTest < ActiveSupport::TestCase created_by_id: 1, ) - result = Ticket::Overviews.index(agent1) + result = Ticket::Overviews.index(@agent1) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) @@ -436,7 +421,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert(result[2][:tickets].empty?) assert_equal(result[2][:count], 0) - result = Ticket::Overviews.index(agent2) + result = Ticket::Overviews.index(@agent2) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) @@ -453,7 +438,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert(result[2][:tickets].empty?) travel 1.second # because of mysql millitime issues - ticket2 = Ticket.create( + ticket2 = Ticket.create!( title: 'overview test 2', group: Group.lookup(name: 'OverviewTest'), customer_id: 2, @@ -462,7 +447,7 @@ class TicketOverviewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - article2 = Ticket::Article.create( + article2 = Ticket::Article.create!( ticket_id: ticket2.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -476,7 +461,7 @@ class TicketOverviewTest < ActiveSupport::TestCase created_by_id: 1, ) - result = Ticket::Overviews.index(agent1) + result = Ticket::Overviews.index(@agent1) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) @@ -495,7 +480,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert(result[2][:tickets].empty?) assert_equal(result[2][:count], 0) - result = Ticket::Overviews.index(agent2) + result = Ticket::Overviews.index(@agent2) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) @@ -511,10 +496,10 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[2][:tickets].class, Array) assert(result[2][:tickets].empty?) - ticket2.owner_id = agent1.id + ticket2.owner_id = @agent1.id ticket2.save! - result = Ticket::Overviews.index(agent1) + result = Ticket::Overviews.index(@agent1) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:tickets].class, Array) @@ -533,7 +518,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[2][:tickets][0][:id], ticket2.id) assert_equal(result[2][:count], 1) - result = Ticket::Overviews.index(agent2) + result = Ticket::Overviews.index(@agent2) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) @@ -550,7 +535,7 @@ class TicketOverviewTest < ActiveSupport::TestCase assert(result[2][:tickets].empty?) travel 1.second # because of mysql millitime issues - ticket3 = Ticket.create( + ticket3 = Ticket.create!( title: 'overview test 3', group: Group.lookup(name: 'OverviewTest'), customer_id: 2, @@ -559,7 +544,7 @@ class TicketOverviewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - article3 = Ticket::Article.create( + article3 = Ticket::Article.create!( ticket_id: ticket3.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -574,15 +559,15 @@ class TicketOverviewTest < ActiveSupport::TestCase ) travel_back - result = Ticket::Overviews.index(agent1) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent1) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:tickets].class, Array) assert_equal(result[0][:tickets][0][:id], ticket2.id) assert_equal(result[0][:count], 1) assert_equal(result[0][:tickets].class, Array) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) @@ -590,47 +575,47 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[1][:tickets][0][:id], ticket1.id) assert_equal(result[1][:tickets][1][:id], ticket3.id) assert_equal(result[1][:count], 2) - assert_equal(result[2][:overview][:id], overview4.id) + assert_equal(result[2][:overview][:id], @overview4.id) assert_equal(result[2][:overview][:name], 'My Tickets only with Note') assert_equal(result[2][:overview][:view], 'my_tickets_onyl_with_note') assert_equal(result[2][:tickets].class, Array) assert_equal(result[2][:tickets][0][:id], ticket2.id) assert_equal(result[2][:count], 1) - result = Ticket::Overviews.index(agent2) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent2) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) assert_equal(result[0][:tickets].class, Array) assert(result[0][:tickets].empty?) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) assert(result[1][:tickets].empty?) assert_equal(result[1][:count], 0) - assert_equal(result[2][:overview][:id], overview3.id) + assert_equal(result[2][:overview][:id], @overview3.id) assert_equal(result[2][:overview][:name], 'My Tickets 2') assert_equal(result[2][:overview][:view], 'my_tickets_2') assert_equal(result[2][:tickets].class, Array) assert(result[2][:tickets].empty?) - overview2.order = { + @overview2.order = { by: 'created_at', direction: 'DESC', } - overview2.save! + @overview2.save! - result = Ticket::Overviews.index(agent1) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent1) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:tickets].class, Array) assert_equal(result[0][:tickets][0][:id], ticket2.id) assert_equal(result[0][:count], 1) assert_equal(result[0][:tickets].class, Array) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) @@ -638,47 +623,47 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[1][:tickets][0][:id], ticket3.id) assert_equal(result[1][:tickets][1][:id], ticket1.id) assert_equal(result[1][:count], 2) - assert_equal(result[2][:overview][:id], overview4.id) + assert_equal(result[2][:overview][:id], @overview4.id) assert_equal(result[2][:overview][:name], 'My Tickets only with Note') assert_equal(result[2][:overview][:view], 'my_tickets_onyl_with_note') assert_equal(result[2][:tickets].class, Array) assert_equal(result[2][:tickets][0][:id], ticket2.id) assert_equal(result[2][:count], 1) - result = Ticket::Overviews.index(agent2) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent2) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) assert_equal(result[0][:tickets].class, Array) assert(result[0][:tickets].empty?) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) assert(result[1][:tickets].empty?) assert_equal(result[1][:count], 0) - assert_equal(result[2][:overview][:id], overview3.id) + assert_equal(result[2][:overview][:id], @overview3.id) assert_equal(result[2][:overview][:name], 'My Tickets 2') assert_equal(result[2][:overview][:view], 'my_tickets_2') assert_equal(result[2][:tickets].class, Array) assert(result[2][:tickets].empty?) - overview2.order = { + @overview2.order = { by: 'priority_id', direction: 'DESC', } - overview2.save! + @overview2.save! - result = Ticket::Overviews.index(agent1) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent1) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:tickets].class, Array) assert_equal(result[0][:tickets][0][:id], ticket2.id) assert_equal(result[0][:count], 1) assert_equal(result[0][:tickets].class, Array) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) @@ -686,47 +671,47 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[1][:tickets][0][:id], ticket1.id) assert_equal(result[1][:tickets][1][:id], ticket3.id) assert_equal(result[1][:count], 2) - assert_equal(result[2][:overview][:id], overview4.id) + assert_equal(result[2][:overview][:id], @overview4.id) assert_equal(result[2][:overview][:name], 'My Tickets only with Note') assert_equal(result[2][:overview][:view], 'my_tickets_onyl_with_note') assert_equal(result[2][:tickets].class, Array) assert_equal(result[2][:tickets][0][:id], ticket2.id) assert_equal(result[2][:count], 1) - result = Ticket::Overviews.index(agent2) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent2) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) assert_equal(result[0][:tickets].class, Array) assert(result[0][:tickets].empty?) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) assert(result[1][:tickets].empty?) assert_equal(result[1][:count], 0) - assert_equal(result[2][:overview][:id], overview3.id) + assert_equal(result[2][:overview][:id], @overview3.id) assert_equal(result[2][:overview][:name], 'My Tickets 2') assert_equal(result[2][:overview][:view], 'my_tickets_2') assert_equal(result[2][:tickets].class, Array) assert(result[2][:tickets].empty?) - overview2.order = { + @overview2.order = { by: 'priority_id', direction: 'ASC', } - overview2.save! + @overview2.save! - result = Ticket::Overviews.index(agent1) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent1) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:tickets].class, Array) assert_equal(result[0][:tickets][0][:id], ticket2.id) assert_equal(result[0][:count], 1) assert_equal(result[0][:tickets].class, Array) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) @@ -734,47 +719,47 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[1][:tickets][0][:id], ticket3.id) assert_equal(result[1][:tickets][1][:id], ticket1.id) assert_equal(result[1][:count], 2) - assert_equal(result[2][:overview][:id], overview4.id) + assert_equal(result[2][:overview][:id], @overview4.id) assert_equal(result[2][:overview][:name], 'My Tickets only with Note') assert_equal(result[2][:overview][:view], 'my_tickets_onyl_with_note') assert_equal(result[2][:tickets].class, Array) assert_equal(result[2][:tickets][0][:id], ticket2.id) assert_equal(result[2][:count], 1) - result = Ticket::Overviews.index(agent2) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent2) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) assert_equal(result[0][:tickets].class, Array) assert(result[0][:tickets].empty?) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) assert(result[1][:tickets].empty?) assert_equal(result[1][:count], 0) - assert_equal(result[2][:overview][:id], overview3.id) + assert_equal(result[2][:overview][:id], @overview3.id) assert_equal(result[2][:overview][:name], 'My Tickets 2') assert_equal(result[2][:overview][:view], 'my_tickets_2') assert_equal(result[2][:tickets].class, Array) assert(result[2][:tickets].empty?) - overview2.order = { + @overview2.order = { by: 'priority', direction: 'DESC', } - overview2.save! + @overview2.save! - result = Ticket::Overviews.index(agent1) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent1) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:tickets].class, Array) assert_equal(result[0][:tickets][0][:id], ticket2.id) assert_equal(result[0][:count], 1) assert_equal(result[0][:tickets].class, Array) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) @@ -782,47 +767,47 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[1][:tickets][0][:id], ticket1.id) assert_equal(result[1][:tickets][1][:id], ticket3.id) assert_equal(result[1][:count], 2) - assert_equal(result[2][:overview][:id], overview4.id) + assert_equal(result[2][:overview][:id], @overview4.id) assert_equal(result[2][:overview][:name], 'My Tickets only with Note') assert_equal(result[2][:overview][:view], 'my_tickets_onyl_with_note') assert_equal(result[2][:tickets].class, Array) assert_equal(result[2][:tickets][0][:id], ticket2.id) assert_equal(result[2][:count], 1) - result = Ticket::Overviews.index(agent2) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent2) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) assert_equal(result[0][:tickets].class, Array) assert(result[0][:tickets].empty?) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) assert(result[1][:tickets].empty?) assert_equal(result[1][:count], 0) - assert_equal(result[2][:overview][:id], overview3.id) + assert_equal(result[2][:overview][:id], @overview3.id) assert_equal(result[2][:overview][:name], 'My Tickets 2') assert_equal(result[2][:overview][:view], 'my_tickets_2') assert_equal(result[2][:tickets].class, Array) assert(result[2][:tickets].empty?) - overview2.order = { + @overview2.order = { by: 'priority', direction: 'ASC', } - overview2.save! + @overview2.save! - result = Ticket::Overviews.index(agent1) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent1) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:tickets].class, Array) assert_equal(result[0][:tickets][0][:id], ticket2.id) assert_equal(result[0][:count], 1) assert_equal(result[0][:tickets].class, Array) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) @@ -830,27 +815,27 @@ class TicketOverviewTest < ActiveSupport::TestCase assert_equal(result[1][:tickets][0][:id], ticket3.id) assert_equal(result[1][:tickets][1][:id], ticket1.id) assert_equal(result[1][:count], 2) - assert_equal(result[2][:overview][:id], overview4.id) + assert_equal(result[2][:overview][:id], @overview4.id) assert_equal(result[2][:overview][:name], 'My Tickets only with Note') assert_equal(result[2][:overview][:view], 'my_tickets_onyl_with_note') assert_equal(result[2][:tickets].class, Array) assert_equal(result[2][:tickets][0][:id], ticket2.id) assert_equal(result[2][:count], 1) - result = Ticket::Overviews.index(agent2) - assert_equal(result[0][:overview][:id], overview1.id) + result = Ticket::Overviews.index(@agent2) + assert_equal(result[0][:overview][:id], @overview1.id) assert_equal(result[0][:overview][:name], 'My assigned Tickets') assert_equal(result[0][:overview][:view], 'my_assigned') assert_equal(result[0][:count], 0) assert_equal(result[0][:tickets].class, Array) assert(result[0][:tickets].empty?) - assert_equal(result[1][:overview][:id], overview2.id) + assert_equal(result[1][:overview][:id], @overview2.id) assert_equal(result[1][:overview][:name], 'Unassigned & Open') assert_equal(result[1][:overview][:view], 'all_unassigned') assert_equal(result[1][:tickets].class, Array) assert(result[1][:tickets].empty?) assert_equal(result[1][:count], 0) - assert_equal(result[2][:overview][:id], overview3.id) + assert_equal(result[2][:overview][:id], @overview3.id) assert_equal(result[2][:overview][:name], 'My Tickets 2') assert_equal(result[2][:overview][:view], 'my_tickets_2') assert_equal(result[2][:tickets].class, Array) diff --git a/test/unit/ticket_ref_object_touch_test.rb b/test/unit/ticket_ref_object_touch_test.rb index 657d091cf..77bb2ed6c 100644 --- a/test/unit/ticket_ref_object_touch_test.rb +++ b/test/unit/ticket_ref_object_touch_test.rb @@ -2,16 +2,11 @@ require 'test_helper' class TicketRefObjectTouchTest < ActiveSupport::TestCase - agent1 = nil - organization1 = nil - customer1 = nil - customer2 = nil - test 'aaa - setup' do - # create base + setup do groups = Group.where(name: 'Users') roles = Role.where(name: 'Agent') - agent1 = User.create_or_update( + @agent1 = User.create_or_update( login: 'ticket-ref-object-update-agent1@example.com', firstname: 'Notification', lastname: 'Agent1', @@ -25,26 +20,26 @@ class TicketRefObjectTouchTest < ActiveSupport::TestCase created_by_id: 1, ) roles = Role.where(name: 'Customer') - organization1 = Organization.create_if_not_exists( + @organization1 = Organization.create_if_not_exists( name: 'Ref Object Update Org', updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - customer1 = User.create_or_update( + @customer1 = User.create_or_update( login: 'ticket-ref-object-update-customer1@example.com', firstname: 'Notification', lastname: 'Customer1', email: 'ticket-ref-object-update-customer1@example.com', password: 'customerpw', active: true, - organization_id: organization1.id, + organization_id: @organization1.id, roles: roles, updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - customer2 = User.create_or_update( + @customer2 = User.create_or_update( login: 'ticket-ref-object-update-customer2@example.com', firstname: 'Notification', lastname: 'Customer2', @@ -64,27 +59,27 @@ class TicketRefObjectTouchTest < ActiveSupport::TestCase ticket = Ticket.create( title: "some title1\n äöüß", group: Group.lookup(name: 'Users'), - customer_id: customer1.id, - owner_id: agent1.id, + customer_id: @customer1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, created_by_id: 1, ) assert(ticket, 'ticket created') - assert_equal(ticket.customer.id, customer1.id) - assert_equal(ticket.organization.id, organization1.id) + assert_equal(ticket.customer.id, @customer1.id) + assert_equal(ticket.organization.id, @organization1.id) # check if customer and organization has been touched - customer1 = User.find(customer1.id) - if customer1.updated_at > 3.seconds.ago + @customer1 = User.find(@customer1.id) + if @customer1.updated_at > 3.seconds.ago assert(true, 'customer1.updated_at has been updated') else assert(false, 'customer1.updated_at has not been updated') end - organization1 = Organization.find(organization1.id) - if organization1.updated_at > 3.seconds.ago + @organization1 = Organization.find(@organization1.id) + if @organization1.updated_at > 3.seconds.ago assert(true, 'organization1.updated_at has been updated') else assert(false, 'organization1.updated_at has not been updated') @@ -96,15 +91,15 @@ class TicketRefObjectTouchTest < ActiveSupport::TestCase assert(delete, 'ticket destroy') # check if customer and organization has been touched - customer1 = User.find(customer1.id) - if customer1.updated_at > 3.seconds.ago + @customer1.reload + if @customer1.updated_at > 3.seconds.ago assert(true, 'customer1.updated_at has been updated') else assert(false, 'customer1.updated_at has not been updated') end - organization1 = Organization.find(organization1.id) - if organization1.updated_at > 3.seconds.ago + @organization1.reload + if @organization1.updated_at > 3.seconds.ago assert(true, 'organization1.updated_at has been updated') else assert(false, 'organization1.updated_at has not been updated') @@ -118,27 +113,27 @@ class TicketRefObjectTouchTest < ActiveSupport::TestCase ticket = Ticket.create( title: "some title2\n äöüß", group: Group.lookup(name: 'Users'), - customer_id: customer2.id, - owner_id: agent1.id, + customer_id: @customer2.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), updated_by_id: 1, created_by_id: 1, ) assert(ticket, 'ticket created') - assert_equal(ticket.customer.id, customer2.id) + assert_equal(ticket.customer.id, @customer2.id) assert_nil(ticket.organization) # check if customer and organization has been touched - customer2 = User.find(customer2.id) - if customer2.updated_at > 3.seconds.ago + @customer2.reload + if @customer2.updated_at > 3.seconds.ago assert(true, 'customer2.updated_at has been updated') else assert(false, 'customer2.updated_at has not been updated') end - organization1 = Organization.find(organization1.id) - if organization1.updated_at > 3.seconds.ago + @organization1.reload + if @organization1.updated_at > 3.seconds.ago assert(false, 'organization1.updated_at has been updated') else assert(true, 'organization1.updated_at has not been updated') @@ -150,15 +145,15 @@ class TicketRefObjectTouchTest < ActiveSupport::TestCase assert(delete, 'ticket destroy') # check if customer and organization has been touched - customer2 = User.find(customer2.id) - if customer2.updated_at > 3.seconds.ago + @customer2.reload + if @customer2.updated_at > 3.seconds.ago assert(true, 'customer2.updated_at has been updated') else assert(false, 'customer2.updated_at has not been updated') end - organization1 = Organization.find(organization1.id) - if organization1.updated_at > 3.seconds.ago + @organization1.reload + if @organization1.updated_at > 3.seconds.ago assert(false, 'organization1.updated_at has been updated') else assert(true, 'organization1.updated_at has not been updated') diff --git a/test/unit/ticket_selector_test.rb b/test/unit/ticket_selector_test.rb index bece536c7..374172b09 100644 --- a/test/unit/ticket_selector_test.rb +++ b/test/unit/ticket_selector_test.rb @@ -2,24 +2,16 @@ require 'test_helper' class TicketSelectorTest < ActiveSupport::TestCase - agent1 = nil - agent2 = nil - group = nil - organization1 = nil - customer1 = nil - customer2 = nil - test 'aaa - setup' do - - # create base - group = Group.create_or_update( + setup do + @group = Group.create_or_update( name: 'SelectorTest', updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - roles = Role.where(name: 'Agent') - agent1 = User.create_or_update( + roles = Role.where(name: 'Agent') + @agent1 = User.create_or_update( login: 'ticket-selector-agent1@example.com', firstname: 'Notification', lastname: 'Agent1', @@ -27,12 +19,12 @@ class TicketSelectorTest < ActiveSupport::TestCase password: 'agentpw', active: true, roles: roles, - groups: [group], + groups: [@group], updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - agent2 = User.create_or_update( + @agent2 = User.create_or_update( login: 'ticket-selector-agent2@example.com', firstname: 'Notification', lastname: 'Agent2', @@ -40,32 +32,31 @@ class TicketSelectorTest < ActiveSupport::TestCase password: 'agentpw', active: true, roles: roles, - #groups: groups, updated_at: '2015-02-05 16:38:00', updated_by_id: 1, created_by_id: 1, ) roles = Role.where(name: 'Customer') - organization1 = Organization.create_if_not_exists( + @organization1 = Organization.create_if_not_exists( name: 'Selector Org', updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - customer1 = User.create_or_update( + @customer1 = User.create_or_update( login: 'ticket-selector-customer1@example.com', firstname: 'Notification', lastname: 'Customer1', email: 'ticket-selector-customer1@example.com', password: 'customerpw', active: true, - organization_id: organization1.id, + organization_id: @organization1.id, roles: roles, updated_at: '2015-02-05 16:37:00', updated_by_id: 1, created_by_id: 1, ) - customer2 = User.create_or_update( + @customer2 = User.create_or_update( login: 'ticket-selector-customer2@example.com', firstname: 'Notification', lastname: 'Customer2', @@ -79,7 +70,7 @@ class TicketSelectorTest < ActiveSupport::TestCase created_by_id: 1, ) - Ticket.where(group_id: group.id).destroy_all + Ticket.where(group_id: @group.id).destroy_all end test 'ticket create' do @@ -88,9 +79,9 @@ class TicketSelectorTest < ActiveSupport::TestCase ticket1 = Ticket.create!( title: 'some title1', - group: group, - customer_id: customer1.id, - owner_id: agent1.id, + group: @group, + customer_id: @customer1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), created_at: '2015-02-05 16:37:00', @@ -99,14 +90,14 @@ class TicketSelectorTest < ActiveSupport::TestCase created_by_id: 1, ) assert(ticket1, 'ticket created') - assert_equal(ticket1.customer.id, customer1.id) - assert_equal(ticket1.organization.id, organization1.id) + assert_equal(ticket1.customer.id, @customer1.id) + assert_equal(ticket1.organization.id, @organization1.id) travel 1.second ticket2 = Ticket.create!( title: 'some title2', - group: group, - customer_id: customer2.id, + group: @group, + customer_id: @customer2.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), created_at: '2015-02-05 16:37:00', @@ -115,14 +106,14 @@ class TicketSelectorTest < ActiveSupport::TestCase created_by_id: 1, ) assert(ticket2, 'ticket created') - assert_equal(ticket2.customer.id, customer2.id) + assert_equal(ticket2.customer.id, @customer2.id) assert_nil(ticket2.organization_id) travel 1.second ticket3 = Ticket.create!( title: 'some title3', - group: group, - customer_id: customer2.id, + group: @group, + customer_id: @customer2.id, state: Ticket::State.lookup(name: 'open'), priority: Ticket::Priority.lookup(name: '2 normal'), created_at: '2015-02-05 16:37:00', @@ -132,7 +123,7 @@ class TicketSelectorTest < ActiveSupport::TestCase ) ticket3.update_columns(escalation_at: '2015-02-06 10:00:00') assert(ticket3, 'ticket created') - assert_equal(ticket3.customer.id, customer2.id) + assert_equal(ticket3.customer.id, @customer2.id) assert_nil(ticket3.organization_id) travel 1.second @@ -143,23 +134,23 @@ class TicketSelectorTest < ActiveSupport::TestCase value: [99], }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) # search matching with empty value / missing key condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.state_id' => { operator: 'is', @@ -169,23 +160,23 @@ class TicketSelectorTest < ActiveSupport::TestCase ticket_count, tickets = Ticket.selectors(condition, 10) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_nil(ticket_count) # search matching with empty value [] condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.state_id' => { operator: 'is', @@ -196,23 +187,23 @@ class TicketSelectorTest < ActiveSupport::TestCase ticket_count, tickets = Ticket.selectors(condition, 10) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_nil(ticket_count) # search matching with empty value '' condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.state_id' => { operator: 'is', @@ -223,23 +214,23 @@ class TicketSelectorTest < ActiveSupport::TestCase ticket_count, tickets = Ticket.selectors(condition, 10) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_nil(ticket_count) # search matching condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.state_id' => { operator: 'is', @@ -250,22 +241,22 @@ class TicketSelectorTest < ActiveSupport::TestCase ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 2) - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 2) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 1) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.state_id' => { operator: 'is not', @@ -275,16 +266,16 @@ class TicketSelectorTest < ActiveSupport::TestCase ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 2) - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 2) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 1) condition = { @@ -296,111 +287,111 @@ class TicketSelectorTest < ActiveSupport::TestCase ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 1) # search - created_at condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.created_at' => { operator: 'after (absolute)', # before (absolute) value: '2015-02-05T16:00:00.000Z', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.created_at' => { operator: 'after (absolute)', # before (absolute) value: '2015-02-05T18:00:00.000Z', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.created_at' => { operator: 'before (absolute)', value: '2015-02-05T18:00:00.000Z', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.created_at' => { operator: 'before (absolute)', value: '2015-02-05T16:00:00.000Z', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.created_at' => { operator: 'before (relative)', @@ -408,22 +399,22 @@ class TicketSelectorTest < ActiveSupport::TestCase value: '10', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.created_at' => { operator: 'within next (relative)', @@ -431,22 +422,22 @@ class TicketSelectorTest < ActiveSupport::TestCase value: '10', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.created_at' => { operator: 'within last (relative)', @@ -454,111 +445,111 @@ class TicketSelectorTest < ActiveSupport::TestCase value: '10', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) # search - updated_at condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.updated_at' => { operator: 'before (absolute)', value: (Time.zone.now + 1.day).iso8601, }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.updated_at' => { operator: 'before (absolute)', value: (Time.zone.now - 1.day).iso8601, }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.updated_at' => { operator: 'after (absolute)', value: (Time.zone.now + 1.day).iso8601, }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.updated_at' => { operator: 'after (absolute)', value: (Time.zone.now - 1.day).iso8601, }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.updated_at' => { operator: 'before (relative)', @@ -566,22 +557,22 @@ class TicketSelectorTest < ActiveSupport::TestCase value: '10', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.updated_at' => { operator: 'within next (relative)', @@ -589,22 +580,22 @@ class TicketSelectorTest < ActiveSupport::TestCase value: '10', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.updated_at' => { operator: 'within last (relative)', @@ -612,16 +603,16 @@ class TicketSelectorTest < ActiveSupport::TestCase value: '10', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) # invalid conditions @@ -633,75 +624,75 @@ class TicketSelectorTest < ActiveSupport::TestCase condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'customer.email' => { operator: 'contains', value: 'ticket-selector-customer1', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'customer.email' => { operator: 'contains not', value: 'ticket-selector-customer1-not_existing', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 3) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) # search with organizations condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'organization.name' => { operator: 'contains', value: 'selector', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) # search with organizations condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'organization.name' => { operator: 'contains', @@ -712,22 +703,22 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'ticket-selector-customer1', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'organization.name' => { operator: 'contains', @@ -738,260 +729,260 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'ticket-selector-customer1', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) # with owner/customer/org condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.owner_id' => { operator: 'is', pre_condition: 'specific', - value: agent1.id, + value: @agent1.id, }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.owner_id' => { operator: 'is', pre_condition: 'specific', - #value: agent1.id, # value is not set, no result should be shown + #value: @agent1.id, # value is not set, no result should be shown }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_nil(ticket_count) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_nil(ticket_count) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.owner_id' => { operator: 'is', pre_condition: 'not_set', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 2) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.owner_id' => { operator: 'is not', pre_condition: 'not_set', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) - UserInfo.current_user_id = agent1.id + UserInfo.current_user_id = @agent1.id condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.owner_id' => { operator: 'is', pre_condition: 'current_user.id', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) - UserInfo.current_user_id = agent2.id + UserInfo.current_user_id = @agent2.id condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.owner_id' => { operator: 'is', pre_condition: 'current_user.id', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) - UserInfo.current_user_id = customer1.id + UserInfo.current_user_id = @customer1.id condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.customer_id' => { operator: 'is', pre_condition: 'current_user.id', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) - UserInfo.current_user_id = customer2.id + UserInfo.current_user_id = @customer2.id condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.customer_id' => { operator: 'is', pre_condition: 'current_user.id', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 2) ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 2) - UserInfo.current_user_id = customer1.id + UserInfo.current_user_id = @customer1.id condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.organization_id' => { operator: 'is', pre_condition: 'current_user.organization_id', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) ticket_count, tickets = Ticket.selectors(condition, 10) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) - UserInfo.current_user_id = customer2.id + UserInfo.current_user_id = @customer2.id condition = { 'ticket.group_id' => { operator: 'is', - value: group.id, + value: @group.id, }, 'ticket.organization_id' => { operator: 'is', pre_condition: 'current_user.organization_id', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, agent2) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent2) assert_equal(ticket_count, 0) - ticket_count, tickets = Ticket.selectors(condition, 10, customer1) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer1) assert_equal(ticket_count, 1) - ticket_count, tickets = Ticket.selectors(condition, 10, customer2) + ticket_count, tickets = Ticket.selectors(condition, 10, @customer2) assert_equal(ticket_count, 0) ticket_count, tickets = Ticket.selectors(condition, 10) @@ -1002,9 +993,9 @@ class TicketSelectorTest < ActiveSupport::TestCase test 'ticket tags filter' do ticket_tags_1 = Ticket.create!( title: 'some title1', - group: group, - customer_id: customer1.id, - owner_id: agent1.id, + group: @group, + customer_id: @customer1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), created_at: '2015-02-05 16:37:00', @@ -1013,9 +1004,9 @@ class TicketSelectorTest < ActiveSupport::TestCase ) ticket_tags_2 = Ticket.create!( title: 'some title1', - group: group, - customer_id: customer1.id, - owner_id: agent1.id, + group: @group, + customer_id: @customer1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), created_at: '2015-02-05 16:37:00', @@ -1024,9 +1015,9 @@ class TicketSelectorTest < ActiveSupport::TestCase ) ticket_tags_3 = Ticket.create!( title: 'some title1', - group: group, - customer_id: customer1.id, - owner_id: agent1.id, + group: @group, + customer_id: @customer1.id, + owner_id: @agent1.id, state: Ticket::State.lookup(name: 'new'), priority: Ticket::Priority.lookup(name: '2 normal'), created_at: '2015-02-05 16:37:00', @@ -1066,7 +1057,7 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'contains_all_1, contains_all_2, contains_all_3', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(1, ticket_count) condition = { @@ -1075,7 +1066,7 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'contains_all_1, contains_all_2, contains_all_3, xxx', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(0, ticket_count) # search all with contains one @@ -1085,7 +1076,7 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'contains_all_1, contains_all_2, contains_all_3', }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(2, ticket_count) condition = { @@ -1094,7 +1085,7 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'contains_all_1, contains_all_2' }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(1, ticket_count) # search all with contains one not @@ -1104,7 +1095,7 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'contains_all_1, contains_all_3' }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(2, ticket_count) condition = { @@ -1113,7 +1104,7 @@ class TicketSelectorTest < ActiveSupport::TestCase value: 'contains_all_1, contains_all_2, contains_all_3' }, } - ticket_count, tickets = Ticket.selectors(condition, 10, agent1) + ticket_count, tickets = Ticket.selectors(condition, 10, @agent1) assert_equal(2, ticket_count) end From 873998ec5a71ebe60a24cde0cd5ad33afe252d83 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 15 Jun 2017 08:38:38 +0200 Subject: [PATCH 041/234] Improved tests. --- test/integration/clearbit_test.rb | 3 ++- test/unit/email_postmaster_test.rb | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/test/integration/clearbit_test.rb b/test/integration/clearbit_test.rb index 001e35936..82ef6bad5 100644 --- a/test/integration/clearbit_test.rb +++ b/test/integration/clearbit_test.rb @@ -276,7 +276,8 @@ class ClearbitTest < ActiveSupport::TestCase assert_equal('3030 16th St, San Francisco, CA 94103, USA', customer6_lookup.address) #assert_equal('San Francisco, CA, USA', customer6_lookup.address) - organization6_lookup = Organization.find_by(name: 'Clearbit') + organization6_lookup = Organization.find_by(name: 'APIHub, Inc') + assert(organization6_lookup, 'unable to find org of user') assert(ExternalSync.find_by(source: 'clearbit', object: 'Organization', o_id: organization6_lookup.id)) assert_equal(false, organization6_lookup.shared) assert_equal('Clearbit provides powerful products and data APIs to help your business grow. Contact enrichment, lead generation, financial compliance, and more...', organization6_lookup.note) diff --git a/test/unit/email_postmaster_test.rb b/test/unit/email_postmaster_test.rb index e17fa6b44..82e0134c5 100644 --- a/test/unit/email_postmaster_test.rb +++ b/test/unit/email_postmaster_test.rb @@ -16,7 +16,7 @@ class EmailPostmasterTest < ActiveSupport::TestCase updated_by_id: 1, ) PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'not used', match: { from: { @@ -34,7 +34,8 @@ class EmailPostmasterTest < ActiveSupport::TestCase created_by_id: 1, updated_by_id: 1, ) - PostmasterFilter.create( + + PostmasterFilter.create!( name: 'used', match: { from: { @@ -55,7 +56,8 @@ class EmailPostmasterTest < ActiveSupport::TestCase created_by_id: 1, updated_by_id: 1, ) - PostmasterFilter.create( + + PostmasterFilter.create!( name: 'used x-any-recipient', match: { 'x-any-recipient' => { @@ -77,6 +79,7 @@ class EmailPostmasterTest < ActiveSupport::TestCase updated_by_id: 1, ) + data = 'From: me@example.com To: customer@example.com Subject: some subject @@ -111,9 +114,8 @@ Some Text' assert_equal('email', article.type.name) assert_equal(true, article.internal) - - PostmasterFilter.create( - name: 'used x-any-recipient', + PostmasterFilter.create!( + name: 'used x-any-recipient 2', match: { 'x-any-recipient' => { operator: 'contains not', @@ -157,7 +159,7 @@ Some Text' PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used - empty selector', match: { from: { @@ -203,7 +205,7 @@ Some Text' PostmasterFilter.destroy_all # follow up with create post master filter test - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used - empty selector', match: { from: { @@ -270,7 +272,7 @@ Some Text" PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used', match: { from: { @@ -310,7 +312,7 @@ Some Text' assert_equal('me@example.com', ticket.customer.email) PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used', match: { from: { @@ -350,7 +352,7 @@ Some Text' assert_equal('me@example.com', ticket.customer.email) PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used', match: { from: { From 350c3ead51872cb619f67b33f764da52c94b15a1 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Fri, 16 Jun 2017 22:43:09 +0200 Subject: [PATCH 042/234] Added enhanced Group access functionality. --- Gemfile | 1 + Gemfile.lock | 3 + .../controllers/_profile/notification.coffee | 17 +- .../controllers/_ui_element/permission.coffee | 17 +- .../_ui_element/user_permission.coffee | 5 +- .../controllers/agent_ticket_create.coffee | 9 +- .../javascripts/app/models/group.coffee | 10 +- app/assets/javascripts/app/models/role.coffee | 2 +- .../app/views/generic/permission.jst.eco | 30 + .../app/views/generic/user_permission.jst.eco | 50 +- .../app/views/profile/notification.jst.eco | 4 +- app/controllers/application_controller.rb | 1 + .../application_controller/checks_access.rb | 9 + app/controllers/concerns/accesses_tickets.rb | 10 - app/controllers/ticket_articles_controller.rb | 31 +- app/controllers/tickets_controller.rb | 44 +- app/controllers/users_controller.rb | 103 ++-- app/models/activity_stream.rb | 2 +- .../application_model/can_associations.rb | 39 +- app/models/application_model/checks_import.rb | 2 +- app/models/application_model/has_cache.rb | 2 + app/models/concerns/has_groups.rb | 331 +++++++++++ app/models/concerns/has_roles.rb | 75 +++ app/models/object_manager/attribute.rb | 2 +- app/models/organization.rb | 3 +- app/models/organization/checks_access.rb | 48 ++ app/models/organization/permission.rb | 39 -- app/models/recent_view.rb | 4 +- app/models/role.rb | 6 +- app/models/role/assets.rb | 57 ++ app/models/role_group.rb | 13 + app/models/ticket.rb | 57 +- app/models/ticket/article.rb | 1 + app/models/ticket/article/checks_access.rb | 42 ++ app/models/ticket/checks_access.rb | 57 ++ app/models/ticket/overviews.rb | 2 +- app/models/ticket/permission.rb | 45 -- app/models/ticket/screen_options.rb | 50 +- app/models/ticket/search.rb | 12 +- app/models/transaction/notification.rb | 30 +- app/models/user.rb | 6 +- app/models/user/assets.rb | 4 +- app/models/user/checks_access.rb | 46 ++ app/models/user/permission.rb | 37 -- app/models/user_group.rb | 13 + config/environments/test.rb | 4 + db/migrate/20120101000001_create_base.rb | 17 +- ...3000001_fixed_admin_user_permission_920.rb | 2 +- .../20170608151442_enhanced_permissions.rb | 23 + db/seeds/object_manager_attributes.rb | 2 +- lib/sessions/backend/base.rb | 2 +- lib/sessions/backend/ticket_create.rb | 2 +- lib/stats/ticket_channel_distribution.rb | 2 +- lib/stats/ticket_escalation.rb | 2 +- lib/stats/ticket_load_measure.rb | 2 +- lib/stats/ticket_waiting_time.rb | 2 +- spec/factories/role.rb | 14 + spec/models/concerns/has_groups_examples.rb | 523 ++++++++++++++++++ spec/models/concerns/has_roles_examples.rb | 268 +++++++++ spec/models/role_spec.rb | 6 + spec/models/user_spec.rb | 4 + test/browser/abb_one_group_test.rb | 23 +- test/browser/agent_ticket_attachment_test.rb | 2 + .../agent_ticket_email_signature_test.rb | 16 +- .../agent_ticket_overview_level0_test.rb | 4 +- test/browser/agent_ticket_tag_test.rb | 4 +- test/browser/chat_test.rb | 1 + test/browser/first_steps_test.rb | 2 +- test/browser_test_helper.rb | 37 +- .../user_organization_controller_test.rb | 36 +- test/integration/otrs_import_test.rb | 32 +- test/integration/zendesk_import_test.rb | 2 +- test/test_helper.rb | 2 + test/unit/assets_test.rb | 26 +- test/unit/model_test.rb | 17 +- test/unit/object_cache_test.rb | 9 +- test/unit/recent_view_test.rb | 38 +- test/unit/session_basic_test.rb | 40 +- 78 files changed, 2041 insertions(+), 496 deletions(-) create mode 100644 app/controllers/application_controller/checks_access.rb delete mode 100644 app/controllers/concerns/accesses_tickets.rb create mode 100644 app/models/concerns/has_groups.rb create mode 100644 app/models/concerns/has_roles.rb create mode 100644 app/models/organization/checks_access.rb delete mode 100644 app/models/organization/permission.rb create mode 100644 app/models/role/assets.rb create mode 100644 app/models/role_group.rb create mode 100644 app/models/ticket/article/checks_access.rb create mode 100644 app/models/ticket/checks_access.rb delete mode 100644 app/models/ticket/permission.rb create mode 100644 app/models/user/checks_access.rb delete mode 100644 app/models/user/permission.rb create mode 100644 app/models/user_group.rb create mode 100644 db/migrate/20170608151442_enhanced_permissions.rb create mode 100644 spec/factories/role.rb create mode 100644 spec/models/concerns/has_groups_examples.rb create mode 100644 spec/models/concerns/has_roles_examples.rb create mode 100644 spec/models/role_spec.rb diff --git a/Gemfile b/Gemfile index 4325682f4..14e914d52 100644 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,7 @@ gem 'mime-types' gem 'biz' +gem 'composite_primary_keys' gem 'delayed_job_active_record' gem 'daemons' diff --git a/Gemfile.lock b/Gemfile.lock index 138fb1c44..94c3b3615 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,6 +75,8 @@ GEM coffee-script execjs json + composite_primary_keys (8.1.5) + activerecord (~> 4.2.0) concurrent-ruby (1.0.2) coveralls (0.8.16) json (>= 1.8, < 3) @@ -430,6 +432,7 @@ DEPENDENCIES coffee-rails coffee-script-source coffeelint + composite_primary_keys coveralls daemons delayed_job_active_record diff --git a/app/assets/javascripts/app/controllers/_profile/notification.coffee b/app/assets/javascripts/app/controllers/_profile/notification.coffee index d037b58b3..244a8681e 100644 --- a/app/assets/javascripts/app/controllers/_profile/notification.coffee +++ b/app/assets/javascripts/app/controllers/_profile/notification.coffee @@ -75,13 +75,14 @@ class Index extends App.ControllerSubContent groups = [] group_ids = @Session.get('group_ids') if group_ids - for group_id in group_ids - group = App.Group.find(group_id) - groups.push group - if !user_group_config - if !config['group_ids'] - config['group_ids'] = [] - config['group_ids'].push group_id.toString() + for group_id, access of group_ids + if _.contains(access, 'full') + group = App.Group.find(group_id) + groups.push group + if !user_group_config + if !config['group_ids'] + config['group_ids'] = [] + config['group_ids'].push group_id.toString() for sound in @sounds sound.selected = sound.file is App.OnlineNotification.soundFile() ? true : false @@ -90,7 +91,7 @@ class Index extends App.ControllerSubContent groups: groups config: config sounds: @sounds - notification_sound_enabled: App.OnlineNotification.soundEnabled() + notificationSoundEnabled: App.OnlineNotification.soundEnabled() update: (e) => diff --git a/app/assets/javascripts/app/controllers/_ui_element/permission.coffee b/app/assets/javascripts/app/controllers/_ui_element/permission.coffee index 96c996467..ec1430a4d 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/permission.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/permission.coffee @@ -4,10 +4,25 @@ class App.UiElement.permission extends App.UiElement.ApplicationUiElement permissions = App.Permission.search(sortBy: 'name') + # get selectable groups and selected groups + groups = [] + groupsSelected = {} + groupsRaw = App.Group.search(sortBy: 'name') + for group in groupsRaw + if group.active + groups.push group + if params.group_ids + for group_id in params.group_ids + if group_id.toString() is group.id.toString() + groupsSelected[group.id] = true + item = $( App.view('generic/permission')( attribute: attribute params: params permissions: permissions + groups: groups + groupsSelected: groupsSelected + groupAccesses: App.Group.accesses() ) ) # show/hide trees @@ -37,4 +52,4 @@ class App.UiElement.permission extends App.UiElement.ApplicationUiElement ) - item \ No newline at end of file + item diff --git a/app/assets/javascripts/app/controllers/_ui_element/user_permission.coffee b/app/assets/javascripts/app/controllers/_ui_element/user_permission.coffee index 936d6885a..79cd7215e 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/user_permission.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/user_permission.coffee @@ -72,6 +72,7 @@ class App.UiElement.user_permission rolesSelected: rolesSelected groupsSelected: groupsSelected hideGroups: hideGroups + groupAccesses: App.Group.accesses() ) ) # if customer, remove admin and agent @@ -105,7 +106,7 @@ class App.UiElement.user_permission # select groups if only one is available if hideGroups - item.find('.js-groupList [name=group_ids]').prop('checked', false) + item.find('.js-groupList .js-groupListItem[value=full]').prop('checked', false) return # if role with groups plugin is selected, show group selection @@ -114,7 +115,7 @@ class App.UiElement.user_permission # select groups if only one is available if hideGroups - item.find('.js-groupList [name=group_ids]').prop('checked', true) + item.find('.js-groupList .js-groupListItem[value=full]').prop('checked', true) for trigger in triggers trigger.trigger('change') diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create.coffee index 80c5fe9f0..38b2228ca 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create.coffee @@ -476,10 +476,11 @@ class App.TicketCreate extends App.Controller ui.scrollTo() # access to group - group_ids = _.map(App.Session.get('group_ids'), (id) -> id.toString()) - if group_ids && _.contains(group_ids, @group_id.toString()) - ui.navigate "#ticket/zoom/#{@id}" - return + for group_id, access of App.Session.get('group_ids') + if @group_id.toString() is group_id.toString() + if _.contains(access, 'read') || _.contains(access, 'full') + ui.navigate "#ticket/zoom/#{@id}" + return # if not, show start screen ui.navigate '#' diff --git a/app/assets/javascripts/app/models/group.coffee b/app/assets/javascripts/app/models/group.coffee index 0ad754349..e58634828 100644 --- a/app/assets/javascripts/app/models/group.coffee +++ b/app/assets/javascripts/app/models/group.coffee @@ -34,4 +34,12 @@ class App.Group extends App.Model cssClass.push("avatar--group-color-#{@id % 3}") return App.view('avatar_group') - cssClass: cssClass.join(' ') \ No newline at end of file + cssClass: cssClass.join(' ') + + @accesses: -> + read: 'Read' + create: 'Create' + change: 'Change' + delete: 'Delete' + overview: 'Overview' + full: 'Full' diff --git a/app/assets/javascripts/app/models/role.coffee b/app/assets/javascripts/app/models/role.coffee index b929e85d8..994f64199 100644 --- a/app/assets/javascripts/app/models/role.coffee +++ b/app/assets/javascripts/app/models/role.coffee @@ -1,5 +1,5 @@ class App.Role extends App.Model - @configure 'Role', 'name', 'permission_ids', 'default_at_signup', 'note', 'active', 'updated_at' + @configure 'Role', 'name', 'permission_ids', 'group_ids', 'default_at_signup', 'note', 'active', 'updated_at' @extend Spine.Model.Ajax @url: @apiPath + '/roles' @configure_attributes = [ diff --git a/app/assets/javascripts/app/views/generic/permission.jst.eco b/app/assets/javascripts/app/views/generic/permission.jst.eco index 4ebbce9b3..55e00e7da 100644 --- a/app/assets/javascripts/app/views/generic/permission.jst.eco +++ b/app/assets/javascripts/app/views/generic/permission.jst.eco @@ -15,6 +15,36 @@ <%- @Icon('checkbox-checked', 'icon-checked') %> <%= permission.displayName().replace(/^.+?\./, '') %> - <%- @T.apply(@, [permission.note].concat(permission.preferences.translations)) %> + <% if _.contains(permission.preferences.plugin, 'groups'): %> +
+ + + + <% for group in @groups: %> + <% accesses = [] %> + <% if @params.group_ids && @params.group_ids[group.id]: %> + <% accesses = @params.group_ids[group.id] %> + <% end %> + + + <% end %> +
<%- @T('Group') %> + <% for key, text of @groupAccesses: %> + <%- @T(text) %> + <% end %> +
+ <%= group.displayName() %> + <% for key, text of @groupAccesses: %> + + + <% end %> +
+
+ <% end %>
<% end %> <% end %> diff --git a/app/assets/javascripts/app/views/generic/user_permission.jst.eco b/app/assets/javascripts/app/views/generic/user_permission.jst.eco index 7bd6b89b7..b8dd7fd64 100644 --- a/app/assets/javascripts/app/views/generic/user_permission.jst.eco +++ b/app/assets/javascripts/app/views/generic/user_permission.jst.eco @@ -1,4 +1,18 @@
+<% showGroups = false %> +<% for role in @roles: %> +<% if role.permissions: %> +<% for permission in role.permissions: %> +<% if _.contains(permission.preferences.plugin, 'groups'): %> +<% if showGroups is true: %> +<% showGroups = false %> +<% break %> +<% end %> +<% showGroups = true %> +<% end %> +<% end %> +<% end %> +<% end %> <% for role in @roles: %> <% if role.permissions: %> <% for permission in role.permissions: %> - <% if _.contains(permission.preferences.plugin, 'groups'): %> -
+ <% if showGroups is true && _.contains(permission.preferences.plugin, 'groups'): %> +
+ + + <% for group in @groups: %> - + <% permissions = [] %> + <% if @params.group_ids && @params.group_ids[group.id]: %> + <% permissions = @params.group_ids[group.id] %> + <% end %> + + <% end %> +
<%- @T('Group') %> + <% for key, text of @groupAccesses: %> + <%- @T(text) %> + <% end %> +
+ <%= group.displayName() %> + <% for key, text of @groupAccesses: %> + + + <% end %> +
<% break %> <% end %> <% end %> <% end %> <% end %> -
\ No newline at end of file +
diff --git a/app/assets/javascripts/app/views/profile/notification.jst.eco b/app/assets/javascripts/app/views/profile/notification.jst.eco index 66ff16384..188fff03c 100644 --- a/app/assets/javascripts/app/views/profile/notification.jst.eco +++ b/app/assets/javascripts/app/views/profile/notification.jst.eco @@ -92,7 +92,7 @@
- \ No newline at end of file + diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f9a6e8475..1d37f3f60 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,4 +12,5 @@ class ApplicationController < ActionController::Base include ApplicationController::HasUser include ApplicationController::PreventsCsrf include ApplicationController::LogsHttpAccess + include ApplicationController::ChecksAccess end diff --git a/app/controllers/application_controller/checks_access.rb b/app/controllers/application_controller/checks_access.rb new file mode 100644 index 000000000..7d246e541 --- /dev/null +++ b/app/controllers/application_controller/checks_access.rb @@ -0,0 +1,9 @@ +module ApplicationController::ChecksAccess + extend ActiveSupport::Concern + + private + + def access!(instance, access) + instance.access!(current_user, access) + end +end diff --git a/app/controllers/concerns/accesses_tickets.rb b/app/controllers/concerns/accesses_tickets.rb deleted file mode 100644 index d8cd734ad..000000000 --- a/app/controllers/concerns/accesses_tickets.rb +++ /dev/null @@ -1,10 +0,0 @@ -module AccessesTickets - extend ActiveSupport::Concern - - private - - def ticket_permission(ticket) - return true if ticket.permission(current_user: current_user) - raise Exceptions::NotAuthorized - end -end diff --git a/app/controllers/ticket_articles_controller.rb b/app/controllers/ticket_articles_controller.rb index 9ef12fe24..43cb987f1 100644 --- a/app/controllers/ticket_articles_controller.rb +++ b/app/controllers/ticket_articles_controller.rb @@ -1,7 +1,6 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class TicketArticlesController < ApplicationController - include AccessesTickets include CreatesTicketArticles prepend_before_action :authentication_check @@ -15,7 +14,7 @@ class TicketArticlesController < ApplicationController # GET /articles/1 def show article = Ticket::Article.find(params[:id]) - article_permission(article) + access!(article, 'read') if params[:expand] result = article.attributes_with_association_names @@ -35,7 +34,7 @@ class TicketArticlesController < ApplicationController # GET /ticket_articles/by_ticket/1 def index_by_ticket ticket = Ticket.find(params[:id]) - ticket_permission(ticket) + access!(ticket, 'read') articles = [] @@ -82,7 +81,7 @@ class TicketArticlesController < ApplicationController # POST /articles def create ticket = Ticket.find(params[:ticket_id]) - ticket_permission(ticket) + access!(ticket, 'create') article = article_create(ticket, params) if params[:expand] @@ -103,7 +102,7 @@ class TicketArticlesController < ApplicationController # PUT /articles/1 def update article = Ticket::Article.find(params[:id]) - article_permission(article) + access!(article, 'change') if !current_user.permissions?('ticket.agent') && !current_user.permissions?('admin') raise Exceptions::NotAuthorized, 'Not authorized (ticket.agent or admin permission required)!' @@ -132,7 +131,7 @@ class TicketArticlesController < ApplicationController # DELETE /articles/1 def destroy article = Ticket::Article.find(params[:id]) - article_permission(article) + access!(article, 'delete') if current_user.permissions?('admin') article.destroy! @@ -209,9 +208,8 @@ class TicketArticlesController < ApplicationController # GET /ticket_attachment/:ticket_id/:article_id/:id def attachment ticket = Ticket.lookup(id: params[:ticket_id]) - if !ticket_permission(ticket) - raise Exceptions::NotAuthorized, 'No such ticket.' - end + access!(ticket, 'read') + article = Ticket::Article.find(params[:article_id]) if ticket.id != article.ticket_id @@ -221,9 +219,7 @@ class TicketArticlesController < ApplicationController end ticket = article.ticket - if !ticket_permission(ticket) - raise Exceptions::NotAuthorized, "No access, for ticket_id '#{ticket.id}'." - end + access!(ticket, 'read') end list = article.attachments || [] @@ -251,7 +247,7 @@ class TicketArticlesController < ApplicationController # GET /ticket_article_plain/1 def article_plain article = Ticket::Article.find(params[:id]) - article_permission(article) + access!(article, 'read') file = article.as_raw @@ -268,15 +264,6 @@ class TicketArticlesController < ApplicationController private - def article_permission(article) - if current_user.permissions?('ticket.customer') - raise Exceptions::NotAuthorized if article.internal == true - end - ticket = Ticket.lookup(id: article.ticket_id) - return true if ticket.permission(current_user: current_user) - raise Exceptions::NotAuthorized - end - def sanitized_disposition disposition = params.fetch(:disposition, 'inline') valid_disposition = %w(inline attachment) diff --git a/app/controllers/tickets_controller.rb b/app/controllers/tickets_controller.rb index 81506f393..69aa617ac 100644 --- a/app/controllers/tickets_controller.rb +++ b/app/controllers/tickets_controller.rb @@ -1,7 +1,6 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ class TicketsController < ApplicationController - include AccessesTickets include CreatesTicketArticles include TicketStats @@ -21,7 +20,7 @@ class TicketsController < ApplicationController per_page = 100 end - access_condition = Ticket.access_condition(current_user) + access_condition = Ticket.access_condition(current_user, 'read') tickets = Ticket.where(access_condition).order(id: 'ASC').offset(offset).limit(per_page) if params[:expand] @@ -52,10 +51,8 @@ class TicketsController < ApplicationController # GET /api/v1/tickets/1 def show - - # permission check ticket = Ticket.find(params[:id]) - ticket_permission(ticket) + access!(ticket, 'read') if params[:expand] result = ticket.attributes_with_association_names @@ -180,10 +177,8 @@ class TicketsController < ApplicationController # PUT /api/v1/tickets/1 def update - - # permission check ticket = Ticket.find(params[:id]) - ticket_permission(ticket) + access!(ticket, 'change') clean_params = Ticket.association_name_to_id_convert(params) clean_params = Ticket.param_cleanup(clean_params, true) @@ -218,10 +213,8 @@ class TicketsController < ApplicationController # DELETE /api/v1/tickets/1 def destroy - - # permission check ticket = Ticket.find(params[:id]) - ticket_permission(ticket) + access!(ticket, 'delete') raise Exceptions::NotAuthorized, 'Not authorized (admin permission required)!' if !current_user.permissions?('admin') @@ -247,9 +240,7 @@ class TicketsController < ApplicationController # get ticket data ticket = Ticket.find(params[:id]) - - # permission check - ticket_permission(ticket) + access!(ticket, 'read') # get history of ticket history = ticket.history_get(true) @@ -265,7 +256,7 @@ class TicketsController < ApplicationController assets = ticket.assets({}) # open tickets by customer - access_condition = Ticket.access_condition(current_user) + access_condition = Ticket.access_condition(current_user, 'read') ticket_lists = Ticket .where( @@ -328,9 +319,7 @@ class TicketsController < ApplicationController } return end - - # permission check - ticket_permission(ticket_master) + access!(ticket_master, 'full') # check slave ticket ticket_slave = Ticket.find_by(id: params[:slave_ticket_id]) @@ -341,11 +330,9 @@ class TicketsController < ApplicationController } return end + access!(ticket_slave, 'full') - # permission check - ticket_permission(ticket_slave) - - # check diffetent ticket ids + # check different ticket ids if ticket_slave.id == ticket_master.id render json: { result: 'failed', @@ -370,10 +357,8 @@ class TicketsController < ApplicationController # GET /api/v1/ticket_split def ticket_split - - # permission check ticket = Ticket.find(params[:ticket_id]) - ticket_permission(ticket) + access!(ticket, 'read') assets = ticket.assets({}) # get related articles @@ -390,7 +375,7 @@ class TicketsController < ApplicationController # get attributes to update attributes_to_change = Ticket::ScreenOptions.attributes_to_change( - user: current_user, + current_user: current_user, ) render json: attributes_to_change end @@ -483,7 +468,7 @@ class TicketsController < ApplicationController # lookup open user tickets limit = 100 assets = {} - access_condition = Ticket.access_condition(current_user) + access_condition = Ticket.access_condition(current_user, 'read') user_tickets = {} if params[:user_id] @@ -578,7 +563,10 @@ class TicketsController < ApplicationController def ticket_all(ticket) # get attributes to update - attributes_to_change = Ticket::ScreenOptions.attributes_to_change(user: current_user, ticket: ticket) + attributes_to_change = Ticket::ScreenOptions.attributes_to_change( + current_user: current_user, + ticket: ticket + ) # get related users assets = attributes_to_change[:assets] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c29663960..8376b1c68 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -75,25 +75,22 @@ class UsersController < ApplicationController # @response_message 200 [User] User record matching the requested identifier. # @response_message 401 Invalid session. def show - - # access deny - permission_check_local + user = User.find(params[:id]) + access!(user, 'read') if params[:expand] - user = User.find(params[:id]).attributes_with_association_names - render json: user, status: :ok - return + result = user.attributes_with_association_names + elsif params[:full] + result = { + id: params[:id], + assets: user.assets({}), + } + else + result = user.attributes_with_association_ids + result.delete('password') end - if params[:full] - full = User.full(params[:id]) - render json: full - return - end - - user = User.find(params[:id]).attributes_with_association_ids - user.delete('password') - render json: user + render json: result end # @path [POST] /users @@ -108,8 +105,6 @@ class UsersController < ApplicationController def create clean_params = User.association_name_to_id_convert(params) clean_params = User.param_cleanup(clean_params, true) - user = User.new(clean_params) - user.associations_from_param(params) # check if it's first user, the admin user # inital admin account @@ -131,6 +126,8 @@ class UsersController < ApplicationController if admin_account_exists && !params[:signup] raise Exceptions::UnprocessableEntity, 'Only signup with not authenticate user possible!' end + user = User.new(clean_params) + user.associations_from_param(params) user.updated_by_id = 1 user.created_by_id = 1 @@ -164,12 +161,8 @@ class UsersController < ApplicationController # permission check permission_check_by_permission(params) - if params[:role_ids] - user.role_ids = params[:role_ids] - end - if params[:group_ids] - user.group_ids = params[:group_ids] - end + user = User.new(clean_params) + user.associations_from_param(params) end # check if user already exists @@ -245,28 +238,25 @@ class UsersController < ApplicationController # @response_message 200 [User] Updated User record. # @response_message 401 Invalid session. def update - - # access deny - permission_check_local + permission_check_by_permission(params) user = User.find(params[:id]) - clean_params = User.association_name_to_id_convert(params) - clean_params = User.param_cleanup(clean_params, true) + access!(user, 'change') # permission check permission_check_by_permission(params) user.with_lock do + clean_params = User.association_name_to_id_convert(params) + clean_params = User.param_cleanup(clean_params, true) user.update_attributes(clean_params) # only allow Admin's if current_user.permissions?('admin.user') && (params[:role_ids] || params[:roles]) - user.role_ids = params[:role_ids] user.associations_from_param({ role_ids: params[:role_ids], roles: params[:roles] }) end # only allow Admin's if current_user.permissions?('admin.user') && (params[:group_ids] || params[:groups]) - user.group_ids = params[:group_ids] user.associations_from_param({ group_ids: params[:group_ids], groups: params[:groups] }) end @@ -298,7 +288,9 @@ class UsersController < ApplicationController # @response_message 200 User successfully deleted. # @response_message 401 Invalid session. def destroy - permission_check('admin.user') + user = User.find(params[:id]) + access!(user, 'delete') + model_references_check(User, params) model_destroy_render(User, params) end @@ -1006,30 +998,25 @@ curl http://localhost/api/v1/users/avatar -v -u #{login}:#{password} -H "Content def permission_check_by_permission(params) return true if current_user.permissions?('admin.user') - if !current_user.permissions?('admin.user') && params[:role_ids] - if params[:role_ids].class != Array - params[:role_ids] = [params[:role_ids]] - end - params[:role_ids].each { |role_id| - role_local = Role.lookup(id: role_id) - if !role_local - logger.info "Invalid role_ids for current_user_id: #{current_user.id} role_ids #{role_id}" - raise Exceptions::NotAuthorized, 'Invalid role_ids!' - end - role_name = role_local.name - # TODO: check role permissions - next if role_name != 'Admin' && role_name != 'Agent' - logger.info "This role assignment is only allowed by admin! current_user_id: #{current_user.id} assigned to #{role_name}" + %i(role_ids roles).each do |key| + next if !params[key] + if current_user.permissions?('ticket.agent') + params.delete(key) + else + logger.info "Role assignment is only allowed by admin! current_user_id: #{current_user.id} assigned to #{params[key].inspect}" raise Exceptions::NotAuthorized, 'This role assignment is only allowed by admin!' - } + end + end + if current_user.permissions?('ticket.agent') && !params[:role_ids] && !params[:roles] && params[:id].blank? + params[:role_ids] = Role.signup_role_ids end - if !current_user.permissions?('admin.user') && params[:group_ids] - if params[:group_ids].class != Array - params[:group_ids] = [params[:group_ids]] - end - if !params[:group_ids].empty? - logger.info "Group relation is only allowed by admin! current_user_id: #{current_user.id} group_ids #{params[:group_ids].inspect}" + %i(group_ids groups).each do |key| + next if !params[key] + if current_user.permissions?('ticket.agent') + params.delete(key) + else + logger.info "Group relation assignment is only allowed by admin! current_user_id: #{current_user.id} assigned to #{params[key].inspect}" raise Exceptions::NotAuthorized, 'Group relation is only allowed by admin!' end end @@ -1039,16 +1026,4 @@ curl http://localhost/api/v1/users/avatar -v -u #{login}:#{password} -H "Content response_access_deny false end - - def permission_check_local - return true if current_user.permissions?('admin.user') - return true if current_user.permissions?('ticket.agent') - - # allow to update any by him self - # TODO check certain attributes like roles_ids and group_ids - return true if params[:id].to_i == current_user.id - - raise Exceptions::NotAuthorized - end - end diff --git a/app/models/activity_stream.rb b/app/models/activity_stream.rb index 42c0df00d..957e7ff2a 100644 --- a/app/models/activity_stream.rb +++ b/app/models/activity_stream.rb @@ -97,7 +97,7 @@ return all activity entries of an user return [] if !user.permissions?('ticket.agent') && !user.permissions?('admin') permission_ids = user.permissions_with_child_ids - group_ids = user.group_ids + group_ids = user.group_ids_access('read') stream = if group_ids.empty? ActivityStream.where('(permission_id IN (?) AND group_id is NULL)', permission_ids) diff --git a/app/models/application_model/can_associations.rb b/app/models/application_model/can_associations.rb index 812217b26..488073ce3 100644 --- a/app/models/application_model/can_associations.rb +++ b/app/models/application_model/can_associations.rb @@ -17,9 +17,21 @@ returns def associations_from_param(params) + # special handling for group access association + { + groups: :group_names_access_map=, + group_ids: :group_ids_access_map= + }.each do |param, setter| + map = params[param] + next if map.blank? + next if !respond_to?(setter) + send(setter, map) + end + # set relations by id/verify if ref exists self.class.reflect_on_all_associations.map { |assoc| assoc_name = assoc.name + next if association_attributes_ignored.include?(assoc_name) real_ids = assoc_name[0, assoc_name.length - 1] + '_ids' real_ids = real_ids.to_sym next if !params.key?(real_ids) @@ -44,6 +56,7 @@ returns # set relations by name/lookup self.class.reflect_on_all_associations.map { |assoc| assoc_name = assoc.name + next if association_attributes_ignored.include?(assoc_name) real_ids = assoc_name[0, assoc_name.length - 1] + '_ids' next if !respond_to?(real_ids) real_values = assoc_name[0, assoc_name.length - 1] + 's' @@ -95,17 +108,20 @@ returns cache = Cache.get(key) return cache if cache - ignored_attributes = self.class.instance_variable_get(:@association_attributes_ignored) || [] - # get relations attributes = self.attributes self.class.reflect_on_all_associations.map { |assoc| + next if association_attributes_ignored.include?(assoc.name) real_ids = assoc.name.to_s[0, assoc.name.to_s.length - 1] + '_ids' - next if ignored_attributes.include?(real_ids.to_sym) next if !respond_to?(real_ids) attributes[real_ids] = send(real_ids) } + # special handling for group access associations + if respond_to?(:group_ids_access_map) + attributes['group_ids'] = send(:group_ids_access_map) + end + filter_attributes(attributes) Cache.write(key, attributes) @@ -131,6 +147,7 @@ returns attributes = attributes_with_association_ids self.class.reflect_on_all_associations.map { |assoc| next if !respond_to?(assoc.name) + next if association_attributes_ignored.include?(assoc.name) ref = send(assoc.name) next if !ref if ref.respond_to?(:first) @@ -156,6 +173,11 @@ returns attributes[assoc.name.to_s] = ref[:name] } + # special handling for group access associations + if respond_to?(:group_names_access_map) + attributes['groups'] = send(:group_names_access_map) + end + # fill created_by/updated_by { 'created_by_id' => 'created_by', @@ -214,6 +236,12 @@ returns true end + private + + def association_attributes_ignored + @association_attributes_ignored ||= self.class.instance_variable_get(:@association_attributes_ignored) || [] + end + # methods defined here are going to extend the class, not the instance of it class_methods do @@ -223,13 +251,14 @@ serve methode to ignore model attribute associations class Model < ApplicationModel include AssociationConcern - association_attributes_ignored :user_ids + association_attributes_ignored :users end =end def association_attributes_ignored(*attributes) - @association_attributes_ignored = attributes + @association_attributes_ignored ||= [] + @association_attributes_ignored |= attributes end =begin diff --git a/app/models/application_model/checks_import.rb b/app/models/application_model/checks_import.rb index 2e5e65868..82e7e6f9a 100644 --- a/app/models/application_model/checks_import.rb +++ b/app/models/application_model/checks_import.rb @@ -13,7 +13,7 @@ module ApplicationModel::ChecksImport # do noting, use id as it is return if !Setting.get('system_init_done') return if Setting.get('import_mode') && import_class_list.include?(self.class.to_s) - + return if !has_attribute?(:id) self[:id] = nil end end diff --git a/app/models/application_model/has_cache.rb b/app/models/application_model/has_cache.rb index 9446cfe29..aea7ef61e 100644 --- a/app/models/application_model/has_cache.rb +++ b/app/models/application_model/has_cache.rb @@ -14,6 +14,7 @@ module ApplicationModel::HasCache def cache_update(o) cache_delete if respond_to?('cache_delete') o.cache_delete if o.respond_to?('cache_delete') + true end def cache_delete @@ -52,6 +53,7 @@ module ApplicationModel::HasCache Cache.delete(key) end end + true end # methods defined here are going to extend the class, not the instance of it diff --git a/app/models/concerns/has_groups.rb b/app/models/concerns/has_groups.rb new file mode 100644 index 000000000..57fe9dbe2 --- /dev/null +++ b/app/models/concerns/has_groups.rb @@ -0,0 +1,331 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +module HasGroups + extend ActiveSupport::Concern + + included do + before_destroy :destroy_group_relations + + attr_accessor :group_access_buffer + + after_create :check_group_access_buffer + after_update :check_group_access_buffer + + association_attributes_ignored :groups + + has_many group_through_identifier + has_many :groups, through: group_through_identifier do + + # A helper to join the :through table into the result of groups to access :through attributes + # + # @param [String, Array] access Limiting to one or more access verbs. 'full' gets added automatically + # + # @example All access groups + # user.groups.access + # #=> [#, ...] + # + # @example Groups for given access(es) plus 'full' + # user.groups.access('read') + # #=> [#, ...] + # + # @example Groups for given access(es)es plus 'full' + # user.groups.access('read', 'write') + # #=> [#, ...] + # + # @return [ActiveRecord::AssociationRelation<[] List of Groups with :through attributes + def access(*access) + table_name = proxy_association.owner.class.group_through.table_name + query = select("groups.*, #{table_name}.*") + return query if access.blank? + + access.push('full') if !access.include?('full') + + query.where("#{table_name}.access" => access) + end + end + end + + # Checks a given Group( ID) for given access(es) for the instance. + # Checks indirect access via Roles if instance has Roles, too. + # + # @example Group ID param + # user.group_access?(1, 'read') + # #=> true + # + # @example Group param + # user.group_access?(group, 'read') + # #=> true + # + # @example Access list + # user.group_access?(group, ['read', 'create']) + # #=> true + # + # @return [Boolean] + def group_access?(group_id, access) + group_id = self.class.ensure_group_id_parameter(group_id) + access = self.class.ensure_group_access_list_parameter(access) + + # check direct access + return true if group_through.klass.includes(:group).exists?( + group_through.foreign_key => id, + group_id: group_id, + access: access, + groups: { + active: true + } + ) + + # check indirect access through Roles if possible + return false if !respond_to?(:role_access?) + role_access?(group_id, access) + end + + # Lists the Group IDs the instance has the given access(es) plus 'full' to. + # Adds indirect accessable Group IDs via Roles if instance has Roles, too. + # + # @example Single access + # user.group_ids_access('read') + # #=> [1, 3, ...] + # + # @example Access list + # user.group_ids_access(['read', 'create']) + # #=> [1, 3, ...] + # + # @return [Array] Group IDs the instance has the given access(es) to. + def group_ids_access(access) + access = self.class.ensure_group_access_list_parameter(access) + + foreign_key = group_through.foreign_key + klass = group_through.klass + + # check direct access + ids = klass.includes(:group).where(foreign_key => id, access: access, groups: { active: true }).pluck(:group_id) + ids ||= [] + + # check indirect access through roles if possible + return ids if !respond_to?(:role_ids) + + role_group_ids = RoleGroup.includes(:group).where(role_id: role_ids, access: access, groups: { active: true }).pluck(:group_id) + + # combines and removes duplicates + # and returns them in one statement + ids | role_group_ids + end + + # Lists Groups the instance has the given access(es) plus 'full' to. + # Adds indirect accessable Groups via Roles if instance has Roles, too. + # + # @example Single access + # user.groups_access('read') + # #=> [#, ...] + # + # @example Access list + # user.groups_access(['read', 'create']) + # #=> [#, ...] + # + # @return [Array] Groups the instance has the given access(es) to. + def groups_access(access) + group_ids = group_ids_access(access) + Group.where(id: group_ids) + end + + # Returns a map of Group name to access + # + # @example + # user.group_names_access_map + # #=> {'Users' => 'full', 'Support' => ['read', 'write']} + # + # @return [HashString,Array>] The map of Group name to access + def group_names_access_map + groups_access_map(:name) + end + + # Stores a map of Group ID to access. Deletes all other relations. + # + # @example + # user.group_names_access_map = {'Users' => 'full', 'Support' => ['read', 'write']} + # #=> {'Users' => 'full', 'Support' => ['read', 'write']} + # + # @return [HashString,Array>] The given map + def group_names_access_map=(name_access_map) + groups_access_map_store(name_access_map) do |group_name| + Group.where(name: group_name).pluck(:id).first + end + end + + # Returns a map of Group ID to access + # + # @example + # user.group_ids_access_map + # #=> {1 => 'full', 42 => ['read', 'write']} + # + # @return [HashString,Array>] The map of Group ID to access + def group_ids_access_map + groups_access_map(:id) + end + + # Stores a map of Group ID to access. Deletes all other relations. + # + # @example + # user.group_ids_access_map = {1 => 'full', 42 => ['read', 'write']} + # #=> {1 => 'full', 42 => ['read', 'write']} + # + # @return [HashString,Array>] The given map + def group_ids_access_map=(id_access_map) + groups_access_map_store(id_access_map) + end + + # An alias to .groups class method + def group_through + @group_through ||= self.class.group_through + end + + private + + def groups_access_map(key) + {}.tap do |hash| + groups.access.where(active: true).pluck(key, :access).each do |entry| + hash[ entry[0] ] ||= [] + hash[ entry[0] ].push(entry[1]) + end + end + end + + def groups_access_map_store(map) + map.each do |group_identifier, accesses| + # use given key as identifier or look it up + # via the given block which returns the identifier + group_id = block_given? ? yield(group_identifier) : group_identifier + + if !accesses.is_a?(Array) + accesses = [accesses] + end + + accesses.each do |access| + push_group_access_buffer( + group_id: group_id, + access: access + ) + + Rails.logger.error "TE DEBUG group_access_buffer = #{group_access_buffer.inspect}" + end + end + + check_group_access_buffer if id + end + + def push_group_access_buffer(entry) + @group_access_buffer ||= [] + @group_access_buffer.push(entry) + end + + def check_group_access_buffer + return if group_access_buffer.blank? + destroy_group_relations + + foreign_key = group_through.foreign_key + entries = group_access_buffer.collect do |entry| + entry[foreign_key] = id + entry + end + + group_through.klass.create!(entries) + + group_access_buffer = nil + + cache_delete + true + end + + def destroy_group_relations + group_through.klass.destroy_all(group_through.foreign_key => id) + end + + # methods defined here are going to extend the class, not the instance of it + class_methods do + + # Lists IDs of instances having the given access(es) to the given Group. + # + # @example Group ID param + # User.group_access_ids(1, 'read') + # #=> [1, 3, ...] + # + # @example Group param + # User.group_access_ids(group, 'read') + # #=> [1, 3, ...] + # + # @example Access list + # User.group_access_ids(group, ['read', 'create']) + # #=> [1, 3, ...] + # + # @return [Array] + def group_access_ids(group_id, access) + group_id = ensure_group_id_parameter(group_id) + access = ensure_group_access_list_parameter(access) + + # check direct access + ids = group_through.klass.includes(name.downcase).where(group_id: group_id, access: access, table_name => { active: true }).pluck(group_through.foreign_key) + ids ||= [] + + # check indirect access through roles if possible + return ids if !respond_to?(:role_access_ids) + role_instance_ids = role_access_ids(group_id, access) + + # combines and removes duplicates + # and returns them in one statement + ids | role_instance_ids + end + + # Lists instances having the given access(es) to the given Group. + # + # @example Group ID param + # User.group_access(1, 'read') + # #=> [#, ...] + # + # @example Group param + # User.group_access(group, 'read') + # #=> [#, ...] + # + # @example Access list + # User.group_access(group, ['read', 'create']) + # #=> [#, ...] + # + # @return [Array] + def group_access(group_id, access) + instance_ids = group_access_ids(group_id, access) + where(id: instance_ids) + end + + # The reflection instance containing the association data + # + # @example + # User.group_through + # #=> + # + # @return [ActiveRecord::Reflection::HasManyReflection] The given map + def group_through + @group_through ||= reflect_on_association(group_through_identifier) + end + + # The identifier of the has_many :through relation + # + # @example + # User.group_through_identifier + # #=> :user_groups + # + # @return [Symbol] The relation identifier + def group_through_identifier + "#{name.downcase}_groups".to_sym + end + + def ensure_group_id_parameter(group_or_id) + return group_or_id if group_or_id.is_a?(Integer) + group_or_id.id + end + + def ensure_group_access_list_parameter(access) + access = [access] if access.is_a?(String) + access.push('full') if !access.include?('full') + access + end + end +end diff --git a/app/models/concerns/has_roles.rb b/app/models/concerns/has_roles.rb new file mode 100644 index 000000000..bc3c75b72 --- /dev/null +++ b/app/models/concerns/has_roles.rb @@ -0,0 +1,75 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +module HasRoles + extend ActiveSupport::Concern + + # Checks a given Group( ID) for given access(es) for the instance associated roles. + # + # @example Group ID param + # user.role_access?(1, 'read') + # #=> true + # + # @example Group param + # user.role_access?(group, 'read') + # #=> true + # + # @example Access list + # user.role_access?(group, ['read', 'create']) + # #=> true + # + # @return [Boolean] + def role_access?(group_id, access) + group_id = self.class.ensure_group_id_parameter(group_id) + access = self.class.ensure_group_access_list_parameter(access) + + RoleGroup.includes(:group, :role).exists?( + role_id: roles.pluck(:id), + group_id: group_id, + access: access, + groups: { + active: true + }, + roles: { + active: true + } + ) + end + + # methods defined here are going to extend the class, not the instance of it + class_methods do + + # Lists IDs of instances having the given access(es) to the given Group through Roles. + # + # @example Group ID param + # User.role_access_ids(1, 'read') + # #=> [1, 3, ...] + # + # @example Group param + # User.role_access_ids(group, 'read') + # #=> [1, 3, ...] + # + # @example Access list + # User.role_access_ids(group, ['read', 'create']) + # #=> [1, 3, ...] + # + # @return [Array] + def role_access_ids(group_id, access) + group_id = ensure_group_id_parameter(group_id) + access = ensure_group_access_list_parameter(access) + + role_ids = RoleGroup.includes(:role).where(group_id: group_id, access: access, roles: { active: true }).pluck(:role_id) + join_table = reflect_on_association(:roles).join_table + includes(:roles).where(active: true, join_table => { role_id: role_ids }).distinct.pluck(:id) + end + + def ensure_group_id_parameter(group_or_id) + return group_or_id if group_or_id.is_a?(Integer) + group_or_id.id + end + + def ensure_group_access_list_parameter(access) + access = [access] if access.is_a?(String) + access.push('full') if !access.include?('full') + access + end + end +end diff --git a/app/models/object_manager/attribute.rb b/app/models/object_manager/attribute.rb index 750128734..6f05f26ff 100644 --- a/app/models/object_manager/attribute.rb +++ b/app/models/object_manager/attribute.rb @@ -49,7 +49,7 @@ add a new attribute entry for an object data_type: 'select', data_option: { relation: 'Group', - relation_condition: { access: 'rw' }, + relation_condition: { access: 'full' }, multiple: false, null: true, translate: false, diff --git a/app/models/organization.rb b/app/models/organization.rb index 18cb20eba..a68b8505e 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -6,9 +6,8 @@ class Organization < ApplicationModel include ChecksLatestChangeObserved include HasHistory include HasSearchIndexBackend + include Organization::ChecksAccess - load 'organization/permission.rb' - include Organization::Permission load 'organization/assets.rb' include Organization::Assets extend Organization::Search diff --git a/app/models/organization/checks_access.rb b/app/models/organization/checks_access.rb new file mode 100644 index 000000000..33c17d489 --- /dev/null +++ b/app/models/organization/checks_access.rb @@ -0,0 +1,48 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class Organization + module ChecksAccess + extend ActiveSupport::Concern + + # Checks the given access of a given user for an organization. + # + # @param [User] The user that will be checked for given access. + # @param [String] The access that should get checked. + # + # @example + # organization.access?(user, 'read') + # #=> true + # + # @return [Boolean] + def access?(user, access) + + # check customer + if user.permissions?('ticket.customer') + + # access ok if its own organization + return false if access != 'read' + return false if !user.organization_id + return id == user.organization_id + end + + # check agent + return true if user.permissions?('admin') + return true if user.permissions?('ticket.agent') + false + end + + # Checks the given access of a given user for an organization and fails with an exception. + # + # @param (see Organization#access?) + # + # @example + # organization.access!(user, 'read') + # + # @raise [NotAuthorized] Gets raised if given user doesn't have the given access. + # + # @return [nil] + def access!(user, access) + return if access?(user, access) + raise Exceptions::NotAuthorized + end + end +end diff --git a/app/models/organization/permission.rb b/app/models/organization/permission.rb deleted file mode 100644 index e81eb158b..000000000 --- a/app/models/organization/permission.rb +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ - -class Organization - module Permission - -=begin - -check if user has access to user - - user = Organization.find(123) - result = organization.permission(type: 'rw', current_user: User.find(123)) - -returns - - result = true|false - -=end - - def permission (data) - - # check customer - if data[:current_user].permissions?('ticket.customer') - - # access ok if its own organization - return false if data[:type] != 'ro' - return false if !data[:current_user].organization_id - return true if id == data[:current_user].organization_id - - # no access - return false - end - - # check agent - return true if data[:current_user].permissions?('admin') - return true if data[:current_user].permissions?('ticket.agent') - false - end - end -end diff --git a/app/models/recent_view.rb b/app/models/recent_view.rb index 8cb226f90..3e44d79f9 100644 --- a/app/models/recent_view.rb +++ b/app/models/recent_view.rb @@ -105,8 +105,8 @@ class RecentView < ApplicationModel end # check permission - return if !record.respond_to?(:permission) - record.permission(current_user: user) + return if !record.respond_to?(:access?) + record.access?(user, 'read') end =begin diff --git a/app/models/role.rb b/app/models/role.rb index 0ff38b23b..174ea005d 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -4,6 +4,10 @@ class Role < ApplicationModel include HasActivityStreamLog include ChecksClientNotification include ChecksLatestChangeObserved + include HasGroups + + load 'role/assets.rb' + include Role::Assets has_and_belongs_to_many :users, after_add: :cache_update, after_remove: :cache_update has_and_belongs_to_many :permissions, after_add: :cache_update, after_remove: :cache_update, before_add: :validate_agent_limit @@ -13,7 +17,7 @@ class Role < ApplicationModel before_create :validate_permissions before_update :validate_permissions - association_attributes_ignored :user_ids + association_attributes_ignored :users activity_stream_permission 'admin.role' diff --git a/app/models/role/assets.rb b/app/models/role/assets.rb new file mode 100644 index 000000000..8b023a303 --- /dev/null +++ b/app/models/role/assets.rb @@ -0,0 +1,57 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Role + module Assets + +=begin + +get all assets / related models for this roles + + role = Role.find(123) + result = role.assets(assets_if_exists) + +returns + + result = { + :Role => { + 123 => role_model_123, + 1234 => role_model_1234, + } + } + +=end + + def assets(data) + + app_model = self.class.to_app_model + + if !data[ app_model ] + data[ app_model ] = {} + end + if !data[ app_model ][ id ] + local_attributes = attributes_with_association_ids + + # set temp. current attributes to assets pool to prevent + # loops, will be updated with lookup attributes later + data[ app_model ][ id ] = local_attributes + + local_attributes['group_ids'].each { |group_id, _access| + group = Group.lookup(id: group_id) + next if !group + data = group.assets(data) + } + end + + return data if !self['created_by_id'] && !self['updated_by_id'] + app_model_user = User.to_app_model + %w(created_by_id updated_by_id).each { |local_user_id| + next if !self[ local_user_id ] + next if data[ app_model_user ] && data[ app_model_user ][ self[ local_user_id ] ] + user = User.lookup(id: self[ local_user_id ]) + next if !user + data = user.assets(data) + } + data + end + end +end diff --git a/app/models/role_group.rb b/app/models/role_group.rb new file mode 100644 index 000000000..c684af14a --- /dev/null +++ b/app/models/role_group.rb @@ -0,0 +1,13 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class RoleGroup < ApplicationModel + self.table_name = 'roles_groups' + self.primary_keys = :role_id, :group_id, :access + belongs_to :role + belongs_to :group + validates :access, presence: true + + def self.ref_key + :role_id + end +end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 668f43c86..d549e9561 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -10,11 +10,10 @@ class Ticket < ApplicationModel include HasOnlineNotifications include HasKarmaActivityLog include HasLinks + include Ticket::ChecksAccess include Ticket::Escalation include Ticket::Subject - load 'ticket/permission.rb' - include Ticket::Permission load 'ticket/assets.rb' include Ticket::Assets load 'ticket/search_index.rb' @@ -75,33 +74,9 @@ class Ticket < ApplicationModel =begin -list of agents in group of ticket - - ticket = Ticket.find(123) - result = ticket.agent_of_group - -returns - - result = [user1, user2, ...] - -=end - - def agent_of_group - roles = Role.with_permissions('ticket.agent') - role_ids = roles.map(&:id) - Group.find(group_id) - .users.where(active: true) - .joins(:roles) - .where('roles.id' => role_ids, 'roles.active' => true) - .order('users.login') - .uniq() - end - -=begin - get user access conditions - conditions = Ticket.access_condition( User.find(1) ) + conditions = Ticket.access_condition( User.find(1) , 'full') returns @@ -109,22 +84,14 @@ returns =end - def self.access_condition(user) - access_condition = [] + def self.access_condition(user, access) if user.permissions?('ticket.agent') - group_ids = Group.select('groups.id').joins(:users) - .where('groups_users.user_id = ?', user.id) - .where('groups.active = ?', true) - .map(&:id) - access_condition = [ 'group_id IN (?)', group_ids ] + ['group_id IN (?)', user.group_ids_access(access)] + elsif !user.organization || ( !user.organization.shared || user.organization.shared == false ) + ['tickets.customer_id = ?', user.id] else - access_condition = if !user.organization || ( !user.organization.shared || user.organization.shared == false ) - [ 'tickets.customer_id = ?', user.id ] - else - [ '(tickets.customer_id = ? OR tickets.organization_id = ?)', user.id, user.organization.id ] - end + ['(tickets.customer_id = ? OR tickets.organization_id = ?)', user.id, user.organization.id] end - access_condition end =begin @@ -393,11 +360,11 @@ returns get count of tickets and tickets which match on selector - ticket_count, tickets = Ticket.selectors(params[:condition], limit, current_user) + ticket_count, tickets = Ticket.selectors(params[:condition], limit, current_user, 'full') =end - def self.selectors(selectors, limit = 10, current_user = nil) + def self.selectors(selectors, limit = 10, current_user = nil, access = 'full') raise 'no selectors given' if !selectors query, bind_params, tables = selector2sql(selectors, current_user) return [] if !query @@ -408,7 +375,7 @@ get count of tickets and tickets which match on selector return [ticket_count, tickets] end - access_condition = Ticket.access_condition(current_user) + access_condition = Ticket.access_condition(current_user, access) ticket_count = Ticket.where(access_condition).where(query, *bind_params).joins(tables).count tickets = Ticket.where(access_condition).where(query, *bind_params).joins(tables).limit(limit) @@ -801,9 +768,9 @@ perform changes on ticket email = User.lookup(id: owner_id).email recipients_raw.push(email) elsif recipient == 'ticket_agents' - agent_of_group.each { |user| + User.group_access(group_id, 'full').order(:login).each do |user| recipients_raw.push(user.email) - } + end else logger.error "Unknown email notification recipient '#{recipient}'" next diff --git a/app/models/ticket/article.rb b/app/models/ticket/article.rb index 738cf112f..546f614fa 100644 --- a/app/models/ticket/article.rb +++ b/app/models/ticket/article.rb @@ -4,6 +4,7 @@ class Ticket::Article < ApplicationModel include ChecksClientNotification include HasHistory include ChecksHtmlSanitized + include Ticket::Article::ChecksAccess load 'ticket/article/assets.rb' include Ticket::Article::Assets diff --git a/app/models/ticket/article/checks_access.rb b/app/models/ticket/article/checks_access.rb new file mode 100644 index 000000000..5bf3eac12 --- /dev/null +++ b/app/models/ticket/article/checks_access.rb @@ -0,0 +1,42 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class Ticket + class Article + module ChecksAccess + extend ActiveSupport::Concern + + # Checks the given access of a given user for a ticket article. + # + # @param [User] The user that will be checked for given access. + # @param [String] The access that should get checked. + # + # @example + # article.access?(user, 'read') + # #=> true + # + # @return [Boolean] + def access?(user, access) + if user.permissions?('ticket.customer') + return false if internal == true + end + + ticket = Ticket.lookup(id: ticket_id) + ticket.access?(user, access) + end + + # Checks the given access of a given user for a ticket article and fails with an exception. + # + # @param (see Ticket::Article#access?) + # + # @example + # article.access!(user, 'read') + # + # @raise [NotAuthorized] Gets raised if given user doesn't have the given access. + # + # @return [nil] + def access!(user, access) + return if access?(user, access) + raise Exceptions::NotAuthorized + end + end + end +end diff --git a/app/models/ticket/checks_access.rb b/app/models/ticket/checks_access.rb new file mode 100644 index 000000000..fe5366239 --- /dev/null +++ b/app/models/ticket/checks_access.rb @@ -0,0 +1,57 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class Ticket + module ChecksAccess + extend ActiveSupport::Concern + + # Checks the given access of a given user for a ticket. + # + # @param [User] The user that will be checked for given access. + # @param [String] The access that should get checked. + # + # @example + # ticket.access?(user, 'read') + # #=> true + # + # @return [Boolean] + def access?(user, access) + + # check customer + if user.permissions?('ticket.customer') + + # access ok if its own ticket + return true if customer_id == user.id + + # access ok if its organization ticket + if user.organization_id && organization_id + return true if organization_id == user.organization_id + end + + # no access + return false + end + + # check agent + + # access if requestor is owner + return true if owner_id == user.id + + # access if requestor is in group + user.group_access?(group.id, access) + end + + # Checks the given access of a given user for a ticket and fails with an exception. + # + # @param (see Ticket#access?) + # + # @example + # ticket.access!(user, 'read') + # + # @raise [NotAuthorized] Gets raised if given user doesn't have the given access. + # + # @return [nil] + def access!(user, access) + return if access?(user, access) + raise Exceptions::NotAuthorized + end + end +end diff --git a/app/models/ticket/overviews.rb b/app/models/ticket/overviews.rb index 6f9e0b4fd..bea92fced 100644 --- a/app/models/ticket/overviews.rb +++ b/app/models/ticket/overviews.rb @@ -89,7 +89,7 @@ returns return [] if overviews.blank? # get only tickets with permissions - access_condition = Ticket.access_condition(user) + access_condition = Ticket.access_condition(user, 'overview') ticket_attributes = Ticket.new.attributes list = [] diff --git a/app/models/ticket/permission.rb b/app/models/ticket/permission.rb deleted file mode 100644 index 774ca9fd4..000000000 --- a/app/models/ticket/permission.rb +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ -module Ticket::Permission - -=begin - -check if user has access to ticket - - ticket = Ticket.find(123) - result = ticket.permission(current_user: User.find(123)) - -returns - - result = true|false - -=end - - def permission(data) - - # check customer - if data[:current_user].permissions?('ticket.customer') - - # access ok if its own ticket - return true if customer_id == data[:current_user].id - - # access ok if its organization ticket - if data[:current_user].organization_id && organization_id - return true if organization_id == data[:current_user].organization_id - end - - # no access - return false - end - - # check agent - - # access if requestor is owner - return true if owner_id == data[:current_user].id - - # access if requestor is in group - data[:current_user].groups.each { |group| - return true if self.group.id == group.id - } - false - end -end diff --git a/app/models/ticket/screen_options.rb b/app/models/ticket/screen_options.rb index 1b9104d03..40053bb72 100644 --- a/app/models/ticket/screen_options.rb +++ b/app/models/ticket/screen_options.rb @@ -10,6 +10,7 @@ list attributes article_id: 123, ticket: ticket_model, + current_user: User.find(123), ) returns @@ -26,6 +27,8 @@ returns =end def self.attributes_to_change(params) + raise 'current_user param needed' if !params[:current_user] + if params[:ticket_id] params[:ticket] = Ticket.find(params[:ticket_id]) end @@ -45,22 +48,22 @@ returns if state_type && !state_types.include?(state_type.name) state_ids.push params[:ticket].state.id end - state_types.each { |type| + state_types.each do |type| state_type = Ticket::StateType.find_by(name: type) next if !state_type - state_type.states.each { |state| + state_type.states.each do |state| assets = state.assets(assets) state_ids.push state.id - } - } + end + end filter[:state_id] = state_ids # get priorities priority_ids = [] - Ticket::Priority.where(active: true).each { |priority| + Ticket::Priority.where(active: true).each do |priority| assets = priority.assets(assets) priority_ids.push priority.id - } + end filter[:priority_id] = priority_ids type_ids = [] @@ -69,36 +72,45 @@ returns if params[:ticket].group.email_address_id types.push 'email' end - types.each { |type_name| + types.each do |type_name| type = Ticket::Article::Type.lookup( name: type_name ) - if type - type_ids.push type.id - end - } + next if type.blank? + type_ids.push type.id + end end filter[:type_id] = type_ids # get group / user relations agents = {} - User.with_permissions('ticket.agent').each { |user| + User.with_permissions('ticket.agent').each do |user| agents[ user.id ] = 1 - } + end dependencies = { group_id: { '' => { owner_id: [] } } } - Group.where(active: true).each { |group| + + filter[:group_id] = [] + groups = if params[:current_user].permissions?('ticket.agent') + params[:current_user].groups_access('create') + else + Group.where(active: true) + end + + groups.each do |group| + filter[:group_id].push group.id assets = group.assets(assets) dependencies[:group_id][group.id] = { owner_id: [] } - group.users.each { |user| + + User.group_access(group.id, 'full').each do |user| next if !agents[ user.id ] assets = user.assets(assets) dependencies[:group_id][ group.id ][ :owner_id ].push user.id - } - } + end + end { - assets: assets, + assets: assets, form_meta: { - filter: filter, + filter: filter, dependencies: dependencies, } } diff --git a/app/models/ticket/search.rb b/app/models/ticket/search.rb index 096d875bb..5979da11c 100644 --- a/app/models/ticket/search.rb +++ b/app/models/ticket/search.rb @@ -105,15 +105,9 @@ returns query_extention['bool']['must'] = [] if current_user.permissions?('ticket.agent') - groups = Group.joins(:users) - .where('groups_users.user_id = ?', current_user.id) - .where('groups.active = ?', true) - group_condition = [] - groups.each { |group| - group_condition.push group.id - } + group_ids = current_user.group_ids_access('read') access_condition = { - 'query_string' => { 'default_field' => 'group_id', 'query' => "\"#{group_condition.join('" OR "')}\"" } + 'query_string' => { 'default_field' => 'group_id', 'query' => "\"#{group_ids.join('" OR "')}\"" } } else access_condition = if !current_user.organization || ( !current_user.organization.shared || current_user.organization.shared == false ) @@ -151,7 +145,7 @@ returns end # fallback do sql query - access_condition = Ticket.access_condition(current_user) + access_condition = Ticket.access_condition(current_user, 'read') # do query # - stip out * we already search for *query* - diff --git a/app/models/transaction/notification.rb b/app/models/transaction/notification.rb index 417e98fa9..f8880c359 100644 --- a/app/models/transaction/notification.rb +++ b/app/models/transaction/notification.rb @@ -46,36 +46,8 @@ class Transaction::Notification # find recipients recipients_and_channels = [] -=begin - # group of agents to work on - if data[:recipient] == 'group' - recipients = ticket.agent_of_group() - - # owner - elsif data[:recipient] == 'owner' - if ticket.owner_id != 1 - recipients.push ticket.owner - end - - # customer - elsif data[:recipient] == 'customer' - if ticket.customer_id != 1 - # temporarily disabled - # recipients.push ticket.customer - end - - # owner or group of agents to work on - elsif data[:recipient] == 'to_work_on' - if ticket.owner_id != 1 - recipients.push ticket.owner - else - recipients = ticket.agent_of_group() - end - end -=end - # loop through all users - possible_recipients = ticket.agent_of_group + possible_recipients = User.group_access(ticket.group_id, 'full').order(:login) if ticket.owner_id == 1 possible_recipients.push ticket.owner end diff --git a/app/models/user.rb b/app/models/user.rb index 6941fb1b4..aa06390d5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,9 +28,10 @@ class User < ApplicationModel include ChecksClientNotification include HasHistory include HasSearchIndexBackend + include HasGroups + include HasRoles + include User::ChecksAccess - load 'user/permission.rb' - include User::Permission load 'user/assets.rb' include User::Assets extend User::Search @@ -44,7 +45,6 @@ class User < ApplicationModel after_update :avatar_for_email_check after_destroy :avatar_destroy - has_and_belongs_to_many :groups, after_add: :cache_update, after_remove: :cache_update, class_name: 'Group' has_and_belongs_to_many :roles, after_add: [:cache_update, :check_notifications], after_remove: :cache_update, before_add: :validate_agent_limit, before_remove: :last_admin_check, class_name: 'Role' has_and_belongs_to_many :organizations, after_add: :cache_update, after_remove: :cache_update, class_name: 'Organization' #has_many :permissions, class_name: 'Permission', through: :roles, class_name: 'Role' diff --git a/app/models/user/assets.rb b/app/models/user/assets.rb index 59d12abc2..1d64c4f8b 100644 --- a/app/models/user/assets.rb +++ b/app/models/user/assets.rb @@ -32,7 +32,7 @@ returns local_attributes = attributes_with_association_ids # do not transfer crypted pw - local_attributes['password'] = '' + local_attributes.delete('password') # set temp. current attributes to assets pool to prevent # loops, will be updated with lookup attributes later @@ -65,7 +65,7 @@ returns # get groups if local_attributes['group_ids'] - local_attributes['group_ids'].each { |group_id| + local_attributes['group_ids'].each { |group_id, _access| group = Group.lookup(id: group_id) next if !group data = group.assets(data) diff --git a/app/models/user/checks_access.rb b/app/models/user/checks_access.rb new file mode 100644 index 000000000..3dfdd48dc --- /dev/null +++ b/app/models/user/checks_access.rb @@ -0,0 +1,46 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +class User + module ChecksAccess + extend ActiveSupport::Concern + + # Checks the given access of a given user for another user. + # + # @param [User] The user that will be checked for given access. + # @param [String] The access that should get checked. + # + # @example + # user.access?(user, 'read') + # #=> true + # + # @return [Boolean] + def access?(user, _access) + + # check agent + return true if user.permissions?('admin.user') + return true if user.permissions?('ticket.agent') + + # check customer + if user.permissions?('ticket.customer') + # access ok if its own user + return id == user.id + end + + false + end + + # Checks the given access of a given user for another user and fails with an exception. + # + # @param (see User#access?) + # + # @example + # user.access!(user, 'read') + # + # @raise [NotAuthorized] Gets raised if given user doesn't have the given access. + # + # @return [nil] + def access!(user, access) + return if access?(user, access) + raise Exceptions::NotAuthorized + end + end +end diff --git a/app/models/user/permission.rb b/app/models/user/permission.rb deleted file mode 100644 index 9b443c943..000000000 --- a/app/models/user/permission.rb +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ - -class User - module Permission - -=begin - -check if user has access to user - - user = User.find(123) - result = user.permission(type: 'rw', current_user: User.find(123)) - -returns - - result = true|false - -=end - - def permission (data) - - # check customer - if data[:current_user].permissions?('ticket.customer') - - # access ok if its own user - return true if id == data[:current_user].id - - # no access - return false - end - - # check agent - return true if data[:current_user].permissions?('admin.user') - return true if data[:current_user].permissions?('ticket.agent') - false - end - end -end diff --git a/app/models/user_group.rb b/app/models/user_group.rb new file mode 100644 index 000000000..795051803 --- /dev/null +++ b/app/models/user_group.rb @@ -0,0 +1,13 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class UserGroup < ApplicationModel + self.table_name = 'groups_users' + self.primary_keys = :user_id, :group_id, :access + belongs_to :user + belongs_to :group + validates :access, presence: true + + def self.ref_key + :user_id + end +end diff --git a/config/environments/test.rb b/config/environments/test.rb index 0177c1bcf..99f46bed0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -46,4 +46,8 @@ Rails.application.configure do # format log config.log_formatter = Logger::Formatter.new + config.after_initialize do + ActiveRecord::Base.logger = Rails.logger.clone + ActiveRecord::Base.logger.level = Logger::INFO + end end diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb index bc746804a..ea3b18ee9 100644 --- a/db/migrate/20120101000001_create_base.rb +++ b/db/migrate/20120101000001_create_base.rb @@ -161,14 +161,27 @@ class CreateBase < ActiveRecord::Migration add_foreign_key :roles_users, :roles create_table :groups_users, id: false do |t| - t.references :user - t.references :group + t.references :user, null: false + t.references :group, null: false + t.string :access, limit: 50, null: false, default: 'full' end add_index :groups_users, [:user_id] add_index :groups_users, [:group_id] + add_index :groups_users, [:access] add_foreign_key :groups_users, :users add_foreign_key :groups_users, :groups + create_table :roles_groups, id: false do |t| + t.references :role, null: false + t.references :group, null: false + t.string :access, limit: 50, null: false, default: 'full' + end + add_index :roles_groups, [:role_id] + add_index :roles_groups, [:group_id] + add_index :roles_groups, [:access] + add_foreign_key :roles_groups, :roles + add_foreign_key :roles_groups, :groups + create_table :organizations_users, id: false do |t| t.references :user t.references :organization diff --git a/db/migrate/20170403000001_fixed_admin_user_permission_920.rb b/db/migrate/20170403000001_fixed_admin_user_permission_920.rb index 970463353..7c8b131ba 100644 --- a/db/migrate/20170403000001_fixed_admin_user_permission_920.rb +++ b/db/migrate/20170403000001_fixed_admin_user_permission_920.rb @@ -84,7 +84,7 @@ class FixedAdminUserPermission920 < ActiveRecord::Migration data_option: { default: '', relation: 'Group', - relation_condition: { access: 'rw' }, + relation_condition: { access: 'full' }, nulloption: true, multiple: false, null: false, diff --git a/db/migrate/20170608151442_enhanced_permissions.rb b/db/migrate/20170608151442_enhanced_permissions.rb new file mode 100644 index 000000000..70748b515 --- /dev/null +++ b/db/migrate/20170608151442_enhanced_permissions.rb @@ -0,0 +1,23 @@ +class EnhancedPermissions < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + change_column_null :groups_users, :user_id, false + change_column_null :groups_users, :group_id, false + add_column :groups_users, :access, :string, limit: 50, null: false, default: 'full' + add_index :groups_users, [:access] + UserGroup.connection.schema_cache.clear! + UserGroup.reset_column_information + + create_table :roles_groups, id: false do |t| + t.references :role, null: false + t.references :group, null: false + t.string :access, limit: 50, null: false, default: 'full' + end + add_index :roles_groups, [:role_id] + add_index :roles_groups, [:group_id] + add_index :roles_groups, [:access] + end +end diff --git a/db/seeds/object_manager_attributes.rb b/db/seeds/object_manager_attributes.rb index 6e70e8ed6..420d4f3ee 100644 --- a/db/seeds/object_manager_attributes.rb +++ b/db/seeds/object_manager_attributes.rb @@ -106,7 +106,7 @@ ObjectManager::Attribute.add( data_option: { default: '', relation: 'Group', - relation_condition: { access: 'rw' }, + relation_condition: { access: 'full' }, nulloption: true, multiple: false, null: false, diff --git a/lib/sessions/backend/base.rb b/lib/sessions/backend/base.rb index a807c64f8..afbb67aa8 100644 --- a/lib/sessions/backend/base.rb +++ b/lib/sessions/backend/base.rb @@ -1,6 +1,6 @@ class Sessions::Backend::Base - def initialize(user, asset_lookup, client, client_id, ttl = 30) + def initialize(user, asset_lookup, client, client_id, ttl = 10) @user = user @client = client @client_id = client_id diff --git a/lib/sessions/backend/ticket_create.rb b/lib/sessions/backend/ticket_create.rb index b4b098e3e..c8e757ad7 100644 --- a/lib/sessions/backend/ticket_create.rb +++ b/lib/sessions/backend/ticket_create.rb @@ -4,7 +4,7 @@ class Sessions::Backend::TicketCreate < Sessions::Backend::Base # get attributes to update ticket_create_attributes = Ticket::ScreenOptions.attributes_to_change( - user: @user.id, + current_user: @user, ) # no data exists diff --git a/lib/stats/ticket_channel_distribution.rb b/lib/stats/ticket_channel_distribution.rb index b75b35e18..06da940b7 100644 --- a/lib/stats/ticket_channel_distribution.rb +++ b/lib/stats/ticket_channel_distribution.rb @@ -8,7 +8,7 @@ class Stats::TicketChannelDistribution time_range = 7.days # get users groups - group_ids = user.groups.map(&:id) + group_ids = user.group_ids_access('full') # get channels channels = [ diff --git a/lib/stats/ticket_escalation.rb b/lib/stats/ticket_escalation.rb index 6c0662f33..3358e3df5 100644 --- a/lib/stats/ticket_escalation.rb +++ b/lib/stats/ticket_escalation.rb @@ -7,7 +7,7 @@ class Stats::TicketEscalation open_state_ids = Ticket::State.by_category(:open).pluck(:id) # get users groups - group_ids = user.groups.map(&:id) + group_ids = user.group_ids_access('full') # owned tickets own_escalated = Ticket.where( diff --git a/lib/stats/ticket_load_measure.rb b/lib/stats/ticket_load_measure.rb index 67c33836d..8f9b992ba 100644 --- a/lib/stats/ticket_load_measure.rb +++ b/lib/stats/ticket_load_measure.rb @@ -10,7 +10,7 @@ class Stats::TicketLoadMeasure count = Ticket.where(owner_id: user.id, state_id: open_state_ids).count # get total open - total = Ticket.where(group_id: user.groups.map(&:id), state_id: open_state_ids).count + total = Ticket.where(group_id: user.group_ids_access('full'), state_id: open_state_ids).count average = '-' state = 'good' diff --git a/lib/stats/ticket_waiting_time.rb b/lib/stats/ticket_waiting_time.rb index efcc10427..7a474dcb8 100644 --- a/lib/stats/ticket_waiting_time.rb +++ b/lib/stats/ticket_waiting_time.rb @@ -5,7 +5,7 @@ class Stats::TicketWaitingTime def self.generate(user) # get users groups - group_ids = user.groups.map(&:id) + group_ids = user.group_ids_access('full') own_waiting = Ticket.where( 'owner_id = ? AND group_id IN (?) AND updated_at > ?', user.id, group_ids, Time.zone.today diff --git a/spec/factories/role.rb b/spec/factories/role.rb new file mode 100644 index 000000000..6a8cd742f --- /dev/null +++ b/spec/factories/role.rb @@ -0,0 +1,14 @@ +FactoryGirl.define do + sequence :test_role_name do |n| + "TestRole#{n}" + end +end + +FactoryGirl.define do + + factory :role do + name { generate(:test_role_name) } + created_by_id 1 + updated_by_id 1 + end +end diff --git a/spec/models/concerns/has_groups_examples.rb b/spec/models/concerns/has_groups_examples.rb new file mode 100644 index 000000000..0fd9b3f43 --- /dev/null +++ b/spec/models/concerns/has_groups_examples.rb @@ -0,0 +1,523 @@ +RSpec.shared_examples 'HasGroups' do + + context 'group' do + + let(:factory_name) { described_class.name.downcase.to_sym } + let(:instance) { create(factory_name) } + let(:instance_inactive) { create(factory_name, active: false) } + let(:group_full) { create(:group) } + let(:group_read) { create(:group) } + let(:group_inactive) { create(:group, active: false) } + + context '.group_through_identifier' do + + it 'responds to group_through_identifier' do + expect(described_class).to respond_to(:group_through_identifier) + end + + it 'returns a Symbol as identifier' do + expect(described_class.group_through_identifier).to be_a(Symbol) + end + + it 'instance responds to group_through_identifier method' do + expect(instance).to respond_to(described_class.group_through_identifier) + end + end + + context '.group_through' do + + it 'responds to group_through' do + expect(described_class).to respond_to(:group_through) + end + + it 'returns the Reflection instance of the has_many :through relation' do + expect(described_class.group_through).to be_a(ActiveRecord::Reflection::HasManyReflection) + end + end + + context '#groups' do + + it 'responds to groups' do + expect(instance).to respond_to(:groups) + end + + context '#groups.access' do + + it 'responds to groups.access' do + expect(instance.groups).to respond_to(:access) + end + + context 'result' do + + before(:each) do + instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => 'read', + group_inactive.name => 'write', + } + end + + it 'returns all related Groups' do + expect(instance.groups.access.size).to eq(3) + end + + it 'adds join table attribute(s like) access' do + expect(instance.groups.access.first).to respond_to(:access) + end + + it 'filters for given access parameter' do + expect(instance.groups.access('read')).to include(group_read) + end + + it 'filters for given access list parameter' do + expect(instance.groups.access('read', 'write')).to include(group_read, group_inactive) + end + + it 'always includes full access groups' do + expect(instance.groups.access('read')).to include(group_full) + end + end + end + end + + context '#group_access?' do + + before(:each) do + instance.group_names_access_map = { + group_read.name => 'read', + } + end + + it 'responds to group_access?' do + expect(instance).to respond_to(:group_access?) + end + + context 'Group ID parameter' do + include_examples '#group_access? call' do + let(:group_parameter) { group_read.id } + end + end + + context 'Group parameter' do + include_examples '#group_access? call' do + let(:group_parameter) { group_read } + end + end + + it 'prevents inactive Group' do + instance.group_names_access_map = { + group_inactive.name => 'read', + } + + expect(instance.group_access?(group_inactive.id, 'read')).to be false + end + end + + context '#group_ids_access' do + + before(:each) do + instance.group_names_access_map = { + group_read.name => 'read', + } + end + + it 'responds to group_ids_access' do + expect(instance).to respond_to(:group_ids_access) + end + + it 'lists only active Group IDs' do + instance.group_names_access_map = { + group_read.name => 'read', + group_inactive.name => 'read', + } + + result = instance.group_ids_access('read') + expect(result).not_to include(group_inactive.id) + end + + context 'single access' do + + it 'lists access Group IDs' do + result = instance.group_ids_access('read') + expect(result).to include(group_read.id) + end + + it "doesn't list for no access" do + result = instance.group_ids_access('write') + expect(result).not_to include(group_read.id) + end + end + + context 'access list' do + + it 'lists access Group IDs' do + result = instance.group_ids_access(%w(read write)) + expect(result).to include(group_read.id) + end + + it "doesn't list for no access" do + result = instance.group_ids_access(%w(write create)) + expect(result).not_to include(group_read.id) + end + end + end + + context '#groups_access' do + + it 'responds to groups_access' do + expect(instance).to respond_to(:groups_access) + end + + it 'wraps #group_ids_access' do + expect(instance).to receive(:group_ids_access) + instance.groups_access('read') + end + + it 'returns Groups' do + instance.group_names_access_map = { + group_read.name => 'read', + } + result = instance.groups_access('read') + expect(result).to include(group_read) + end + end + + context '#group_names_access_map=' do + + it 'responds to group_names_access_map=' do + expect(instance).to respond_to(:group_names_access_map=) + end + + context 'Group name => access relation storage' do + + it 'stores Hash with String values' do + expect do + instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => 'read', + } + end.to change { + described_class.group_through.klass.count + }.by(2) + end + + it 'stores Hash with String values' do + expect do + instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => %w(read write), + } + end.to change { + described_class.group_through.klass.count + }.by(3) + end + + context 'new instance' do + let(:new_instance) { build(factory_name) } + + it "doesn't store directly" do + expect do + new_instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => 'read', + } + end.not_to change { + described_class.group_through.klass.count + } + end + + it 'stores after save' do + expect do + new_instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => 'read', + } + + new_instance.save + end.to change { + described_class.group_through.klass.count + }.by(2) + end + end + end + end + + context '#group_names_access_map' do + + it 'responds to group_names_access_map' do + expect(instance).to respond_to(:group_names_access_map) + end + + it 'returns instance Group name => access relations as Hash' do + expected = { + group_full.name => ['full'], + group_read.name => ['read'], + } + + instance.group_names_access_map = expected + + expect(instance.group_names_access_map).to eq(expected) + end + end + + context '#group_ids_access_map=' do + + it 'responds to group_ids_access_map=' do + expect(instance).to respond_to(:group_ids_access_map=) + end + + context 'Group ID => access relation storage' do + + it 'stores Hash with String values' do + expect do + instance.group_ids_access_map = { + group_full.id => 'full', + group_read.id => 'read', + } + end.to change { + described_class.group_through.klass.count + }.by(2) + end + + it 'stores Hash with String values' do + expect do + instance.group_ids_access_map = { + group_full.id => 'full', + group_read.id => %w(read write), + } + end.to change { + described_class.group_through.klass.count + }.by(3) + end + + context 'new instance' do + let(:new_instance) { build(factory_name) } + + it "doesn't store directly" do + expect do + new_instance.group_ids_access_map = { + group_full.id => 'full', + group_read.id => 'read', + } + end.not_to change { + described_class.group_through.klass.count + } + end + + it 'stores after save' do + expect do + new_instance.group_ids_access_map = { + group_full.id => 'full', + group_read.id => 'read', + } + + new_instance.save + end.to change { + described_class.group_through.klass.count + }.by(2) + end + end + end + end + + context '#group_ids_access_map' do + + it 'responds to group_ids_access_map' do + expect(instance).to respond_to(:group_ids_access_map) + end + + it 'returns instance Group ID => access relations as Hash' do + expected = { + group_full.id => ['full'], + group_read.id => ['read'], + } + + instance.group_ids_access_map = expected + + expect(instance.group_ids_access_map).to eq(expected) + end + end + + context '#associations_from_param' do + + it 'handles group_ids parameter as group_ids_access_map' do + expected = { + group_full.id => ['full'], + group_read.id => ['read'], + } + + instance.associations_from_param(group_ids: expected) + expect(instance.group_ids_access_map).to eq(expected) + end + + it 'handles groups parameter as group_names_access_map' do + expected = { + group_full.name => ['full'], + group_read.name => ['read'], + } + + instance.associations_from_param(groups: expected) + expect(instance.group_names_access_map).to eq(expected) + end + end + + context '#attributes_with_association_ids' do + + it 'includes group_ids as group_ids_access_map' do + expected = { + group_full.id => ['full'], + group_read.id => ['read'], + } + + instance.group_ids_access_map = expected + + result = instance.attributes_with_association_ids + expect(result['group_ids']).to eq(expected) + end + end + + context '#attributes_with_association_names' do + + it 'includes group_ids as group_ids_access_map' do + expected = { + group_full.id => ['full'], + group_read.id => ['read'], + } + + instance.group_ids_access_map = expected + + result = instance.attributes_with_association_names + expect(result['group_ids']).to eq(expected) + end + + it 'includes groups as group_names_access_map' do + expected = { + group_full.name => ['full'], + group_read.name => ['read'], + } + + instance.group_names_access_map = expected + + result = instance.attributes_with_association_names + expect(result['groups']).to eq(expected) + end + end + + context '.group_access_ids' do + + before(:each) do + instance.group_names_access_map = { + group_read.name => 'read', + } + end + + it 'responds to group_access_ids' do + expect(described_class).to respond_to(:group_access_ids) + end + + it 'lists only active instance IDs' do + instance_inactive.group_names_access_map = { + group_read.name => 'read', + } + + result = described_class.group_access_ids(group_read.id, 'read') + expect(result).not_to include(instance_inactive.id) + end + + context 'Group ID parameter' do + include_examples '.group_access_ids call' do + let(:group_parameter) { group_read.id } + end + end + + context 'Group parameter' do + include_examples '.group_access_ids call' do + let(:group_parameter) { group_read.id } + end + end + end + + context '.group_access' do + + it 'responds to group_access' do + expect(described_class).to respond_to(:group_access) + end + + it 'wraps .group_access_ids' do + expect(described_class).to receive(:group_access_ids) + described_class.group_access(group_read, 'read') + end + + it 'returns class instances' do + instance.group_names_access_map = { + group_read.name => 'read', + } + + result = described_class.group_access(group_read, 'read') + expect(result).to include(instance) + end + end + + it 'destroys relations before instance gets destroyed' do + + instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => 'read', + group_inactive.name => 'write', + } + expect do + instance.destroy + end.to change { + described_class.group_through.klass.count + }.by(-3) + end + end +end + +RSpec.shared_examples '#group_access? call' do + context 'single access' do + + it 'checks positive' do + expect(instance.group_access?(group_parameter, 'read')).to be true + end + + it 'checks negative' do + expect(instance.group_access?(group_parameter, 'write')).to be false + end + end + + context 'access list' do + + it 'checks positive' do + expect(instance.group_access?(group_parameter, %w(read write))).to be true + end + + it 'checks negative' do + expect(instance.group_access?(group_parameter, %w(write create))).to be false + end + end +end + +RSpec.shared_examples '.group_access_ids call' do + context 'single access' do + + it 'lists access IDs' do + expect(described_class.group_access_ids(group_parameter, 'read')).to include(instance.id) + end + + it 'excludes non access IDs' do + expect(described_class.group_access_ids(group_parameter, 'write')).not_to include(instance.id) + end + end + + context 'access list' do + + it 'lists access IDs' do + expect(described_class.group_access_ids(group_parameter, %w(read write))).to include(instance.id) + end + + it 'excludes non access IDs' do + expect(described_class.group_access_ids(group_parameter, %w(write create))).not_to include(instance.id) + end + end +end diff --git a/spec/models/concerns/has_roles_examples.rb b/spec/models/concerns/has_roles_examples.rb new file mode 100644 index 000000000..83f5fe29c --- /dev/null +++ b/spec/models/concerns/has_roles_examples.rb @@ -0,0 +1,268 @@ +RSpec.shared_examples 'HasRoles' do + + context 'role' do + + let(:factory_name) { described_class.name.downcase.to_sym } + let(:instance) { create(factory_name) } + let(:instance_inactive) { create(factory_name, active: false) } + let(:role) { create(:role) } + let(:group_instance) { create(:group) } + let(:group_role) { create(:group) } + let(:group_inactive) { create(:group, active: false) } + + context '#role_access?' do + + before(:each) do + role.group_names_access_map = { + group_role.name => 'read', + } + + instance.roles = [role] + end + + it 'responds to role_access?' do + expect(instance).to respond_to(:role_access?) + end + + context 'Group ID parameter' do + include_examples '#role_access? call' do + let(:group_parameter) { group_role.id } + end + end + + context 'Group parameter' do + include_examples '#role_access? call' do + let(:group_parameter) { group_role } + end + end + + it 'prevents inactive Group' do + role.group_names_access_map = { + group_inactive.name => 'read', + } + + expect(instance.group_access?(group_inactive.id, 'read')).to be false + end + + it 'prevents inactive Role' do + role_inactive = create(:role, active: false) + role_inactive.group_names_access_map = { + group_role.name => 'read', + } + + instance.roles = [role_inactive] + + expect(instance.group_access?(group_role.id, 'read')).to be false + end + end + + context '.role_access_ids' do + + before(:each) do + role.group_names_access_map = { + group_role.name => 'read', + } + + instance.roles = [role] + end + + it 'responds to role_access_ids' do + expect(described_class).to respond_to(:role_access_ids) + end + + it 'lists only active instance IDs' do + role.group_names_access_map = { + group_role.name => 'read', + } + + result = described_class.group_access_ids(group_role.id, 'read') + expect(result).not_to include(instance_inactive.id) + end + + it 'lists only active instance IDs' do + role.group_names_access_map = { + group_role.name => 'read', + } + + instance_inactive.roles = [role] + + result = described_class.role_access_ids(group_role.id, 'read') + expect(result).not_to include(instance_inactive.id) + end + + context 'Group ID parameter' do + include_examples '.role_access_ids call' do + let(:group_parameter) { group_role.id } + end + end + + context 'Group parameter' do + include_examples '.role_access_ids call' do + let(:group_parameter) { group_role.id } + end + end + end + + context 'group' do + + before(:each) do + role.group_names_access_map = { + group_role.name => 'read', + } + + instance.roles = [role] + + instance.group_names_access_map = { + group_instance.name => 'read', + } + end + + context '#group_access?' do + + it 'falls back to #role_access?' do + expect(instance).to receive(:role_access?) + instance.group_access?(group_role, 'read') + end + + it "doesn't fall back to #role_access? if not needed" do + expect(instance).not_to receive(:role_access?) + instance.group_access?(group_instance, 'read') + end + end + + context '#group_ids_access' do + + before(:each) do + role.group_names_access_map = { + group_role.name => 'read', + } + + instance.roles = [role] + + instance.group_names_access_map = { + group_instance.name => 'read', + } + end + + it 'lists only active Group IDs' do + role.group_names_access_map = { + group_role.name => 'read', + group_inactive.name => 'read', + } + + result = instance.group_ids_access('read') + expect(result).not_to include(group_inactive.id) + end + + context 'single access' do + + it 'lists access Group IDs' do + result = instance.group_ids_access('read') + expect(result).to include(group_role.id) + end + + it "doesn't list for no access" do + result = instance.group_ids_access('write') + expect(result).not_to include(group_role.id) + end + + it "doesn't contain duplicate IDs" do + instance.group_names_access_map = { + group_role.name => 'read', + } + + result = instance.group_ids_access('read') + expect(result.uniq).to eq(result) + end + end + + context 'access list' do + + it 'lists access Group IDs' do + result = instance.group_ids_access(%w(read write)) + expect(result).to include(group_role.id) + end + + it "doesn't list for no access" do + result = instance.group_ids_access(%w(write create)) + expect(result).not_to include(group_role.id) + end + + it "doesn't contain duplicate IDs" do + instance.group_names_access_map = { + group_role.name => 'read', + } + + result = instance.group_ids_access(%w(read create)) + expect(result.uniq).to eq(result) + end + end + end + + context '.group_access_ids' do + + it 'includes the result of .role_access_ids' do + result = described_class.group_access_ids(group_role, 'read') + expect(result).to include(instance.id) + end + + it "doesn't contain duplicate IDs" do + instance.group_names_access_map = { + group_role.name => 'read', + } + + result = described_class.group_access_ids(group_role, 'read') + expect(result.uniq).to eq(result) + end + end + end + end +end + +RSpec.shared_examples '#role_access? call' do + context 'single access' do + + it 'checks positive' do + expect(instance.role_access?(group_parameter, 'read')).to be true + end + + it 'checks negative' do + expect(instance.role_access?(group_parameter, 'write')).to be false + end + end + + context 'access list' do + + it 'checks positive' do + expect(instance.role_access?(group_parameter, %w(read write))).to be true + end + + it 'checks negative' do + expect(instance.role_access?(group_parameter, %w(write create))).to be false + end + end +end + +RSpec.shared_examples '.role_access_ids call' do + context 'single access' do + + it 'lists access IDs' do + expect(described_class.role_access_ids(group_parameter, 'read')).to include(instance.id) + end + + it 'excludes non access IDs' do + expect(described_class.role_access_ids(group_parameter, 'write')).not_to include(instance.id) + end + end + + context 'access list' do + + it 'lists access IDs' do + expect(described_class.role_access_ids(group_parameter, %w(read write))).to include(instance.id) + end + + it 'excludes non access IDs' do + expect(described_class.role_access_ids(group_parameter, %w(write create))).not_to include(instance.id) + end + end +end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb new file mode 100644 index 000000000..9e9d307d7 --- /dev/null +++ b/spec/models/role_spec.rb @@ -0,0 +1,6 @@ +require 'rails_helper' +require 'models/concerns/has_groups_examples' + +RSpec.describe Role do + include_examples 'HasGroups' +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7c932eebb..de8985e17 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,6 +1,10 @@ require 'rails_helper' +require 'models/concerns/has_groups_examples' +require 'models/concerns/has_roles_examples' RSpec.describe User do + include_examples 'HasGroups' + include_examples 'HasRoles' let(:new_password) { 'N3W54V3PW!' } diff --git a/test/browser/abb_one_group_test.rb b/test/browser/abb_one_group_test.rb index e42349985..13de33b8a 100644 --- a/test/browser/abb_one_group_test.rb +++ b/test/browser/abb_one_group_test.rb @@ -59,10 +59,10 @@ class AgentTicketActionLevel0Test < TestCase ) exists( displayed: false, - css: '.modal [name="group_ids"]', + css: '.modal .js-groupList', ) exists( - css: '.modal [name="group_ids"]:checked', + css: '.modal .js-groupListItem[value=full]:checked', ) click( css: '.modal button.btn.btn--primary', @@ -105,10 +105,10 @@ class AgentTicketActionLevel0Test < TestCase exists( displayed: false, - css: '.modal [name="group_ids"]', + css: '.modal .js-groupList', ) exists_not( - css: '.modal [name="group_ids"]:checked', + css: '.modal .js-groupListItem[value=full]:checked', ) # enable agent role @@ -117,10 +117,11 @@ class AgentTicketActionLevel0Test < TestCase ) exists( - css: '.modal [name="group_ids"]', + displayed: false, + css: '.modal .js-groupList', ) exists( - css: '.modal [name="group_ids"]:checked', + css: '.modal .js-groupListItem[value=full]:checked', ) click( @@ -214,8 +215,14 @@ class AgentTicketActionLevel0Test < TestCase data: { name: "some group #{rand(999_999_999)}", member: [ - 'master@example.com', - 'agent1@example.com', + { + login: 'master@example.com', + access: 'full', + }, + { + login: 'agent1@example.com', + access: 'full', + }, ], }, ) diff --git a/test/browser/agent_ticket_attachment_test.rb b/test/browser/agent_ticket_attachment_test.rb index b91a4f6c2..2e35f5f56 100644 --- a/test/browser/agent_ticket_attachment_test.rb +++ b/test/browser/agent_ticket_attachment_test.rb @@ -233,6 +233,7 @@ class AgentTicketAttachmentTest < TestCase sleep 2 set(browser: browser1, css: '.modal [name="address"]', value: 'some new address') click(browser: browser1, css: '.modal .js-submit') + modal_disappear(browser: browser1) # verify is customer has chnaged other browser too click(browser: browser2, css: '.content.active .tabsSidebar-tab[data-tab="customer"]') @@ -255,6 +256,7 @@ class AgentTicketAttachmentTest < TestCase click(browser: browser1, css: '.modal .js-option') click(browser: browser1, css: '.modal .js-submit') + modal_disappear(browser: browser1) # check if org has changed in second browser sleep 3 diff --git a/test/browser/agent_ticket_email_signature_test.rb b/test/browser/agent_ticket_email_signature_test.rb index e4165af8f..3e8ad43cb 100644 --- a/test/browser/agent_ticket_email_signature_test.rb +++ b/test/browser/agent_ticket_email_signature_test.rb @@ -45,7 +45,10 @@ class AgentTicketEmailSignatureTest < TestCase name: group_name1, signature: signature_name1, member: [ - 'master@example.com' + { + login: 'master@example.com', + access: 'full', + }, ], } ) @@ -54,7 +57,10 @@ class AgentTicketEmailSignatureTest < TestCase name: group_name2, signature: signature_name2, member: [ - 'master@example.com' + { + login: 'master@example.com', + access: 'full', + }, ], } ) @@ -62,10 +68,14 @@ class AgentTicketEmailSignatureTest < TestCase data: { name: group_name3, member: [ - 'master@example.com' + { + login: 'master@example.com', + access: 'full', + }, ], } ) + sleep 6 # # check signature in new ticket diff --git a/test/browser/agent_ticket_overview_level0_test.rb b/test/browser/agent_ticket_overview_level0_test.rb index e16878901..3385b6dca 100644 --- a/test/browser/agent_ticket_overview_level0_test.rb +++ b/test/browser/agent_ticket_overview_level0_test.rb @@ -112,7 +112,7 @@ class AgentTicketOverviewLevel0Test < TestCase css: '.modal input[value="article_count"]', ) click(css: '.modal .js-submit') - sleep 6 + modal_disappear # check if number and article count is shown match( @@ -160,7 +160,7 @@ class AgentTicketOverviewLevel0Test < TestCase css: '.modal input[value="article_count"]', ) click(css: '.modal .js-submit') - sleep 6 + modal_disappear # check if number and article count is gone match_not( diff --git a/test/browser/agent_ticket_tag_test.rb b/test/browser/agent_ticket_tag_test.rb index a298304f4..337d2ab13 100644 --- a/test/browser/agent_ticket_tag_test.rb +++ b/test/browser/agent_ticket_tag_test.rb @@ -264,7 +264,7 @@ class AgentTicketTagTest < TestCase browser: browser2, css: '.modal .js-submit', ) - sleep 4 + modal_disappear(browser: browser2) ticket_open_by_search( browser: browser2, number: ticket3[:number], @@ -313,7 +313,7 @@ class AgentTicketTagTest < TestCase browser: browser2, css: '.modal .js-submit', ) - sleep 4 + modal_disappear(browser: browser2) ticket_open_by_search( browser: browser2, number: ticket3[:number], diff --git a/test/browser/chat_test.rb b/test/browser/chat_test.rb index 1d8954bb4..5fa781d37 100644 --- a/test/browser/chat_test.rb +++ b/test/browser/chat_test.rb @@ -471,6 +471,7 @@ class ChatTest < TestCase browser: agent, css: '.modal .js-submit', ) + modal_disappear(browser: agent) customer = browser_instance location( diff --git a/test/browser/first_steps_test.rb b/test/browser/first_steps_test.rb index 167703b23..b6316c865 100644 --- a/test/browser/first_steps_test.rb +++ b/test/browser/first_steps_test.rb @@ -36,7 +36,7 @@ class FirstStepsTest < TestCase css: '.modal [name="email"]', value: "#{agent}@example.com", ) - check(css: '.modal [name="group_ids"]') + check(css: '.modal .js-groupListItem[value=full]') click( css: '.modal button.btn.btn--primary', fast: true, diff --git a/test/browser_test_helper.rb b/test/browser_test_helper.rb index 55e527f3b..1e221f193 100644 --- a/test/browser_test_helper.rb +++ b/test/browser_test_helper.rb @@ -472,13 +472,14 @@ class TestCase < Test::Unit::TestCase if params[:position] == 'botton' position = 'false' end - + screenshot(browser: instance, comment: 'scroll_to_before') execute( browser: instance, js: "\$('#{params[:css]}').get(0).scrollIntoView(#{position})", mute_log: params[:mute_log] ) sleep 0.3 + screenshot(browser: instance, comment: 'scroll_to_after') end =begin @@ -495,7 +496,9 @@ class TestCase < Test::Unit::TestCase instance = params[:browser] || @browser + screenshot(browser: instance, comment: 'modal_ready_before') sleep 3 + screenshot(browser: instance, comment: 'modal_ready_after') end =begin @@ -513,11 +516,13 @@ class TestCase < Test::Unit::TestCase instance = params[:browser] || @browser + screenshot(browser: instance, comment: 'modal_disappear_before') watch_for_disappear( browser: instance, css: '.modal', timeout: params[:timeout] || 8, ) + screenshot(browser: instance, comment: 'modal_disappear_after') end =begin @@ -1864,17 +1869,31 @@ wait untill text in selector disabppears # check if owner selection exists count = instance.find_elements(css: '.content.active .newTicket select[name="group_id"] option').count + if count.nonzero? + instance.find_elements(css: '.content.active .newTicket select[name="group_id"] option').each { |element| + log('ticket_create invalid group count', text: element.text) + } + end assert_equal(0, count, 'owner selection should not be showm') # check count of agents, should be only 3 / - selection + master + agent on init screen count = instance.find_elements(css: '.content.active .newTicket select[name="owner_id"] option').count + if count != 3 + instance.find_elements(css: '.content.active .newTicket select[name="owner_id"] option').each { |element| + log('ticket_create invalid owner count', text: element.text) + } + end assert_equal(3, count, 'check if owner selection is - selection + master + agent per default') - else # check count of agents, should be only 1 / - selection on init screen if !params[:disable_group_check] count = instance.find_elements(css: '.content.active .newTicket select[name="owner_id"] option').count + if count != 1 + instance.find_elements(css: '.content.active .newTicket select[name="owner_id"] option').each { |element| + log('ticket_create invalid owner count', text: element.text) + } + end assert_equal(1, count, 'check if owner selection is empty per default') end select( @@ -2869,7 +2888,10 @@ wait untill text in selector disabppears name: 'some sla' + random, signature: 'some signature bame', member: [ - 'some_user_login', + { + login: 'some_user_login', + access: 'all', + }, ], }, ) @@ -2922,20 +2944,21 @@ wait untill text in selector disabppears # add member if data[:member] - data[:member].each { |login| + data[:member].each { |member| instance.find_elements(css: 'a[href="#manage"]')[0].click sleep 1 instance.find_elements(css: '.content.active a[href="#manage/users"]')[0].click sleep 3 element = instance.find_elements(css: '.content.active [name="search"]')[0] element.clear - element.send_keys(login) + element.send_keys(member[:login]) sleep 3 #instance.find_elements(:css => '.content.active table [data-id]')[0].click instance.execute_script('$(".content.active table [data-id] td").first().click()') - sleep 3 + modal_ready(browser: instance) #instance.find_elements(:css => 'label:contains(" ' + action[:name] + '")')[0].click - instance.execute_script('$(\'label:contains(" ' + data[:name] + '")\').first().click()') + instance.execute_script('$(".js-groupList tr:contains(\"' + data[:name] + '\") .js-groupListItem[value=' + member[:access] + ']").prop("checked", true)') + screenshot(browser: instance, comment: 'group_create_member') instance.find_elements(css: '.modal button.js-submit')[0].click modal_disappear(browser: instance) } diff --git a/test/controllers/user_organization_controller_test.rb b/test/controllers/user_organization_controller_test.rb index 71159adc6..1107547ce 100644 --- a/test/controllers/user_organization_controller_test.rb +++ b/test/controllers/user_organization_controller_test.rb @@ -12,6 +12,18 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest groups = Group.all UserInfo.current_user_id = 1 + + @backup_admin = User.create_or_update( + login: 'backup-admin', + firstname: 'Backup', + lastname: 'Agent', + email: 'backup-admin@example.com', + password: 'adminpw', + active: true, + roles: roles, + groups: groups, + ) + @admin = User.create_or_update( login: 'rest-admin', firstname: 'Rest', @@ -384,17 +396,29 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest role = Role.lookup(name: 'Admin') params = { firstname: "Admin#{firstname}", lastname: 'Admin Last', email: 'new_admin_by_agent@example.com', role_ids: [ role.id ] } post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials) - assert_response(401) - result = JSON.parse(@response.body) - assert(result) + assert_response(201) + result_user1 = JSON.parse(@response.body) + assert(result_user1) + user = User.find(result_user1['id']) + assert_not(user.role?('Admin')) + assert_not(user.role?('Agent')) + assert(user.role?('Customer')) + assert_equal('new_admin_by_agent@example.com', result_user1['login']) + assert_equal('new_admin_by_agent@example.com', result_user1['email']) # create user with agent role role = Role.lookup(name: 'Agent') params = { firstname: "Agent#{firstname}", lastname: 'Agent Last', email: 'new_agent_by_agent@example.com', role_ids: [ role.id ] } post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials) - assert_response(401) - result = JSON.parse(@response.body) - assert(result) + assert_response(201) + result_user1 = JSON.parse(@response.body) + assert(result_user1) + user = User.find(result_user1['id']) + assert_not(user.role?('Admin')) + assert_not(user.role?('Agent')) + assert(user.role?('Customer')) + assert_equal('new_agent_by_agent@example.com', result_user1['login']) + assert_equal('new_agent_by_agent@example.com', result_user1['email']) # create user with customer role role = Role.lookup(name: 'Customer') diff --git a/test/integration/otrs_import_test.rb b/test/integration/otrs_import_test.rb index 307a949ae..818727393 100644 --- a/test/integration/otrs_import_test.rb +++ b/test/integration/otrs_import_test.rb @@ -67,15 +67,15 @@ class OtrsImportTest < ActiveSupport::TestCase assert_equal( true, user1.active ) assert( user1.roles.include?( role_agent ) ) - assert( !user1.roles.include?( role_admin ) ) - assert( !user1.roles.include?( role_customer ) ) - #assert( !user1.roles.include?( role_report ) ) + assert_not( user1.roles.include?( role_admin ) ) + assert_not( user1.roles.include?( role_customer ) ) + #assert_not( user1.roles.include?( role_report ) ) group_dasa = Group.where( name: 'dasa' ).first group_raw = Group.where( name: 'Raw' ).first - assert( !user1.groups.include?( group_dasa ) ) - assert( user1.groups.include?( group_raw ) ) + assert_not( user1.groups_access('full').include?( group_dasa ) ) + assert( user1.groups_access('full').include?( group_raw ) ) user2 = User.find(3) assert_equal( 'agent-2 firstname äöüß', user2.firstname ) @@ -86,11 +86,11 @@ class OtrsImportTest < ActiveSupport::TestCase assert( user2.roles.include?( role_agent ) ) assert( user2.roles.include?( role_admin ) ) - assert( !user2.roles.include?( role_customer ) ) + assert_not( user2.roles.include?( role_customer ) ) #assert( user2.roles.include?( role_report ) ) - assert( user2.groups.include?( group_dasa ) ) - assert( user2.groups.include?( group_raw ) ) + assert( user2.groups_access('full').include?( group_dasa ) ) + assert( user2.groups_access('full').include?( group_raw ) ) user3 = User.find(7) assert_equal( 'invalid', user3.firstname ) @@ -100,12 +100,12 @@ class OtrsImportTest < ActiveSupport::TestCase assert_equal( false, user3.active ) assert( user3.roles.include?( role_agent ) ) - assert( !user3.roles.include?( role_admin ) ) - assert( !user3.roles.include?( role_customer ) ) + assert_not( user3.roles.include?( role_admin ) ) + assert_not( user3.roles.include?( role_customer ) ) #assert( user3.roles.include?( role_report ) ) - assert( !user3.groups.include?( group_dasa ) ) - assert( !user3.groups.include?( group_raw ) ) + assert_not( user3.groups_access('full').include?( group_dasa ) ) + assert_not( user3.groups_access('full').include?( group_raw ) ) user4 = User.find(8) assert_equal( 'invalid-temp', user4.firstname ) @@ -115,12 +115,12 @@ class OtrsImportTest < ActiveSupport::TestCase assert_equal( false, user4.active ) assert( user4.roles.include?( role_agent ) ) - assert( !user4.roles.include?( role_admin ) ) - assert( !user4.roles.include?( role_customer ) ) + assert_not( user4.roles.include?( role_admin ) ) + assert_not( user4.roles.include?( role_customer ) ) #assert( user4.roles.include?( role_report ) ) - assert( !user4.groups.include?( group_dasa ) ) - assert( !user4.groups.include?( group_raw ) ) + assert_not( user4.groups_access('full').include?( group_dasa ) ) + assert_not( user4.groups_access('full').include?( group_raw ) ) end diff --git a/test/integration/zendesk_import_test.rb b/test/integration/zendesk_import_test.rb index 3e59fa1de..96191bd55 100644 --- a/test/integration/zendesk_import_test.rb +++ b/test/integration/zendesk_import_test.rb @@ -146,7 +146,7 @@ class ZendeskImportTest < ActiveSupport::TestCase end } assert_equal(check[:roles], user.roles.sort.to_a, "#{user.login} roles") - assert_equal(check[:groups], user.groups.sort.to_a, "#{user.login} groups") + assert_equal(check[:groups], user.groups_access('full').sort.to_a, "#{user.login} groups") } end diff --git a/test/test_helper.rb b/test/test_helper.rb index 24f283f45..7be5849fc 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -77,6 +77,8 @@ class ActiveSupport::TestCase ApplicationHandleInfo.current = 'unknown' Rails.logger.info '++++NEW++++TEST++++' + + travel_back end # Add more helper methods to be used by all tests here... diff --git a/test/unit/assets_test.rb b/test/unit/assets_test.rb index 95ed76cfa..52b065de7 100644 --- a/test/unit/assets_test.rb +++ b/test/unit/assets_test.rb @@ -62,7 +62,7 @@ class AssetsTest < ActiveSupport::TestCase user1 = User.find(user1.id) attributes = user1.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user1.id]), 'check assets' ) @@ -70,7 +70,7 @@ class AssetsTest < ActiveSupport::TestCase user2 = User.find(user2.id) attributes = user2.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user2.id]), 'check assets' ) @@ -78,7 +78,7 @@ class AssetsTest < ActiveSupport::TestCase user3 = User.find(user3.id) attributes = user3.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user3.id]), 'check assets' ) @@ -96,7 +96,7 @@ class AssetsTest < ActiveSupport::TestCase user1_new = User.find(user1.id) attributes = user1_new.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( !diff(attributes, assets[:User][user1_new.id]), 'check assets' ) @@ -110,7 +110,7 @@ class AssetsTest < ActiveSupport::TestCase user1 = User.find(user1.id) attributes = user1.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user1.id]), 'check assets' ) @@ -118,7 +118,7 @@ class AssetsTest < ActiveSupport::TestCase user2 = User.find(user2.id) attributes = user2.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user2.id]), 'check assets' ) @@ -126,7 +126,7 @@ class AssetsTest < ActiveSupport::TestCase user3 = User.find(user3.id) attributes = user3.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user3.id]), 'check assets' ) @@ -209,7 +209,7 @@ class AssetsTest < ActiveSupport::TestCase admin1 = User.find(admin1.id) attributes = admin1.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][admin1.id]), 'check assets' ) @@ -217,7 +217,7 @@ class AssetsTest < ActiveSupport::TestCase user1 = User.find(user1.id) attributes = user1.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user1.id]), 'check assets' ) @@ -225,7 +225,7 @@ class AssetsTest < ActiveSupport::TestCase user2 = User.find(user2.id) attributes = user2.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user2.id]), 'check assets' ) @@ -233,7 +233,7 @@ class AssetsTest < ActiveSupport::TestCase user3 = User.find(user3.id) attributes = user3.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert_nil( assets[:User][user3.id], 'check assets' ) @@ -251,7 +251,7 @@ class AssetsTest < ActiveSupport::TestCase attributes = user_new_2.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user_new_2.id]), 'check assets' ) @@ -264,7 +264,7 @@ class AssetsTest < ActiveSupport::TestCase attributes = user_new_2.attributes_with_association_ids attributes['accounts'] = {} - attributes['password'] = '' + attributes.delete('password') attributes.delete('token_ids') attributes.delete('authorization_ids') assert( diff(attributes, assets[:User][user_new_2.id]), 'check assets' ) diff --git a/test/unit/model_test.rb b/test/unit/model_test.rb index 89de3260e..c86389560 100644 --- a/test/unit/model_test.rb +++ b/test/unit/model_test.rb @@ -64,8 +64,8 @@ class ModelTest < ActiveSupport::TestCase test 'references test' do # create base - groups = Group.where( name: 'Users' ) - roles = Role.where( name: %w(Agent Admin) ) + groups = Group.where(name: 'Users') + roles = Role.where(name: %w(Agent Admin)) agent1 = User.create_or_update( login: 'model-agent1@example.com', firstname: 'Model', @@ -104,7 +104,7 @@ class ModelTest < ActiveSupport::TestCase updated_by_id: agent1.id, created_by_id: 1, ) - roles = Role.where( name: 'Customer' ) + roles = Role.where(name: 'Customer') customer1 = User.create_or_update( login: 'model-customer1@example.com', firstname: 'Model', @@ -153,10 +153,11 @@ class ModelTest < ActiveSupport::TestCase assert_equal(references1['User']['updated_by_id'], 3) assert_equal(references1['User']['created_by_id'], 1) assert_equal(references1['Organization']['updated_by_id'], 1) + assert_equal(references1['UserGroup']['user_id'], 1) assert(!references1['Group']) references_total1 = Models.references_total('User', agent1.id) - assert_equal(references_total1, 7) + assert_equal(references_total1, 8) # verify agent2 references2 = Models.references('User', agent2.id) @@ -164,10 +165,10 @@ class ModelTest < ActiveSupport::TestCase assert(!references2['User']) assert(!references2['Organization']) assert(!references2['Group']) - assert(references2.empty?) + assert_equal(references2['UserGroup']['user_id'], 1) references_total2 = Models.references_total('User', agent2.id) - assert_equal(references_total2, 0) + assert_equal(references_total2, 1) Models.merge('User', agent2.id, agent1.id) @@ -177,6 +178,7 @@ class ModelTest < ActiveSupport::TestCase assert(!references1['User']) assert(!references1['Organization']) assert(!references1['Group']) + assert(!references1['UserGroup']) assert(references1.empty?) references_total1 = Models.references_total('User', agent1.id) @@ -188,10 +190,11 @@ class ModelTest < ActiveSupport::TestCase assert_equal(references2['User']['updated_by_id'], 3) assert_equal(references2['User']['created_by_id'], 1) assert_equal(references2['Organization']['updated_by_id'], 1) + assert_equal(references2['UserGroup']['user_id'], 2) assert(!references2['Group']) references_total2 = Models.references_total('User', agent2.id) - assert_equal(references_total2, 7) + assert_equal(references_total2, 9) # org diff --git a/test/unit/object_cache_test.rb b/test/unit/object_cache_test.rb index 285b157d1..26d9b05ec 100644 --- a/test/unit/object_cache_test.rb +++ b/test/unit/object_cache_test.rb @@ -36,7 +36,7 @@ class ObjectCacheTest < ActiveSupport::TestCase test 'user cache' do roles = Role.where(name: %w(Agent Admin)) - groups = Group.all + groups = Group.all.order(:id) # be sure that minimum one admin is available User.create_or_update( @@ -65,7 +65,7 @@ class ObjectCacheTest < ActiveSupport::TestCase groups: groups, ) assets = user1.assets({}) - assert_equal(user1.group_ids.sort, assets[:User][user1.id]['group_ids'].sort) + assert_equal(user1.group_ids_access_map.sort, assets[:User][user1.id]['group_ids'].sort) # update group group1 = groups.first @@ -73,15 +73,16 @@ class ObjectCacheTest < ActiveSupport::TestCase group1.save assets = user1.assets({}) + assert(assets[:Group][group1.id]) assert_equal(group1.note, assets[:Group][group1.id]['note']) # update group - assert_equal(user1.group_ids.sort, assets[:User][user1.id]['group_ids'].sort) + assert_equal(user1.group_ids_access_map.sort, assets[:User][user1.id]['group_ids'].sort) user1.group_ids = [] user1.save assets = user1.assets({}) - assert_equal(user1.group_ids.sort, assets[:User][user1.id]['group_ids'].sort) + assert_equal(user1.group_ids_access_map.sort, assets[:User][user1.id]['group_ids'].sort) # update role assert_equal(user1.role_ids.sort, assets[:User][user1.id]['role_ids'].sort) diff --git a/test/unit/recent_view_test.rb b/test/unit/recent_view_test.rb index 912ca6931..6604b87ce 100644 --- a/test/unit/recent_view_test.rb +++ b/test/unit/recent_view_test.rb @@ -48,7 +48,7 @@ class RecentViewTest < ActiveSupport::TestCase ticket2.destroy list = RecentView.list(user1) - assert(!list[0], 'check if recent view list is empty') + assert_not(list[0], 'check if recent view list is empty') travel_back end @@ -61,7 +61,7 @@ class RecentViewTest < ActiveSupport::TestCase # check if list is empty list = RecentView.list(user) - assert(!list[0], 'check if recent view list is empty') + assert_not(list[0], 'check if recent view list is empty') # log entry of not existing record RecentView.user_log_destroy(user) @@ -69,7 +69,7 @@ class RecentViewTest < ActiveSupport::TestCase # check if list is empty list = RecentView.list(user) - assert(!list[0], 'check if recent view list is empty') + assert_not(list[0], 'check if recent view list is empty') # log entry of not existing model with permission check RecentView.user_log_destroy(user) @@ -77,7 +77,7 @@ class RecentViewTest < ActiveSupport::TestCase # check if list is empty list = RecentView.list(user) - assert(!list[0], 'check if recent view list is empty') + assert_not(list[0], 'check if recent view list is empty') end test 'permission tests' do @@ -103,6 +103,12 @@ class RecentViewTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1 ) + organization2 = Organization.create_if_not_exists( + name: 'Customer Organization Recent View 2', + note: 'some note', + updated_by_id: 1, + created_by_id: 1, + ) # no access for customer ticket1 = Ticket.create( @@ -122,7 +128,7 @@ class RecentViewTest < ActiveSupport::TestCase # check if list is empty list = RecentView.list(customer) - assert(!list[0], 'check if recent view list is empty') + assert_not(list[0], 'check if recent view list is empty') # log entry of not existing object RecentView.user_log_destroy(agent) @@ -130,7 +136,7 @@ class RecentViewTest < ActiveSupport::TestCase # check if list is empty list = RecentView.list(agent) - assert(!list[0], 'check if recent view list is empty') + assert_not(list[0], 'check if recent view list is empty') # access for customer via customer id ticket1 = Ticket.create( @@ -152,27 +158,31 @@ class RecentViewTest < ActiveSupport::TestCase list = RecentView.list(customer) assert(list[0]['o_id'], ticket1.id) assert(list[0]['object'], 'Ticket') - assert(!list[1], 'check if recent view list is empty') + assert_not(list[1], 'check if recent view list is empty') # log entry - organization = Organization.find(1) + organization1 = Organization.find(1) RecentView.user_log_destroy(customer) - RecentView.log(organization.class.to_s, organization.id, customer) + RecentView.log(organization1.class.to_s, organization1.id, customer) + RecentView.log(organization2.class.to_s, organization2.id, customer) # check if list is empty list = RecentView.list(customer) - assert(!list[0], 'check if recent view list is empty') + assert(list[0], 'check if recent view list is empty') + assert_not(list[1], 'check if recent view list is empty') # log entry - organization = Organization.find(1) + organization1 = Organization.find(1) RecentView.user_log_destroy(agent) - RecentView.log(organization.class.to_s, organization.id, agent) + RecentView.log(organization1.class.to_s, organization1.id, agent) # check if list is empty list = RecentView.list(agent) - assert(list[0]['o_id'], organization.id) + assert(list[0]['o_id'], organization1.id) assert(list[0]['object'], 'Organization') - assert(!list[1], 'check if recent view list is empty') + assert_not(list[1], 'check if recent view list is empty') + + organization2.destroy end end diff --git a/test/unit/session_basic_test.rb b/test/unit/session_basic_test.rb index 3e239a2df..aa9e7f398 100644 --- a/test/unit/session_basic_test.rb +++ b/test/unit/session_basic_test.rb @@ -56,7 +56,7 @@ class SessionBasicTest < ActiveSupport::TestCase test 'c session create / update' do # create users - roles = Role.where(name: ['Agent']) + roles = Role.where(name: %w(Agent)) groups = Group.all UserInfo.current_user_id = 1 @@ -148,7 +148,10 @@ class SessionBasicTest < ActiveSupport::TestCase assert_nil(result2, 'check collections - after touch - recall') # change collection - group = Group.create(name: "SomeGroup::#{rand(999_999)}", active: true) + group = Group.create( + name: "SomeGroup::#{rand(999_999)}", + active: true + ) travel 4.seconds # get whole collections @@ -242,31 +245,52 @@ class SessionBasicTest < ActiveSupport::TestCase test 'c ticket_create' do - UserInfo.current_user_id = 2 - user = User.lookup(id: 1) - ticket_create_client1 = Sessions::Backend::TicketCreate.new(user, {}, false, '123-1', 3) + # create users + roles = Role.where(name: %w(Agent)) + groups = Group.all + + UserInfo.current_user_id = 1 + agent1 = User.create_or_update( + login: 'session-agent-1', + firstname: 'Session', + lastname: 'Agent 1', + email: 'session-agent-1@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + agent1.save! + + ticket_create_client1 = Sessions::Backend::TicketCreate.new(agent1, {}, false, '123-1', 3) # get as stream result1 = ticket_create_client1.push assert(result1, 'check ticket_create') - sleep 0.6 + travel 1.second # next check should be empty result1 = ticket_create_client1.push assert(!result1, 'check ticket_create - recall') # next check should be empty - sleep 0.6 + travel 1.second result1 = ticket_create_client1.push assert(!result1, 'check ticket_create - recall 2') - Group.create(name: "SomeTicketCreateGroup::#{rand(999_999)}", active: true) + group = Group.create(name: "SomeTicketCreateGroup::#{rand(999_999)}", active: true) + agent1.groups = Group.all + agent1.save! + + # next check should be empty + result1 = ticket_create_client1.push travel 4.seconds # get as stream result1 = ticket_create_client1.push assert(result1, 'check ticket_create - recall 3') + travel_back end From d93d4049025be2f5a544c3894d257f12a14285f3 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 17 Jun 2017 00:53:20 +0200 Subject: [PATCH 043/234] Improved tests (do not break on before/after callbacks). --- .../application_model/can_latest_change.rb | 4 +- .../checks_attribute_length.rb | 1 + app/models/application_model/checks_import.rb | 1 + .../checks_user_columns_fillup.rb | 10 ++- .../application_model/has_recent_views.rb | 1 + app/models/calendar.rb | 7 +- .../concerns/checks_condition_validation.rb | 3 +- app/models/concerns/checks_html_sanitized.rb | 3 +- .../concerns/checks_latest_change_observed.rb | 2 + .../concerns/has_activity_stream_log.rb | 9 +- app/models/concerns/has_karma_activity_log.rb | 1 + app/models/concerns/has_links.rb | 1 + .../concerns/has_online_notifications.rb | 1 + .../concerns/has_search_index_backend.rb | 3 + app/models/concerns/has_tags.rb | 1 + app/models/job.rb | 2 + .../ticket/article/fillup_from_email.rb | 15 ++-- .../ticket/article/fillup_from_general.rb | 18 ++-- app/models/observer/ticket/close_time.rb | 4 +- app/models/observer/transaction.rb | 8 +- app/models/observer/user/geo.rb | 2 + app/models/organization.rb | 3 +- app/models/overview.rb | 3 + app/models/role.rb | 8 +- app/models/setting.rb | 10 ++- app/models/tag.rb | 1 + app/models/ticket.rb | 36 ++++---- app/models/ticket/article.rb | 12 ++- app/models/token.rb | 2 +- app/models/translation.rb | 6 +- app/models/user.rb | 65 ++++++++------ lib/sessions/backend/collections/base.rb | 6 +- test/controllers/search_controller_test.rb | 8 +- test/controllers/tickets_controller_test.rb | 2 +- test/unit/session_basic_test.rb | 84 +++++++++++-------- test/unit/session_basic_ticket_test.rb | 7 +- test/unit/session_collections_test.rb | 17 ++-- test/unit/session_enhanced_test.rb | 16 ++-- 38 files changed, 218 insertions(+), 165 deletions(-) diff --git a/app/models/application_model/can_latest_change.rb b/app/models/application_model/can_latest_change.rb index ad69dcc48..caaa6456e 100644 --- a/app/models/application_model/can_latest_change.rb +++ b/app/models/application_model/can_latest_change.rb @@ -23,7 +23,7 @@ returns # if we do not have it cached, do lookup if !updated_at - o = select(:updated_at).order(updated_at: :desc).limit(1).first + o = select(:updated_at).order(updated_at: :desc, id: :desc).limit(1).first if o updated_at = o.updated_at latest_change_set(updated_at) @@ -34,7 +34,7 @@ returns def latest_change_set(updated_at) key = "#{new.class.name}_latest_change" - expires_in = 31_536_000 # 1 year + expires_in = 86_400 # 1 day if updated_at.nil? Cache.delete(key) diff --git a/app/models/application_model/checks_attribute_length.rb b/app/models/application_model/checks_attribute_length.rb index 5e49f4fe7..5254b035c 100644 --- a/app/models/application_model/checks_attribute_length.rb +++ b/app/models/application_model/checks_attribute_length.rb @@ -34,5 +34,6 @@ check string/varchar size and cut them if needed self[attribute[0]] = self[ attribute[0] ].utf8_to_3bytesutf8 end } + true end end diff --git a/app/models/application_model/checks_import.rb b/app/models/application_model/checks_import.rb index 82e7e6f9a..feb1def4f 100644 --- a/app/models/application_model/checks_import.rb +++ b/app/models/application_model/checks_import.rb @@ -15,5 +15,6 @@ module ApplicationModel::ChecksImport return if Setting.get('import_mode') && import_class_list.include?(self.class.to_s) return if !has_attribute?(:id) self[:id] = nil + true end end diff --git a/app/models/application_model/checks_user_columns_fillup.rb b/app/models/application_model/checks_user_columns_fillup.rb index b70bf8060..a7b640197 100644 --- a/app/models/application_model/checks_user_columns_fillup.rb +++ b/app/models/application_model/checks_user_columns_fillup.rb @@ -31,14 +31,15 @@ returns end end - return if !self.class.column_names.include? 'created_by_id' + return true if !self.class.column_names.include? 'created_by_id' - return if !UserInfo.current_user_id + return true if !UserInfo.current_user_id if created_by_id && created_by_id != UserInfo.current_user_id logger.info "NOTICE create - self.created_by_id is different: #{created_by_id}/#{UserInfo.current_user_id}" end self.created_by_id = UserInfo.current_user_id + true end =begin @@ -56,9 +57,10 @@ returns =end def fill_up_user_update - return if !self.class.column_names.include? 'updated_by_id' - return if !UserInfo.current_user_id + return true if !self.class.column_names.include? 'updated_by_id' + return true if !UserInfo.current_user_id self.updated_by_id = UserInfo.current_user_id + true end end diff --git a/app/models/application_model/has_recent_views.rb b/app/models/application_model/has_recent_views.rb index b8f493e86..b753a18e8 100644 --- a/app/models/application_model/has_recent_views.rb +++ b/app/models/application_model/has_recent_views.rb @@ -17,5 +17,6 @@ delete object recent viewed list, will be executed automatically def recent_view_destroy RecentView.log_destroy(self.class.to_s, id) + true end end diff --git a/app/models/calendar.rb b/app/models/calendar.rb index 882e19a6b..56fee6a93 100644 --- a/app/models/calendar.rb +++ b/app/models/calendar.rb @@ -240,13 +240,14 @@ returns # if changed calendar is default, set all others default to false def sync_default - return if !default + return true if !default Calendar.find_each { |calendar| next if calendar.id == id next if !calendar.default calendar.default = false calendar.save } + true end # check if min one is set to default true @@ -270,11 +271,13 @@ returns sla.save! end } + true end # fetch ical feed def fetch_ical sync(true) + true end # validate format of public holidays @@ -292,6 +295,6 @@ returns false end } - + true end end diff --git a/app/models/concerns/checks_condition_validation.rb b/app/models/concerns/checks_condition_validation.rb index 945f978c0..7bdee3770 100644 --- a/app/models/concerns/checks_condition_validation.rb +++ b/app/models/concerns/checks_condition_validation.rb @@ -27,8 +27,7 @@ module ChecksConditionValidation } ticket_count, tickets = Ticket.selectors(validate_condition, 1, User.find(1)) - return if ticket_count.present? - + return true if ticket_count.present? raise Exceptions::UnprocessableEntity, 'Invalid ticket selector conditions' end end diff --git a/app/models/concerns/checks_html_sanitized.rb b/app/models/concerns/checks_html_sanitized.rb index f3db21a9a..e89a12fa4 100644 --- a/app/models/concerns/checks_html_sanitized.rb +++ b/app/models/concerns/checks_html_sanitized.rb @@ -9,7 +9,7 @@ module ChecksHtmlSanitized def sanitized_html_attributes html_attributes = self.class.instance_variable_get(:@sanitized_html) || [] - return if html_attributes.empty? + return true if html_attributes.empty? html_attributes.each do |attribute| value = send(attribute) @@ -19,6 +19,7 @@ module ChecksHtmlSanitized send("#{attribute}=".to_sym, HtmlSanitizer.strict(value)) end + true end def sanitizeable?(_attribute, _value) diff --git a/app/models/concerns/checks_latest_change_observed.rb b/app/models/concerns/checks_latest_change_observed.rb index 97b8a37ca..09bad426f 100644 --- a/app/models/concerns/checks_latest_change_observed.rb +++ b/app/models/concerns/checks_latest_change_observed.rb @@ -11,9 +11,11 @@ module ChecksLatestChangeObserved def latest_change_set_from_observer self.class.latest_change_set(updated_at) + true end def latest_change_set_from_observer_destroy self.class.latest_change_set(nil) + true end end diff --git a/app/models/concerns/has_activity_stream_log.rb b/app/models/concerns/has_activity_stream_log.rb index 8af68e63a..d921945ea 100644 --- a/app/models/concerns/has_activity_stream_log.rb +++ b/app/models/concerns/has_activity_stream_log.rb @@ -19,6 +19,7 @@ log object create activity stream, if configured - will be executed automaticall def activity_stream_create activity_stream_log('create', self['created_by_id']) + true end =begin @@ -31,7 +32,7 @@ log object update activity stream, if configured - will be executed automaticall =end def activity_stream_update - return if !changed? + return true if !changed? ignored_attributes = self.class.instance_variable_get(:@activity_stream_attributes_ignored) || [] ignored_attributes += %i(created_at updated_at created_by_id updated_by_id) @@ -42,10 +43,9 @@ log object update activity stream, if configured - will be executed automaticall log = true } - - return if !log - + return true if !log activity_stream_log('update', self['updated_by_id']) + true end =begin @@ -59,6 +59,7 @@ delete object activity stream, will be executed automatically def activity_stream_destroy ActivityStream.remove(self.class.to_s, id) + true end # methods defined here are going to extend the class, not the instance of it diff --git a/app/models/concerns/has_karma_activity_log.rb b/app/models/concerns/has_karma_activity_log.rb index 70768a309..6c7f61ceb 100644 --- a/app/models/concerns/has_karma_activity_log.rb +++ b/app/models/concerns/has_karma_activity_log.rb @@ -17,5 +17,6 @@ delete object online notification list, will be executed automatically def karma_activity_log_destroy Karma::ActivityLog.remove(self.class.to_s, id) + true end end diff --git a/app/models/concerns/has_links.rb b/app/models/concerns/has_links.rb index 4a8e0dc5f..088fa104f 100644 --- a/app/models/concerns/has_links.rb +++ b/app/models/concerns/has_links.rb @@ -20,5 +20,6 @@ delete object link list, will be executed automatically link_object: self.class.to_s, link_object_value: id, ) + true end end diff --git a/app/models/concerns/has_online_notifications.rb b/app/models/concerns/has_online_notifications.rb index cb2ee529f..a6fd9653e 100644 --- a/app/models/concerns/has_online_notifications.rb +++ b/app/models/concerns/has_online_notifications.rb @@ -17,5 +17,6 @@ delete object online notification list, will be executed automatically def online_notification_destroy OnlineNotification.remove(self.class.to_s, id) + true end end diff --git a/app/models/concerns/has_search_index_backend.rb b/app/models/concerns/has_search_index_backend.rb index 42b14a822..8ab494ce2 100644 --- a/app/models/concerns/has_search_index_backend.rb +++ b/app/models/concerns/has_search_index_backend.rb @@ -24,6 +24,7 @@ update search index, if configured - will be executed automatically # start background job to transfer data to search index return if !SearchIndexBackend.enabled? Delayed::Job.enqueue(BackgroundJobSearchIndex.new(self.class.to_s, id)) + true end =begin @@ -38,6 +39,7 @@ delete search index object, will be executed automatically def search_index_destroy return if ignore_search_indexing?(:destroy) SearchIndexBackend.remove(self.class.to_s, id) + true end =begin @@ -60,6 +62,7 @@ returns # update backend SearchIndexBackend.add(self.class.to_s, attributes) + true end =begin diff --git a/app/models/concerns/has_tags.rb b/app/models/concerns/has_tags.rb index 5dbbd5fae..3575d832b 100644 --- a/app/models/concerns/has_tags.rb +++ b/app/models/concerns/has_tags.rb @@ -73,6 +73,7 @@ destroy all tags of an object o_id: id, created_by_id: current_user_id, ) + true end end diff --git a/app/models/job.rb b/app/models/job.rb index 332a58944..03f06eecf 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -203,10 +203,12 @@ class Job < ApplicationModel def updated_matching self.matching = matching_count + true end def update_next_run_at self.next_run_at = next_run_at_calculate + true end def match_minutes(minutes) diff --git a/app/models/observer/ticket/article/fillup_from_email.rb b/app/models/observer/ticket/article/fillup_from_email.rb index abc376395..1fc206ecd 100644 --- a/app/models/observer/ticket/article/fillup_from_email.rb +++ b/app/models/observer/ticket/article/fillup_from_email.rb @@ -6,22 +6,22 @@ class Observer::Ticket::Article::FillupFromEmail < ActiveRecord::Observer def before_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # only do fill of email from if article got created via application_server (e. g. not # if article and sender type is set via *.postmaster) - return if ApplicationHandleInfo.current.split('.')[1] == 'postmaster' + return true if ApplicationHandleInfo.current.split('.')[1] == 'postmaster' # if sender is customer, do not change anything - return if !record.sender_id + return true if !record.sender_id sender = Ticket::Article::Sender.lookup(id: record.sender_id) - return if sender.nil? - return if sender['name'] == 'Customer' + return true if sender.nil? + return true if sender['name'] == 'Customer' # set email attributes - return if !record.type_id + return true if !record.type_id type = Ticket::Article::Type.lookup(id: record.type_id) - return if type['name'] != 'email' + return true if type['name'] != 'email' # set subject if empty ticket = Ticket.lookup(id: record.ticket_id) @@ -59,5 +59,6 @@ class Observer::Ticket::Article::FillupFromEmail < ActiveRecord::Observer else record.from = Channel::EmailBuild.recipient_line(email_address.realname, email_address.email) end + true end end diff --git a/app/models/observer/ticket/article/fillup_from_general.rb b/app/models/observer/ticket/article/fillup_from_general.rb index 8fbb9504d..7c6ce9166 100644 --- a/app/models/observer/ticket/article/fillup_from_general.rb +++ b/app/models/observer/ticket/article/fillup_from_general.rb @@ -6,24 +6,24 @@ class Observer::Ticket::Article::FillupFromGeneral < ActiveRecord::Observer def before_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # only do fill of from if article got created via application_server (e. g. not # if article and sender type is set via *.postmaster) - return if ApplicationHandleInfo.current.split('.')[1] == 'postmaster' + return true if ApplicationHandleInfo.current.split('.')[1] == 'postmaster' # set from on all article types excluding email|twitter status|twitter direct-message|facebook feed post|facebook feed comment - return if !record.type_id + return true if !record.type_id type = Ticket::Article::Type.lookup(id: record.type_id) - return if type['name'] == 'email' + return true if type['name'] == 'email' # from will be set by channel backend - return if type['name'] == 'twitter status' - return if type['name'] == 'twitter direct-message' - return if type['name'] == 'facebook feed post' - return if type['name'] == 'facebook feed comment' + return true if type['name'] == 'twitter status' + return true if type['name'] == 'twitter direct-message' + return true if type['name'] == 'facebook feed post' + return true if type['name'] == 'facebook feed comment' - return if !record.created_by_id + return true if !record.created_by_id user = User.find(record.created_by_id) if type.name == 'web' record.from = "#{user.firstname} #{user.lastname} <#{user.email}>" diff --git a/app/models/observer/ticket/close_time.rb b/app/models/observer/ticket/close_time.rb index bcedea1fd..afb1ce845 100644 --- a/app/models/observer/ticket/close_time.rb +++ b/app/models/observer/ticket/close_time.rb @@ -16,13 +16,13 @@ class Observer::Ticket::CloseTime < ActiveRecord::Observer def _check(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # check if close_at is already set return true if record.close_at # check if ticket is closed now - return if !record.state_id + return true if !record.state_id state = Ticket::State.lookup(id: record.state_id) state_type = Ticket::StateType.lookup(id: state.state_type_id) return true if state_type.name != 'closed' diff --git a/app/models/observer/transaction.rb b/app/models/observer/transaction.rb index be7092504..e20e5965d 100644 --- a/app/models/observer/transaction.rb +++ b/app/models/observer/transaction.rb @@ -176,7 +176,7 @@ class Observer::Transaction < ActiveRecord::Observer def after_create(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') e = { object: record.class.name, @@ -187,12 +187,13 @@ class Observer::Transaction < ActiveRecord::Observer created_at: Time.zone.now, } EventBuffer.add('transaction', e) + true end def before_update(record) # return if we run import mode - return if Setting.get('import_mode') + return true if Setting.get('import_mode') # ignore certain attributes real_changes = {} @@ -210,7 +211,7 @@ class Observer::Transaction < ActiveRecord::Observer } # do not send anything if nothing has changed - return if real_changes.empty? + return true if real_changes.empty? changed_by_id = nil changed_by_id = if record.respond_to?('updated_by_id') @@ -229,6 +230,7 @@ class Observer::Transaction < ActiveRecord::Observer created_at: Time.zone.now, } EventBuffer.add('transaction', e) + true end end diff --git a/app/models/observer/user/geo.rb b/app/models/observer/user/geo.rb index 9d55129c2..f78327f10 100644 --- a/app/models/observer/user/geo.rb +++ b/app/models/observer/user/geo.rb @@ -5,10 +5,12 @@ class Observer::User::Geo < ActiveRecord::Observer def before_create(record) check_geo(record) + true end def before_update(record) check_geo(record) + true end # check if geo need to be updated diff --git a/app/models/organization.rb b/app/models/organization.rb index a68b8505e..967e72558 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -25,11 +25,12 @@ class Organization < ApplicationModel private def domain_cleanup - return if domain.blank? + return true if domain.blank? domain.gsub!(/@/, '') domain.gsub!(/\s*/, '') domain.strip! domain.downcase! + true end end diff --git a/app/models/overview.rb b/app/models/overview.rb index aa1213b4b..1abc9f2be 100644 --- a/app/models/overview.rb +++ b/app/models/overview.rb @@ -24,17 +24,20 @@ class Overview < ApplicationModel def fill_prio return true if prio self.prio = 9999 + true end def fill_link_on_create return true if !link.empty? self.link = link_name(name) + true end def fill_link_on_update return true if link.empty? return true if !changes['name'] self.link = link_name(name) + true end def link_name(name) diff --git a/app/models/role.rb b/app/models/role.rb index 174ea005d..2a362a4c0 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -125,7 +125,7 @@ returns private def validate_permissions - return if !self.permission_ids + return true if !self.permission_ids permission_ids.each { |permission_id| permission = Permission.lookup(id: permission_id) raise "Unable to find permission for id #{permission_id}" if !permission @@ -137,17 +137,19 @@ returns raise "Permission #{permission.name} conflicts with #{local_permission.name}" if permission_ids.include?(local_permission.id) } } + true end def validate_agent_limit(permission) - return if !Setting.get('system_agent_limit') - return if permission.name != 'ticket.agent' + return true if !Setting.get('system_agent_limit') + return true if permission.name != 'ticket.agent' ticket_agent_role_ids = Role.joins(:permissions).where(permissions: { name: 'ticket.agent' }).pluck(:id) ticket_agent_role_ids.push(id) count = User.joins(:roles).where(roles: { id: ticket_agent_role_ids }, users: { active: true }).count raise Exceptions::UnprocessableEntity, 'Agent limit exceeded, please check your account settings.' if count > Setting.get('system_agent_limit') + true end end diff --git a/app/models/setting.rb b/app/models/setting.rb index 17ff09e71..3734cb848 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -131,6 +131,7 @@ reload config settings # set initial value in state_initial def set_initial self.state_initial = state_current + true end def reset_change_id @@ -139,6 +140,7 @@ reload config settings logger.debug "Setting.reset_change_id: set new cache, #{change_id}" Cache.write('Setting::ChangeId', change_id, { expires_in: 24.hours }) @@lookup_at = nil # rubocop:disable Style/ClassVars + true end # check if cache is still valid @@ -160,14 +162,15 @@ reload config settings # convert state into hash to be able to store it as store def state_check - return if !state - return if state && state.respond_to?('has_key?') && state.key?(:value) + return true if !state + return true if state && state.respond_to?('has_key?') && state.key?(:value) self.state_current = { value: state } + true end # notify clients about public config changes def check_broadcast - return if frontend != true + return true if frontend != true value = state_current if state_current.key?(:value) value = state_current[:value] @@ -179,5 +182,6 @@ reload config settings }, 'public' ) + true end end diff --git a/app/models/tag.rb b/app/models/tag.rb index 529114905..3c327071e 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -292,6 +292,7 @@ remove tag item (destroy with reverences) def fill_namedowncase self.name_downcase = name.downcase + true end end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index d549e9561..ecb483310 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -1045,42 +1045,42 @@ result private def check_generate - return if number + return true if number self.number = Ticket::Number.generate + true end def check_title - return if !title + return true if !title title.gsub!(/\s|\t|\r/, ' ') + true end def check_defaults if !owner_id self.owner_id = 1 end - - return if !customer_id - + return true if !customer_id customer = User.find_by(id: customer_id) - return if !customer - return if organization_id == customer.organization_id - + return true if !customer + return true if organization_id == customer.organization_id self.organization_id = customer.organization_id + true end def reset_pending_time # ignore if no state has changed - return if !changes['state_id'] + return true if !changes['state_id'] # check if new state isn't pending* current_state = Ticket::State.lookup(id: state_id) current_state_type = Ticket::StateType.lookup(id: current_state.state_type_id) # in case, set pending_time to nil - return if current_state_type.name =~ /^pending/i - + return true if current_state_type.name =~ /^pending/i self.pending_time = nil + true end def check_escalation_update @@ -1089,20 +1089,18 @@ result end def set_default_state - return if state_id - + return true if state_id default_ticket_state = Ticket::State.find_by(default_create: true) - return if !default_ticket_state - + return true if !default_ticket_state self.state_id = default_ticket_state.id + true end def set_default_priority - return if priority_id - + return true if priority_id default_ticket_priority = Ticket::Priority.find_by(default_create: true) - return if !default_ticket_priority - + return true if !default_ticket_priority self.priority_id = default_ticket_priority.id + true end end diff --git a/app/models/ticket/article.rb b/app/models/ticket/article.rb index 546f614fa..8bc557bfe 100644 --- a/app/models/ticket/article.rb +++ b/app/models/ticket/article.rb @@ -37,8 +37,7 @@ class Ticket::Article < ApplicationModel # fillup md5 of message id to search easier on very long message ids def check_message_id_md5 - return if !message_id - return if message_id_md5 + return true if message_id.blank? self.message_id_md5 = Digest::MD5.hexdigest(message_id.to_s) end @@ -55,7 +54,7 @@ returns =end def self.insert_urls(article) - return article if article['attachments'].empty? + return article if article['attachments'].blank? return article if article['content_type'] !~ %r{text/html}i return article if article['body'] !~ / Setting.get('system_agent_limit') + true end def domain_based_assignment - return if !email - return if organization_id + return true if !email + return true if organization_id begin domain = Mail::Address.new(email).domain - return if !domain + return true if !domain organization = Organization.find_by(domain: domain.downcase, domain_assignment: true) - return if !organization + return true if !organization self.organization_id = organization.id rescue - return + return true end + true end # sets locale of the user def set_locale # set the user's locale to the one of the "executing" user - return if !UserInfo.current_user_id + return true if !UserInfo.current_user_id user = User.find_by(id: UserInfo.current_user_id) - return if !user - return if !user.preferences[:locale] + return true if !user + return true if !user.preferences[:locale] preferences[:locale] = user.preferences[:locale] + true end def avatar_for_email_check - return if email.blank? - return if email !~ /@/ - return if !changes['email'] && updated_at > Time.zone.now - 10.days + return true if email.blank? + return true if email !~ /@/ + return true if !changes['email'] && updated_at > Time.zone.now - 10.days # save/update avatar avatar = Avatar.auto_detection( @@ -974,10 +982,11 @@ raise 'Minimum one user need to have admin permissions' ) # update user link - return if !avatar + return true if !avatar update_column(:image, avatar.store_hash) cache_delete + true end def avatar_destroy @@ -985,9 +994,10 @@ raise 'Minimum one user need to have admin permissions' end def ensure_password - return if password_empty? - return if PasswordHash.crypted?(password) + return true if password_empty? + return true if PasswordHash.crypted?(password) self.password = PasswordHash.crypt(password) + true end def password_empty? @@ -1006,8 +1016,9 @@ raise 'Minimum one user need to have admin permissions' # reset login_failed if password is changed def reset_login_failed - return if !changes - return if !changes['password'] + return true if !changes + return true if !changes['password'] self.login_failed = 0 + true end end diff --git a/lib/sessions/backend/collections/base.rb b/lib/sessions/backend/collections/base.rb index d85c5dd14..32cf96ba3 100644 --- a/lib/sessions/backend/collections/base.rb +++ b/lib/sessions/backend/collections/base.rb @@ -36,13 +36,13 @@ class Sessions::Backend::Collections::Base < Sessions::Backend::Base # check if update has been done last_change = self.class.model.constantize.latest_change - return if last_change == @last_change - @last_change = last_change + return if last_change.to_s == @last_change + @last_change = last_change.to_s # load current data items = load - return if !items || items.empty? + return if items.blank? # get relations of data all = [] diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb index dffe4e693..d348240ac 100644 --- a/test/controllers/search_controller_test.rb +++ b/test/controllers/search_controller_test.rb @@ -85,8 +85,6 @@ class SearchControllerTest < ActionDispatch::IntegrationTest organization_id: @organization.id, ) - Ticket.all.destroy_all - @ticket1 = Ticket.create!( title: 'test 1234-1', group: Group.lookup(name: 'Users'), @@ -105,7 +103,7 @@ class SearchControllerTest < ActionDispatch::IntegrationTest sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, ) - sleep 1 + travel 1.second @ticket2 = Ticket.create!( title: 'test 1234-2', group: Group.lookup(name: 'Users'), @@ -124,7 +122,7 @@ class SearchControllerTest < ActionDispatch::IntegrationTest sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, ) - sleep 1 + travel 1.second @ticket3 = Ticket.create!( title: 'test 1234-2', group: Group.lookup(name: 'Users'), @@ -162,6 +160,8 @@ class SearchControllerTest < ActionDispatch::IntegrationTest Setting.set('es_index', ENV['ES_INDEX']) end + travel 1.minute + # drop/create indexes Rake::Task.clear Zammad::Application.load_tasks diff --git a/test/controllers/tickets_controller_test.rb b/test/controllers/tickets_controller_test.rb index 5fe9c8537..799eb78d4 100644 --- a/test/controllers/tickets_controller_test.rb +++ b/test/controllers/tickets_controller_test.rb @@ -822,7 +822,7 @@ AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO created_by_id: 1, ) tickets.push ticket - sleep 1 + travel 2.seconds } credentials = ActionController::HttpAuthentication::Basic.encode_credentials('tickets-admin', 'adminpw') diff --git a/test/unit/session_basic_test.rb b/test/unit/session_basic_test.rb index aa9e7f398..02fd41fc8 100644 --- a/test/unit/session_basic_test.rb +++ b/test/unit/session_basic_test.rb @@ -2,12 +2,6 @@ require 'test_helper' class SessionBasicTest < ActiveSupport::TestCase - setup do - user = User.lookup(id: 1) - roles = Role.where(name: %w(Agent Admin)) - user.roles = roles - user.save! - end test 'b cache' do Sessions::CacheIn.set('last_run_test', true, { expires_in: 1.second }) @@ -59,7 +53,6 @@ class SessionBasicTest < ActiveSupport::TestCase roles = Role.where(name: %w(Agent)) groups = Group.all - UserInfo.current_user_id = 1 agent1 = User.create_or_update( login: 'session-agent-1', firstname: 'Session', @@ -69,9 +62,9 @@ class SessionBasicTest < ActiveSupport::TestCase active: true, roles: roles, groups: groups, + updated_by_id: 1, + created_by_id: 1, ) - agent1.roles = roles - agent1.save # create sessions client_id1 = '123456789' @@ -109,10 +102,25 @@ class SessionBasicTest < ActiveSupport::TestCase test 'c collections group' do require 'sessions/backend/collections/group.rb' - UserInfo.current_user_id = 2 - user = User.lookup(id: 1) - collection_client1 = Sessions::Backend::Collections::Group.new(user, {}, false, '123-1', 3) - collection_client2 = Sessions::Backend::Collections::Group.new(user, {}, false, '234-2', 3) + # create users + roles = Role.where(name: ['Agent']) + groups = Group.all + + agent1 = User.create_or_update( + login: 'session-collection-agent-1', + firstname: 'Session', + lastname: 'Agent 1', + email: 'session-collection-agent1@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + updated_by_id: 1, + created_by_id: 1, + ) + + collection_client1 = Sessions::Backend::Collections::Group.new(agent1, {}, false, '123-1', 3) + collection_client2 = Sessions::Backend::Collections::Group.new(agent1, {}, false, '234-2', 3) # get whole collections result1 = collection_client1.push @@ -131,12 +139,14 @@ class SessionBasicTest < ActiveSupport::TestCase # change collection group = Group.first + travel 4.seconds group.touch travel 4.seconds # get whole collections result1 = collection_client1.push assert(!result1.empty?, 'check collections - after touch') + result2 = collection_client2.push assert(!result2.empty?, 'check collections - after touch') assert_equal(result1.to_yaml, result2.to_yaml, 'check collections') @@ -148,9 +158,11 @@ class SessionBasicTest < ActiveSupport::TestCase assert_nil(result2, 'check collections - after touch - recall') # change collection - group = Group.create( - name: "SomeGroup::#{rand(999_999)}", - active: true + group = Group.create!( + name: "SomeGroup::#{rand(999_999)}", + active: true, + created_by_id: 1, + updated_by_id: 1, ) travel 4.seconds @@ -194,7 +206,6 @@ class SessionBasicTest < ActiveSupport::TestCase roles = Role.where(name: %w(Agent Admin)) groups = Group.all - UserInfo.current_user_id = 2 agent1 = User.create_or_update( login: 'activity-stream-agent-1', firstname: 'Session', @@ -204,9 +215,9 @@ class SessionBasicTest < ActiveSupport::TestCase active: true, roles: roles, groups: groups, + updated_by_id: 1, + created_by_id: 1, ) - agent1.roles = roles - assert(agent1.save, 'create/update agent1') # create min. on activity record random_name = "Random:#{rand(9_999_999_999)}" @@ -233,7 +244,15 @@ class SessionBasicTest < ActiveSupport::TestCase assert(!result1, 'check as agent1 - recall 2') agent1.update_attribute(:email, 'activity-stream-agent11@example.com') - ticket = Ticket.create(title: '12323', group_id: 1, priority_id: 1, state_id: 1, customer_id: 1) + ticket = Ticket.create( + title: '12323', + group_id: 1, + priority_id: 1, + state_id: 1, + customer_id: 1, + updated_by_id: 1, + created_by_id: 1, + ) travel 4.seconds @@ -246,21 +265,21 @@ class SessionBasicTest < ActiveSupport::TestCase test 'c ticket_create' do # create users - roles = Role.where(name: %w(Agent)) + roles = Role.where(name: %w(Agent Admin)) groups = Group.all - UserInfo.current_user_id = 1 agent1 = User.create_or_update( - login: 'session-agent-1', + login: 'ticket_create-agent-1', firstname: 'Session', - lastname: 'Agent 1', - email: 'session-agent-1@example.com', + lastname: "ticket_create #{rand(99_999)}", + email: 'ticket_create-agent1@example.com', password: 'agentpw', active: true, roles: roles, groups: groups, + updated_by_id: 1, + created_by_id: 1, ) - agent1.save! ticket_create_client1 = Sessions::Backend::TicketCreate.new(agent1, {}, false, '123-1', 3) @@ -278,19 +297,18 @@ class SessionBasicTest < ActiveSupport::TestCase result1 = ticket_create_client1.push assert(!result1, 'check ticket_create - recall 2') - group = Group.create(name: "SomeTicketCreateGroup::#{rand(999_999)}", active: true) - agent1.groups = Group.all - agent1.save! - - # next check should be empty - result1 = ticket_create_client1.push + Group.create( + name: "SomeTicketCreateGroup::#{rand(999_999)}", + active: true, + updated_by_id: 1, + created_by_id: 1, + ) travel 4.seconds # get as stream result1 = ticket_create_client1.push assert(result1, 'check ticket_create - recall 3') - travel_back end diff --git a/test/unit/session_basic_ticket_test.rb b/test/unit/session_basic_ticket_test.rb index 072c9e1a4..7cc0d8ddc 100644 --- a/test/unit/session_basic_ticket_test.rb +++ b/test/unit/session_basic_ticket_test.rb @@ -5,9 +5,6 @@ class SessionBasicTicketTest < ActiveSupport::TestCase test 'b ticket_overview_List' do UserInfo.current_user_id = 1 - Ticket.destroy_all - - # create users roles = Role.where(name: ['Agent']) groups = Group.all @@ -21,9 +18,7 @@ class SessionBasicTicketTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - - agent1.roles = roles - assert(agent1.save, 'create/update agent1') + assert(agent1.save!, 'create/update agent1') Ticket.create(title: 'default overview test', group_id: 1, priority_id: 1, state_id: 1, customer_id: 1) diff --git a/test/unit/session_collections_test.rb b/test/unit/session_collections_test.rb index 08b5b61d5..2b5691909 100644 --- a/test/unit/session_collections_test.rb +++ b/test/unit/session_collections_test.rb @@ -22,8 +22,7 @@ class SessionCollectionsTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent1.roles = roles - agent1.save + agent1.save! roles = Role.where(name: ['Agent']) groups = Group.all @@ -39,8 +38,7 @@ class SessionCollectionsTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent2.roles = roles - agent2.save + agent2.save! roles = Role.where(name: ['Customer']) customer1 = User.create_or_update( @@ -53,9 +51,7 @@ class SessionCollectionsTest < ActiveSupport::TestCase active: true, roles: roles, ) - customer1.roles = roles - customer1.save - + customer1.save! collection_client1 = Sessions::Backend::Collections.new(agent1, {}, nil, 'aaa-1', 2) collection_client2 = Sessions::Backend::Collections.new(agent2, {}, nil, 'bbb-2', 2) collection_client3 = Sessions::Backend::Collections.new(customer1, {}, nil, 'ccc-2', 2) @@ -107,11 +103,13 @@ class SessionCollectionsTest < ActiveSupport::TestCase # change collection group = Group.first + travel 6.seconds group.touch - travel 4.seconds + travel 6.seconds # get whole collections result1 = collection_client1.push + assert(result1, 'check collections - after touch') assert(check_if_collection_exists(result1, :Group), 'check collections - after touch') travel 0.1.seconds @@ -173,7 +171,6 @@ class SessionCollectionsTest < ActiveSupport::TestCase end test 'b assets' do - # create users roles = Role.where(name: %w(Agent Admin)) groups = Group.all.order(id: :asc) @@ -188,7 +185,7 @@ class SessionCollectionsTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - assert(agent1.save, 'create/update agent1') + assert(agent1.save!, 'create/update agent1') assets = {} client1 = Sessions::Backend::Collections::Group.new(agent1, assets, false, '123-1', 4) diff --git a/test/unit/session_enhanced_test.rb b/test/unit/session_enhanced_test.rb index 9ce8c0ece..0c3303003 100644 --- a/test/unit/session_enhanced_test.rb +++ b/test/unit/session_enhanced_test.rb @@ -19,8 +19,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent1.roles = roles - agent1.save + agent1.save! agent2 = User.create_or_update( login: 'session-agent-2', firstname: 'Session', @@ -31,8 +30,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent2.roles = roles - agent2.save + agent2.save! agent3 = User.create_or_update( login: 'session-agent-3', firstname: 'Session', @@ -43,8 +41,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent3.roles = roles - agent3.save + agent3.save! # create sessions client_id1 = 'a1234' @@ -197,7 +194,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent1.save + agent1.save! agent2 = User.create_or_update( login: 'session-agent-2', firstname: 'Session', @@ -209,7 +206,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent2.save + agent2.save! agent3 = User.create_or_update( login: 'session-agent-3', firstname: 'Session', @@ -221,7 +218,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent3.save + agent3.save! # create sessions client_id1_0 = 'b1234-1' @@ -288,6 +285,7 @@ class SessionEnhancedTest < ActiveSupport::TestCase # change collection group = Group.first + travel 4.seconds group.touch travel 12.seconds From 6f349abcbef51825be3003cd88f00b7c1246a800 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 17 Jun 2017 00:54:20 +0200 Subject: [PATCH 044/234] Improved memory usage on writing storage from/to fs/database. --- app/models/store/file.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/store/file.rb b/app/models/store/file.rb index c430e3610..e79a7ad48 100644 --- a/app/models/store/file.rb +++ b/app/models/store/file.rb @@ -116,7 +116,9 @@ nice move to keep system responsive adapter_source = load_adapter("Store::Provider::#{source}") adapter_target = load_adapter("Store::Provider::#{target}") - Store::File.all.each { |item| + file_ids = Store::File.all.pluck(:id) + file_ids.each { |item_id| + item = Store::File.find(item_id) next if item.provider == target content = item.content From 1f14ae589530654ababdf9dcedc125e3a9c6252c Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 17 Jun 2017 00:56:28 +0200 Subject: [PATCH 045/234] Fixed typo in pod. --- lib/search_index_backend.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/search_index_backend.rb b/lib/search_index_backend.rb index f511abd16..14bd49144 100644 --- a/lib/search_index_backend.rb +++ b/lib/search_index_backend.rb @@ -15,7 +15,7 @@ create/update/delete index :articles => { :type => 'nested', :properties => { - 'attachments' => { :type => 'attachment' } + 'attachment' => { :type => 'attachment' } } } } From 6f7632c8ea1f5406a59491b75572c65339a70f2e Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 17 Jun 2017 01:02:13 +0200 Subject: [PATCH 046/234] Improved code layout. --- app/controllers/first_steps_controller.rb | 4 ++-- app/controllers/form_controller.rb | 4 ++-- app/controllers/recent_view_controller.rb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/controllers/first_steps_controller.rb b/app/controllers/first_steps_controller.rb index 9136a5eb4..e6641e272 100644 --- a/app/controllers/first_steps_controller.rb +++ b/app/controllers/first_steps_controller.rb @@ -185,12 +185,12 @@ class FirstStepsController < ApplicationController raw: true, ) UserInfo.current_user_id = customer.id - ticket = Ticket.create( + ticket = Ticket.create!( group_id: Group.find_by(active: true, name: 'Users').id, customer_id: customer.id, title: result[:subject], ) - article = Ticket::Article.create( + article = Ticket::Article.create!( ticket_id: ticket.id, type_id: Ticket::Article::Type.find_by(name: 'phone').id, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, diff --git a/app/controllers/form_controller.rb b/app/controllers/form_controller.rb index 13804e9d6..664f34f5d 100644 --- a/app/controllers/form_controller.rb +++ b/app/controllers/form_controller.rb @@ -97,12 +97,12 @@ class FormController < ApplicationController # set current user UserInfo.current_user_id = customer.id - ticket = Ticket.create( + ticket = Ticket.create!( group_id: 1, customer_id: customer.id, title: params[:title], ) - article = Ticket::Article.create( + article = Ticket::Article.create!( ticket_id: ticket.id, type_id: Ticket::Article::Type.find_by(name: 'web').id, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, diff --git a/app/controllers/recent_view_controller.rb b/app/controllers/recent_view_controller.rb index 9f54e2be4..bd1b275d3 100644 --- a/app/controllers/recent_view_controller.rb +++ b/app/controllers/recent_view_controller.rb @@ -19,7 +19,7 @@ curl http://localhost/api/v1/recent_view -v -u #{login}:#{password} -H "Content- =end def index - recent_viewed = RecentView.list_full( current_user, 10 ) + recent_viewed = RecentView.list_full(current_user, 10) # return result render json: recent_viewed @@ -46,7 +46,7 @@ curl http://localhost/api/v1/recent_view -v -u #{login}:#{password} -H "Content- def create - RecentView.log( params[:object], params[:o_id], current_user ) + RecentView.log(params[:object], params[:o_id], current_user) # return result render json: { message: 'ok' } From 15a50ba0a351aa501622ca1cb747c3c1b9d86f12 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 17 Jun 2017 01:02:34 +0200 Subject: [PATCH 047/234] Fixed test. --- test/unit/session_basic_test.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/session_basic_test.rb b/test/unit/session_basic_test.rb index 02fd41fc8..b7756008c 100644 --- a/test/unit/session_basic_test.rb +++ b/test/unit/session_basic_test.rb @@ -244,7 +244,7 @@ class SessionBasicTest < ActiveSupport::TestCase assert(!result1, 'check as agent1 - recall 2') agent1.update_attribute(:email, 'activity-stream-agent11@example.com') - ticket = Ticket.create( + ticket = Ticket.create!( title: '12323', group_id: 1, priority_id: 1, @@ -297,12 +297,15 @@ class SessionBasicTest < ActiveSupport::TestCase result1 = ticket_create_client1.push assert(!result1, 'check ticket_create - recall 2') - Group.create( + Group.create!( name: "SomeTicketCreateGroup::#{rand(999_999)}", active: true, updated_by_id: 1, created_by_id: 1, ) + groups = Group.all + agent1.groups = groups + agent1.save! travel 4.seconds From 3f9e7c20881bdfc50653288a74489fbb56f20c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bauer?= Date: Sat, 17 Jun 2017 11:25:47 +0200 Subject: [PATCH 048/234] set env vars --- contrib/packager.io/functions | 7 +++++++ contrib/packager.io/postinstall.sh | 2 ++ 2 files changed, 9 insertions(+) diff --git a/contrib/packager.io/functions b/contrib/packager.io/functions index b650fcdc5..00ec34ee1 100644 --- a/contrib/packager.io/functions +++ b/contrib/packager.io/functions @@ -233,6 +233,13 @@ function update_or_install () { fi } +function set_env_vars () { + zammad config:set RUBY_GC_MALLOC_LIMIT=1077216 + zammad config:set RUBY_GC_MALLOC_LIMIT_MAX=2177216 + zammad config:set RUBY_GC_OLDMALLOC_LIMIT=2177216 + zammad config:set RUBY_GC_OLDMALLOC_LIMIT_MAX=3000100 +} + function final_message () { echo -e "####################################################################################" echo -e "\nAdd your fully qualified domain name or public IP to servername directive of" diff --git a/contrib/packager.io/postinstall.sh b/contrib/packager.io/postinstall.sh index 1aea49a3e..726e3cea2 100755 --- a/contrib/packager.io/postinstall.sh +++ b/contrib/packager.io/postinstall.sh @@ -30,6 +30,8 @@ stop_zammad update_or_install +set_env_vars + start_zammad create_webserver_config From 8235cd59596a0b3745a1926c82975291ea30706e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bauer?= Date: Sat, 17 Jun 2017 11:30:01 +0200 Subject: [PATCH 049/234] set env vars --- contrib/packager.io/functions | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contrib/packager.io/functions b/contrib/packager.io/functions index 00ec34ee1..502ccfd8b 100644 --- a/contrib/packager.io/functions +++ b/contrib/packager.io/functions @@ -234,10 +234,10 @@ function update_or_install () { } function set_env_vars () { - zammad config:set RUBY_GC_MALLOC_LIMIT=1077216 - zammad config:set RUBY_GC_MALLOC_LIMIT_MAX=2177216 - zammad config:set RUBY_GC_OLDMALLOC_LIMIT=2177216 - zammad config:set RUBY_GC_OLDMALLOC_LIMIT_MAX=3000100 + zammad config:set RUBY_GC_MALLOC_LIMIT=${ZAMMAD_RUBY_GC_MALLOC_LIMIT:=1077216} + zammad config:set RUBY_GC_MALLOC_LIMIT_MAX=${ZAMMAD_RUBY_GC_MALLOC_LIMIT_MAX:=2177216} + zammad config:set RUBY_GC_OLDMALLOC_LIMIT=${ZAMMAD_RUBY_GC_OLDMALLOC_LIMIT:=2177216} + zammad config:set RUBY_GC_OLDMALLOC_LIMIT_MAX=${ZAMMAD_RUBY_GC_OLDMALLOC_LIMIT_MAX:=3000100} } function final_message () { From cc151fb4bbaeb437618b03baf2ba38a9bc8709fd Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Sat, 17 Jun 2017 20:03:50 +0200 Subject: [PATCH 050/234] Removed debug line. --- app/models/concerns/has_groups.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/models/concerns/has_groups.rb b/app/models/concerns/has_groups.rb index 57fe9dbe2..c5be27977 100644 --- a/app/models/concerns/has_groups.rb +++ b/app/models/concerns/has_groups.rb @@ -205,8 +205,6 @@ module HasGroups group_id: group_id, access: access ) - - Rails.logger.error "TE DEBUG group_access_buffer = #{group_access_buffer.inspect}" end end From 3d4e3edaed56704fca1c361ddcf2429309985cc8 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sun, 18 Jun 2017 22:21:06 +0200 Subject: [PATCH 051/234] Improved test. --- test/controllers/search_controller_test.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb index d348240ac..2528238a2 100644 --- a/test/controllers/search_controller_test.rb +++ b/test/controllers/search_controller_test.rb @@ -152,14 +152,17 @@ class SearchControllerTest < ActionDispatch::IntegrationTest # Setting.set('es_user', 'elasticsearch') # Setting.set('es_password', 'zammad') + if ENV['ES_INDEX_RAND'].present? + ENV['ES_INDEX'] = "es_index_#{rand(999_999_999)}" + end + if ENV['ES_INDEX'].blank? + raise "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" + end + Setting.set('es_index', ENV['ES_INDEX']) + # set max attachment size in mb Setting.set('es_attachment_max_size_in_mb', 1) - if ENV['ES_INDEX'].present? - #fail "ERROR: Need ES_INDEX - hint ES_INDEX='estest.local_zammad'" - Setting.set('es_index', ENV['ES_INDEX']) - end - travel 1.minute # drop/create indexes From de925979b75726410ce78dc10bd59787c4a3320f Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 19 Jun 2017 06:46:04 +0200 Subject: [PATCH 052/234] Updated to tails 4.2.8. --- Gemfile | 2 +- Gemfile.lock | 87 ++++++++++++++++++++++++++-------------------------- 2 files changed, 44 insertions(+), 45 deletions(-) diff --git a/Gemfile b/Gemfile index 14e914d52..f38dbef72 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' ruby '2.3.1' -gem 'rails', '4.2.7.1' +gem 'rails', '4.2.8' gem 'rails-observers' gem 'activerecord-session_store' diff --git a/Gemfile.lock b/Gemfile.lock index 94c3b3615..d609034ca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,34 +1,34 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (4.2.7.1) - actionpack (= 4.2.7.1) - actionview (= 4.2.7.1) - activejob (= 4.2.7.1) + actionmailer (4.2.8) + actionpack (= 4.2.8) + actionview (= 4.2.8) + activejob (= 4.2.8) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.7.1) - actionview (= 4.2.7.1) - activesupport (= 4.2.7.1) + actionpack (4.2.8) + actionview (= 4.2.8) + activesupport (= 4.2.8) rack (~> 1.6) rack-test (~> 0.6.2) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.7.1) - activesupport (= 4.2.7.1) + actionview (4.2.8) + activesupport (= 4.2.8) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - activejob (4.2.7.1) - activesupport (= 4.2.7.1) + rails-html-sanitizer (~> 1.0, >= 1.0.3) + activejob (4.2.8) + activesupport (= 4.2.8) globalid (>= 0.3.0) - activemodel (4.2.7.1) - activesupport (= 4.2.7.1) + activemodel (4.2.8) + activesupport (= 4.2.8) builder (~> 3.1) - activerecord (4.2.7.1) - activemodel (= 4.2.7.1) - activesupport (= 4.2.7.1) + activerecord (4.2.8) + activemodel (= 4.2.8) + activesupport (= 4.2.8) arel (~> 6.0) activerecord-nulldb-adapter (0.3.6) activerecord (>= 2.0.0) @@ -38,14 +38,13 @@ GEM multi_json (~> 1.11, >= 1.11.2) rack (>= 1.5.2, < 3) railties (>= 4.0, < 5.1) - activesupport (4.2.7.1) + activesupport (4.2.8) i18n (~> 0.7) - json (~> 1.7, >= 1.7.7) minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) addressable (2.4.0) - arel (6.0.3) + arel (6.0.4) argon2 (1.1.3) ffi (~> 1.9) ffi-compiler (~> 0.1) @@ -57,7 +56,7 @@ GEM tzinfo browser (2.2.0) buftok (0.2.0) - builder (3.2.2) + builder (3.2.3) childprocess (0.5.9) ffi (~> 1.0, >= 1.0.11) clavius (1.0.2) @@ -77,7 +76,7 @@ GEM json composite_primary_keys (8.1.5) activerecord (~> 4.2.0) - concurrent-ruby (1.0.2) + concurrent-ruby (1.0.5) coveralls (0.8.16) json (>= 1.8, < 3) simplecov (~> 0.12.0) @@ -138,8 +137,8 @@ GEM rainbow (>= 2.1) rake (>= 10.0) retriable (~> 2.1) - globalid (0.3.7) - activesupport (>= 4.1.0) + globalid (0.4.0) + activesupport (>= 4.2.0) guard (2.14.0) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -169,7 +168,7 @@ GEM domain_name (~> 0.5) http-form_data (1.0.1) http_parser.rb (0.6.0) - i18n (0.8.1) + i18n (0.8.4) icalendar (2.4.1) inflection (1.0.0) json (1.8.6) @@ -187,7 +186,7 @@ GEM loofah (2.0.3) nokogiri (>= 1.5.9) lumberjack (1.0.10) - mail (2.6.4) + mail (2.6.6) mime-types (>= 1.16, < 4) memoizable (0.4.2) thread_safe (~> 0.3, >= 0.3.1) @@ -196,7 +195,7 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mini_portile2 (2.2.0) - minitest (5.10.1) + minitest (5.10.2) multi_json (1.12.1) multi_xml (0.5.5) multipart-post (2.0.0) @@ -261,21 +260,21 @@ GEM method_source (~> 0.8.1) slop (~> 3.4) puma (3.6.0) - rack (1.6.4) + rack (1.6.8) rack-livereload (0.3.16) rack rack-test (0.6.3) rack (>= 1.0) - rails (4.2.7.1) - actionmailer (= 4.2.7.1) - actionpack (= 4.2.7.1) - actionview (= 4.2.7.1) - activejob (= 4.2.7.1) - activemodel (= 4.2.7.1) - activerecord (= 4.2.7.1) - activesupport (= 4.2.7.1) + rails (4.2.8) + actionmailer (= 4.2.8) + actionpack (= 4.2.8) + actionview (= 4.2.8) + activejob (= 4.2.8) + activemodel (= 4.2.8) + activerecord (= 4.2.8) + activesupport (= 4.2.8) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.7.1) + railties (= 4.2.8) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) @@ -287,9 +286,9 @@ GEM loofah (~> 2.0) rails-observers (0.1.2) activemodel (~> 4.0) - railties (4.2.7.1) - actionpack (= 4.2.7.1) - activesupport (= 4.2.7.1) + railties (4.2.8) + actionpack (= 4.2.8) + activesupport (= 4.2.8) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (2.1.0) @@ -361,7 +360,7 @@ GEM spring (1.7.2) spring-commands-rspec (1.0.4) spring (>= 0.9.1) - sprockets (3.7.0) + sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.2.0) @@ -378,7 +377,7 @@ GEM therubyracer (0.12.2) libv8 (~> 3.16.14.0) ref - thor (0.19.1) + thor (0.19.4) thread_safe (0.3.6) tilt (2.0.5) tins (1.13.0) @@ -393,7 +392,7 @@ GEM memoizable (~> 0.4.0) naught (~> 1.0) simple_oauth (~> 0.3.0) - tzinfo (1.2.2) + tzinfo (1.2.3) thread_safe (~> 0.1) uglifier (3.0.2) execjs (>= 0.3.0, < 3) @@ -471,7 +470,7 @@ DEPENDENCIES pre-commit puma rack-livereload - rails (= 4.2.7.1) + rails (= 4.2.8) rails-observers rb-fsevent rspec-rails From 99c05d51767ff3ceee00d932a7a0510f4b818d48 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 19 Jun 2017 08:21:07 +0200 Subject: [PATCH 053/234] Clear cache after model has changed (prevent issues on upgrade). --- db/migrate/20170608151442_enhanced_permissions.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/db/migrate/20170608151442_enhanced_permissions.rb b/db/migrate/20170608151442_enhanced_permissions.rb index 70748b515..558741b10 100644 --- a/db/migrate/20170608151442_enhanced_permissions.rb +++ b/db/migrate/20170608151442_enhanced_permissions.rb @@ -19,5 +19,7 @@ class EnhancedPermissions < ActiveRecord::Migration add_index :roles_groups, [:role_id] add_index :roles_groups, [:group_id] add_index :roles_groups, [:access] + + Cache.clear end end From e3214e3ac258421ea34b370651f613fb39d4b997 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 19 Jun 2017 08:23:07 +0200 Subject: [PATCH 054/234] Improved memory usage on verify storage contend based on checksums. --- app/models/store/file.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/store/file.rb b/app/models/store/file.rb index e79a7ad48..1488c7264 100644 --- a/app/models/store/file.rb +++ b/app/models/store/file.rb @@ -78,7 +78,9 @@ in case of fixing sha hash use: def self.verify(fix_it = nil) success = true - Store::File.all.each { |item| + file_ids = Store::File.all.pluck(:id) + file_ids.each { |item_id| + item = Store::File.find(item_id) content = item.content sha = Digest::SHA256.hexdigest(content) logger.info "CHECK: Store::File.find(#{item.id})" From e76422952c7997ad6f9e13e524c59c3cd2bdcb4d Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 19 Jun 2017 11:42:02 +0200 Subject: [PATCH 055/234] Avoid "RuntimeError: can't modify frozen Time". --- Gemfile.lock | 12 ++++++------ app/models/channel/driver/twitter.rb | 7 +++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d609034ca..1632ea661 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,7 +95,7 @@ GEM diffy (3.1.0) dnsruby (1.59.3) docile (1.1.5) - domain_name (0.5.20160826) + domain_name (0.5.20170404) unf (>= 0.0.5, < 1.0.0) doorkeeper (4.2.0) railties (>= 4.2) @@ -164,9 +164,9 @@ GEM http-cookie (~> 1.0) http-form_data (~> 1.0.1) http_parser.rb (~> 0.6.0) - http-cookie (1.0.2) + http-cookie (1.0.3) domain_name (~> 0.5) - http-form_data (1.0.1) + http-form_data (1.0.3) http_parser.rb (0.6.0) i18n (0.8.4) icalendar (2.4.1) @@ -199,7 +199,7 @@ GEM multi_json (1.12.1) multi_xml (0.5.5) multipart-post (2.0.0) - mysql2 (0.4.4) + mysql2 (0.4.6) naught (1.1.0) nenv (0.3.0) nestful (1.1.1) @@ -381,7 +381,7 @@ GEM thread_safe (0.3.6) tilt (2.0.5) tins (1.13.0) - twitter (5.16.0) + twitter (5.17.0) addressable (~> 2.3) buftok (~> 0.2.0) equalizer (= 0.0.10) @@ -398,7 +398,7 @@ GEM execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext - unf_ext (0.0.7.2) + unf_ext (0.0.7.4) unicode-display_width (1.1.1) unicorn (5.2.0) kgio (~> 2.6) diff --git a/app/models/channel/driver/twitter.rb b/app/models/channel/driver/twitter.rb index 56d7146bb..dbb6b2ee2 100644 --- a/app/models/channel/driver/twitter.rb +++ b/app/models/channel/driver/twitter.rb @@ -304,14 +304,13 @@ returns next if !track_retweets? && tweet.retweet? # ignore older messages - if (@channel.created_at - 15.days) > tweet.created_at || older_import >= older_import_max + if (@channel.created_at - 15.days) > tweet.created_at.dup.utc || older_import >= older_import_max older_import += 1 Rails.logger.debug "tweet to old: #{tweet.id}/#{tweet.created_at}" next end next if @rest_client.locale_sender?(tweet) && own_tweet_already_imported?(tweet) - next if Ticket::Article.find_by(message_id: tweet.id) break if @rest_client.tweet_limit_reached(tweet) @rest_client.to_group(tweet, search[:group_id], @channel) @@ -329,7 +328,7 @@ returns next if !track_retweets? && tweet.retweet? # ignore older messages - if (@channel.created_at - 15.days) > tweet.created_at || older_import >= older_import_max + if (@channel.created_at - 15.days) > tweet.created_at.dup.utc || older_import >= older_import_max older_import += 1 Rails.logger.debug "tweet to old: #{tweet.id}/#{tweet.created_at}" next @@ -349,7 +348,7 @@ returns @rest_client.client.direct_messages(full_text: 'true').each { |tweet| # ignore older messages - if (@channel.created_at - 15.days) > tweet.created_at || older_import >= older_import_max + if (@channel.created_at - 15.days) > tweet.created_at.dup.utc || older_import >= older_import_max older_import += 1 Rails.logger.debug "tweet to old: #{tweet.id}/#{tweet.created_at}" next From d662546927c97b511041564c969492e17bac355c Mon Sep 17 00:00:00 2001 From: Denny Bresch Date: Thu, 15 Jun 2017 10:14:46 +0200 Subject: [PATCH 056/234] Fixed tag merge bug. --- app/models/tag.rb | 6 +++--- test/unit/tag_test.rb | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/models/tag.rb b/app/models/tag.rb index 3c327071e..79606689f 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -211,12 +211,12 @@ rename tag items def self.rename(data) - new_tag_name = data[:name].strip - old_tag_item = Tag::Item.find(data[:id]) + new_tag_name = data[:name].strip + old_tag_item = Tag::Item.find(data[:id]) already_existing_tag = Tag::Item.lookup(name: new_tag_name) # check if no remame is needed - return true if new_tag_name.downcase == old_tag_item.name.downcase + return true if new_tag_name == old_tag_item.name # merge old with new tag if already existing if already_existing_tag diff --git a/test/unit/tag_test.rb b/test/unit/tag_test.rb index fff602e9e..a193b1092 100644 --- a/test/unit/tag_test.rb +++ b/test/unit/tag_test.rb @@ -342,4 +342,53 @@ class TagTest < ActiveSupport::TestCase assert(tags_ticket2.include?('some rename tag2')) end + + test 'tags - rename and merge tag with existing tag' do + + ticket1 = Ticket.create( + title: 'rename tag1', + group: Group.lookup(name: 'Users'), + customer_id: 2, + updated_by_id: 1, + created_by_id: 1, + ) + ticket2 = Ticket.create( + title: 'rename tag2', + group: Group.lookup(name: 'Users'), + customer_id: 2, + updated_by_id: 1, + created_by_id: 1, + ) + + ticket1.tag_add('tagname1', 1) + ticket1.tag_add('tagname2', 1) + + ticket2.tag_add('Tagname2', 1) + + tags_ticket1 = ticket1.tag_list + assert_equal(2, tags_ticket1.count) + assert(tags_ticket1.include?('tagname1')) + assert(tags_ticket1.include?('tagname2')) + + tags_ticket2 = ticket2.tag_list + assert_equal(1, tags_ticket2.count) + assert(tags_ticket2.include?('Tagname2')) + + tag_item1 = Tag::Item.lookup(name: 'Tagname2') + Tag::Item.rename( + id: tag_item1.id, + name: 'tagname2', + created_by_id: 1, + ) + + tags_ticket1 = ticket1.tag_list + assert_equal(2, tags_ticket1.count) + assert(tags_ticket1.include?('tagname1')) + assert(tags_ticket1.include?('tagname2')) + + tags_ticket2 = ticket2.tag_list + assert_equal(1, tags_ticket2.count) + assert(tags_ticket2.include?('tagname2')) + + end end From 8b72ce5933406f28a84a854e4c97d1dad2334860 Mon Sep 17 00:00:00 2001 From: Denny Bresch Date: Thu, 15 Jun 2017 11:01:08 +0200 Subject: [PATCH 057/234] Added 'name' and 'requied' to searchabeSelect input to interact with this input via coffeescript. --- .../javascripts/app/views/generic/searchable_select.jst.eco | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco index 2cdbba9a5..62c0126aa 100644 --- a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco +++ b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco @@ -12,6 +12,8 @@ placeholder="<%= @placeholder %>" value="<%= @valueName %>" autocomplete="new-password" + name="select_<%= @name %>" + <%= @required %> >
From a548009b86ba95b5684165276943fe9abde06b34 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 19 Jun 2017 22:24:22 +0200 Subject: [PATCH 058/234] Improved email sender parsing with multiple senders in from. --- app/models/channel/email_parser.rb | 22 +++++++++++++------ test/fixtures/mail58.box | 24 +++++++++++++++++++++ test/fixtures/mail59.box | 24 +++++++++++++++++++++ test/unit/email_parser_test.rb | 34 ++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/mail58.box create mode 100644 test/fixtures/mail59.box diff --git a/app/models/channel/email_parser.rb b/app/models/channel/email_parser.rb index 300a2b791..11b36d4a6 100644 --- a/app/models/channel/email_parser.rb +++ b/app/models/channel/email_parser.rb @@ -641,12 +641,22 @@ returns data = {} begin - data[:from_email] = Mail::Address.new(from).address - data[:from_local] = Mail::Address.new(from).local - data[:from_domain] = Mail::Address.new(from).domain - data[:from_display_name] = Mail::Address.new(from).display_name || - (Mail::Address.new(from).comments && Mail::Address.new(from).comments[0]) - rescue + list = Mail::AddressList.new(from) + list.addresses.each { |address| + data[:from_email] = address.address + data[:from_local] = address.local + data[:from_domain] = address.domain + data[:from_display_name] = address.display_name || + (address.comments && address.comments[0]) + break if data[:from_email].present? && data[:from_email] =~ /@/ + } + rescue => e + if from =~ /<>/ && from =~ /<.+?>/ + data = sender_properties(from.gsub(/<>/, '')) + end + end + + if data.empty? || data[:from_email].blank? from.strip! if from =~ /^(.+?)<(.+?)@(.+?)>$/ data[:from_email] = "#{$2}@#{$3}" diff --git a/test/fixtures/mail58.box b/test/fixtures/mail58.box new file mode 100644 index 000000000..959705f56 --- /dev/null +++ b/test/fixtures/mail58.box @@ -0,0 +1,24 @@ +From: "Yangzhou ABC Lighting Equipment " , "LTD" +Reply-To: zsm@example.com +To: "verkauf" +Subject: new design solar street lights +Message-ID: <201609141249219194555@example.com> +Date: Wed, 14 Sep 2016 12:49:21 +0800 +X-Mailer: Foxmail 6, 10, 201, 20 [cn] +MIME-Version: 1.0 +X-Priority: 3 +X-CM-TRANSID:iOCowAC39vrU1thXd4NnAQ--.234S2 +X-Coremail-Antispam: 1Uf129KBjDUn29KB7ZKAUJUUUUU529EdanIXcx71UUUUU7v73 + VFW2AGmfu7bjvjm3AaLaJ3UbIYCTnIWIevJa73UjIFyTuYvjxUg3xhUUUUU +X-Originating-IP: [121.233.254.237] +X-CM-SenderInfo: to16vxpkrqw63pof0z/1tbiTBXk7ldp+Z4oIwAAsB +Content-Type: text/plain; + charset=iso-8859-1 +Content-Transfer-Encoding: quoted-printable + +=E4=F6=FC=DF ad asd + +-Martin + +-- +Old programmers never die. They just branch to a new address. \ No newline at end of file diff --git a/test/fixtures/mail59.box b/test/fixtures/mail59.box new file mode 100644 index 000000000..215add685 --- /dev/null +++ b/test/fixtures/mail59.box @@ -0,0 +1,24 @@ +From: "Yangzhou ABC Lighting Equipment " <>, "LTD" +Reply-To: zsm@example.com +To: "verkauf" +Subject: new design solar street lights +Message-ID: <201609141249219194555@example.com> +Date: Wed, 14 Sep 2016 12:49:21 +0800 +X-Mailer: Foxmail 6, 10, 201, 20 [cn] +MIME-Version: 1.0 +X-Priority: 3 +X-CM-TRANSID:iOCowAC39vrU1thXd4NnAQ--.234S2 +X-Coremail-Antispam: 1Uf129KBjDUn29KB7ZKAUJUUUUU529EdanIXcx71UUUUU7v73 + VFW2AGmfu7bjvjm3AaLaJ3UbIYCTnIWIevJa73UjIFyTuYvjxUg3xhUUUUU +X-Originating-IP: [121.233.254.237] +X-CM-SenderInfo: to16vxpkrqw63pof0z/1tbiTBXk7ldp+Z4oIwAAsB +Content-Type: text/plain; + charset=iso-8859-1 +Content-Transfer-Encoding: quoted-printable + +=E4=F6=FC=DF ad asd + +-Martin + +-- +Old programmers never die. They just branch to a new address. \ No newline at end of file diff --git a/test/unit/email_parser_test.rb b/test/unit/email_parser_test.rb index 63f478b7f..121b6e5db 100644 --- a/test/unit/email_parser_test.rb +++ b/test/unit/email_parser_test.rb @@ -1157,6 +1157,40 @@ Bob Smith ', }, }, + { + data: IO.binread('test/fixtures/mail58.box'), + body_md5: '548917e0bff0806f9b27c09bbf23bb38', + params: { + from: 'Yangzhou ABC Lighting Equipment , LTD ', + from_email: 'bob@example.com', + from_display_name: 'Yangzhou ABC Lighting Equipment', + subject: 'new design solar street lights', + content_type: 'text/plain', + body: "äöüß ad asd + +-Martin + +-- +Old programmers never die. They just branch to a new address." + }, + }, + { + data: IO.binread('test/fixtures/mail59.box'), + body_md5: '548917e0bff0806f9b27c09bbf23bb38', + params: { + from: '"Yangzhou ABC Lighting Equipment " <>, "LTD" ', + from_email: 'ly@example.com', + from_display_name: 'LTD', + subject: 'new design solar street lights', + content_type: 'text/plain', + body: "äöüß ad asd + +-Martin + +-- +Old programmers never die. They just branch to a new address." + }, + }, ] count = 0 From 284dbb3de8e79985ced4feddc6b118a43e869647 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 20 Jun 2017 11:05:16 +0200 Subject: [PATCH 059/234] Replaced autocomplete value new-password with off to prevent browser autocomplete feature in certain fields (only use it for user password now). --- .../app/controllers/_application_controller_form.coffee | 2 +- .../javascripts/app/views/facebook/app_config.jst.eco | 4 ++-- .../javascripts/app/views/generic/autocompletion.jst.eco | 2 +- .../app/views/generic/object_search/input.jst.eco | 2 +- .../javascripts/app/views/generic/searchable_select.jst.eco | 3 +-- .../javascripts/app/views/getting_started/email.jst.eco | 6 +++--- app/assets/javascripts/app/views/telegram/bot_add.jst.eco | 4 ++-- app/assets/javascripts/app/views/telegram/bot_edit.jst.eco | 4 ++-- app/assets/javascripts/app/views/twitter/app_config.jst.eco | 4 ++-- app/assets/javascripts/app/views/widget/text_module.jst.eco | 2 +- 10 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/app/controllers/_application_controller_form.coffee b/app/assets/javascripts/app/controllers/_application_controller_form.coffee index 499602a72..bc82db034 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_form.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_form.coffee @@ -229,7 +229,7 @@ class App.ControllerForm extends App.Controller if attribute.type is 'hidden' attribute.autocomplete = '' else - attribute.autocomplete = 'autocomplete="new-password"' + attribute.autocomplete = 'autocomplete="off"' else attribute.autocomplete = 'autocomplete="' + attribute.autocomplete + '"' diff --git a/app/assets/javascripts/app/views/facebook/app_config.jst.eco b/app/assets/javascripts/app/views/facebook/app_config.jst.eco index 92009d7ba..0bc02adb6 100644 --- a/app/assets/javascripts/app/views/facebook/app_config.jst.eco +++ b/app/assets/javascripts/app/views/facebook/app_config.jst.eco @@ -9,7 +9,7 @@
- +
@@ -17,7 +17,7 @@
- +

<%- @T('Your callback URL') %>

diff --git a/app/assets/javascripts/app/views/generic/autocompletion.jst.eco b/app/assets/javascripts/app/views/generic/autocompletion.jst.eco index 574b091a7..6e6e91787 100644 --- a/app/assets/javascripts/app/views/generic/autocompletion.jst.eco +++ b/app/assets/javascripts/app/views/generic/autocompletion.jst.eco @@ -1,3 +1,3 @@ /> - <%= @attribute.autofocus %> <%- @attribute.autocapitalize %> <% if @attribute.placeholder: %>placeholder="<%- @Ti(@attribute.placeholder) %>"<% end %> autocomplete="new-password"/> + <%= @attribute.autofocus %> <%- @attribute.autocapitalize %> <% if @attribute.placeholder: %>placeholder="<%- @Ti(@attribute.placeholder) %>"<% end %> autocomplete="off"/> diff --git a/app/assets/javascripts/app/views/generic/object_search/input.jst.eco b/app/assets/javascripts/app/views/generic/object_search/input.jst.eco index 3021dc709..939750df5 100644 --- a/app/assets/javascripts/app/views/generic/object_search/input.jst.eco +++ b/app/assets/javascripts/app/views/generic/object_search/input.jst.eco @@ -3,7 +3,7 @@ <% if @attribute.multiple: %> <%- @tokens %> <% end %> - role="textbox" aria-autocomplete="list" value="<%= @name %>" aria-haspopup="true"> + role="textbox" aria-autocomplete="list" value="<%= @name %>" aria-haspopup="true"> <% if @attribute.disableCreateObject isnt true: %><%- @Icon('arrow-down', 'dropdown-arrow') %><% end %> diff --git a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco index 62c0126aa..74ae5805b 100644 --- a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco +++ b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco @@ -11,8 +11,7 @@ class="searchableSelect-main form-control js-input<%= " #{ @class }" if @class %>" placeholder="<%= @placeholder %>" value="<%= @valueName %>" - autocomplete="new-password" - name="select_<%= @name %>" + autocomplete="off" <%= @required %> >
diff --git a/app/assets/javascripts/app/views/getting_started/email.jst.eco b/app/assets/javascripts/app/views/getting_started/email.jst.eco index b82d01e8a..e3ef082f8 100644 --- a/app/assets/javascripts/app/views/getting_started/email.jst.eco +++ b/app/assets/javascripts/app/views/getting_started/email.jst.eco @@ -9,15 +9,15 @@
- +
- +
- +
diff --git a/app/assets/javascripts/app/views/telegram/bot_add.jst.eco b/app/assets/javascripts/app/views/telegram/bot_add.jst.eco index 7d47c4335..4626b023f 100644 --- a/app/assets/javascripts/app/views/telegram/bot_add.jst.eco +++ b/app/assets/javascripts/app/views/telegram/bot_add.jst.eco @@ -9,7 +9,7 @@
- +

<%- @T('Settings') %>

@@ -18,7 +18,7 @@
- +
diff --git a/app/assets/javascripts/app/views/telegram/bot_edit.jst.eco b/app/assets/javascripts/app/views/telegram/bot_edit.jst.eco index 39b9b61a0..07b6de1c2 100644 --- a/app/assets/javascripts/app/views/telegram/bot_edit.jst.eco +++ b/app/assets/javascripts/app/views/telegram/bot_edit.jst.eco @@ -6,7 +6,7 @@
- +

<%- @T('Settings') %>

@@ -15,7 +15,7 @@
- +
diff --git a/app/assets/javascripts/app/views/twitter/app_config.jst.eco b/app/assets/javascripts/app/views/twitter/app_config.jst.eco index d7b8a1e25..92f322b9c 100644 --- a/app/assets/javascripts/app/views/twitter/app_config.jst.eco +++ b/app/assets/javascripts/app/views/twitter/app_config.jst.eco @@ -9,7 +9,7 @@
- +
@@ -17,7 +17,7 @@
- +

<%- @T('Your callback URL') %>

diff --git a/app/assets/javascripts/app/views/widget/text_module.jst.eco b/app/assets/javascripts/app/views/widget/text_module.jst.eco index 5c3f4d4af..ecd943f64 100644 --- a/app/assets/javascripts/app/views/widget/text_module.jst.eco +++ b/app/assets/javascripts/app/views/widget/text_module.jst.eco @@ -4,7 +4,7 @@ <%- @T( 'Search' ) %>
--> - + ×
From fb8130da18d2936217cdd126b4e4667464f4d82a Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 20 Jun 2017 17:08:59 +0200 Subject: [PATCH 060/234] Added tree selection attribute for object manager. --- .../object_manager_attribute.coffee | 66 ++++- .../_ui_element/searchable_select.coffee | 16 +- .../_ui_element/tree_select.coffee | 41 +++ .../app/controllers/object_manager.coffee | 46 +++ ..._object_organization_autocompletion.coffee | 8 +- .../app/lib/app_post/searchable_select.coffee | 267 +++++++++++++++--- .../app_post/z_searchable_ajax_select.coffee | 3 +- .../views/generic/searchable_select.jst.eco | 29 +- .../generic/searchable_select_option.jst.eco | 8 + .../generic/searchable_select_options.jst.eco | 5 - .../generic/searchable_select_submenu.jst.eco | 11 + .../attribute/tree_select.jst.eco | 29 ++ .../app/views/object_manager/edit.jst.eco | 1 + app/assets/stylesheets/zammad.scss | 67 ++++- .../object_manager_attributes_controller.rb | 2 +- app/models/object_manager/attribute.rb | 61 +++- app/views/tests/form_tree_select.html.erb | 22 ++ config/routes/test.rb | 1 + db/migrate/20120101000001_create_base.rb | 4 +- db/migrate/20170619000001_tree_select.rb | 6 + public/assets/tests/form_searchable_select.js | 16 +- public/assets/tests/form_tree_select.js | 194 +++++++++++++ test/browser/aab_unit_test.rb | 7 + 23 files changed, 819 insertions(+), 91 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee create mode 100644 app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco delete mode 100644 app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/searchable_select_submenu.jst.eco create mode 100644 app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco create mode 100644 app/views/tests/form_tree_select.html.erb create mode 100644 db/migrate/20170619000001_tree_select.rb create mode 100644 public/assets/tests/form_tree_select.js diff --git a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee index 050a0fa8e..0ac160b3c 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee @@ -15,7 +15,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi attribute: attribute params: params )) - @[localParams.data_type](element, localParams, params) + @[localParams.data_type](element, localParams, params, attribute) localItem.find('.js-dataMap').html(element) localItem.find('.js-dataScreens').html(@dataScreens(attribute, localParams, params)) @@ -24,6 +24,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi date: 'Date' input: 'Text' select: 'Select' + tree_select: 'Tree Select' boolean: 'Boolean' integer: 'Integer' @@ -308,6 +309,69 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi lastSelected = value ) + @buildRow: (element, child, level = 0, parentElement) -> + newRow = element.find('.js-template').clone().removeClass('js-template') + newRow.find('.js-key').attr('level', level) + newRow.find('.js-key').val(child.name) + newRow.find('td').first().css('padding-left', "#{(level * 20) + 10}px") + if level is 5 + newRow.find('.js-addChild').addClass('hide') + + if parentElement + parentElement.after(newRow) + return + + element.find('.js-treeTable').append(newRow) + if child.children + for subChild in child.children + @buildRow(element, subChild, level + 1) + + @tree_select: (item, localParams, params, attribute) -> + params.data_option ||= {} + params.data_option.options ||= [] + if _.isEmpty(params.data_option.options) + @buildRow(item, {}) + else + for child in params.data_option.options + @buildRow(item, child) + + item.on('click', '.js-addRow', (e) => + e.stopPropagation() + e.preventDefault() + addRow = $(e.currentTarget).closest('tr') + level = parseInt(addRow.find('.js-key').attr('level')) + @buildRow(item, {}, level, addRow) + ) + + item.on('click', '.js-addChild', (e) => + e.stopPropagation() + e.preventDefault() + addRow = $(e.currentTarget).closest('tr') + level = parseInt(addRow.find('.js-key').attr('level')) + 1 + @buildRow(item, {}, level, addRow) + ) + + item.on('click', '.js-remove', (e) -> + e.stopPropagation() + e.preventDefault() + e.stopPro + element = $(e.target).closest('tr') + level = parseInt(element.find('.js-key').attr('level')) + subElements = 0 + nextElement = element + elementsToDelete = [element] + loop + nextElement = nextElement.next() + break if !nextElement.get(0) + nextLevel = parseInt(nextElement.find('.js-key').attr('level')) + break if nextLevel <= level + subElements += 1 + elementsToDelete.push nextElement + return if subElements isnt 0 && !confirm("Delete #{subElements} sub elements?") + for element in elementsToDelete + element.remove() + ) + @boolean: (item, localParams, params) -> lastSelected = undefined item.on('click', '.js-selected', (e) -> diff --git a/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee b/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee index df950af65..e4d528a0e 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee @@ -9,24 +9,24 @@ class App.UiElement.searchable_select extends App.UiElement.ApplicationUiElement attribute.multiple = '' # build options list based on config - @getConfigOptionList( attribute, params ) + @getConfigOptionList(attribute, params) # build options list based on relation - @getRelationOptionList( attribute, params ) + @getRelationOptionList(attribute, params) # add null selection if needed - @addNullOption( attribute, params ) + @addNullOption(attribute, params) # sort attribute.options - @sortOptions( attribute, params ) + @sortOptions(attribute, params) # finde selected/checked item of list - @selectedOptions( attribute, params ) + @selectedOptions(attribute, params) # disable item of list - @disabledOptions( attribute, params ) + @disabledOptions(attribute, params) # filter attributes - @filterOption( attribute, params ) + @filterOption(attribute, params) - new App.SearchableSelect( attribute: attribute ).element() + new App.SearchableSelect(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee b/app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee new file mode 100644 index 000000000..1962ab644 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee @@ -0,0 +1,41 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.tree_select extends App.UiElement.ApplicationUiElement + @optionsSelect: (children, value) -> + return if !children + for child in children + if child.value is value + child.selected = true + if child.children + @optionsSelect(child.children, value) + + @render: (attribute, params) -> + + # set multiple option + if attribute.multiple + attribute.multiple = 'multiple' + else + attribute.multiple = '' + + # build options list based on config + @getConfigOptionList(attribute, params) + + # build options list based on relation + @getRelationOptionList(attribute, params) + + # add null selection if needed + @addNullOption(attribute, params) + + # sort attribute.options + @sortOptions(attribute, params) + + # finde selected/checked item of list + if attribute.options + @optionsSelect(attribute.options, attribute.value) + + # disable item of list + @disabledOptions(attribute, params) + + # filter attributes + @filterOption(attribute, params) + + new App.SearchableSelect(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/object_manager.coffee b/app/assets/javascripts/app/controllers/object_manager.coffee index 5311380cf..2a6f6cd71 100644 --- a/app/assets/javascripts/app/controllers/object_manager.coffee +++ b/app/assets/javascripts/app/controllers/object_manager.coffee @@ -1,4 +1,46 @@ # coffeelint: disable=duplicate_key +treeParams = (e, params) -> + tree = [] + lastLevel = 0 + lastLevels = [] + valueLevels = [] + + $(e.target).closest('.modal').find('.js-treeTable .js-key').each( -> + $element = $(@) + level = parseInt($element.attr('level')) + name = $element.val() + item = + name: name + + if level is 0 + tree.push item + else if lastLevels[level-1] + lastLevels[level-1].children ||= [] + lastLevels[level-1].children.push item + else + console.log('ERROR', item) + if level is 0 + valueLevels = [] + else if lastLevel is level + valueLevels.pop() + else if lastLevel > level + down = lastLevel - level + for count in [1..down] + valueLevels.pop() + if lastLevel <= level + valueLevels.push name + + item.value = valueLevels.join('::') + lastLevels[level] = item + lastLevel = level + + ) + if tree[0] + if !params.data_option + params.data_option = {} + params.data_option.options = tree + params + class Index extends App.ControllerTabs requiredPermission: 'admin.object' constructor: -> @@ -135,6 +177,7 @@ class New extends App.ControllerGenericNew onSubmit: (e) => params = @formParam(e.target) + params = treeParams(e, params) # show attributes for create_middle in two column style if params.screens && params.screens.create_middle @@ -184,6 +227,8 @@ class Edit extends App.ControllerGenericEdit #if attribute.name is 'data_type' # attribute.disabled = true + console.log('configure_attributes', configure_attributes) + @controller = new App.ControllerForm( model: configure_attributes: configure_attributes @@ -195,6 +240,7 @@ class Edit extends App.ControllerGenericEdit onSubmit: (e) => params = @formParam(e.target) + params = treeParams(e, params) # show attributes for create_middle in two column style if params.screens && params.screens.create_middle diff --git a/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee b/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee index ac222a436..04f4482b5 100644 --- a/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee +++ b/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee @@ -390,14 +390,14 @@ class App.ObjectOrganizationAutocompletion extends App.Controller properties: translateX: 0 options: - speed: 300 + duration: 240 # fade out list @recipientList.velocity properties: translateX: '-100%' options: - speed: 300 + duration: 240 complete: => @recipientList.height(@organizationList.height()) hideOrganizationMembers: (e) => @@ -413,7 +413,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller properties: translateX: 0 options: - speed: 300 + duration: 240 # reset list height @recipientList.height('') @@ -423,7 +423,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller properties: translateX: '100%' options: - speed: 300 + duration: 240 complete: => @organizationList.addClass('hide') newObject: (e) -> diff --git a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee index a072a057f..010bbddcf 100644 --- a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee @@ -1,19 +1,25 @@ class App.SearchableSelect extends Spine.Controller events: - 'input .js-input': 'onInput' - 'blur .js-input': 'onBlur' - 'focus .js-input': 'onFocus' - 'click .js-option': 'selectItem' - 'mouseenter .js-option': 'highlightItem' - 'shown.bs.dropdown': 'onDropdownShown' - 'hidden.bs.dropdown': 'onDropdownHidden' + 'input .js-input': 'onInput' + 'blur .js-input': 'onBlur' + 'focus .js-input': 'onFocus' + 'click .js-option': 'selectItem' + 'click .js-enter': 'navigateIn' + 'click .js-back': 'navigateOut' + 'mouseenter .js-option': 'highlightItem' + 'mouseenter .js-enter': 'highlightItem' + 'mouseenter .js-back': 'highlightItem' + 'shown.bs.dropdown': 'onDropdownShown' + 'hidden.bs.dropdown': 'onDropdownHidden' elements: - '.js-option': 'option_items' + '.js-dropdown': 'dropdown' + '.js-option, .js-enter': 'optionItems' '.js-input': 'input' '.js-shadow': 'shadowInput' '.js-optionsList': 'optionsList' + '.js-optionsSubmenu': 'optionsSubmenu' '.js-autocomplete-invisible': 'invisiblePart' '.js-autocomplete-visible': 'visiblePart' @@ -27,32 +33,95 @@ class App.SearchableSelect extends Spine.Controller @render() render: -> - firstSelected = _.find @options.attribute.options, (option) -> option.selected + firstSelected = _.find @attribute.options, (option) -> option.selected if firstSelected - @options.attribute.valueName = firstSelected.name - @options.attribute.value = firstSelected.value - else if @options.attribute.unknown && @options.attribute.value - @options.attribute.valueName = @options.attribute.value + @attribute.valueName = firstSelected.name + @attribute.value = firstSelected.value + else if @attribute.unknown && @attribute.value + @attribute.valueName = @attribute.value + else if @hasSubmenu @attribute.options + @attribute.valueName = @getName @attribute.value, @attribute.options - @options.attribute.renderedOptions = App.view('generic/searchable_select_options') - options: @options.attribute.options + @html App.view('generic/searchable_select') + attribute: @attribute + options: @renderAllOptions '', @attribute.options, 0 + submenus: @renderSubmenus @attribute.options - @html App.view('generic/searchable_select')( @options.attribute ) + # initial data + @currentMenu = @findMenuContainingValue @attribute.value + @level = @getIndex @currentMenu - @input.on 'keydown', @navigate + renderSubmenus: (options) -> + html = '' + if options + for option in options + if option.children + html += App.view('generic/searchable_select_submenu') + options: @renderOptions(option.children) + parentValue: option.value + title: option.name + + if @hasSubmenu(option.children) + html += @renderSubmenus option.children + html + + hasSubmenu: (options) -> + return false if !options + for option in options + return true if option.children + return false + + getName: (value, options) -> + for option in options + if option.value is value + return option.name + if option.children + name = @getName value, option.children + return name if name isnt undefined + undefined + + renderOptions: (options) -> + html = '' + for option in options + html += App.view('generic/searchable_select_option') + option: option + class: if option.children then 'js-enter' else 'js-option' + html + + renderAllOptions: (parentName, options, level) -> + html = '' + if options + for option in options + className = if option.children then 'js-enter' else 'js-option' + if level && level > 0 + className += ' is-hidden is-child' + + html += App.view('generic/searchable_select_option') + option: option + class: className + detail: parentName + + if option.children + html += @renderAllOptions "#{parentName} — #{option.name}", option.children, level+1 + html onDropdownShown: => @input.on 'click', @stopPropagation @highlightFirst() + $(document).on 'keydown.searchable_select', @navigate + if @level > 0 + @showSubmenu(@currentMenu) @isOpen = true onDropdownHidden: => @input.off 'click', @stopPropagation - @option_items.removeClass '.is-active' + @unhighlightCurrentItem() + $(document).off 'keydown.searchable_select' @isOpen = false toggle: => + @currentItem = null @$('[data-toggle="dropdown"]').dropdown('toggle') stopPropagation: (event) -> @@ -62,8 +131,8 @@ class App.SearchableSelect extends Spine.Controller switch event.keyCode when 40 then @nudge event, 1 # down when 38 then @nudge event, -1 # up - when 39 then @fillWithAutocompleteSuggestion event # right - when 37 then @fillWithAutocompleteSuggestion event # left + when 39 then @autocompleteOrNavigateIn event # right + when 37 then @autocompleteOrNavigateOut event # left when 13 then @onEnter event when 27 then @onEscape() when 9 then @onTab event @@ -71,12 +140,20 @@ class App.SearchableSelect extends Spine.Controller onEscape: -> @toggle() if @isOpen + getCurrentOptions: -> + @currentMenu.find('.js-option, .js-enter, .js-back') + + getOptionIndex: (menu, value) -> + menu.find('.js-option, .js-enter').filter("[data-value=\"#{value}\"]").index() + nudge: (event, direction) -> return @toggle() if not @isOpen + options = @getCurrentOptions() + event.preventDefault() - visibleOptions = @option_items.not('.is-hidden') - highlightedItem = @option_items.filter('.is-active') + visibleOptions = options.not('.is-hidden') + highlightedItem = options.filter('.is-active') currentPosition = visibleOptions.index(highlightedItem) currentPosition += direction @@ -84,10 +161,24 @@ class App.SearchableSelect extends Spine.Controller return if currentPosition < 0 return if currentPosition > visibleOptions.size() - 1 - @option_items.removeClass('is-active') - visibleOptions.eq(currentPosition).addClass('is-active') + @unhighlightCurrentItem() + @currentItem = visibleOptions.eq(currentPosition) + @currentItem.addClass('is-active') @clearAutocomplete() + autocompleteOrNavigateIn: (event) -> + if @currentItem.hasClass('js-enter') + @navigateIn(event) + else + @fillWithAutocompleteSuggestion(event) + + autocompleteOrNavigateOut: (event) -> + # if we're in a depth then navigateOut + if @level != 0 + @navigateOut(event) + else + @fillWithAutocompleteSuggestion(event) + fillWithAutocompleteSuggestion: (event) -> if !@suggestion return @@ -129,11 +220,96 @@ class App.SearchableSelect extends Spine.Controller @shadowInput.val event.currentTarget.getAttribute('data-value') @shadowInput.trigger('change') + navigateIn: (event) -> + event.stopPropagation() + @navigateDepth(1) + + navigateOut: (event) -> + event.stopPropagation() + @navigateDepth(-1) + + navigateDepth: (dir) -> + return if @animating + if dir > 0 + target = @currentItem.attr('data-value') + target_menu = @optionsSubmenu.filter("[data-parent-value=\"#{target}\"]") + else + target_menu = @findMenuContainingValue @currentMenu.attr('data-parent-value') + + @animateToSubmenu(target_menu, dir) + + @level+=dir + + animateToSubmenu: (target_menu, direction) -> + @animating = true + target_menu.prop('hidden', false) + @dropdown.height(Math.max(target_menu.height(), @currentMenu.height())) + oldCurrentItem = @currentItem + + @currentMenu.data('current_item_index', @currentItem.index()) + # default: 1 (first item after the back button) + target_item_index = target_menu.data('current_item_index') || 1 + # if the direction is out then we know the target item -> its the parent item + if direction is -1 + value = @currentMenu.attr('data-parent-value') + target_item_index = @getOptionIndex(target_menu, value) + + @currentItem = target_menu.children().eq(target_item_index) + @currentItem.addClass('is-active') + + target_menu.velocity + properties: + translateX: [0, direction*100+'%'] + options: + duration: 240 + + @currentMenu.velocity + properties: + translateX: [direction*-100+'%', 0] + options: + duration: 240 + complete: => + oldCurrentItem.removeClass('is-active') + $.Velocity.hook(@currentMenu, 'translateX', '') + @currentMenu.prop('hidden', true) + @dropdown.height(target_menu.height()) + @currentMenu = target_menu + @animating = false + + showSubmenu: (menu) -> + @currentMenu.prop('hidden', true) + menu.prop('hidden', false) + @dropdown.height(menu.height()) + + findMenuContainingValue: (value) -> + return @optionsList if !value + + # in case of numbers + if !value.split && value.toString + value = value.toString() + path = value.split('::') + if path.length == 1 + return @optionsList + else + path.pop() + return @optionsSubmenu.filter("[data-parent-value=\"#{path.join('::')}\"]") + + getIndex: (menu) -> + parentValue = menu.attr('data-parent-value') + return 0 if !parentValue + return parentValue.split('::').length + onTab: (event) -> return if not @isOpen event.preventDefault() onEnter: (event) -> + if @currentItem + if @currentItem.hasClass('js-enter') + return @navigateIn(event) + else if @currentItem.hasClass('js-back') + return @navigateOut(event) + @clearAutocomplete() if not @isOpen @@ -144,13 +320,14 @@ class App.SearchableSelect extends Spine.Controller event.preventDefault() - selected = @option_items.filter('.is-active') - if selected.length || !@options.attribute.unknown - valueName = selected.text().trim() - value = selected.attr('data-value') + if @currentItem || !@attribute.unknown + valueName = @currentItem.text().trim() + value = @currentItem.attr('data-value') @input.val valueName @shadowInput.val value + @currentItem = null + @input.trigger('change') @shadowInput.trigger('change') @toggle() @@ -169,32 +346,46 @@ class App.SearchableSelect extends Spine.Controller @query = @input.val() @filterByQuery @query - if @options.attribute.unknown + if @attribute.unknown @shadowInput.val @query filterByQuery: (query) -> query = escapeRegExp(query) regex = new RegExp(query.split(' ').join('.*'), 'i') - @option_items + @optionsList.addClass 'is-filtered' + + @optionItems .addClass 'is-hidden' .filter -> @textContent.match(regex) .removeClass 'is-hidden' - if @options.attribute.unknown && @option_items.length == @option_items.filter('.is-hidden').length - @option_items.removeClass 'is-hidden' - @option_items.removeClass 'is-active' + if !query + @optionItems.filter('.is-child').addClass 'is-hidden' + + # if all are hidden + if @attribute.unknown && @optionItems.length == @optionItems.filter('.is-hidden').length + @optionItems.not('.is-child').removeClass 'is-hidden' + @unhighlightCurrentItem() + @optionsList.removeClass 'is-filtered' else @highlightFirst(true) highlightFirst: (autocomplete) -> - first = @option_items.removeClass('is-active').not('.is-hidden').first() - first.addClass 'is-active' + @unhighlightCurrentItem() + @currentItem = @getCurrentOptions().not('.is-hidden').first() + @currentItem.addClass 'is-active' if autocomplete - @autocomplete first.attr('data-value'), first.text().trim() + @autocomplete @currentItem.attr('data-value'), @currentItem.text().trim() highlightItem: (event) => - @option_items.removeClass('is-active') - $(event.currentTarget).addClass('is-active') \ No newline at end of file + @unhighlightCurrentItem() + @currentItem = $(event.currentTarget) + @currentItem.addClass('is-active') + + unhighlightCurrentItem: -> + return if !@currentItem + @currentItem.removeClass('is-active') + @currentItem = null diff --git a/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee b/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee index a23409d6a..9c3da1044 100644 --- a/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee @@ -70,8 +70,7 @@ class App.SearchableAjaxSelect extends App.SearchableSelect options.push data # fill template with gathered options - @optionsList.html App.view('generic/searchable_select_options') - options: options + @optionsList.html @renderOptions options # refresh elements @refreshElements() diff --git a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco index 74ae5805b..70139147e 100644 --- a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco +++ b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco @@ -1,26 +1,29 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco new file mode 100644 index 000000000..bbefe2890 --- /dev/null +++ b/app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco deleted file mode 100644 index bf6cf3fc2..000000000 --- a/app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco +++ /dev/null @@ -1,5 +0,0 @@ -<% if @options: %> - <% for option in @options: %> -
  • ' + $(@).html() + '
  • ') + $(@).remove() + last_level = cur_level + else + last_level = 0 + ) + + $('[style]', editor).removeAttr('style') + $('[align]', editor).removeAttr('align') + $('span', editor).replaceWith(-> + $(@).contents() + ) + $('span:empty', editor).remove() + $("[class^='Mso']", editor).removeAttr('class') + $('p:empty', editor).remove() + editor + + removeAttribute: (element) -> + return if !element + $element = $(element) + for att in element.attributes + if att && att.name + element.removeAttribute(att.name) + #$element.removeAttr(att.name) + + $element.removeAttr('style') + .removeAttr('class') + .removeAttr('lang') + .removeAttr('type') + .removeAttr('align') + .removeAttr('id') + .removeAttr('wrap') + .removeAttr('title') + + removeAttributes: (html, parent = true) => + if parent + html.each((index, element) => @removeAttribute(element) ) + html.find('*').each((index, element) => @removeAttribute(element) ) + html + window.ZammadChat = ZammadChat diff --git a/public/assets/chat/chat.js b/public/assets/chat/chat.js index aa9f016fe..7526115be 100644 --- a/public/assets/chat/chat.js +++ b/public/assets/chat/chat.js @@ -1,3 +1,64 @@ +if (!window.zammadChatTemplates) { + window.zammadChatTemplates = {}; +} +window.zammadChatTemplates["agent"] = function (__obj) { + if (!__obj) __obj = {}; + var __out = [], __capture = function(callback) { + var out = __out, result; + __out = []; + callback.call(this); + result = __out.join(''); + __out = out; + return __safe(result); + }, __sanitize = function(value) { + if (value && value.ecoSafe) { + return value; + } else if (typeof value !== 'undefined' && value != null) { + return __escape(value); + } else { + return ''; + } + }, __safe, __objSafe = __obj.safe, __escape = __obj.escape; + __safe = __obj.safe = function(value) { + if (value && value.ecoSafe) { + return value; + } else { + if (!(typeof value !== 'undefined' && value != null)) value = ''; + var result = new String(value); + result.ecoSafe = true; + return result; + } + }; + if (!__escape) { + __escape = __obj.escape = function(value) { + return ('' + value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + } + (function() { + (function() { + if (this.agent.avatar) { + __out.push('\n\n'); + } + + __out.push('\n\n '); + + __out.push(__sanitize(this.agent.name)); + + __out.push('\n'); + + }).call(this); + + }).call(__obj); + __obj.safe = __objSafe, __obj.escape = __escape; + return __out.join(''); +}; + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, slice = [].slice, extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, @@ -60,7 +121,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; Log.prototype.log = function(level, items) { - var i, item, len, logString; + var item, j, len, logString; items.unshift('||'); items.unshift(level); items.unshift(this.options.logPrefix); @@ -69,8 +130,8 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); return; } logString = ''; - for (i = 0, len = items.length; i < len; i++) { - item = items[i]; + for (j = 0, len = items.length; j < len; j++) { + item = items[j]; logString += ' '; if (typeof item === 'object') { logString += JSON.stringify(item); @@ -173,11 +234,11 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); })(this); this.ws.onmessage = (function(_this) { return function(e) { - var i, len, pipe, pipes; + var j, len, pipe, pipes; pipes = JSON.parse(e.data); _this.log.debug('onMessage', e.data); - for (i = 0, len = pipes.length; i < len; i++) { - pipe = pipes[i]; + for (j = 0, len = pipes.length; j < len; j++) { + pipe = pipes[j]; if (pipe.event === 'pong') { _this.ping(); } @@ -386,8 +447,15 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); ZammadChat.prototype.scrollSnapTolerance = 10; + ZammadChat.prototype.richTextFormatKey = { + 66: true, + 73: true, + 85: true, + 83: true + }; + ZammadChat.prototype.T = function() { - var i, item, items, len, string, translations; + var item, items, j, len, string, translations; string = arguments[0], items = 2 <= arguments.length ? slice.call(arguments, 1) : []; if (this.options.lang && this.options.lang !== 'en') { if (!this.translations[this.options.lang]) { @@ -401,8 +469,8 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); } } if (items) { - for (i = 0, len = items.length; i < len; i++) { - item = items[i]; + for (j = 0, len = items.length; j < len; j++) { + item = items[j]; string = string.replace(/%s/, item); } } @@ -425,6 +493,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; function ZammadChat(options) { + this.removeAttributes = bind(this.removeAttributes, this); this.startTimeoutObservers = bind(this.startTimeoutObservers, this); this.onCssLoaded = bind(this.onCssLoaded, this); this.setAgentOnlineState = bind(this.setAgentOnlineState, this); @@ -552,6 +621,203 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); keydown: this.checkForEnter, input: this.onInput }); + this.input.on('keydown', (function(_this) { + return function(e) { + var richtTextControl; + richtTextControl = false; + if (!e.altKey && !e.ctrlKey && e.metaKey) { + richtTextControl = true; + } else if (!e.altKey && e.ctrlKey && !e.metaKey) { + richtTextControl = true; + } + if (richtTextControl && _this.richTextFormatKey[e.keyCode]) { + e.preventDefault(); + if (e.keyCode === 66) { + document.execCommand('bold'); + return true; + } + if (e.keyCode === 73) { + document.execCommand('italic'); + return true; + } + if (e.keyCode === 85) { + document.execCommand('underline'); + return true; + } + if (e.keyCode === 83) { + document.execCommand('strikeThrough'); + return true; + } + } + }; + })(this)); + this.input.on('paste', (function(_this) { + return function(e) { + var clipboardData, docType, error, html, htmlTmp, imageFile, imageInserted, item, match, reader, regex, replacementTag, text; + e.stopPropagation(); + e.preventDefault(); + clipboardData; + if (e.clipboardData) { + clipboardData = e.clipboardData; + } else if (window.clipboardData) { + clipboardData = window.clipboardData; + } else if (e.originalEvent.clipboardData) { + clipboardData = e.originalEvent.clipboardData; + } else { + throw 'No clipboardData support'; + } + imageInserted = false; + if (clipboardData && clipboardData.items && clipboardData.items[0]) { + item = clipboardData.items[0]; + if (item.kind === 'file' && (item.type === 'image/png' || item.type === 'image/jpeg')) { + imageFile = item.getAsFile(); + reader = new FileReader(); + reader.onload = function(e) { + var img, insert, result; + result = e.target.result; + img = document.createElement('img'); + img.src = result; + insert = function(dataUrl, width, height, isRetina) { + if (_this.isRetina()) { + width = width / 2; + height = height / 2; + } + result = dataUrl; + img = ""; + return document.execCommand('insertHTML', false, img); + }; + return _this.resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert); + }; + reader.readAsDataURL(imageFile); + imageInserted = true; + } + } + if (imageInserted) { + return; + } + text = void 0; + docType = void 0; + try { + text = clipboardData.getData('text/html'); + docType = 'html'; + if (!text || text.length === 0) { + docType = 'text'; + text = clipboardData.getData('text/plain'); + } + if (!text || text.length === 0) { + docType = 'text2'; + text = clipboardData.getData('text'); + } + } catch (error) { + e = error; + console.log('Sorry, can\'t insert markup because browser is not supporting it.'); + docType = 'text3'; + text = clipboardData.getData('text'); + } + if (docType === 'text' || docType === 'text2' || docType === 'text3') { + text = '
    ' + text.replace(/\n/g, '
    ') + '
    '; + text = text.replace(/
    <\/div>/g, '

    '); + } + console.log('p', docType, text); + if (docType === 'html') { + html = $("
    " + text + "
    "); + match = false; + htmlTmp = text; + regex = new RegExp('<(/w|w)\:[A-Za-z]'); + if (htmlTmp.match(regex)) { + match = true; + htmlTmp = htmlTmp.replace(regex, ''); + } + regex = new RegExp('<(/o|o)\:[A-Za-z]'); + if (htmlTmp.match(regex)) { + match = true; + htmlTmp = htmlTmp.replace(regex, ''); + } + if (match) { + html = _this.wordFilter(html); + } + html = $(html); + html.contents().each(function() { + if (this.nodeType === 8) { + return $(this).remove(); + } + }); + html.find('a, font, small, time, form, label').replaceWith(function() { + return $(this).contents(); + }); + replacementTag = 'div'; + html.find('textarea').each(function() { + var newTag, outer; + outer = this.outerHTML; + regex = new RegExp('<' + this.tagName, 'i'); + newTag = outer.replace(regex, '<' + replacementTag); + regex = new RegExp('"); + img = img.get(0); + if (document.caretPositionFromPoint) { + pos = document.caretPositionFromPoint(x, y); + range = document.createRange(); + range.setStart(pos.offsetNode, pos.offset); + range.collapse(); + return range.insertNode(img); + } else if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(x, y); + return range.insertNode(img); + } else { + return console.log('could not find carat'); + } + }; + return _this.resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert); + }; + return reader.readAsDataURL(file); + } + }; + })(this)); $(window).on('beforeunload', (function(_this) { return function() { return _this.onLeaveTemporary(); @@ -595,9 +861,9 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; ZammadChat.prototype.onWebSocketMessage = function(pipes) { - var i, len, pipe; - for (i = 0, len = pipes.length; i < len; i++) { - pipe = pipes[i]; + var j, len, pipe; + for (j = 0, len = pipes.length; j < len; j++) { + pipe = pipes[j]; this.log.debug('ws:onmessage', pipe); switch (pipe.event) { case 'chat_error': @@ -683,15 +949,15 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; ZammadChat.prototype.onReopenSession = function(data) { - var i, len, message, ref, unfinishedMessage; + var j, len, message, ref, unfinishedMessage; this.log.debug('old messages', data.session); this.inactiveTimeout.start(); unfinishedMessage = sessionStorage.getItem('unfinished_message'); if (data.agent) { this.onConnectionEstablished(data); ref = data.session; - for (i = 0, len = ref.length; i < len; i++) { - message = ref[i]; + for (j = 0, len = ref.length; j < len; j++) { + message = ref[j]; this.renderMessage({ message: message.content, id: message.id, @@ -1322,73 +1588,223 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); } }; + ZammadChat.prototype.isRetina = function() { + var mq; + if (window.matchMedia) { + mq = window.matchMedia('only screen and (min--moz-device-pixel-ratio: 1.3), only screen and (-o-min-device-pixel-ratio: 2.6/2), only screen and (-webkit-min-device-pixel-ratio: 1.3), only screen and (min-device-pixel-ratio: 1.3), only screen and (min-resolution: 1.3dppx)'); + return mq && mq.matches || (window.devicePixelRatio > 1); + } + return false; + }; + + ZammadChat.prototype.resizeImage = function(dataURL, x, y, sizeFactor, type, quallity, callback, force) { + var imageObject; + if (x == null) { + x = 'auto'; + } + if (y == null) { + y = 'auto'; + } + if (sizeFactor == null) { + sizeFactor = 1; + } + if (force == null) { + force = true; + } + imageObject = new Image(); + imageObject.onload = function() { + var canvas, context, factor, imageHeight, imageWidth, newDataUrl, resize; + imageWidth = imageObject.width; + imageHeight = imageObject.height; + console.log('ImageService', 'current size', imageWidth, imageHeight); + if (y === 'auto' && x === 'auto') { + x = imageWidth; + y = imageHeight; + } + if (y === 'auto') { + factor = imageWidth / x; + y = imageHeight / factor; + } + if (x === 'auto') { + factor = imageWidth / y; + x = imageHeight / factor; + } + resize = false; + if (x < imageWidth || y < imageHeight) { + resize = true; + x = x * sizeFactor; + y = y * sizeFactor; + } else { + x = imageWidth; + y = imageHeight; + } + canvas = document.createElement('canvas'); + canvas.width = x; + canvas.height = y; + context = canvas.getContext('2d'); + context.drawImage(imageObject, 0, 0, x, y); + if (quallity === 'auto') { + if (x < 200 && y < 200) { + quallity = 1; + } else if (x < 400 && y < 400) { + quallity = 0.9; + } else if (x < 600 && y < 600) { + quallity = 0.8; + } else if (x < 900 && y < 900) { + quallity = 0.7; + } else { + quallity = 0.6; + } + } + newDataUrl = canvas.toDataURL(type, quallity); + if (resize) { + console.log('ImageService', 'resize', x / sizeFactor, y / sizeFactor, quallity, (newDataUrl.length * 0.75) / 1024 / 1024, 'in mb'); + callback(newDataUrl, x / sizeFactor, y / sizeFactor, true); + return; + } + console.log('ImageService', 'no resize', x, y, quallity, (newDataUrl.length * 0.75) / 1024 / 1024, 'in mb'); + return callback(newDataUrl, x, y, false); + }; + return imageObject.src = dataURL; + }; + + ZammadChat.prototype.pasteHtmlAtCaret = function(html) { + var el, frag, lastNode, node, range, sel; + sel = void 0; + range = void 0; + if (window.getSelection) { + sel = window.getSelection(); + if (sel.getRangeAt && sel.rangeCount) { + range = sel.getRangeAt(0); + range.deleteContents(); + el = document.createElement('div'); + el.innerHTML = html; + frag = document.createDocumentFragment(node, lastNode); + while (node = el.firstChild) { + lastNode = frag.appendChild(node); + } + range.insertNode(frag); + if (lastNode) { + range = range.cloneRange(); + range.setStartAfter(lastNode); + range.collapse(true); + sel.removeAllRanges(); + return sel.addRange(range); + } + } + } else if (document.selection && document.selection.type !== 'Control') { + return document.selection.createRange().pasteHTML(html); + } + }; + + ZammadChat.prototype.wordFilter = function(editor) { + var content, last_level, pnt; + content = editor.html(); + content = content.replace(//gi, ''); + content = content.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi, ''); + content = content.replace(/<(\/?)s>/gi, '<$1strike>'); + content = content.replace(/ /gi, ' '); + editor.html(content); + $('p', editor).each(function() { + var matches, str; + str = $(this).attr('style'); + matches = /mso-list:\w+ \w+([0-9]+)/.exec(str); + if (matches) { + return $(this).data('_listLevel', parseInt(matches[1], 10)); + } + }); + last_level = 0; + pnt = null; + $('p', editor).each(function() { + var cur_level, i, j, list_tag, matches, ref, ref1, ref2, start, txt; + cur_level = $(this).data('_listLevel'); + if (cur_level !== void 0) { + txt = $(this).text(); + list_tag = '
      '; + if (/^\s*\w+\./.test(txt)) { + matches = /([0-9])\./.exec(txt); + if (matches) { + start = parseInt(matches[1], 10); + list_tag = (ref = start > 1) != null ? ref : '
        ': '
          ' + }; + } else { + list_tag = '
            '; + } + } + if (cur_level > last_level) { + if (last_level === 0) { + $(this).before(list_tag); + pnt = $(this).prev(); + } else { + pnt = $(list_tag).appendTo(pnt); + } + } + if (cur_level < last_level) { + for (i = j = ref1 = i, ref2 = last_level - cur_level; ref1 <= ref2 ? j <= ref2 : j >= ref2; i = ref1 <= ref2 ? ++j : --j) { + pnt = pnt.parent(); + } + } + $('span:first', this).remove(); + pnt.append('
          1. ' + $(this).html() + '
          2. '); + $(this).remove(); + return last_level = cur_level; + } else { + return last_level = 0; + } + }); + $('[style]', editor).removeAttr('style'); + $('[align]', editor).removeAttr('align'); + $('span', editor).replaceWith(function() { + return $(this).contents(); + }); + $('span:empty', editor).remove(); + $("[class^='Mso']", editor).removeAttr('class'); + $('p:empty', editor).remove(); + return editor; + }; + + ZammadChat.prototype.removeAttribute = function(element) { + var $element, att, j, len, ref; + if (!element) { + return; + } + $element = $(element); + ref = element.attributes; + for (j = 0, len = ref.length; j < len; j++) { + att = ref[j]; + if (att && att.name) { + element.removeAttribute(att.name); + } + } + return $element.removeAttr('style').removeAttr('class').removeAttr('lang').removeAttr('type').removeAttr('align').removeAttr('id').removeAttr('wrap').removeAttr('title'); + }; + + ZammadChat.prototype.removeAttributes = function(html, parent) { + if (parent == null) { + parent = true; + } + if (parent) { + html.each((function(_this) { + return function(index, element) { + return _this.removeAttribute(element); + }; + })(this)); + } + html.find('*').each((function(_this) { + return function(index, element) { + return _this.removeAttribute(element); + }; + })(this)); + return html; + }; + return ZammadChat; })(Base); return window.ZammadChat = ZammadChat; })(window.jQuery, window); -if (!window.zammadChatTemplates) { - window.zammadChatTemplates = {}; -} -window.zammadChatTemplates["agent"] = function (__obj) { - if (!__obj) __obj = {}; - var __out = [], __capture = function(callback) { - var out = __out, result; - __out = []; - callback.call(this); - result = __out.join(''); - __out = out; - return __safe(result); - }, __sanitize = function(value) { - if (value && value.ecoSafe) { - return value; - } else if (typeof value !== 'undefined' && value != null) { - return __escape(value); - } else { - return ''; - } - }, __safe, __objSafe = __obj.safe, __escape = __obj.escape; - __safe = __obj.safe = function(value) { - if (value && value.ecoSafe) { - return value; - } else { - if (!(typeof value !== 'undefined' && value != null)) value = ''; - var result = new String(value); - result.ecoSafe = true; - return result; - } - }; - if (!__escape) { - __escape = __obj.escape = function(value) { - return ('' + value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - }; - } - (function() { - (function() { - if (this.agent.avatar) { - __out.push('\n\n'); - } - - __out.push('\n\n '); - - __out.push(__sanitize(this.agent.name)); - - __out.push('\n'); - - }).call(this); - - }).call(__obj); - __obj.safe = __objSafe, __obj.escape = __escape; - return __out.join(''); -}; - if (!window.zammadChatTemplates) { window.zammadChatTemplates = {}; } diff --git a/public/assets/chat/chat.min.js b/public/assets/chat/chat.min.js index fed77d7b7..91f5131c5 100644 --- a/public/assets/chat/chat.min.js +++ b/public/assets/chat/chat.min.js @@ -1,2 +1,2 @@ -var bind=function(t,e){return function(){return t.apply(e,arguments)}},slice=[].slice,extend=function(t,e){function s(){this.constructor=t}for(var n in e)hasProp.call(e,n)&&(t[n]=e[n]);return s.prototype=e.prototype,t.prototype=new s,t.__super__=e.prototype,t},hasProp={}.hasOwnProperty;!function(t,e){var s,n,i,o,a,r,l,h,d;return d=document.getElementsByTagName("script"),r=d[d.length-1],l=r.src.match(".*://([^:/]*).*")[1],h=r.src.match("(.*)://[^:/]*.*")[1],s=function(){function e(e){this.options=t.extend({},this.defaults,e),this.log=new i({debug:this.options.debug,logPrefix:this.options.logPrefix||this.logPrefix})}return e.prototype.defaults={debug:!1},e}(),i=function(){function e(e){this.log=bind(this.log,this),this.error=bind(this.error,this),this.notice=bind(this.notice,this),this.debug=bind(this.debug,this),this.options=t.extend({},this.defaults,e)}return e.prototype.defaults={debug:!1},e.prototype.debug=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.options.debug?this.log("debug",t):void 0},e.prototype.notice=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("notice",t)},e.prototype.error=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("error",t)},e.prototype.log=function(e,s){var n,i,o,a;if(s.unshift("||"),s.unshift(e),s.unshift(this.options.logPrefix),console.log.apply(console,s),this.options.debug){for(a="",n=0,o=s.length;o>n;n++)i=s[n],a+=" ",a+="object"==typeof i?JSON.stringify(i):i&&i.toString?i.toString():i;return t(".js-chatLogDisplay").prepend("
            "+a+"
            ")}},e}(),o=function(t){function e(t){this.stop=bind(this.stop,this),this.start=bind(this.start,this),e.__super__.constructor.call(this,t)}return extend(e,t),e.prototype.timeoutStartedAt=null,e.prototype.logPrefix="timeout",e.prototype.defaults={debug:!1,timeout:4,timeoutIntervallCheck:.5},e.prototype.start=function(){var t,e;return this.stop(),e=new Date,t=function(t){return function(){var s;return s=new Date-new Date(e.getTime()+1e3*t.options.timeout*60),t.log.debug("Timeout check for "+t.options.timeout+" minutes (left "+s/1e3+" sec.)"),0>s?void 0:(t.stop(),t.options.callback())}}(this),this.log.debug("Start timeout in "+this.options.timeout+" minutes"),this.intervallId=setInterval(t,1e3*this.options.timeoutIntervallCheck*60)},e.prototype.stop=function(){return this.intervallId?(this.log.debug("Stop timeout of "+this.options.timeout+" minutes"),clearInterval(this.intervallId)):void 0},e}(s),n=function(t){function s(t){this.ping=bind(this.ping,this),this.send=bind(this.send,this),this.reconnect=bind(this.reconnect,this),this.close=bind(this.close,this),this.connect=bind(this.connect,this),this.set=bind(this.set,this),s.__super__.constructor.call(this,t)}return extend(s,t),s.prototype.logPrefix="io",s.prototype.set=function(t){var e,s,n;s=[];for(e in t)n=t[e],s.push(this.options[e]=n);return s},s.prototype.connect=function(){return this.log.debug("Connecting to "+this.options.host),this.ws=new e.WebSocket(""+this.options.host),this.ws.onopen=function(t){return function(e){return t.log.debug("onOpen",e),t.options.onOpen(e),t.ping()}}(this),this.ws.onmessage=function(t){return function(e){var s,n,i,o;for(o=JSON.parse(e.data),t.log.debug("onMessage",e.data),s=0,n=o.length;n>s;s++)i=o[s],"pong"===i.event&&t.ping();return t.options.onMessage?t.options.onMessage(o):void 0}}(this),this.ws.onclose=function(t){return function(e){if(t.log.debug("close websocket connection",e),t.pingDelayId&&clearTimeout(t.pingDelayId),t.manualClose){if(t.log.debug("manual close, onClose callback"),t.manualClose=!1,t.options.onClose)return t.options.onClose(e)}else if(t.log.debug("error close, onError callback"),t.options.onError)return t.options.onError("Connection lost...")}}(this),this.ws.onerror=function(t){return function(e){return t.log.debug("onError",e),t.options.onError?t.options.onError(e):void 0}}(this)},s.prototype.close=function(){return this.log.debug("close websocket manually"),this.manualClose=!0,this.ws.close()},s.prototype.reconnect=function(){return this.log.debug("reconnect"),this.close(),this.connect()},s.prototype.send=function(t,e){var s;return null==e&&(e={}),this.log.debug("send",t,e),s=JSON.stringify({event:t,data:e}),this.ws.send(s)},s.prototype.ping=function(){var t;return t=function(t){return function(){return t.send("ping")}}(this),this.pingDelayId=setTimeout(t,29e3)},s}(s),a=function(s){function i(s){return this.startTimeoutObservers=bind(this.startTimeoutObservers,this),this.onCssLoaded=bind(this.onCssLoaded,this),this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.setSessionId=bind(this.setSessionId,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.destroy=bind(this.destroy,this),this.onScrollHintClick=bind(this.onScrollHintClick,this),this.detectScrolledtoBottom=bind(this.detectScrolledtoBottom,this),this.onLeaveTemporary=bind(this.onLeaveTemporary,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onQueueScreen=bind(this.onQueueScreen,this),this.onWebSocketClose=bind(this.onWebSocketClose,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.close=bind(this.close,this),this.toggle=bind(this.toggle,this),this.sessionClose=bind(this.sessionClose,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.open=bind(this.open,this),this.renderMessage=bind(this.renderMessage,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onFocus=bind(this.onFocus,this),this.onInput=bind(this.onInput,this),this.onReopenSession=bind(this.onReopenSession,this),this.onError=bind(this.onError,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.render=bind(this.render,this),this.view=bind(this.view,this),this.T=bind(this.T,this),this.options=t.extend({},this.defaults,s),i.__super__.constructor.call(this,this.options),this.isFullscreen=e.matchMedia&&e.matchMedia("(max-width: 768px)").matches,this.scrollRoot=t(this.getScrollRoot()),t?e.WebSocket&&sessionStorage?this.options.chatId?(this.options.lang||(this.options.lang=t("html").attr("lang")),this.options.lang&&(this.translations[this.options.lang]||(this.log.debug("lang: No "+this.options.lang+" found, try first two letters"),this.options.lang=this.options.lang.replace(/-.+?$/,"")),this.log.debug("lang: "+this.options.lang)),this.options.host||this.detectHost(),this.loadCss(),this.io=new n(this.options),this.io.set({onOpen:this.render,onClose:this.onWebSocketClose,onMessage:this.onWebSocketMessage,onError:this.onError}),void this.io.connect()):(this.state="unsupported",void this.log.error("Chat: need chatId as option!")):(this.state="unsupported",void this.log.notice("Chat: Browser not supported!")):(this.state="unsupported",void this.log.notice("Chat: no jquery found!"))}return extend(i,s),i.prototype.defaults={chatId:void 0,show:!0,target:t("body"),host:"",debug:!1,flat:!1,lang:void 0,cssAutoload:!0,cssUrl:void 0,fontSize:void 0,buttonClass:"open-zammad-chat",inactiveClass:"is-inactive",title:"Chat with us!",scrollHint:"Scrolle nach unten um neue Nachrichten zu sehen",idleTimeout:6,idleTimeoutIntervallCheck:.5,inactiveTimeout:8,inactiveTimeoutIntervallCheck:.5,waitingListTimeout:4,waitingListTimeoutIntervallCheck:.5},i.prototype.logPrefix="chat",i.prototype._messageCount=0,i.prototype.isOpen=!1,i.prototype.blinkOnlineInterval=null,i.prototype.stopBlinOnlineStateTimeout=null,i.prototype.showTimeEveryXMinutes=2,i.prototype.lastTimestamp=null,i.prototype.lastAddedType=null,i.prototype.inputTimeout=null,i.prototype.isTyping=!1,i.prototype.state="offline",i.prototype.initialQueueDelay=1e4,i.prototype.translations={de:{"Chat with us!":"Chatte mit uns!","Scroll down to see new messages":"Scrolle nach unten um neue Nachrichten zu sehen",Online:"Online",Online:"Online",Offline:"Offline",Connecting:"Verbinden","Connection re-established":"Verbindung wiederhergestellt",Today:"Heute",Send:"Senden","Compose your message...":"Ihre Nachricht...","All colleagues are busy.":"Alle Kollegen sind belegt.","You are on waiting list position %s.":"Sie sind in der Warteliste an der Position %s.","Start new conversation":"Neue Konversation starten","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit %s geschlossen.","Since you didn't respond in the last %s minutes your conversation got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Es tut uns leid, es dauert länger als erwartet, um einen freien Platz zu erhalten. Bitte versuchen Sie es zu einem späteren Zeitpunkt noch einmal oder schicken Sie uns eine E-Mail. Vielen Dank!"},fr:{"Chat with us!":"Chattez avec nous!","Scroll down to see new messages":"Faites défiler pour lire les nouveaux messages",Online:"En-ligne",Online:"En-ligne",Offline:"Hors-ligne",Connecting:"Connexion en cours","Connection re-established":"Connexion rétablie",Today:"Aujourdhui",Send:"Envoyer","Compose your message...":"Composez votre message...","All colleagues are busy.":"Tous les collègues sont actuellement occupés.","You are on waiting list position %s.":"Vous êtes actuellement en %s position dans la file d'attente.","Start new conversation":"Démarrer une nouvelle conversation","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation avec %s va être fermée.","Since you didn't respond in the last %s minutes your conversation got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation va être fermée.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Nous sommes désolés, il faut plus de temps que prévu pour obtenir un emplacement vide. Veuillez réessayer ultérieurement ou nous envoyer un courriel. Je vous remercie!"},"zh-cn":{"Chat with us!":"发起即时对话!","Scroll down to see new messages":"向下滚动以查看新消息",Online:"在线",Online:"在线",Offline:"离线",Connecting:"连接中","Connection re-established":"正在重新建立连接",Today:"今天",Send:"发送","Compose your message...":"正在输入信息...","All colleagues are busy.":"所有工作人员都在忙碌中.","You are on waiting list position %s.":"您目前的等候位置是第 %s 位.","Start new conversation":"开始新的会话","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由于您超过 %s 分钟没有回复, 您与 %s 的会话已被关闭.","Since you didn't respond in the last %s minutes your conversation got closed.":"由于您超过 %s 分钟没有任何回复, 该对话已被关闭.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 目前需要等候更长的时间才能接入对话, 请稍后重试或向我们发送电子邮件. 谢谢!"},"zh-tw":{"Chat with us!":"開始即時對话!","Scroll down to see new messages":"向下滑動以查看新訊息",Online:"線上",Online:"線上",Offline:"离线",Connecting:"連線中","Connection re-established":"正在重新建立連線中",Today:"今天",Send:"發送","Compose your message...":"正在輸入訊息...","All colleagues are busy.":"所有服務人員都在忙碌中.","You are on waiting list position %s.":"你目前的等候位置是第 %s 順位.","Start new conversation":"開始新的對話","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由於你超過 %s 分鐘沒有回應, 你與 %s 的對話已被關閉.","Since you didn't respond in the last %s minutes your conversation got closed.":"由於你超過 %s 分鐘沒有任何回應, 該對話已被關閉.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 當前需要等候更長的時間方可排入對話程序, 請稍後重試或向我們寄送電子郵件. 謝謝!"}},i.prototype.sessionId=void 0,i.prototype.scrolledToBottom=!0,i.prototype.scrollSnapTolerance=10,i.prototype.T=function(){var t,e,s,n,i,o;if(i=arguments[0],s=2<=arguments.length?slice.call(arguments,1):[],this.options.lang&&"en"!==this.options.lang&&(this.translations[this.options.lang]?(o=this.translations[this.options.lang],o[i]||this.log.notice("Translation needed for '"+i+"'"),i=o[i]||i):this.log.notice("Translation '"+this.options.lang+"' needed!")),s)for(t=0,n=s.length;n>t;t++)e=s[t],i=i.replace(/%s/,e);return i},i.prototype.view=function(t){return function(s){return function(n){return n||(n={}),n.T=s.T,n.background=s.options.background,n.flat=s.options.flat,n.fontSize=s.options.fontSize,e.zammadChatTemplates[t](n)}}(this)},i.prototype.getScrollRoot=function(){var t,e,s;return"scrollingElement"in document?document.scrollingElement:(e=document.documentElement,s=e.scrollTop,e.scrollTop=s+1,t=e.scrollTop,e.scrollTop=s,t>s?e:document.body)},i.prototype.render=function(){return this.el&&t(".zammad-chat").get(0)||this.renderBase(),t("."+this.options.buttonClass).addClass(this.inactiveClass),this.setAgentOnlineState("online"),this.log.debug("widget rendered"),this.startTimeoutObservers(),this.idleTimeout.start(),this.sessionId=sessionStorage.getItem("sessionId"),this.send("chat_status_customer",{session_id:this.sessionId,url:e.location.href})},i.prototype.renderBase=function(){return this.el=t(this.view("chat")({title:this.options.title,scrollHint:this.options.scrollHint})),this.options.target.append(this.el),this.input=this.el.find(".zammad-chat-input"),this.el.find(".js-chat-open").click(this.open),this.el.find(".js-chat-toggle").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-body").on("scroll",this.detectScrolledtoBottom),this.el.find(".zammad-scroll-hint").click(this.onScrollHintClick),this.input.on({keydown:this.checkForEnter,input:this.onInput}),t(e).on("beforeunload",function(t){return function(){return t.onLeaveTemporary()}}(this)),t(e).bind("hashchange",function(t){return function(){return t.isOpen?void(t.sessionId&&t.send("chat_session_notice",{session_id:t.sessionId,message:e.location.href})):t.idleTimeout.start()}}(this)),this.isFullscreen?this.input.on({focus:this.onFocus,focusout:this.onFocusOut}):void 0},i.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},i.prototype.send=function(t,e){return null==e&&(e={}),e.chat_id=this.options.chatId,this.io.send(t,e)},i.prototype.onWebSocketMessage=function(t){var e,s,n;for(e=0,s=t.length;s>e;e++)switch(n=t[e],this.log.debug("ws:onmessage",n),n.event){case"chat_error":this.log.notice(n.data),n.data&&"chat_disabled"===n.data.state&&this.destroy({remove:!0});break;case"chat_session_message":if(n.data.self_written)return;this.receiveMessage(n.data);break;case"chat_session_typing":if(n.data.self_written)return;this.onAgentTypingStart();break;case"chat_session_start":this.onConnectionEstablished(n.data);break;case"chat_session_queue":this.onQueueScreen(n.data);break;case"chat_session_closed":this.onSessionClosed(n.data);break;case"chat_session_left":this.onSessionClosed(n.data);break;case"chat_status_customer":switch(n.data.state){case"online":this.sessionId=void 0,!this.options.cssAutoload||this.cssLoaded?this.onReady():this.socketReady=!0;break;case"offline":this.onError("Zammad Chat: No agent online");break;case"chat_disabled":this.onError("Zammad Chat: Chat is disabled");break;case"no_seats_available":this.onError("Zammad Chat: Too many clients in queue. Clients in queue: "+n.data.queue);break;case"reconnect":this.onReopenSession(n.data)}}},i.prototype.onReady=function(){return this.log.debug("widget ready for use"),t("."+this.options.buttonClass).click(this.open).removeClass(this.inactiveClass),this.options.show?this.show():void 0},i.prototype.onError=function(e){return this.log.debug(e),this.addStatus(e),t("."+this.options.buttonClass).hide(),this.isOpen?(this.disableInput(),this.destroy({remove:!1})):this.destroy({remove:!0})},i.prototype.onReopenSession=function(t){var e,s,n,i,o;if(this.log.debug("old messages",t.session),this.inactiveTimeout.start(),o=sessionStorage.getItem("unfinished_message"),t.agent){for(this.onConnectionEstablished(t),i=t.session,e=0,s=i.length;s>e;e++)n=i[e],this.renderMessage({message:n.content,id:n.id,from:n.created_by_id?"agent":"customer"});o&&this.input.html(o)}return t.position&&this.onQueue(t),this.show(),this.open(),this.scrollToBottom(),o?this.input.focus():void 0},i.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),sessionStorage.setItem("unfinished_message",this.input.html()),this.onTyping()},i.prototype.onFocus=function(){var s;return t(e).scrollTop(10),s=t(e).scrollTop()>0,t(e).scrollTop(0),s?this.log.notice("virtual keyboard shown"):void 0},i.prototype.onFocusOut=function(){},i.prototype.onTyping=function(){return this.isTyping&&this.isTyping>new Date((new Date).getTime()-1500)?void 0:(this.isTyping=new Date,this.send("chat_session_typing",{session_id:this.sessionId}),this.inactiveTimeout.start())},i.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},i.prototype.sendMessage=function(){var t,e;return(t=this.input.html())?(this.inactiveTimeout.start(),sessionStorage.removeItem("unfinished_message"),e=this.view("message")({message:t,from:"customer",id:this._messageCount++,unreadClass:""}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").get(0)?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.input.html(""),this.scrollToBottom(),this.send("chat_session_message",{content:t,id:this._messageCount,session_id:this.sessionId})):void 0},i.prototype.receiveMessage=function(t){return this.inactiveTimeout.start(),this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.renderMessage({message:t.message.content,id:t.id,from:"agent"}),this.scrollToBottom({showHint:!0})},i.prototype.renderMessage=function(t){return this.lastAddedType="message--"+t.from,t.unreadClass=document.hidden?" zammad-chat-message--unread":"",this.el.find(".zammad-chat-body").append(this.view("message")(t))},i.prototype.open=function(){var t;return this.isOpen?void this.log.debug("widget already open, block"):(this.isOpen=!0,this.log.debug("open widget"),this.sessionId||this.showLoader(),this.el.addClass("zammad-chat-is-open"),t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t),this.sessionId?(this.el.css("bottom",0),this.onOpenAnimationEnd()):(this.el.animate({bottom:0},500,this.onOpenAnimationEnd),this.send("chat_session_init",{url:e.location.href})))},i.prototype.onOpenAnimationEnd=function(){return this.idleTimeout.stop(),this.isFullscreen?this.disableScrollOnRoot():void 0},i.prototype.sessionClose=function(){return this.send("chat_session_close",{session_id:this.sessionId}),this.inactiveTimeout.stop(),this.waitingListTimeout.stop(),sessionStorage.removeItem("unfinished_message"),this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.setSessionId(void 0)},i.prototype.toggle=function(t){return this.isOpen?this.close(t):this.open(t)},i.prototype.close=function(t){var e;return this.isOpen?(this.initDelayId&&clearTimeout(this.initDelayId),this.sessionId?(this.log.debug("close widget"),t&&t.stopPropagation(),this.sessionClose(),this.isFullscreen&&this.enableScrollOnRoot(),e=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-e},500,this.onCloseAnimationEnd)):void this.log.debug("can't close widget without sessionId")):void this.log.debug("can't close widget, it's not open")},i.prototype.onCloseAnimationEnd=function(){return this.el.css("bottom",""),this.el.removeClass("zammad-chat-is-open"),this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden"),this.isOpen=!1,this.io.reconnect()},i.prototype.onWebSocketClose=function(){return this.isOpen?void 0:this.el?(this.el.removeClass("zammad-chat-is-shown"),this.el.removeClass("zammad-chat-is-loaded")):void 0},i.prototype.show=function(){return"offline"!==this.state?(this.el.addClass("zammad-chat-is-loaded"),this.el.addClass("zammad-chat-is-shown")):void 0},i.prototype.disableInput=function(){return this.input.prop("disabled",!0),this.el.find(".zammad-chat-send").prop("disabled",!0)},i.prototype.enableInput=function(){return this.input.prop("disabled",!1),this.el.find(".zammad-chat-send").prop("disabled",!1)},i.prototype.hideModal=function(){return this.el.find(".zammad-chat-modal").html("")},i.prototype.onQueueScreen=function(t){var e;return this.setSessionId(t.session_id),e=function(e){return function(){return e.onQueue(t),e.waitingListTimeout.start()}}(this),this.initialQueueDelay&&!this.onInitialQueueDelayId?void(this.onInitialQueueDelayId=setTimeout(e,this.initialQueueDelay)):(this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),e())},i.prototype.onQueue=function(t){return this.log.notice("onQueue",t.position),this.inQueue=!0,this.el.find(".zammad-chat-modal").html(this.view("waiting")({position:t.position}))},i.prototype.onAgentTypingStart=function(){return this.stopTypingId&&clearTimeout(this.stopTypingId),this.stopTypingId=setTimeout(this.onAgentTypingEnd,3e3),!this.el.find(".zammad-chat-message--typing").get(0)&&(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.isVisible(this.el.find(".zammad-chat-message--typing"),!0))?this.scrollToBottom():void 0},i.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},i.prototype.onLeaveTemporary=function(){return this.sessionId?this.send("chat_session_leave_temporary",{session_id:this.sessionId}):void 0},i.prototype.maybeAddTimestamp=function(){var t,e,s;return s=Date.now(),!this.lastTimestamp||s-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=s):(this.el.find(".zammad-chat-body").append(this.view("timestamp")({label:t,time:e})),this.lastTimestamp=s,this.lastAddedType="timestamp",this.scrollToBottom())):void 0},i.prototype.updateLastTimestamp=function(t,e){return this.el?this.el.find(".zammad-chat-body").find(".zammad-chat-timestamp").last().replaceWith(this.view("timestamp")({label:t,time:e})):void 0},i.prototype.addStatus=function(t){return this.el?(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("status")({status:t})),this.scrollToBottom()):void 0},i.prototype.detectScrolledtoBottom=function(){var t;return t=this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-chat-body").outerHeight(),this.scrolledToBottom=Math.abs(t-this.el.find(".zammad-chat-body").prop("scrollHeight"))<=this.scrollSnapTolerance,this.scrolledToBottom?this.el.find(".zammad-scroll-hint").addClass("is-hidden"):void 0},i.prototype.showScrollHint=function(){return this.el.find(".zammad-scroll-hint").removeClass("is-hidden"),this.el.find(".zammad-chat-body").scrollTop(this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-scroll-hint").outerHeight())},i.prototype.onScrollHintClick=function(){return this.el.find(".zammad-chat-body").animate({scrollTop:this.el.find(".zammad-chat-body").prop("scrollHeight")},300)},i.prototype.scrollToBottom=function(e){var s;return s=(null!=e?e:{showHint:!1}).showHint,this.scrolledToBottom?this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight")):s?this.showScrollHint():void 0},i.prototype.destroy=function(t){return null==t&&(t={}),this.log.debug("destroy widget",t),this.setAgentOnlineState("offline"),t.remove&&this.el&&this.el.remove(),this.waitingListTimeout&&this.waitingListTimeout.stop(),this.inactiveTimeout&&this.inactiveTimeout.stop(),this.idleTimeout&&this.idleTimeout.stop(),this.io.close()},i.prototype.reconnect=function(){return this.log.notice("reconnecting"),this.disableInput(),this.lastAddedType="status",this.setAgentOnlineState("connecting"),this.addStatus(this.T("Connection lost"))},i.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.setAgentOnlineState("online"),this.addStatus(this.T("Connection re-established"))},i.prototype.onSessionClosed=function(t){return this.addStatus(this.T("Chat closed by %s",t.realname)),this.disableInput(),this.setAgentOnlineState("offline"),this.inactiveTimeout.stop()},i.prototype.setSessionId=function(t){return this.sessionId=t,void 0===t?sessionStorage.removeItem("sessionId"):sessionStorage.setItem("sessionId",t)},i.prototype.onConnectionEstablished=function(t){return this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.inQueue=!1,t.agent&&(this.agent=t.agent),t.session_id&&this.setSessionId(t.session_id),this.el.find(".zammad-chat-body").html(""),this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:this.agent})),this.enableInput(),this.hideModal(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.isFullscreen||this.input.focus(),this.setAgentOnlineState("online"),this.waitingListTimeout.stop(),this.idleTimeout.stop(),this.inactiveTimeout.start()},i.prototype.showCustomerTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("customer_timeout")({agent:this.agent.name,delay:this.options.inactiveTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showWaitingListTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("waiting_list_timeout")({delay:this.options.watingListTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showLoader=function(){return this.el.find(".zammad-chat-modal").html(this.view("loader")())},i.prototype.setAgentOnlineState=function(t){var e;return this.state=t,this.el?(e=t.charAt(0).toUpperCase()+t.slice(1),this.el.find(".zammad-chat-agent-status").attr("data-status",t).text(this.T(e))):void 0},i.prototype.detectHost=function(){var t;return t="ws://","https"===h&&(t="wss://"),this.options.host=""+t+l+"/ws"},i.prototype.loadCss=function(){var t,e,s;if(this.options.cssAutoload)return s=this.options.cssUrl,s||(s=this.options.host.replace(/^wss/i,"https").replace(/^ws/i,"http").replace(/\/ws/i,""),s+="/assets/chat/chat.css"),this.log.debug("load css from '"+s+"'"),e="@import url('"+s+"');",t=document.createElement("link"),t.onload=this.onCssLoaded,t.rel="stylesheet",t.href="data:text/css,"+escape(e),document.getElementsByTagName("head")[0].appendChild(t)},i.prototype.onCssLoaded=function(){return this.socketReady?this.onReady():this.cssLoaded=!0},i.prototype.startTimeoutObservers=function(){return this.idleTimeout=new o({logPrefix:"idleTimeout",debug:this.options.debug,timeout:this.options.idleTimeout,timeoutIntervallCheck:this.options.idleTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Idle timeout reached, hide widget",new Date),t.destroy({remove:!0})}}(this)}),this.inactiveTimeout=new o({logPrefix:"inactiveTimeout",debug:this.options.debug,timeout:this.options.inactiveTimeout,timeoutIntervallCheck:this.options.inactiveTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Inactive timeout reached, show timeout screen.",new Date),t.showCustomerTimeout(),t.destroy({remove:!1})}}(this)}),this.waitingListTimeout=new o({logPrefix:"waitingListTimeout",debug:this.options.debug,timeout:this.options.waitingListTimeout,timeoutIntervallCheck:this.options.waitingListTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Waiting list timeout reached, show timeout screen.",new Date),t.showWaitingListTimeout(),t.destroy({remove:!1})}}(this)})},i.prototype.disableScrollOnRoot=function(){return this.rootScrollOffset=this.scrollRoot.scrollTop(),this.scrollRoot.css({overflow:"hidden",position:"fixed"})},i.prototype.enableScrollOnRoot=function(){return this.scrollRoot.scrollTop(this.rootScrollOffset),this.scrollRoot.css({overflow:"",position:""})},i.prototype.isVisible=function(s,n,i,o){var a,r,l,h,d,c,u,p,m,g,f,y,v,b,w,T,z,C,S,I,k,_,O,A,E,L;if(!(s.length<1))if(r=t(e),a=s.length>1?s.eq(0):s,C=a.get(0),L=r.width(),E=r.height(),o=o?o:"both",p=i===!0?C.offsetWidth*C.offsetHeight:!0,"function"==typeof C.getBoundingClientRect){if(z=C.getBoundingClientRect(),S=z.top>=0&&z.top0&&z.bottom<=E,b=z.left>=0&&z.left0&&z.right<=L,I=n?S||u:S&&u,v=n?b||T:b&&T,"both"===o)return p&&I&&v;if("vertical"===o)return p&&I;if("horizontal"===o)return p&&v}else{if(A=r.scrollTop(),k=A+E,_=r.scrollLeft(),O=_+L,w=a.offset(),c=w.top,l=c+a.height(),h=w.left,d=h+a.width(),y=n===!0?l:c,m=n===!0?c:l,g=n===!0?d:h,f=n===!0?h:d,"both"===o)return!!p&&k>=m&&y>=A&&O>=f&&g>=_;if("vertical"===o)return!!p&&k>=m&&y>=A;if("horizontal"===o)return!!p&&O>=f&&g>=_}},i}(s),e.ZammadChat=a}(window.jQuery,window),window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){this.agent.avatar&&(s.push('\n\n')),s.push('\n\n '),s.push(n(this.agent.name)),s.push("\n")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.chat=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            \n
            \n
            \n \n \n \n \n \n
            \n
            \n
            \n
            \n \n '), -s.push(this.T(this.title)),s.push('\n
            \n
            \n
            \n \n
            \n
            \n
            \n \n
            \n
            ")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.customer_timeout=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            \n '),this.agent?(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation with %s got closed.",this.delay,this.agent)),s.push("\n ")):(s.push("\n "),s.push(this.T("Since you didn't respond in the last %s minutes your conversation got closed.",this.delay)),s.push("\n ")),s.push('\n
            \n
            "),s.push(this.T("Start new conversation")),s.push("
            \n
            ")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.loader=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('\n \n \n \n\n'),s.push(this.T("Connecting")),s.push("")}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.message=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            \n "),s.push(this.message),s.push("\n
            ")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.status=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            \n
            \n '),s.push(this.status),s.push("\n
            \n
            ")}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.timestamp=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            '),s.push(n(this.label)),s.push(" "),s.push(n(this.time)),s.push("
            ")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.typingIndicator=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            \n \n \n \n \n \n \n \n
            ')}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting=function(t){t||(t={});var e,s=[],n=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            \n \n \n \n \n \n '),s.push(this.T("All colleagues are busy.")),s.push("
            \n "),s.push(this.T("You are on waiting list position %s.",this.position)),s.push("\n
            ")}).call(this)}.call(t),t.safe=n,t.escape=i,s.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting_list_timeout=function(t){t||(t={});var e,s=[],n=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){s.push('
            \n '),s.push(this.T("We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!")),s.push('\n
            \n
            "),s.push(this.T("Start new conversation")),s.push("
            \n
            ")}).call(this)}.call(t),t.safe=i,t.escape=o,s.join("")}; \ No newline at end of file +window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.agent=function(t){t||(t={});var e,n=[],s=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){this.agent.avatar&&(n.push('\n\n')),n.push('\n\n '),n.push(s(this.agent.name)),n.push("\n")}).call(this)}.call(t),t.safe=i,t.escape=o,n.join("")};var bind=function(t,e){return function(){return t.apply(e,arguments)}},slice=[].slice,extend=function(t,e){function n(){this.constructor=t}for(var s in e)hasProp.call(e,s)&&(t[s]=e[s]);return n.prototype=e.prototype,t.prototype=new n,t.__super__=e.prototype,t},hasProp={}.hasOwnProperty;!function(t,e){var n,s,i,o,a,r,l,h,d;return d=document.getElementsByTagName("script"),r=d[d.length-1],l=r.src.match(".*://([^:/]*).*")[1],h=r.src.match("(.*)://[^:/]*.*")[1],n=function(){function e(e){this.options=t.extend({},this.defaults,e),this.log=new i({debug:this.options.debug,logPrefix:this.options.logPrefix||this.logPrefix})}return e.prototype.defaults={debug:!1},e}(),i=function(){function e(e){this.log=bind(this.log,this),this.error=bind(this.error,this),this.notice=bind(this.notice,this),this.debug=bind(this.debug,this),this.options=t.extend({},this.defaults,e)}return e.prototype.defaults={debug:!1},e.prototype.debug=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.options.debug?this.log("debug",t):void 0},e.prototype.notice=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("notice",t)},e.prototype.error=function(){var t;return t=1<=arguments.length?slice.call(arguments,0):[],this.log("error",t)},e.prototype.log=function(e,n){var s,i,o,a;if(n.unshift("||"),n.unshift(e),n.unshift(this.options.logPrefix),console.log.apply(console,n),this.options.debug){for(a="",i=0,o=n.length;o>i;i++)s=n[i],a+=" ",a+="object"==typeof s?JSON.stringify(s):s&&s.toString?s.toString():s;return t(".js-chatLogDisplay").prepend("
            "+a+"
            ")}},e}(),o=function(t){function e(t){this.stop=bind(this.stop,this),this.start=bind(this.start,this),e.__super__.constructor.call(this,t)}return extend(e,t),e.prototype.timeoutStartedAt=null,e.prototype.logPrefix="timeout",e.prototype.defaults={debug:!1,timeout:4,timeoutIntervallCheck:.5},e.prototype.start=function(){var t,e;return this.stop(),e=new Date,t=function(t){return function(){var n;return n=new Date-new Date(e.getTime()+1e3*t.options.timeout*60),t.log.debug("Timeout check for "+t.options.timeout+" minutes (left "+n/1e3+" sec.)"),0>n?void 0:(t.stop(),t.options.callback())}}(this),this.log.debug("Start timeout in "+this.options.timeout+" minutes"),this.intervallId=setInterval(t,1e3*this.options.timeoutIntervallCheck*60)},e.prototype.stop=function(){return this.intervallId?(this.log.debug("Stop timeout of "+this.options.timeout+" minutes"),clearInterval(this.intervallId)):void 0},e}(n),s=function(t){function n(t){this.ping=bind(this.ping,this),this.send=bind(this.send,this),this.reconnect=bind(this.reconnect,this),this.close=bind(this.close,this),this.connect=bind(this.connect,this),this.set=bind(this.set,this),n.__super__.constructor.call(this,t)}return extend(n,t),n.prototype.logPrefix="io",n.prototype.set=function(t){var e,n,s;n=[];for(e in t)s=t[e],n.push(this.options[e]=s);return n},n.prototype.connect=function(){return this.log.debug("Connecting to "+this.options.host),this.ws=new e.WebSocket(""+this.options.host),this.ws.onopen=function(t){return function(e){return t.log.debug("onOpen",e),t.options.onOpen(e),t.ping()}}(this),this.ws.onmessage=function(t){return function(e){var n,s,i,o;for(o=JSON.parse(e.data),t.log.debug("onMessage",e.data),n=0,s=o.length;s>n;n++)i=o[n],"pong"===i.event&&t.ping();return t.options.onMessage?t.options.onMessage(o):void 0}}(this),this.ws.onclose=function(t){return function(e){if(t.log.debug("close websocket connection",e),t.pingDelayId&&clearTimeout(t.pingDelayId),t.manualClose){if(t.log.debug("manual close, onClose callback"),t.manualClose=!1,t.options.onClose)return t.options.onClose(e)}else if(t.log.debug("error close, onError callback"),t.options.onError)return t.options.onError("Connection lost...")}}(this),this.ws.onerror=function(t){return function(e){return t.log.debug("onError",e),t.options.onError?t.options.onError(e):void 0}}(this)},n.prototype.close=function(){return this.log.debug("close websocket manually"),this.manualClose=!0,this.ws.close()},n.prototype.reconnect=function(){return this.log.debug("reconnect"),this.close(),this.connect()},n.prototype.send=function(t,e){var n;return null==e&&(e={}),this.log.debug("send",t,e),n=JSON.stringify({event:t,data:e}),this.ws.send(n)},n.prototype.ping=function(){var t;return t=function(t){return function(){return t.send("ping")}}(this),this.pingDelayId=setTimeout(t,29e3)},n}(n),a=function(n){function i(n){return this.removeAttributes=bind(this.removeAttributes,this),this.startTimeoutObservers=bind(this.startTimeoutObservers,this),this.onCssLoaded=bind(this.onCssLoaded,this),this.setAgentOnlineState=bind(this.setAgentOnlineState,this),this.onConnectionEstablished=bind(this.onConnectionEstablished,this),this.setSessionId=bind(this.setSessionId,this),this.onConnectionReestablished=bind(this.onConnectionReestablished,this),this.reconnect=bind(this.reconnect,this),this.destroy=bind(this.destroy,this),this.onScrollHintClick=bind(this.onScrollHintClick,this),this.detectScrolledtoBottom=bind(this.detectScrolledtoBottom,this),this.onLeaveTemporary=bind(this.onLeaveTemporary,this),this.onAgentTypingEnd=bind(this.onAgentTypingEnd,this),this.onAgentTypingStart=bind(this.onAgentTypingStart,this),this.onQueue=bind(this.onQueue,this),this.onQueueScreen=bind(this.onQueueScreen,this),this.onWebSocketClose=bind(this.onWebSocketClose,this),this.onCloseAnimationEnd=bind(this.onCloseAnimationEnd,this),this.close=bind(this.close,this),this.toggle=bind(this.toggle,this),this.sessionClose=bind(this.sessionClose,this),this.onOpenAnimationEnd=bind(this.onOpenAnimationEnd,this),this.open=bind(this.open,this),this.renderMessage=bind(this.renderMessage,this),this.receiveMessage=bind(this.receiveMessage,this),this.onSubmit=bind(this.onSubmit,this),this.onFocus=bind(this.onFocus,this),this.onInput=bind(this.onInput,this),this.onReopenSession=bind(this.onReopenSession,this),this.onError=bind(this.onError,this),this.onWebSocketMessage=bind(this.onWebSocketMessage,this),this.send=bind(this.send,this),this.checkForEnter=bind(this.checkForEnter,this),this.render=bind(this.render,this),this.view=bind(this.view,this),this.T=bind(this.T,this),this.options=t.extend({},this.defaults,n),i.__super__.constructor.call(this,this.options),this.isFullscreen=e.matchMedia&&e.matchMedia("(max-width: 768px)").matches,this.scrollRoot=t(this.getScrollRoot()),t?e.WebSocket&&sessionStorage?this.options.chatId?(this.options.lang||(this.options.lang=t("html").attr("lang")),this.options.lang&&(this.translations[this.options.lang]||(this.log.debug("lang: No "+this.options.lang+" found, try first two letters"),this.options.lang=this.options.lang.replace(/-.+?$/,"")),this.log.debug("lang: "+this.options.lang)),this.options.host||this.detectHost(),this.loadCss(),this.io=new s(this.options),this.io.set({onOpen:this.render,onClose:this.onWebSocketClose,onMessage:this.onWebSocketMessage,onError:this.onError}),void this.io.connect()):(this.state="unsupported",void this.log.error("Chat: need chatId as option!")):(this.state="unsupported",void this.log.notice("Chat: Browser not supported!")):(this.state="unsupported",void this.log.notice("Chat: no jquery found!"))}return extend(i,n),i.prototype.defaults={chatId:void 0,show:!0,target:t("body"),host:"",debug:!1,flat:!1,lang:void 0,cssAutoload:!0,cssUrl:void 0,fontSize:void 0,buttonClass:"open-zammad-chat",inactiveClass:"is-inactive",title:"Chat with us!",scrollHint:"Scrolle nach unten um neue Nachrichten zu sehen",idleTimeout:6,idleTimeoutIntervallCheck:.5,inactiveTimeout:8,inactiveTimeoutIntervallCheck:.5,waitingListTimeout:4,waitingListTimeoutIntervallCheck:.5},i.prototype.logPrefix="chat",i.prototype._messageCount=0,i.prototype.isOpen=!1,i.prototype.blinkOnlineInterval=null,i.prototype.stopBlinOnlineStateTimeout=null,i.prototype.showTimeEveryXMinutes=2,i.prototype.lastTimestamp=null,i.prototype.lastAddedType=null,i.prototype.inputTimeout=null,i.prototype.isTyping=!1,i.prototype.state="offline",i.prototype.initialQueueDelay=1e4,i.prototype.translations={de:{"Chat with us!":"Chatte mit uns!","Scroll down to see new messages":"Scrolle nach unten um neue Nachrichten zu sehen",Online:"Online",Online:"Online",Offline:"Offline",Connecting:"Verbinden","Connection re-established":"Verbindung wiederhergestellt",Today:"Heute",Send:"Senden","Compose your message...":"Ihre Nachricht...","All colleagues are busy.":"Alle Kollegen sind belegt.","You are on waiting list position %s.":"Sie sind in der Warteliste an der Position %s.","Start new conversation":"Neue Konversation starten","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit %s geschlossen.","Since you didn't respond in the last %s minutes your conversation got closed.":"Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Es tut uns leid, es dauert länger als erwartet, um einen freien Platz zu erhalten. Bitte versuchen Sie es zu einem späteren Zeitpunkt noch einmal oder schicken Sie uns eine E-Mail. Vielen Dank!"},fr:{"Chat with us!":"Chattez avec nous!","Scroll down to see new messages":"Faites défiler pour lire les nouveaux messages",Online:"En-ligne",Online:"En-ligne",Offline:"Hors-ligne",Connecting:"Connexion en cours","Connection re-established":"Connexion rétablie",Today:"Aujourdhui",Send:"Envoyer","Compose your message...":"Composez votre message...","All colleagues are busy.":"Tous les collègues sont actuellement occupés.","You are on waiting list position %s.":"Vous êtes actuellement en %s position dans la file d'attente.","Start new conversation":"Démarrer une nouvelle conversation","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation avec %s va être fermée.","Since you didn't respond in the last %s minutes your conversation got closed.":"Si vous ne répondez pas dans les %s minutes, votre conversation va être fermée.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"Nous sommes désolés, il faut plus de temps que prévu pour obtenir un emplacement vide. Veuillez réessayer ultérieurement ou nous envoyer un courriel. Je vous remercie!"},"zh-cn":{"Chat with us!":"发起即时对话!","Scroll down to see new messages":"向下滚动以查看新消息",Online:"在线",Online:"在线",Offline:"离线",Connecting:"连接中","Connection re-established":"正在重新建立连接",Today:"今天",Send:"发送","Compose your message...":"正在输入信息...","All colleagues are busy.":"所有工作人员都在忙碌中.","You are on waiting list position %s.":"您目前的等候位置是第 %s 位.","Start new conversation":"开始新的会话","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由于您超过 %s 分钟没有回复, 您与 %s 的会话已被关闭.","Since you didn't respond in the last %s minutes your conversation got closed.":"由于您超过 %s 分钟没有任何回复, 该对话已被关闭.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 目前需要等候更长的时间才能接入对话, 请稍后重试或向我们发送电子邮件. 谢谢!"},"zh-tw":{"Chat with us!":"開始即時對话!","Scroll down to see new messages":"向下滑動以查看新訊息",Online:"線上",Online:"線上",Offline:"离线",Connecting:"連線中","Connection re-established":"正在重新建立連線中",Today:"今天",Send:"發送","Compose your message...":"正在輸入訊息...","All colleagues are busy.":"所有服務人員都在忙碌中.","You are on waiting list position %s.":"你目前的等候位置是第 %s 順位.","Start new conversation":"開始新的對話","Since you didn't respond in the last %s minutes your conversation with %s got closed.":"由於你超過 %s 分鐘沒有回應, 你與 %s 的對話已被關閉.","Since you didn't respond in the last %s minutes your conversation got closed.":"由於你超過 %s 分鐘沒有任何回應, 該對話已被關閉.","We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!":"非常抱歉, 當前需要等候更長的時間方可排入對話程序, 請稍後重試或向我們寄送電子郵件. 謝謝!"}},i.prototype.sessionId=void 0,i.prototype.scrolledToBottom=!0,i.prototype.scrollSnapTolerance=10,i.prototype.richTextFormatKey={66:!0,73:!0,85:!0,83:!0},i.prototype.T=function(){var t,e,n,s,i,o;if(i=arguments[0],e=2<=arguments.length?slice.call(arguments,1):[],this.options.lang&&"en"!==this.options.lang&&(this.translations[this.options.lang]?(o=this.translations[this.options.lang],o[i]||this.log.notice("Translation needed for '"+i+"'"),i=o[i]||i):this.log.notice("Translation '"+this.options.lang+"' needed!")),e)for(n=0,s=e.length;s>n;n++)t=e[n],i=i.replace(/%s/,t);return i},i.prototype.view=function(t){return function(n){return function(s){return s||(s={}),s.T=n.T,s.background=n.options.background,s.flat=n.options.flat,s.fontSize=n.options.fontSize,e.zammadChatTemplates[t](s)}}(this)},i.prototype.getScrollRoot=function(){var t,e,n;return"scrollingElement"in document?document.scrollingElement:(e=document.documentElement,n=e.scrollTop,e.scrollTop=n+1,t=e.scrollTop,e.scrollTop=n,t>n?e:document.body)},i.prototype.render=function(){return this.el&&t(".zammad-chat").get(0)||this.renderBase(),t("."+this.options.buttonClass).addClass(this.inactiveClass),this.setAgentOnlineState("online"),this.log.debug("widget rendered"),this.startTimeoutObservers(),this.idleTimeout.start(),this.sessionId=sessionStorage.getItem("sessionId"),this.send("chat_status_customer",{session_id:this.sessionId,url:e.location.href})},i.prototype.renderBase=function(){return this.el=t(this.view("chat")({title:this.options.title,scrollHint:this.options.scrollHint})),this.options.target.append(this.el),this.input=this.el.find(".zammad-chat-input"),this.el.find(".js-chat-open").click(this.open),this.el.find(".js-chat-toggle").click(this.toggle),this.el.find(".zammad-chat-controls").on("submit",this.onSubmit),this.el.find(".zammad-chat-body").on("scroll",this.detectScrolledtoBottom),this.el.find(".zammad-scroll-hint").click(this.onScrollHintClick),this.input.on({keydown:this.checkForEnter,input:this.onInput}),this.input.on("keydown",function(t){return function(e){var n;if(n=!1,e.altKey||e.ctrlKey||!e.metaKey?e.altKey||!e.ctrlKey||e.metaKey||(n=!0):n=!0,n&&t.richTextFormatKey[e.keyCode]){if(e.preventDefault(),66===e.keyCode)return document.execCommand("bold"),!0;if(73===e.keyCode)return document.execCommand("italic"),!0;if(85===e.keyCode)return document.execCommand("underline"),!0;if(83===e.keyCode)return document.execCommand("strikeThrough"),!0}}}(this)),this.input.on("paste",function(n){return function(s){var i,o,a,r,l,h,d,c,u,p,m,g,f;if(s.stopPropagation(),s.preventDefault(),s.clipboardData)i=s.clipboardData;else if(e.clipboardData)i=e.clipboardData;else{if(!s.originalEvent.clipboardData)throw"No clipboardData support";i=s.originalEvent.clipboardData}if(d=!1,i&&i.items&&i.items[0]&&(c=i.items[0],"file"!==c.kind||"image/png"!==c.type&&"image/jpeg"!==c.type||(h=c.getAsFile(),p=new FileReader,p.onload=function(t){var e,s,i;return i=t.target.result,e=document.createElement("img"),e.src=i,s=function(t,s,o,a){return n.isRetina()&&(s/=2,o/=2),i=t,e='',document.execCommand("insertHTML",!1,e)},n.resizeImage(e.src,460,"auto",2,"image/jpeg","auto",s)},p.readAsDataURL(h),d=!0)),!d){f=void 0,o=void 0;try{f=i.getData("text/html"),o="html",f&&0!==f.length||(o="text",f=i.getData("text/plain")),f&&0!==f.length||(o="text2",f=i.getData("text"))}catch(a){s=a,console.log("Sorry, can't insert markup because browser is not supporting it."),o="text3",f=i.getData("text")}return("text"===o||"text2"===o||"text3"===o)&&(f="
            "+f.replace(/\n/g,"
            ")+"
            ",f=f.replace(/
            <\/div>/g,"

            ")),console.log("p",o,f),"html"===o&&(r=t("
            "+f+"
            "),u=!1,l=f,m=new RegExp("<(/w|w):[A-Za-z]"),l.match(m)&&(u=!0,l=l.replace(m,"")),m=new RegExp("<(/o|o):[A-Za-z]"),l.match(m)&&(u=!0,l=l.replace(m,"")),u&&(r=n.wordFilter(r)),r=t(r),r.contents().each(function(){return 8===this.nodeType?t(this).remove():void 0}),r.find("a, font, small, time, form, label").replaceWith(function(){return t(this).contents()}),g="div",r.find("textarea").each(function(){var e,n;return n=this.outerHTML,m=new RegExp("<"+this.tagName,"i"),e=n.replace(m,"<"+g),m=new RegExp("'),s=s.get(0),document.caretPositionFromPoint?(d=document.caretPositionFromPoint(r,l),c=document.createRange(),c.setStart(d.offsetNode,d.offset),c.collapse(),c.insertNode(s)):document.caretRangeFromPoint?(c=document.caretRangeFromPoint(r,l),c.insertNode(s)):console.log("could not find carat")},n.resizeImage(s.src,460,"auto",2,"image/jpeg","auto",i)},a.readAsDataURL(o)):void 0}}(this)),t(e).on("beforeunload",function(t){return function(){return t.onLeaveTemporary()}}(this)),t(e).bind("hashchange",function(t){return function(){return t.isOpen?void(t.sessionId&&t.send("chat_session_notice",{session_id:t.sessionId,message:e.location.href})):t.idleTimeout.start()}}(this)),this.isFullscreen?this.input.on({focus:this.onFocus,focusout:this.onFocusOut}):void 0},i.prototype.checkForEnter=function(t){return t.shiftKey||13!==t.keyCode?void 0:(t.preventDefault(),this.sendMessage())},i.prototype.send=function(t,e){return null==e&&(e={}),e.chat_id=this.options.chatId,this.io.send(t,e)},i.prototype.onWebSocketMessage=function(t){var e,n,s;for(e=0,n=t.length;n>e;e++)switch(s=t[e],this.log.debug("ws:onmessage",s),s.event){case"chat_error":this.log.notice(s.data),s.data&&"chat_disabled"===s.data.state&&this.destroy({remove:!0});break;case"chat_session_message":if(s.data.self_written)return;this.receiveMessage(s.data);break;case"chat_session_typing":if(s.data.self_written)return;this.onAgentTypingStart();break;case"chat_session_start":this.onConnectionEstablished(s.data);break;case"chat_session_queue":this.onQueueScreen(s.data);break;case"chat_session_closed":this.onSessionClosed(s.data);break;case"chat_session_left":this.onSessionClosed(s.data);break;case"chat_status_customer":switch(s.data.state){case"online":this.sessionId=void 0,!this.options.cssAutoload||this.cssLoaded?this.onReady():this.socketReady=!0;break;case"offline":this.onError("Zammad Chat: No agent online");break;case"chat_disabled":this.onError("Zammad Chat: Chat is disabled");break;case"no_seats_available":this.onError("Zammad Chat: Too many clients in queue. Clients in queue: "+s.data.queue);break;case"reconnect":this.onReopenSession(s.data)}}},i.prototype.onReady=function(){return this.log.debug("widget ready for use"),t("."+this.options.buttonClass).click(this.open).removeClass(this.inactiveClass),this.options.show?this.show():void 0},i.prototype.onError=function(e){return this.log.debug(e),this.addStatus(e),t("."+this.options.buttonClass).hide(),this.isOpen?(this.disableInput(),this.destroy({remove:!1})):this.destroy({remove:!0})},i.prototype.onReopenSession=function(t){var e,n,s,i,o;if(this.log.debug("old messages",t.session),this.inactiveTimeout.start(),o=sessionStorage.getItem("unfinished_message"),t.agent){for(this.onConnectionEstablished(t),i=t.session,e=0,n=i.length;n>e;e++)s=i[e],this.renderMessage({message:s.content,id:s.id,from:s.created_by_id?"agent":"customer"});o&&this.input.html(o)}return t.position&&this.onQueue(t),this.show(),this.open(),this.scrollToBottom(),o?this.input.focus():void 0},i.prototype.onInput=function(){return this.el.find(".zammad-chat-message--unread").removeClass("zammad-chat-message--unread"),sessionStorage.setItem("unfinished_message",this.input.html()),this.onTyping()},i.prototype.onFocus=function(){var n;return t(e).scrollTop(10),n=t(e).scrollTop()>0,t(e).scrollTop(0),n?this.log.notice("virtual keyboard shown"):void 0},i.prototype.onFocusOut=function(){},i.prototype.onTyping=function(){return this.isTyping&&this.isTyping>new Date((new Date).getTime()-1500)?void 0:(this.isTyping=new Date,this.send("chat_session_typing",{session_id:this.sessionId}),this.inactiveTimeout.start())},i.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},i.prototype.sendMessage=function(){var t,e;return(t=this.input.html())?(this.inactiveTimeout.start(),sessionStorage.removeItem("unfinished_message"),e=this.view("message")({message:t,from:"customer",id:this._messageCount++,unreadClass:""}),this.maybeAddTimestamp(),this.el.find(".zammad-chat-message--typing").get(0)?(this.lastAddedType="typing-placeholder",this.el.find(".zammad-chat-message--typing").before(e)):(this.lastAddedType="message--customer",this.el.find(".zammad-chat-body").append(e)),this.input.html(""),this.scrollToBottom(),this.send("chat_session_message",{content:t,id:this._messageCount,session_id:this.sessionId})):void 0},i.prototype.receiveMessage=function(t){return this.inactiveTimeout.start(),this.onAgentTypingEnd(),this.maybeAddTimestamp(),this.renderMessage({message:t.message.content,id:t.id,from:"agent"}),this.scrollToBottom({showHint:!0})},i.prototype.renderMessage=function(t){return this.lastAddedType="message--"+t.from,t.unreadClass=document.hidden?" zammad-chat-message--unread":"",this.el.find(".zammad-chat-body").append(this.view("message")(t))},i.prototype.open=function(){var t;return this.isOpen?void this.log.debug("widget already open, block"):(this.isOpen=!0,this.log.debug("open widget"),this.sessionId||this.showLoader(),this.el.addClass("zammad-chat-is-open"),t=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.css("bottom",-t),this.sessionId?(this.el.css("bottom",0),this.onOpenAnimationEnd()):(this.el.animate({bottom:0},500,this.onOpenAnimationEnd),this.send("chat_session_init",{url:e.location.href})))},i.prototype.onOpenAnimationEnd=function(){return this.idleTimeout.stop(),this.isFullscreen?this.disableScrollOnRoot():void 0},i.prototype.sessionClose=function(){return this.send("chat_session_close",{session_id:this.sessionId}),this.inactiveTimeout.stop(),this.waitingListTimeout.stop(),sessionStorage.removeItem("unfinished_message"),this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.setSessionId(void 0)},i.prototype.toggle=function(t){return this.isOpen?this.close(t):this.open(t)},i.prototype.close=function(t){var e;return this.isOpen?(this.initDelayId&&clearTimeout(this.initDelayId),this.sessionId?(this.log.debug("close widget"),t&&t.stopPropagation(),this.sessionClose(),this.isFullscreen&&this.enableScrollOnRoot(),e=this.el.height()-this.el.find(".zammad-chat-header").outerHeight(),this.el.animate({bottom:-e},500,this.onCloseAnimationEnd)):void this.log.debug("can't close widget without sessionId")):void this.log.debug("can't close widget, it's not open")},i.prototype.onCloseAnimationEnd=function(){return this.el.css("bottom",""),this.el.removeClass("zammad-chat-is-open"),this.showLoader(),this.el.find(".zammad-chat-welcome").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").addClass("zammad-chat-is-hidden"),this.isOpen=!1,this.io.reconnect()},i.prototype.onWebSocketClose=function(){return this.isOpen?void 0:this.el?(this.el.removeClass("zammad-chat-is-shown"),this.el.removeClass("zammad-chat-is-loaded")):void 0},i.prototype.show=function(){return"offline"!==this.state?(this.el.addClass("zammad-chat-is-loaded"),this.el.addClass("zammad-chat-is-shown")):void 0},i.prototype.disableInput=function(){return this.input.prop("disabled",!0),this.el.find(".zammad-chat-send").prop("disabled",!0)},i.prototype.enableInput=function(){return this.input.prop("disabled",!1),this.el.find(".zammad-chat-send").prop("disabled",!1)},i.prototype.hideModal=function(){return this.el.find(".zammad-chat-modal").html("")},i.prototype.onQueueScreen=function(t){var e;return this.setSessionId(t.session_id),e=function(e){return function(){return e.onQueue(t),e.waitingListTimeout.start()}}(this),this.initialQueueDelay&&!this.onInitialQueueDelayId?void(this.onInitialQueueDelayId=setTimeout(e,this.initialQueueDelay)):(this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),e())},i.prototype.onQueue=function(t){return this.log.notice("onQueue",t.position),this.inQueue=!0,this.el.find(".zammad-chat-modal").html(this.view("waiting")({position:t.position}))},i.prototype.onAgentTypingStart=function(){return this.stopTypingId&&clearTimeout(this.stopTypingId),this.stopTypingId=setTimeout(this.onAgentTypingEnd,3e3),!this.el.find(".zammad-chat-message--typing").get(0)&&(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("typingIndicator")()),this.isVisible(this.el.find(".zammad-chat-message--typing"),!0))?this.scrollToBottom():void 0},i.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},i.prototype.onLeaveTemporary=function(){return this.sessionId?this.send("chat_session_leave_temporary",{session_id:this.sessionId}):void 0},i.prototype.maybeAddTimestamp=function(){var t,e,n;return n=Date.now(),!this.lastTimestamp||n-this.lastTimestamp>6e4*this.showTimeEveryXMinutes?(t=this.T("Today"),e=(new Date).toTimeString().substr(0,5),"timestamp"===this.lastAddedType?(this.updateLastTimestamp(t,e),this.lastTimestamp=n):(this.el.find(".zammad-chat-body").append(this.view("timestamp")({label:t,time:e})),this.lastTimestamp=n,this.lastAddedType="timestamp",this.scrollToBottom())):void 0},i.prototype.updateLastTimestamp=function(t,e){return this.el?this.el.find(".zammad-chat-body").find(".zammad-chat-timestamp").last().replaceWith(this.view("timestamp")({label:t,time:e})):void 0},i.prototype.addStatus=function(t){return this.el?(this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("status")({status:t})),this.scrollToBottom()):void 0},i.prototype.detectScrolledtoBottom=function(){var t;return t=this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-chat-body").outerHeight(),this.scrolledToBottom=Math.abs(t-this.el.find(".zammad-chat-body").prop("scrollHeight"))<=this.scrollSnapTolerance,this.scrolledToBottom?this.el.find(".zammad-scroll-hint").addClass("is-hidden"):void 0},i.prototype.showScrollHint=function(){return this.el.find(".zammad-scroll-hint").removeClass("is-hidden"),this.el.find(".zammad-chat-body").scrollTop(this.el.find(".zammad-chat-body").scrollTop()+this.el.find(".zammad-scroll-hint").outerHeight())},i.prototype.onScrollHintClick=function(){return this.el.find(".zammad-chat-body").animate({scrollTop:this.el.find(".zammad-chat-body").prop("scrollHeight")},300)},i.prototype.scrollToBottom=function(e){var n;return n=(null!=e?e:{showHint:!1}).showHint,this.scrolledToBottom?this.el.find(".zammad-chat-body").scrollTop(t(".zammad-chat-body").prop("scrollHeight")):n?this.showScrollHint():void 0},i.prototype.destroy=function(t){return null==t&&(t={}),this.log.debug("destroy widget",t),this.setAgentOnlineState("offline"),t.remove&&this.el&&this.el.remove(),this.waitingListTimeout&&this.waitingListTimeout.stop(),this.inactiveTimeout&&this.inactiveTimeout.stop(),this.idleTimeout&&this.idleTimeout.stop(),this.io.close()},i.prototype.reconnect=function(){return this.log.notice("reconnecting"),this.disableInput(),this.lastAddedType="status",this.setAgentOnlineState("connecting"),this.addStatus(this.T("Connection lost"))},i.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.setAgentOnlineState("online"),this.addStatus(this.T("Connection re-established"))},i.prototype.onSessionClosed=function(t){return this.addStatus(this.T("Chat closed by %s",t.realname)),this.disableInput(),this.setAgentOnlineState("offline"),this.inactiveTimeout.stop()},i.prototype.setSessionId=function(t){return this.sessionId=t,void 0===t?sessionStorage.removeItem("sessionId"):sessionStorage.setItem("sessionId",t)},i.prototype.onConnectionEstablished=function(t){return this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),this.inQueue=!1,t.agent&&(this.agent=t.agent),t.session_id&&this.setSessionId(t.session_id),this.el.find(".zammad-chat-body").html(""),this.el.find(".zammad-chat-agent").html(this.view("agent")({agent:this.agent})),this.enableInput(),this.hideModal(),this.el.find(".zammad-chat-welcome").addClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent").removeClass("zammad-chat-is-hidden"),this.el.find(".zammad-chat-agent-status").removeClass("zammad-chat-is-hidden"),this.isFullscreen||this.input.focus(),this.setAgentOnlineState("online"),this.waitingListTimeout.stop(),this.idleTimeout.stop(),this.inactiveTimeout.start()},i.prototype.showCustomerTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("customer_timeout")({agent:this.agent.name,delay:this.options.inactiveTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showWaitingListTimeout=function(){var t;return this.el.find(".zammad-chat-modal").html(this.view("waiting_list_timeout")({delay:this.options.watingListTimeout})),t=function(){return location.reload()},this.el.find(".js-restart").click(t),this.sessionClose()},i.prototype.showLoader=function(){return this.el.find(".zammad-chat-modal").html(this.view("loader")())},i.prototype.setAgentOnlineState=function(t){var e;return this.state=t,this.el?(e=t.charAt(0).toUpperCase()+t.slice(1),this.el.find(".zammad-chat-agent-status").attr("data-status",t).text(this.T(e))):void 0},i.prototype.detectHost=function(){var t;return t="ws://","https"===h&&(t="wss://"),this.options.host=""+t+l+"/ws"},i.prototype.loadCss=function(){var t,e,n;if(this.options.cssAutoload)return n=this.options.cssUrl,n||(n=this.options.host.replace(/^wss/i,"https").replace(/^ws/i,"http").replace(/\/ws/i,""),n+="/assets/chat/chat.css"),this.log.debug("load css from '"+n+"'"),e="@import url('"+n+"');",t=document.createElement("link"),t.onload=this.onCssLoaded,t.rel="stylesheet",t.href="data:text/css,"+escape(e),document.getElementsByTagName("head")[0].appendChild(t); +},i.prototype.onCssLoaded=function(){return this.socketReady?this.onReady():this.cssLoaded=!0},i.prototype.startTimeoutObservers=function(){return this.idleTimeout=new o({logPrefix:"idleTimeout",debug:this.options.debug,timeout:this.options.idleTimeout,timeoutIntervallCheck:this.options.idleTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Idle timeout reached, hide widget",new Date),t.destroy({remove:!0})}}(this)}),this.inactiveTimeout=new o({logPrefix:"inactiveTimeout",debug:this.options.debug,timeout:this.options.inactiveTimeout,timeoutIntervallCheck:this.options.inactiveTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Inactive timeout reached, show timeout screen.",new Date),t.showCustomerTimeout(),t.destroy({remove:!1})}}(this)}),this.waitingListTimeout=new o({logPrefix:"waitingListTimeout",debug:this.options.debug,timeout:this.options.waitingListTimeout,timeoutIntervallCheck:this.options.waitingListTimeoutIntervallCheck,callback:function(t){return function(){return t.log.debug("Waiting list timeout reached, show timeout screen.",new Date),t.showWaitingListTimeout(),t.destroy({remove:!1})}}(this)})},i.prototype.disableScrollOnRoot=function(){return this.rootScrollOffset=this.scrollRoot.scrollTop(),this.scrollRoot.css({overflow:"hidden",position:"fixed"})},i.prototype.enableScrollOnRoot=function(){return this.scrollRoot.scrollTop(this.rootScrollOffset),this.scrollRoot.css({overflow:"",position:""})},i.prototype.isVisible=function(n,s,i,o){var a,r,l,h,d,c,u,p,m,g,f,v,y,b,w,T,C,z,S,k,I,A,_,x,E,O;if(!(n.length<1))if(r=t(e),a=n.length>1?n.eq(0):n,z=a.get(0),O=r.width(),E=r.height(),o=o?o:"both",p=i===!0?z.offsetWidth*z.offsetHeight:!0,"function"==typeof z.getBoundingClientRect){if(C=z.getBoundingClientRect(),S=C.top>=0&&C.top0&&C.bottom<=E,b=C.left>=0&&C.left0&&C.right<=O,k=s?S||u:S&&u,y=s?b||T:b&&T,"both"===o)return p&&k&&y;if("vertical"===o)return p&&k;if("horizontal"===o)return p&&y}else{if(x=r.scrollTop(),I=x+E,A=r.scrollLeft(),_=A+O,w=a.offset(),c=w.top,l=c+a.height(),h=w.left,d=h+a.width(),v=s===!0?l:c,m=s===!0?c:l,g=s===!0?d:h,f=s===!0?h:d,"both"===o)return!!p&&I>=m&&v>=x&&_>=f&&g>=A;if("vertical"===o)return!!p&&I>=m&&v>=x;if("horizontal"===o)return!!p&&_>=f&&g>=A}},i.prototype.isRetina=function(){var t;return e.matchMedia?(t=e.matchMedia("only screen and (min--moz-device-pixel-ratio: 1.3), only screen and (-o-min-device-pixel-ratio: 2.6/2), only screen and (-webkit-min-device-pixel-ratio: 1.3), only screen and (min-device-pixel-ratio: 1.3), only screen and (min-resolution: 1.3dppx)"),t&&t.matches||e.devicePixelRatio>1):!1},i.prototype.resizeImage=function(t,e,n,s,i,o,a,r){var l;return null==e&&(e="auto"),null==n&&(n="auto"),null==s&&(s=1),null==r&&(r=!0),l=new Image,l.onload=function(){var t,r,h,d,c,u,p;return c=l.width,d=l.height,console.log("ImageService","current size",c,d),"auto"===n&&"auto"===e&&(e=c,n=d),"auto"===n&&(h=c/e,n=d/h),"auto"===e&&(h=c/n,e=d/h),p=!1,c>e||d>n?(p=!0,e*=s,n*=s):(e=c,n=d),t=document.createElement("canvas"),t.width=e,t.height=n,r=t.getContext("2d"),r.drawImage(l,0,0,e,n),"auto"===o&&(o=200>e&&200>n?1:400>e&&400>n?.9:600>e&&600>n?.8:900>e&&900>n?.7:.6),u=t.toDataURL(i,o),p?(console.log("ImageService","resize",e/s,n/s,o,.75*u.length/1024/1024,"in mb"),void a(u,e/s,n/s,!0)):(console.log("ImageService","no resize",e,n,o,.75*u.length/1024/1024,"in mb"),a(u,e,n,!1))},l.src=t},i.prototype.pasteHtmlAtCaret=function(t){var n,s,i,o,a,r;if(r=void 0,a=void 0,e.getSelection){if(r=e.getSelection(),r.getRangeAt&&r.rangeCount){for(a=r.getRangeAt(0),a.deleteContents(),n=document.createElement("div"),n.innerHTML=t,s=document.createDocumentFragment(o,i);o=n.firstChild;)i=s.appendChild(o);if(a.insertNode(s),i)return a=a.cloneRange(),a.setStartAfter(i),a.collapse(!0),r.removeAllRanges(),r.addRange(a)}}else if(document.selection&&"Control"!==document.selection.type)return document.selection.createRange().pasteHTML(t)},i.prototype.wordFilter=function(e){var n,s,i;return n=e.html(),n=n.replace(//gi,""),n=n.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi,""),n=n.replace(/<(\/?)s>/gi,"<$1strike>"),n=n.replace(/ /gi," "),e.html(n),t("p",e).each(function(){var e,n;return n=t(this).attr("style"),e=/mso-list:\w+ \w+([0-9]+)/.exec(n),e?t(this).data("_listLevel",parseInt(e[1],10)):void 0}),s=0,i=null,t("p",e).each(function(){var e,n,o,a,r,l,h,d,c,u;if(e=t(this).data("_listLevel"),void 0!==e){if(u=t(this).text(),a="
              ",/^\s*\w+\./.test(u)&&(r=/([0-9])\./.exec(u),r?(c=parseInt(r[1],10),a=null!=(l=c>1)?l:'
                ':"
                  "}):a="
                    "),e>s&&(0===s?(t(this).before(a),i=t(this).prev()):i=t(a).appendTo(i)),s>e)for(n=o=h=n,d=s-e;d>=h?d>=o:o>=d;n=d>=h?++o:--o)i=i.parent();return t("span:first",this).remove(),i.append("
                  1. "+t(this).html()+"
                  2. "),t(this).remove(),s=e}return s=0}),t("[style]",e).removeAttr("style"),t("[align]",e).removeAttr("align"),t("span",e).replaceWith(function(){return t(this).contents()}),t("span:empty",e).remove(),t("[class^='Mso']",e).removeAttr("class"),t("p:empty",e).remove(),e},i.prototype.removeAttribute=function(e){var n,s,i,o,a;if(e){for(n=t(e),a=e.attributes,i=0,o=a.length;o>i;i++)s=a[i],s&&s.name&&e.removeAttribute(s.name);return n.removeAttr("style").removeAttr("class").removeAttr("lang").removeAttr("type").removeAttr("align").removeAttr("id").removeAttr("wrap").removeAttr("title")}},i.prototype.removeAttributes=function(t,e){return null==e&&(e=!0),e&&t.each(function(t){return function(e,n){return t.removeAttribute(n)}}(this)),t.find("*").each(function(t){return function(e,n){return t.removeAttribute(n)}}(this)),t},i}(n),e.ZammadChat=a}(window.jQuery,window),window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.chat=function(t){t||(t={});var e,n=[],s=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    \n
                    \n
                    \n \n \n \n \n \n
                    \n
                    \n
                    \n
                    \n \n '),n.push(this.T(this.title)),n.push('\n
                    \n
                    \n
                    \n \n
                    \n
                    \n
                    \n \n
                    \n
                    ")}).call(this)}.call(t),t.safe=i,t.escape=o,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.customer_timeout=function(t){t||(t={});var e,n=[],s=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    \n '),this.agent?(n.push("\n "),n.push(this.T("Since you didn't respond in the last %s minutes your conversation with %s got closed.",this.delay,this.agent)),n.push("\n ")):(n.push("\n "),n.push(this.T("Since you didn't respond in the last %s minutes your conversation got closed.",this.delay)),n.push("\n ")),n.push('\n
                    \n
                    "),n.push(this.T("Start new conversation")),n.push("
                    \n
                    ")}).call(this)}.call(t),t.safe=i,t.escape=o,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.loader=function(t){t||(t={});var e,n=[],s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('\n \n \n \n\n'),n.push(this.T("Connecting")),n.push("")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.message=function(t){t||(t={});var e,n=[],s=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    \n "),n.push(this.message),n.push("\n
                    ")}).call(this)}.call(t),t.safe=i,t.escape=o,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.status=function(t){t||(t={});var e,n=[],s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    \n
                    \n '),n.push(this.status),n.push("\n
                    \n
                    ")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.timestamp=function(t){t||(t={});var e,n=[],s=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    '),n.push(s(this.label)),n.push(" "),n.push(s(this.time)),n.push("
                    ")}).call(this)}.call(t),t.safe=i,t.escape=o,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.typingIndicator=function(t){t||(t={});var e,n=[],s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    \n \n \n \n \n \n \n \n
                    ')}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting=function(t){t||(t={});var e,n=[],s=t.safe,i=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},i||(i=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    \n \n \n \n \n \n '),n.push(this.T("All colleagues are busy.")),n.push("
                    \n "),n.push(this.T("You are on waiting list position %s.",this.position)),n.push("\n
                    ")}).call(this)}.call(t),t.safe=s,t.escape=i,n.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting_list_timeout=function(t){t||(t={});var e,n=[],s=function(t){return t&&t.ecoSafe?t:"undefined"!=typeof t&&null!=t?o(t):""},i=t.safe,o=t.escape;return e=t.safe=function(t){if(t&&t.ecoSafe)return t;("undefined"==typeof t||null==t)&&(t="");var e=new String(t);return e.ecoSafe=!0,e},o||(o=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){n.push('
                    \n '),n.push(this.T("We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!")),n.push('\n
                    \n
                    "),n.push(this.T("Start new conversation")),n.push("
                    \n
                    ")}).call(this)}.call(t),t.safe=i,t.escape=o,n.join("")}; \ No newline at end of file From b10ee11c86821e91fa822b505bbc25117c5918ee Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Jul 2017 07:29:52 +0200 Subject: [PATCH 120/234] Strip null byte - postgresql will complain about it. --- app/models/application_model.rb | 2 +- .../checks_attribute_length.rb | 39 -------------- .../checks_attribute_values_and_length.rb | 54 +++++++++++++++++++ config/initializers/db_preferences_mysql.rb | 1 + .../initializers/db_preferences_postgresql.rb | 1 + lib/stats/ticket_reopen.rb | 1 + test/unit/ticket_null_byte_test.rb | 34 ++++++++++++ 7 files changed, 92 insertions(+), 40 deletions(-) delete mode 100644 app/models/application_model/checks_attribute_length.rb create mode 100644 app/models/application_model/checks_attribute_values_and_length.rb create mode 100644 test/unit/ticket_null_byte_test.rb diff --git a/app/models/application_model.rb b/app/models/application_model.rb index 551fe482b..c3d267e5f 100644 --- a/app/models/application_model.rb +++ b/app/models/application_model.rb @@ -5,7 +5,7 @@ class ApplicationModel < ActiveRecord::Base include ApplicationModel::HasCache include ApplicationModel::CanLookup include ApplicationModel::CanLookupSearchIndexAttributes - include ApplicationModel::ChecksAttributeLength + include ApplicationModel::ChecksAttributeValuesAndLength include ApplicationModel::CanCleanupParam include ApplicationModel::HasRecentViews include ApplicationModel::ChecksUserColumnsFillup diff --git a/app/models/application_model/checks_attribute_length.rb b/app/models/application_model/checks_attribute_length.rb deleted file mode 100644 index 5254b035c..000000000 --- a/app/models/application_model/checks_attribute_length.rb +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ -module ApplicationModel::ChecksAttributeLength - extend ActiveSupport::Concern - - included do - before_create :check_attribute_length - before_update :check_attribute_length - end - -=begin - -check string/varchar size and cut them if needed - -=end - - def check_attribute_length - attributes.each { |attribute| - next if !self[ attribute[0] ] - next if !self[ attribute[0] ].instance_of?(String) - next if self[ attribute[0] ].empty? - column = self.class.columns_hash[ attribute[0] ] - next if !column - limit = column.limit - if column && limit - current_length = attribute[1].to_s.length - if limit < current_length - logger.warn "WARNING: cut string because of database length #{self.class}.#{attribute[0]}(#{limit} but is #{current_length}:#{attribute[1]})" - self[ attribute[0] ] = attribute[1][ 0, limit ] - end - end - - # strip 4 bytes utf8 chars if needed - if column && self[ attribute[0] ] - self[attribute[0]] = self[ attribute[0] ].utf8_to_3bytesutf8 - end - } - true - end -end diff --git a/app/models/application_model/checks_attribute_values_and_length.rb b/app/models/application_model/checks_attribute_values_and_length.rb new file mode 100644 index 000000000..27a680866 --- /dev/null +++ b/app/models/application_model/checks_attribute_values_and_length.rb @@ -0,0 +1,54 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +module ApplicationModel::ChecksAttributeValuesAndLength + extend ActiveSupport::Concern + + included do + before_create :check_attribute_values_and_length + before_update :check_attribute_values_and_length + end + +=begin + +1) check string/varchar size and cut them if needed + +2) check string for null byte \u0000 and remove it + +=end + + def check_attribute_values_and_length + columns = self.class.columns_hash + attributes.each { |name, value| + next if value.blank? + next if !value.instance_of?(String) + column = columns[name] + next if !column + + # strip null byte chars (postgresql will complain about it) + if column.sql_type == 'text' + if Rails.application.config.db_null_byte == false + self[name].delete!("\u0000") + end + end + + # for varchar check length and replace null bytes + limit = column.limit + if limit + current_length = value.length + if limit < current_length + logger.warn "WARNING: cut string because of database length #{self.class}.#{name}(#{limit} but is #{current_length}:#{value})" + self[name] = value[0, limit] + end + + # strip null byte chars (postgresql will complain about it) + if Rails.application.config.db_null_byte == false + self[name].delete!("\u0000") + end + end + + # strip 4 bytes utf8 chars if needed (mysql/mariadb will complain it) + next if self[name].blank? + self[name] = self[name].utf8_to_3bytesutf8 + } + true + end +end diff --git a/config/initializers/db_preferences_mysql.rb b/config/initializers/db_preferences_mysql.rb index 121608f10..9de0ac255 100644 --- a/config/initializers/db_preferences_mysql.rb +++ b/config/initializers/db_preferences_mysql.rb @@ -1,6 +1,7 @@ # mysql if ActiveRecord::Base.connection_config[:adapter] == 'mysql2' Rails.application.config.db_4bytes_utf8 = false + Rails.application.config.db_null_byte = true # mysql version check # mysql example: "5.7.3" diff --git a/config/initializers/db_preferences_postgresql.rb b/config/initializers/db_preferences_postgresql.rb index 8f72504c4..b62f7082c 100644 --- a/config/initializers/db_preferences_postgresql.rb +++ b/config/initializers/db_preferences_postgresql.rb @@ -2,6 +2,7 @@ if ActiveRecord::Base.connection_config[:adapter] == 'postgresql' Rails.application.config.db_case_sensitive = true Rails.application.config.db_like = 'ILIKE' + Rails.application.config.db_null_byte = false # postgresql version check # example output: "9.5.0" diff --git a/lib/stats/ticket_reopen.rb b/lib/stats/ticket_reopen.rb index 38b3afacc..360d549d4 100644 --- a/lib/stats/ticket_reopen.rb +++ b/lib/stats/ticket_reopen.rb @@ -66,6 +66,7 @@ class Stats::TicketReopen def self.log(object, o_id, changes, updated_by_id) return if object != 'Ticket' ticket = Ticket.lookup(id: o_id) + return if !ticket # check if close_at is already set / if not, ticket is not reopend return if !ticket.close_at diff --git a/test/unit/ticket_null_byte_test.rb b/test/unit/ticket_null_byte_test.rb new file mode 100644 index 000000000..aa3fdbd6a --- /dev/null +++ b/test/unit/ticket_null_byte_test.rb @@ -0,0 +1,34 @@ +# encoding: utf-8 +require 'test_helper' + +class TicketNullByteTest < ActiveSupport::TestCase + test 'null byte test' do + ticket1 = Ticket.create!( + title: "some title \u0000 123", + group: Group.lookup(name: 'Users'), + customer_id: 2, + updated_by_id: 1, + created_by_id: 1, + ) + assert(ticket1, 'ticket created') + + article1 = Ticket::Article.create!( + ticket_id: ticket1.id, + from: 'some_customer_com-1@example.com', + to: 'some_zammad_com-1@example.com', + subject: "com test 1\u0000", + message_id: 'some@id_com_1', + body: "some\u0000message 123", + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Customer'), + type: Ticket::Article::Type.find_by(name: 'email'), + updated_by_id: 1, + created_by_id: 1, + ) + assert(article1, 'ticket created') + + ticket1.destroy! + article1.destroy! + + end +end From 337d8f96d63d8f7dd8cfc5f6e54dfef5223bb4f0 Mon Sep 17 00:00:00 2001 From: "Michael R.|SchlaumeierTV" Date: Wed, 19 Jul 2017 07:57:27 +0200 Subject: [PATCH 121/234] Translation Fix (#1258) Generic OAUTH 2 is not gitlab --- db/seeds/settings.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 5802c1063..606dd2754 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -1225,7 +1225,7 @@ Setting.create_if_not_exists( null: true, name: 'site', tag: 'input', - placeholder: 'https://gitlab.YOURDOMAIN.com', + placeholder: 'https://oauth.YOURDOMAIN.com', }, { display: 'authorize_url', From 04c97d1201e3a7f9aeae667c292a429cff99a4cf Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Jul 2017 10:37:29 +0200 Subject: [PATCH 122/234] Show organization tab only for agents. --- .../app/controllers/ticket_zoom/sidebar_organization.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee index ad612da6b..6fc2ef5cb 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee @@ -1,5 +1,6 @@ class SidebarOrganization extends App.Controller sidebarItem: => + return if !@permissionCheck('ticket.agent') return if !@ticket.organization_id { head: 'Organization' From 51d416277582a27a6b285949706d7564ef0e80fc Mon Sep 17 00:00:00 2001 From: Rolf Schmidt Date: Wed, 19 Jul 2017 12:03:17 +0200 Subject: [PATCH 123/234] Fixed issue #1216 - Race condition if agents merge ticket at same time but in different directions. --- app/models/ticket.rb | 7 ++++++- spec/models/ticket_spec.rb | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 01c8a502e..e7f1a1fca 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -244,6 +244,11 @@ returns def merge_to(data) + # prevent cross merging tickets + target_ticket = Ticket.find(data[:ticket_id]) + raise 'no target ticket given' if !target_ticket + raise 'invalid state for target ticket' if target_ticket.state.name == 'merged' + # update articles Transaction.execute do @@ -296,7 +301,7 @@ returns save! # touch new ticket (to broadcast change) - Ticket.find(data[:ticket_id]).touch + target_ticket.touch end true end diff --git a/spec/models/ticket_spec.rb b/spec/models/ticket_spec.rb index 3ef97f67e..bd19972eb 100644 --- a/spec/models/ticket_spec.rb +++ b/spec/models/ticket_spec.rb @@ -32,6 +32,24 @@ RSpec.describe Ticket do expect(check_ticket_ids).to match_array(expected_ticket_ids) end + it 'prevents cross merging tickets' do + source_ticket = create(:ticket) + target_ticket = create(:ticket) + + result = source_ticket.merge_to( + ticket_id: target_ticket.id, + user_id: 1, + ) + expect(result).to be(true) + + expect { + result = target_ticket.merge_to( + ticket_id: source_ticket.id, + user_id: 1, + ) + }.to raise_error('invalid state for target ticket') + end + end describe '.destroy' do From d8ad2b1b2f2d3c30d62c8c3ea8e41015c2067ac9 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 20 Jul 2017 14:01:14 +0200 Subject: [PATCH 124/234] Implemented issue #539 - Add email address without importing historical emails. --- .gitlab-ci.yml | 11 + .../app/controllers/_channel/email.coffee | 28 ++- .../app/controllers/getting_started.coffee | 38 +-- .../app/views/getting_started/email.jst.eco | 2 +- app/assets/stylesheets/zammad.scss | 4 + app/models/channel/driver/imap.rb | 61 +++-- test/integration/email_deliver_test.rb | 16 +- test/integration/email_keep_on_server_test.rb | 232 ++++++++++++++++++ 8 files changed, 340 insertions(+), 52 deletions(-) create mode 100644 test/integration/email_keep_on_server_test.rb diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 99741dc47..20297b184 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -127,6 +127,17 @@ test:integration:email_deliver: - ruby -I test/ test/integration/email_deliver_test.rb - rake db:drop +test:integration:email_keep_on_server: + stage: test + tags: + - core + script: + - export RAILS_ENV=test + - rake db:create + - rake db:migrate + - ruby -I test/ test/integration/email_keep_on_server_test.rb + - rake db:drop + test:integration:twitter: stage: test tags: diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index 99a71620e..a6855923a 100644 --- a/app/assets/javascripts/app/controllers/_channel/email.coffee +++ b/app/assets/javascripts/app/controllers/_channel/email.coffee @@ -560,21 +560,24 @@ class App.ChannelEmailAccountWizard extends App.WizardModal # inbound configureAttributesInbound = [ - { name: 'adapter', display: 'Type', tag: 'select', multiple: false, null: false, options: @channelDriver.email.inbound }, - { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false }, - { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'off', }, - { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true }, - { name: 'options::ssl', display: 'SSL', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, default: true, translate: true, item_class: 'formGroup--halfSize' }, - { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false, default: '993', item_class: 'formGroup--halfSize' }, - { name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false }, + { name: 'adapter', display: 'Type', tag: 'select', multiple: false, null: false, options: @channelDriver.email.inbound }, + { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false }, + { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'off' }, + { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true }, + { name: 'options::ssl', display: 'SSL', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, default: true, translate: true, item_class: 'formGroup--halfSize' }, + { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false, default: '993', item_class: 'formGroup--halfSize' }, + { name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, item_class: 'formGroup--halfSize' }, + { name: 'options::keep_on_server', display: 'Keep messages on server', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false, item_class: 'formGroup--halfSize' }, ] showHideFolder = (params, attribute, attributes, classname, form, ui) -> return if !params if params.adapter is 'imap' ui.show('options::folder') + ui.show('options::keep_on_server') return ui.hide('options::folder') + ui.hide('options::keep_on_server') handlePort = (params, attribute, attributes, classname, form, ui) -> return if !params @@ -606,9 +609,10 @@ class App.ChannelEmailAccountWizard extends App.WizardModal # fill user / password based on intro info channel_used = { options: {} } if @account['meta'] - channel_used['options']['user'] = @account['meta']['email'] - channel_used['options']['password'] = @account['meta']['password'] - channel_used['options']['folder'] = @account['meta']['folder'] + channel_used['options']['user'] = @account['meta']['email'] + channel_used['options']['password'] = @account['meta']['password'] + channel_used['options']['folder'] = @account['meta']['folder'] + channel_used['options']['keep_on_server'] = @account['meta']['keep_on_server'] # show used backend @$('.base-outbound-settings').html('') @@ -670,7 +674,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal for key, value of data.setting @account[key] = value - if data.content_messages && data.content_messages > 0 + if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true) message = App.i18n.translateContent('We have already found %s email(s) in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages) @$('.js-inbound-acknowledge .js-message').html(message) @$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-intro') @@ -724,7 +728,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal # remember account settings @account.inbound = params - if data.content_messages && data.content_messages > 0 + if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true) message = App.i18n.translateContent('We have already found %s email(s) in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages) @$('.js-inbound-acknowledge .js-message').html(message) @$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-inbound') diff --git a/app/assets/javascripts/app/controllers/getting_started.coffee b/app/assets/javascripts/app/controllers/getting_started.coffee index c7d236267..8c2fbb71d 100644 --- a/app/assets/javascripts/app/controllers/getting_started.coffee +++ b/app/assets/javascripts/app/controllers/getting_started.coffee @@ -450,8 +450,8 @@ class EmailNotification extends App.WizardFullScreen if adapter is 'smtp' configureAttributesOutbound = [ { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autofocus: true }, - { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password' }, - { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', single: true }, + { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'off' }, + { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'off', single: true }, { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false }, ] @form = new App.ControllerForm( @@ -671,20 +671,24 @@ class ChannelEmail extends App.WizardFullScreen # inbound configureAttributesInbound = [ - { name: 'adapter', display: 'Type', tag: 'select', multiple: false, null: false, options: @channelDriver.email.inbound }, - { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false }, - { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', }, - { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true }, - { name: 'options::ssl', display: 'SSL', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, default: true, translate: true, item_class: 'formGroup--halfSize' }, - { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false, default: '993', item_class: 'formGroup--halfSize' }, + { name: 'adapter', display: 'Type', tag: 'select', multiple: false, null: false, options: @channelDriver.email.inbound }, + { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false }, + { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autocomplete: 'off', }, + { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'off', single: true }, + { name: 'options::ssl', display: 'SSL', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, default: true, translate: true, item_class: 'formGroup--halfSize' }, + { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false, default: '993', item_class: 'formGroup--halfSize' }, + { name: 'options::folder', display: 'Folder', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, item_class: 'formGroup--halfSize' }, + { name: 'options::keep_on_server', display: 'Keep messages on server', tag: 'boolean', null: true, options: { true: 'yes', false: 'no' }, translate: true, default: false, item_class: 'formGroup--halfSize' }, ] showHideFolder = (params, attribute, attributes, classname, form, ui) -> return if !params if params.adapter is 'imap' ui.show('options::folder') + ui.show('options::keep_on_server') return ui.hide('options::folder') + ui.hide('options::keep_on_server') handlePort = (params, attribute, attributes, classname, form, ui) -> return if !params @@ -700,7 +704,7 @@ class ChannelEmail extends App.WizardFullScreen return new App.ControllerForm( - el: @$('.base-inbound-settings'), + el: @$('.base-inbound-settings') model: configure_attributes: configureAttributesInbound className: '' @@ -716,8 +720,10 @@ class ChannelEmail extends App.WizardFullScreen # fill user / password based on intro info channel_used = { options: {} } if @account['meta'] - channel_used['options']['user'] = @account['meta']['email'] - channel_used['options']['password'] = @account['meta']['password'] + channel_used['options']['user'] = @account['meta']['email'] + channel_used['options']['password'] = @account['meta']['password'] + channel_used['options']['folder'] = @account['meta']['folder'] + channel_used['options']['keep_on_server'] = @account['meta']['keep_on_server'] # show used backend @$('.base-outbound-settings').html('') @@ -725,8 +731,8 @@ class ChannelEmail extends App.WizardFullScreen if adapter is 'smtp' configureAttributesOutbound = [ { name: 'options::host', display: 'Host', tag: 'input', type: 'text', limit: 120, null: false, autocapitalize: false, autofocus: true }, - { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', }, - { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'new-password', single: true }, + { name: 'options::user', display: 'User', tag: 'input', type: 'text', limit: 120, null: true, autocapitalize: false, autocomplete: 'off', }, + { name: 'options::password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: true, autocapitalize: false, autocomplete: 'off', single: true }, { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false }, ] @form = new App.ControllerForm( @@ -745,7 +751,7 @@ class ChannelEmail extends App.WizardFullScreen @account.meta = params @disable(e) - @$('.js-probe .js-email').text( params.email ) + @$('.js-probe .js-email').text(params.email) @showSlide('js-probe') @ajax( @@ -760,7 +766,7 @@ class ChannelEmail extends App.WizardFullScreen for key, value of data.setting @account[key] = value - if data.content_messages && data.content_messages > 0 + if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true) message = App.i18n.translateContent('We have already found %s email(s) in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages) @$('.js-inbound-acknowledge .js-message').html(message) @$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-intro') @@ -809,7 +815,7 @@ class ChannelEmail extends App.WizardFullScreen # remember account settings @account.inbound = params - if data.content_messages && data.content_messages > 0 + if data.content_messages && data.content_messages > 0 && (!@account['inbound']['options'] || @account['inbound']['options']['keep_on_server'] isnt true) message = App.i18n.translateContent('We have already found %s emails in your mailbox. Zammad will move it all from your mailbox into Zammad.', data.content_messages) @$('.js-inbound-acknowledge .js-message').html(message) @$('.js-inbound-acknowledge .js-back').attr('data-slide', 'js-inbound') diff --git a/app/assets/javascripts/app/views/getting_started/email.jst.eco b/app/assets/javascripts/app/views/getting_started/email.jst.eco index e3ef082f8..1d2d0cb50 100644 --- a/app/assets/javascripts/app/views/getting_started/email.jst.eco +++ b/app/assets/javascripts/app/views/getting_started/email.jst.eco @@ -63,7 +63,7 @@
                    -
                    +

                    <%- @T('Email Inbound') %>

                    diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 8da4e8e50..2a6020b51 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -6457,6 +6457,10 @@ footer { width: 400px; padding-bottom: 18px; margin-bottom: 20px; + + &.wizard-slide--large { + width: 460px; + } } .wizard h2 { diff --git a/app/models/channel/driver/imap.rb b/app/models/channel/driver/imap.rb index d2e0043bc..344b971b9 100644 --- a/app/models/channel/driver/imap.rb +++ b/app/models/channel/driver/imap.rb @@ -52,6 +52,7 @@ example host: 'outlook.office365.com', user: 'xxx@znuny.onmicrosoft.com', password: 'xxx', + keep_on_server: true, } channel = Channel.last instance = Channel::Driver::Imap.new @@ -60,13 +61,18 @@ example =end def fetch (options, channel, check_type = '', verify_string = '') - ssl = true - port = 993 + ssl = true + port = 993 + keep_on_server = false + folder = 'INBOX' + if options[:keep_on_server] == true || options[:keep_on_server] == 'true' + keep_on_server = true + end if options.key?(:ssl) && options[:ssl] == false ssl = false port = 143 end - if options.key?(:port) && !options[:port].empty? + if options.key?(:port) && options[:port].present? port = options[:port] # disable ssl for non ssl ports @@ -74,8 +80,11 @@ example ssl = false end end + if options[:folder].present? + folder = options[:folder] + end - Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl},folder=#{options[:folder]})" + Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl},folder=#{folder},keep_on_server=#{keep_on_server})" # on check, reduce open_timeout to have faster probing timeout = 45 @@ -90,17 +99,17 @@ example @imap.login(options[:user], options[:password]) # select folder - if !options[:folder] || options[:folder].empty? - @imap.select('INBOX') - else - @imap.select(options[:folder]) - end + @imap.select(folder) # sort messages by date on server (if not supported), if not fetch messages via search (first in, first out) + filter = ['ALL'] + if keep_on_server && check_type != 'check' && check_type != 'verify' + filter = %w(NOT SEEN) + end begin - message_ids = @imap.sort(['DATE'], ['ALL'], 'US-ASCII') + message_ids = @imap.sort(['DATE'], filter, 'US-ASCII') rescue - message_ids = @imap.search(['ALL']) + message_ids = @imap.search(filter) end # check mode only @@ -168,9 +177,8 @@ example message_ids.each do |message_id| count += 1 Rails.logger.info " - message #{count}/#{count_all}" - #Rails.logger.info msg.to_s - message_meta = @imap.fetch(message_id, ['RFC822.SIZE', 'FLAGS', 'INTERNALDATE'])[0] + message_meta = @imap.fetch(message_id, ['RFC822.SIZE', 'ENVELOPE', 'FLAGS', 'INTERNALDATE'])[0] # ignore to big messages info = too_big?(message_meta, count, count_all) @@ -182,14 +190,23 @@ example # ignore deleted messages next if deleted?(message_meta, count, count_all) + # ignore already imported + next if already_imported?(message_id, message_meta, count, count_all, keep_on_server) + # delete email from server after article was created msg = @imap.fetch(message_id, 'RFC822')[0].attr['RFC822'] next if !msg process(channel, msg, false) - @imap.store(message_id, '+FLAGS', [:Deleted]) + if !keep_on_server + @imap.store(message_id, '+FLAGS', [:Deleted]) + else + @imap.store(message_id, '+FLAGS', [:Seen]) + end count_fetched += 1 end - @imap.expunge() + if !keep_on_server + @imap.expunge() + end disconnect if count.zero? Rails.logger.info ' - no message' @@ -209,6 +226,20 @@ example private + def already_imported?(message_id, message_meta, count, count_all, keep_on_server) + return false if !keep_on_server + return false if !message_meta.attr + return false if !message_meta.attr['ENVELOPE'] + local_message_id = message_meta.attr['ENVELOPE'].message_id + return false if local_message_id.blank? + local_message_id_md5 = Digest::MD5.hexdigest(local_message_id) + article = Ticket::Article.where(message_id_md5: local_message_id_md5).order('created_at DESC, id DESC').limit(1).first + return false if !article + @imap.store(message_id, '+FLAGS', [:Seen]) + Rails.logger.info " - ignore message #{count}/#{count_all} - because message message id already imported" + true + end + def deleted?(message_meta, count, count_all) return false if !message_meta.attr['FLAGS'].include?(:Deleted) Rails.logger.info " - ignore message #{count}/#{count_all} - because message has already delete flag" diff --git a/test/integration/email_deliver_test.rb b/test/integration/email_deliver_test.rb index 6d13296b6..440dc550b 100644 --- a/test/integration/email_deliver_test.rb +++ b/test/integration/email_deliver_test.rb @@ -4,16 +4,16 @@ require 'test_helper' class EmailDeliverTest < ActiveSupport::TestCase test 'basic check' do - if !ENV['MAIL_SERVER'] + if ENV['MAIL_SERVER'].blank? raise "Need MAIL_SERVER as ENV variable like export MAIL_SERVER='mx.example.com'" end - if !ENV['MAIL_SERVER_ACCOUNT'] + if ENV['MAIL_SERVER_ACCOUNT'].blank? raise "Need MAIL_SERVER_ACCOUNT as ENV variable like export MAIL_SERVER_ACCOUNT='user:somepass'" end server_login = ENV['MAIL_SERVER_ACCOUNT'].split(':')[0] server_password = ENV['MAIL_SERVER_ACCOUNT'].split(':')[1] - email_address = EmailAddress.create( + email_address = EmailAddress.create!( realname: 'me Helpdesk', email: "me#{rand(999_999_999)}@example.com", updated_by_id: 1, @@ -27,7 +27,7 @@ class EmailDeliverTest < ActiveSupport::TestCase created_by_id: 1, ) - channel = Channel.create( + channel = Channel.create!( area: 'Email::Account', group_id: group.id, options: { @@ -50,9 +50,9 @@ class EmailDeliverTest < ActiveSupport::TestCase ) email_address.channel_id = channel.id - email_address.save + email_address.save! - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some delivery test', group: group, customer_id: 2, @@ -63,7 +63,7 @@ class EmailDeliverTest < ActiveSupport::TestCase ) assert(ticket1, 'ticket created') - article1 = Ticket::Article.create( + article1 = Ticket::Article.create!( ticket_id: ticket1.id, to: 'some_recipient@example_not_existing_what_ever.com', subject: 'some subject', @@ -189,7 +189,7 @@ class EmailDeliverTest < ActiveSupport::TestCase # remove background jobs Delayed::Job.destroy_all - article2 = Ticket::Article.create( + article2 = Ticket::Article.create!( ticket_id: ticket1.id, to: 'some_recipient@example_not_existing_what_ever.com', subject: 'some subject2', diff --git a/test/integration/email_keep_on_server_test.rb b/test/integration/email_keep_on_server_test.rb new file mode 100644 index 000000000..99d8069b9 --- /dev/null +++ b/test/integration/email_keep_on_server_test.rb @@ -0,0 +1,232 @@ +# encoding: utf-8 +require 'test_helper' +require 'net/imap' + +class EmailKeepOnServerTest < ActiveSupport::TestCase + setup do + + if ENV['KEEP_ON_MAIL_SERVER'].blank? + raise "Need KEEP_ON_MAIL_SERVER as ENV variable like export KEEP_ON_MAIL_SERVER='mx.example.com'" + end + if ENV['KEEP_ON_MAIL_SERVER_ACCOUNT'].blank? + raise "Need KEEP_ON_MAIL_SERVER_ACCOUNT as ENV variable like export KEEP_ON_MAIL_SERVER_ACCOUNT='user:somepass'" + end + @server_login = ENV['KEEP_ON_MAIL_SERVER_ACCOUNT'].split(':')[0] + @server_password = ENV['KEEP_ON_MAIL_SERVER_ACCOUNT'].split(':')[1] + + @folder = "keep_on_mail_server_#{rand(999_999_999)}" + + email_address = EmailAddress.create!( + realname: 'me Helpdesk', + email: "me#{rand(999_999_999)}@example.com", + updated_by_id: 1, + created_by_id: 1, + ) + + group = Group.create_or_update( + name: 'KeepOnServerTest', + email_address_id: email_address.id, + updated_by_id: 1, + created_by_id: 1, + ) + + @channel = Channel.create!( + area: 'Email::Account', + group_id: group.id, + options: { + inbound: { + adapter: 'imap', + options: { + host: ENV['KEEP_ON_MAIL_SERVER'], + user: @server_login, + password: @server_password, + ssl: true, + folder: @folder, + #keep_on_server: true, + } + }, + outbound: { + adapter: 'sendmail' + } + }, + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + email_address.channel_id = @channel.id + email_address.save! + + end + + test 'keep on server' do + @channel.options[:inbound][:options][:keep_on_server] = true + @channel.save! + + # clean mailbox + imap = Net::IMAP.new(ENV['KEEP_ON_MAIL_SERVER'], 993, true, nil, false) + imap.login(@server_login, @server_password) + imap.create(@folder) + imap.select(@folder) + + # put unseen message in it + imap.append(@folder, "Subject: hello1 +From: shugo@example.com +To: shugo@example.com +Message-ID: + +hello world +".gsub(/\n/, "\r\n"), [], Time.zone.now) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + message_meta = imap.fetch(1, ['FLAGS'])[0].attr + assert_not(message_meta['FLAGS'].include?(:Seen)) + + # fetch messages + article_count = Ticket::Article.count + @channel.fetch(true) + assert_equal(article_count + 1, Ticket::Article.count) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + message_meta = imap.fetch(1, ['RFC822.HEADER', 'FLAGS'])[0].attr + assert(message_meta['FLAGS'].include?(:Seen)) + + # fetch messages + article_count = Ticket::Article.count + @channel.fetch(true) + assert_equal(article_count, Ticket::Article.count) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + # put unseen message in it + imap.append(@folder, "Subject: hello2 +From: shugo@example.com +To: shugo@example.com +Message-ID: + +hello world +".gsub(/\n/, "\r\n"), [], Time.zone.now) + + message_meta = imap.fetch(1, ['FLAGS'])[0].attr + assert(message_meta['FLAGS'].include?(:Seen)) + message_meta = imap.fetch(2, ['FLAGS'])[0].attr + assert_not(message_meta['FLAGS'].include?(:Seen)) + + # fetch messages + article_count = Ticket::Article.count + @channel.fetch(true) + assert_equal(article_count + 1, Ticket::Article.count) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(2, message_ids.count) + + message_meta = imap.fetch(1, ['FLAGS'])[0].attr + assert(message_meta['FLAGS'].include?(:Seen)) + message_meta = imap.fetch(2, ['FLAGS'])[0].attr + assert(message_meta['FLAGS'].include?(:Seen)) + + # set messages to not seen + imap.store(1, '-FLAGS', [:Seen]) + imap.store(2, '-FLAGS', [:Seen]) + + # fetch messages + article_count = Ticket::Article.count + @channel.fetch(true) + assert_equal(article_count, Ticket::Article.count) + + imap.delete(@folder) + @channel.destroy! + end + + test 'keep not on server' do + @channel.options[:inbound][:options][:keep_on_server] = false + @channel.save! + + # clean mailbox + imap = Net::IMAP.new(ENV['KEEP_ON_MAIL_SERVER'], 993, true, nil, false) + imap.login(@server_login, @server_password) + imap.create(@folder) + imap.select(@folder) + + # put unseen message in it + imap.append(@folder, "Subject: hello1 +From: shugo@example.com +To: shugo@example.com +Message-ID: + +hello world +".gsub(/\n/, "\r\n"), [], Time.zone.now) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + message_meta = imap.fetch(1, ['FLAGS'])[0].attr + assert_not(message_meta['FLAGS'].include?(:Seen)) + + # fetch messages + article_count = Ticket::Article.count + @channel.fetch(true) + assert_equal(article_count + 1, Ticket::Article.count) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + # put unseen message in it + imap.append(@folder, "Subject: hello2 +From: shugo@example.com +To: shugo@example.com +Message-ID: + +hello world +".gsub(/\n/, "\r\n"), [], Time.zone.now) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + message_meta = imap.fetch(1, ['FLAGS'])[0].attr + assert_not(message_meta['FLAGS'].include?(:Seen)) + + # fetch messages + article_count = Ticket::Article.count + @channel.fetch(true) + assert_equal(article_count + 1, Ticket::Article.count) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + # put unseen message in it + imap.append(@folder, "Subject: hello2 +From: shugo@example.com +To: shugo@example.com +Message-ID: + +hello world +".gsub(/\n/, "\r\n"), [], Time.zone.now) + + # verify if message is still on server + message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII') + assert_equal(1, message_ids.count) + + # fetch messages + article_count = Ticket::Article.count + @channel.fetch(true) + assert_equal(article_count + 1, Ticket::Article.count) + + imap.delete(@folder) + @channel.destroy! + + end + +end From 4b6496c5452c3f635262394e29c79c05ddd0ac70 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 20 Jul 2017 15:58:03 +0200 Subject: [PATCH 125/234] Do html cleanup directly on content fields too (not only on finished article). --- app/models/signature.rb | 4 ++++ app/models/text_module.rb | 3 +++ 2 files changed, 7 insertions(+) diff --git a/app/models/signature.rb b/app/models/signature.rb index 0435e0fff..04053bad0 100644 --- a/app/models/signature.rb +++ b/app/models/signature.rb @@ -2,7 +2,11 @@ class Signature < ApplicationModel include ChecksLatestChangeObserved + include ChecksHtmlSanitized has_many :groups, after_add: :cache_update, after_remove: :cache_update validates :name, presence: true + + sanitized_html :body + end diff --git a/app/models/text_module.rb b/app/models/text_module.rb index 357f0c78b..7fcb7da83 100644 --- a/app/models/text_module.rb +++ b/app/models/text_module.rb @@ -2,10 +2,13 @@ class TextModule < ApplicationModel include ChecksClientNotification + include ChecksHtmlSanitized validates :name, presence: true validates :content, presence: true + sanitized_html :content + =begin load text modules from online From d2d7c534eb90c4161add2e0847029fa24e29cdf4 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 20 Jul 2017 17:17:46 +0200 Subject: [PATCH 126/234] Using text modules not working in IE 11 (only in IE 11). --- .../app/lib/base/jquery.contenteditable.js | 66 ++++++++----------- .../app/lib/base/jquery.textmodule.js | 25 ++++--- 2 files changed, 41 insertions(+), 50 deletions(-) diff --git a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js index 2a1994828..d074e3dff 100644 --- a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js +++ b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js @@ -295,7 +295,7 @@ else { img = "" } - document.execCommand('insertHTML', false, img) + _this.paste(img) } // resize if to big @@ -367,13 +367,7 @@ text = App.Utils.removeEmptyLines(text) _this.log('insert', text) - // as fallback, insert html via pasteHtmlAtCaret (for IE 11 and lower) - if (docType == 'text3') { - _this.pasteHtmlAtCaret(text) - } - else { - document.execCommand('insertHTML', false, text) - } + _this.paste(text) return true }) @@ -533,37 +527,6 @@ return this.$element.html().trim() } - // taken from https://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div/6691294#6691294 - Plugin.prototype.pasteHtmlAtCaret = function(html) { - var sel, range; - if (window.getSelection) { - sel = window.getSelection() - if (sel.getRangeAt && sel.rangeCount) { - range = sel.getRangeAt(0) - range.deleteContents() - - var el = document.createElement('div') - el.innerHTML = html; - var frag = document.createDocumentFragment(), node, lastNode - while ( (node = el.firstChild) ) { - lastNode = frag.appendChild(node) - } - range.insertNode(frag) - - if (lastNode) { - range = range.cloneRange() - range.setStartAfter(lastNode) - range.collapse(true) - sel.removeAllRanges() - sel.addRange(range) - } - } - } - else if (document.selection && document.selection.type != 'Control') { - document.selection.createRange().pasteHTML(html) - } - } - // log method Plugin.prototype.log = function() { if (App && App.Log) { @@ -574,7 +537,30 @@ } } - $.fn[pluginName] = function ( options ) { + // paste some content + Plugin.prototype.paste = function(string) { + var isIE11 = !!window.MSInputMethodContext && !!document.documentMode; + + // IE <= 10 + if (document.selection && document.selection.createRange) { + var range = document.selection.createRange() + if (range.pasteHTML) { + range.pasteHTML(string) + } + } + // IE == 11 + else if (isIE11 && document.getSelection) { + var range = document.getSelection().getRangeAt(0) + var nnode = document.createElement('div') + range.surroundContents(nnode) + nnode.innerHTML = string + } + else { + document.execCommand('insertHTML', false, string) + } + } + + $.fn[pluginName] = function (options) { return this.each(function () { if (!$.data(this, 'plugin_' + pluginName)) { $.data(this, 'plugin_' + pluginName, diff --git a/app/assets/javascripts/app/lib/base/jquery.textmodule.js b/app/assets/javascripts/app/lib/base/jquery.textmodule.js index 6236f42d3..699ff1847 100644 --- a/app/assets/javascripts/app/lib/base/jquery.textmodule.js +++ b/app/assets/javascripts/app/lib/base/jquery.textmodule.js @@ -250,9 +250,21 @@ // paste some content Plugin.prototype.paste = function(string) { - if (document.selection) { // IE + var isIE11 = !!window.MSInputMethodContext && !!document.documentMode; + + // IE <= 10 + if (document.selection && document.selection.createRange) { var range = document.selection.createRange() - range.pasteHTML(string) + if (range.pasteHTML) { + range.pasteHTML(string) + } + } + // IE == 11 + else if (isIE11 && document.getSelection) { + var range = document.getSelection().getRangeAt(0) + var nnode = document.createElement('div') + range.surroundContents(nnode) + nnode.innerHTML = string } else { document.execCommand('insertHTML', false, string) @@ -295,14 +307,7 @@ // for chrome, insert space again if (start) { if (spacerChar === ' ') { - string = " " - if (document.selection) { // IE - var range = document.selection.createRange() - range.pasteHTML(string) - } - else { - document.execCommand('insertHTML', false, string) - } + this.paste(' ') } } } From 7468b3ed36bed206f64ba3c584f3a60b67cac673 Mon Sep 17 00:00:00 2001 From: Felix Niklas Date: Fri, 21 Jul 2017 11:46:52 +0200 Subject: [PATCH 127/234] Ticket Zoom: position Settings Button --- app/assets/javascripts/app/views/ticket_zoom.jst.eco | 1 + app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco | 2 +- app/assets/stylesheets/zammad.scss | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/app/views/ticket_zoom.jst.eco b/app/assets/javascripts/app/views/ticket_zoom.jst.eco index 89d8733a8..b6bf082c7 100644 --- a/app/assets/javascripts/app/views/ticket_zoom.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom.jst.eco @@ -9,6 +9,7 @@
                    +
                    diff --git a/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco index 491eab3c3..7997f7e8f 100644 --- a/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco @@ -1,3 +1,3 @@ -
                    +
                    <%- @Icon('cog', 'dropdown-icon') %>
                    \ No newline at end of file diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 2a6020b51..70b569381 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -415,7 +415,7 @@ pre code.hljs { font-size: 12px; letter-spacing: 0.05em; height: 31px; - padding: 2px 11px 0 !important; + padding: 0 11px !important; display: inline-flex; align-items: center; From ad8de497689bf42c8419c99fe90a3bf29c25f95f Mon Sep 17 00:00:00 2001 From: Felix Niklas Date: Fri, 21 Jul 2017 11:47:25 +0200 Subject: [PATCH 128/234] Searchfield: fix clipped focus shadow on high-res screens fixes #1210 --- app/assets/stylesheets/zammad.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 70b569381..71b17c243 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -1860,6 +1860,7 @@ input.has-error { appearance: textfield; border-radius: 19px; padding: 0 17px 0 42px; + will-change: transform; &.is-empty + .empty-search { visibility: hidden; From a9ef5ea8ff739e2748c29bcbfd47ea264dfd1f21 Mon Sep 17 00:00:00 2001 From: Felix Niklas Date: Fri, 21 Jul 2017 14:05:17 +0200 Subject: [PATCH 129/234] Wizard: make sure its scrollable on small screens --- app/assets/stylesheets/zammad.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 71b17c243..77f690c57 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -6444,6 +6444,9 @@ footer { @extend .u-textTruncate; } +.wizard { + margin: auto; // makes sure that the wizard is scrollable +} .wizard-logo { fill: white; From 3df5cfa4ec9750779172ae15737b3df32cbc4b9f Mon Sep 17 00:00:00 2001 From: Never Date: Fri, 21 Jul 2017 20:30:13 +0800 Subject: [PATCH 130/234] fixed #1267 (#1275) --- public/assets/chat/chat.coffee | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/public/assets/chat/chat.coffee b/public/assets/chat/chat.coffee index b551ea076..2118ff1aa 100644 --- a/public/assets/chat/chat.coffee +++ b/public/assets/chat/chat.coffee @@ -197,6 +197,23 @@ do($ = window.jQuery, window) -> 'Since you didn\'t respond in the last %s minutes your conversation with %s got closed.': 'Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation mit %s geschlossen.' 'Since you didn\'t respond in the last %s minutes your conversation got closed.': 'Da Sie in den letzten %s Minuten nichts geschrieben haben wurde Ihre Konversation geschlossen.' 'We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!': 'Es tut uns leid, es dauert länger als erwartet, um einen freien Platz zu erhalten. Bitte versuchen Sie es zu einem späteren Zeitpunkt noch einmal oder schicken Sie uns eine E-Mail. Vielen Dank!' + 'es' + 'Chat with us!': 'Chatee con nosotros!' + 'Scroll down to see new messages': 'Haga scroll hacia abajo para ver nuevos mensajes' + 'Online': 'En linea' + 'Online': 'En linea' + 'Offline': 'Desconectado' + 'Connecting': 'Conectando' + 'Connection re-established': 'Conexión restablecida' + 'Today': 'Hoy' + 'Send': 'Enviar' + 'Compose your message...': 'Escriba su mensaje...' + 'All colleagues are busy.': 'Todos los agentes están ocupados.' + 'You are on waiting list position %s.': 'Usted está en la posición %s de la lista de espera.' + 'Start new conversation': 'Iniciar nueva conversación' + 'Since you didn\'t respond in the last %s minutes your conversation with %s got closed.': 'Puesto que usted no respondió en los últimos %s minutos su conversación con %s se ha cerrado.' + 'Since you didn\'t respond in the last %s minutes your conversation got closed.': 'Puesto que usted no respondió en los últimos %s minutos su conversación se ha cerrado.' + 'We are sorry, it takes longer as expected to get an empty slot. Please try again later or send us an email. Thank you!': 'Lo sentimos, se tarda más tiempo de lo esperado para ser atendido por un agente. Inténtelo de nuevo más tarde o envíenos un correo electrónico. ¡Gracias!' 'fr': 'Chat with us!': 'Chattez avec nous!' 'Scroll down to see new messages': 'Faites défiler pour lire les nouveaux messages' From 961a5ecfa5df79734c6f76d798695ceb92f8b82a Mon Sep 17 00:00:00 2001 From: Felix Niklas Date: Fri, 21 Jul 2017 14:32:03 +0200 Subject: [PATCH 131/234] Dropdown: prevent overlap with modal --- app/assets/stylesheets/zammad.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 77f690c57..3ab10f4d1 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -5968,6 +5968,8 @@ footer { } .dropdown-menu { + @extend .zIndex-9; // has to be behind modal windows + position: absolute; margin: 0; padding: 0; min-width: 100%; From c8fb10e1b5d1fe3a1a2e56e1e23aaa898433960c Mon Sep 17 00:00:00 2001 From: Rolf Schmidt Date: Fri, 21 Jul 2017 15:02:16 +0200 Subject: [PATCH 132/234] Revert "Fixed issue #986 - Zammad sends reply to itself." This reverts commit 084ef3f50dd31b6898ef08097b9d6c35ae4e5bb1. --- .../app/controllers/ticket_zoom/article_actions.coffee | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee index ad9f73d79..df150405a 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee @@ -336,7 +336,8 @@ class App.TicketZoomArticleActions extends App.Controller articleNew.to = ticket.customer.email else - if article.sender.name is 'Agent' + user = App.User.find(article.created_by_id) + if article.sender.name is 'Agent' && !article.from.match(user.email) articleNew.to = article.to else if article.reply_to From 1c46b61c4b4f6468f95a95eb7492725f78e0e96f Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Fri, 21 Jul 2017 15:03:34 +0200 Subject: [PATCH 133/234] Fixed failing TravisCI jobs caused by confusion about TravisCI ENV and job ENV. --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 510217e74..c8153f76e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,6 @@ notifications: env: - DB=mysql - DB=postgresql - - BUNDLE_JOBS=8 addons: postgresql: "9.4" apt: From 7253631f54d9fc0d08163b7ffe220d816310df65 Mon Sep 17 00:00:00 2001 From: Felix Niklas Date: Fri, 21 Jul 2017 15:06:07 +0200 Subject: [PATCH 134/234] Inputs: readonly and disabled state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inputs and textareas will get a dark background as an indicator when they’re set to readonly - checkboxes and radios can’t be set to readonly (html limitation) - added disabled support for the zammad-switch --- .../app/views/layout_ref/inputs.jst.eco | 4 ++ app/assets/stylesheets/zammad.scss | 62 +++++++++++++------ 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/app/views/layout_ref/inputs.jst.eco b/app/assets/javascripts/app/views/layout_ref/inputs.jst.eco index 7fc1e34c6..50ff41cc4 100644 --- a/app/assets/javascripts/app/views/layout_ref/inputs.jst.eco +++ b/app/assets/javascripts/app/views/layout_ref/inputs.jst.eco @@ -51,6 +51,10 @@
                    +
                    + + +
                    diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 3ab10f4d1..e4557822d 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -398,7 +398,8 @@ pre code.hljs { } &.is-disabled, - &[disabled] { + &[disabled], + &:disabled { pointer-events: none; cursor: not-allowed; opacity: .33; @@ -1623,6 +1624,24 @@ textarea, border-color: hsl(200,71%,59%); box-shadow: 0 0 0 3px hsl(201,62%,90%); } + + &.is-disabled, // .is-disabled should not be used - legacy support + &[disabled], + &[readonly] { + background: hsl(210,17%,93%); + border-color: hsl(210,10%,85%); + + &:focus, + &.focus { + border-color: hsl(200,71%,59%); + } + } + + &.is-disabled, // .is-disabled should not be used + &[disabled] { + cursor: not-allowed; + opacity: 1; + } } input[type=url] { @@ -1700,13 +1719,6 @@ select.form-control:not([multiple]) { display: none; } -.form-control[disabled], .form-control.is-disabled { - cursor: not-allowed; - background-color: #fff; - color: #d5d5d5; - opacity: 1; -} - .form-control.form-control--borderless { border: none; padding: 0; @@ -5487,8 +5499,13 @@ footer { .newTicket .sidebar { width: 290px; } - .newTicket .form-control:not(:focus):not(.focus) { + .newTicket .form-control { border-color: hsl(0,0%,90%); + + &:focus, + &.focus { + border-color: hsl(200,71%,59%); + } } .newTicket .article-form-top { margin-top: 15px; @@ -6309,15 +6326,18 @@ footer { cursor: default; } -.checkbox.form-group .controls label { - padding: 2px 0; - font: inherit; - font-size: 13px; - margin-bottom: 0; - color: inherit; - text-transform: inherit; - letter-spacing: 0; - @extend .u-clickable; +.checkbox, +.radio { + &.form-group .controls label { + padding: 2px 0; + font: inherit; + font-size: 13px; + margin-bottom: 0; + color: inherit; + text-transform: inherit; + letter-spacing: 0; + @extend .u-clickable; + } } .userSearch-label { @@ -7191,6 +7211,12 @@ output { .zammad-switch input { display: none; + &[disabled] + label { + cursor: not-allowed; + background: hsl(210,17%,93%); + border-color: hsl(210,10%,85%); + } + &:focus + label { transition: none; background: hsl(200,71%,59%); From 6b3bc0701c10bbfd97fb935d8469e669a8557930 Mon Sep 17 00:00:00 2001 From: Felix Niklas Date: Fri, 21 Jul 2017 15:23:18 +0200 Subject: [PATCH 135/234] Searchfield: fix that icon is visible --- app/assets/javascripts/app/views/layout_ref/user_list.jst.eco | 2 +- app/assets/javascripts/app/views/search/index.jst.eco | 2 +- app/assets/javascripts/app/views/user.jst.eco | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/app/views/layout_ref/user_list.jst.eco b/app/assets/javascripts/app/views/layout_ref/user_list.jst.eco index 93f91446e..07f1630aa 100644 --- a/app/assets/javascripts/app/views/layout_ref/user_list.jst.eco +++ b/app/assets/javascripts/app/views/layout_ref/user_list.jst.eco @@ -116,8 +116,8 @@
                    - <%- @Icon('magnifier') %> + <%- @Icon('magnifier') %>
                    diff --git a/app/assets/javascripts/app/views/search/index.jst.eco b/app/assets/javascripts/app/views/search/index.jst.eco index 504e997f3..55637065d 100644 --- a/app/assets/javascripts/app/views/search/index.jst.eco +++ b/app/assets/javascripts/app/views/search/index.jst.eco @@ -3,8 +3,8 @@