diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index adb7fb768..d742f2338 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ before_script: - which ruby - env - test -n "$RNAME" && script/build/test_db_config.sh - - test -n "$RNAME" && bundle install + - test -n "$RNAME" && bundle install --jobs 8 stages: - pre @@ -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: @@ -229,7 +240,7 @@ test:integration:slack: - rake db:create - rake db:migrate - echo "gem 'slack-api'" >> Gemfile.local - - bundle install + - bundle install --jobs 8 - ruby -I test test/integration/slack_test.rb - rake db:drop @@ -281,6 +292,7 @@ test:integration:es_mysql: - ruby -I test/ test/integration/elasticsearch_test.rb - ruby -I test/ test/controllers/search_controller_test.rb - ruby -I test/ test/integration/report_test.rb + - ruby -I test/ test/controllers/form_controller_test.rb - rake db:drop test:integration:es_postgresql: @@ -297,6 +309,7 @@ test:integration:es_postgresql: - ruby -I test/ test/integration/elasticsearch_test.rb - ruby -I test/ test/controllers/search_controller_test.rb - ruby -I test/ test/integration/report_test.rb + - ruby -I test/ test/controllers/form_controller_test.rb - rake db:drop test:integration:zendesk_mysql: @@ -330,7 +343,7 @@ test:integration:otrs_5_mysql: - mysql script: - export RAILS_ENV=test - - export IMPORT_OTRS_ENDPOINT="http://vz599.demo.znuny.com/otrs/public.pl?Action=ZammadMigrator" + - export IMPORT_OTRS_ENDPOINT="http://vz1109.demo.znuny.com/otrs/public.pl?Action=ZammadMigrator" - rake db:create - rake db:migrate - ruby -I test/ test/integration/otrs_import_test.rb @@ -343,7 +356,7 @@ test:integration:otrs_5_postgresql: - postgresql script: - export RAILS_ENV=test - - export IMPORT_OTRS_ENDPOINT="http://vz599.demo.znuny.com/otrs/public.pl?Action=ZammadMigrator" + - export IMPORT_OTRS_ENDPOINT="http://vz1109.demo.znuny.com/otrs/public.pl?Action=ZammadMigrator" - rake db:create - rake db:migrate - ruby -I test/ test/integration/otrs_import_test.rb @@ -409,6 +422,7 @@ browser:build: - rake assets:precompile - rake db:drop artifacts: + expire_in: 1 week paths: - public/assets/.sprockets-manifest* - public/assets/application-* @@ -426,7 +440,7 @@ test:browser:integration:api_client_ruby: - script/build/test_startup.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 1 - git clone git@github.com:zammad/zammad-api-client-ruby.git || script/build/test_shutdown.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 1 1 - cd zammad-api-client-ruby - - bundle install + - bundle install --jobs 8 - export TEST_URL=http://$IP:$BROWSER_PORT - rspec || (cd .. && script/build/test_shutdown.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 1 1) - cd .. && script/build/test_shutdown.sh $RAILS_ENV $BROWSER_PORT $WS_PORT 0 1 diff --git a/.pkgr.yml b/.pkgr.yml index 85265c521..652c94518 100644 --- a/.pkgr.yml +++ b/.pkgr.yml @@ -5,19 +5,23 @@ notifications: false targets: centos-7: dependencies: + - elasticsearch - nginx - postgresql-server - which debian-8: dependencies: + - elasticsearch - nginx|apache2 - postgresql|mysql-server|mariadb-server|sqlite ubuntu-16.04: dependencies: + - elasticsearch - nginx|apache2 - postgresql|mysql-server|mariadb-server|sqlite sles-12: dependencies: + - elasticsearch - nginx - postgresql-server before: diff --git a/.ruby-version b/.ruby-version index 2bf1c1ccf..005119baa 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.3.1 +2.4.1 diff --git a/.travis.yml b/.travis.yml index c8153f76e..e3cfaaa1e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ services: - mysql language: ruby rvm: - - 2.3.1 + - 2.4.1 before_install: - sudo apt-get -qq update - sudo apt-get install -y curl git-core patch build-essential bison zlib1g-dev libssl-dev libxml2-dev libxml2-dev sqlite3 libsqlite3-dev autotools-dev libxslt1-dev libyaml-0-2 autoconf automake libreadline6-dev libyaml-dev libtool libgmp-dev libgdbm-dev libncurses5-dev pkg-config libffi-dev libmysqlclient-dev postfix @@ -55,3 +55,4 @@ script: - ruby -I test/ test/integration/user_device_controller_test.rb - ruby -I test/ test/integration/sipgate_controller_test.rb - rake db:drop +after_success: contrib/travis-ci.org/trigger-docker-compose-build.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 7248b9c81..364e7da85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Change Log -## [1.6.0](https://github.com/zammad/zammad/tree/1.6.0) (2017-xx-xx) -[Full Changelog](https://github.com/zammad/zammad/compare/1.4.0...1.5.0) +## [2.1.0](https://github.com/zammad/zammad/tree/2.1.0) (2017-xx-xx) +[Full Changelog](https://github.com/zammad/zammad/compare/2.0.0...2.1.0) **Implemented enhancements:** diff --git a/Gemfile b/Gemfile index 4325682f4..4d43388a0 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,8 @@ source 'https://rubygems.org' -ruby '2.3.1' +ruby '2.4.1' -gem 'rails', '4.2.7.1' +gem 'rails', '4.2.9' gem 'rails-observers' gem 'activerecord-session_store' @@ -40,18 +40,20 @@ gem 'omniauth-gitlab' gem 'omniauth-google-oauth2' gem 'omniauth-linkedin-oauth2' gem 'omniauth-twitter' +gem 'omniauth-microsoft-office365' gem 'twitter' gem 'telegramAPI' gem 'koala' gem 'mail' -gem 'email_verifier' +gem 'valid_email2' gem 'htmlentities' gem 'mime-types' gem 'biz' +gem 'composite_primary_keys' gem 'delayed_job_active_record' gem 'daemons' @@ -72,12 +74,16 @@ gem 'argon2' gem 'writeexcel' gem 'icalendar' +gem 'icalendar-recurrence' gem 'browser' # integrations gem 'slack-notifier' gem 'clearbit' gem 'zendesk_api' +gem 'viewpoint' +gem 'rubyntlm', git: 'https://github.com/wimm/rubyntlm.git' +gem 'autodiscover', git: 'https://github.com/thorsteneckel/autodiscover.git' # event machine gem 'eventmachine' diff --git a/Gemfile.lock b/Gemfile.lock index 4efc085b7..96e3ada43 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,67 +1,82 @@ +GIT + remote: https://github.com/thorsteneckel/autodiscover.git + revision: 29d713ee0c8c25fcf74c4292ff13fe1fa4d0d827 + specs: + autodiscover (1.0.2) + httpclient + logging + nokogiri + nori + +GIT + remote: https://github.com/wimm/rubyntlm.git + revision: 53969639b87b9e5d5fef560f19cf0d977259591c + specs: + rubyntlm (0.1.2) + 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.9) + actionpack (= 4.2.9) + actionview (= 4.2.9) + activejob (= 4.2.9) 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.9) + actionview (= 4.2.9) + activesupport (= 4.2.9) 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.9) + activesupport (= 4.2.9) 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.9) + activesupport (= 4.2.9) globalid (>= 0.3.0) - activemodel (4.2.7.1) - activesupport (= 4.2.7.1) + activemodel (4.2.9) + activesupport (= 4.2.9) builder (~> 3.1) - activerecord (4.2.7.1) - activemodel (= 4.2.7.1) - activesupport (= 4.2.7.1) + activerecord (4.2.9) + activemodel (= 4.2.9) + activesupport (= 4.2.9) arel (~> 6.0) - activerecord-nulldb-adapter (0.3.6) + activerecord-nulldb-adapter (0.3.7) activerecord (>= 2.0.0) - activerecord-session_store (1.0.0) - actionpack (>= 4.0, < 5.1) - activerecord (>= 4.0, < 5.1) + activerecord-session_store (1.1.0) + actionpack (>= 4.0, < 5.2) + activerecord (>= 4.0, < 5.2) multi_json (~> 1.11, >= 1.11.2) rack (>= 1.5.2, < 3) - railties (>= 4.0, < 5.1) - activesupport (4.2.7.1) + railties (>= 4.0, < 5.2) + activesupport (4.2.9) 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) ast (2.3.0) - autoprefixer-rails (6.4.1.1) + autoprefixer-rails (7.1.2.4) execjs - biz (1.6.0) + biz (1.7.0) clavius (~> 1.0) 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) - clearbit (0.2.5) + clearbit (0.2.7) nestful (~> 1.1.0) coderay (1.1.1) coffee-rails (4.2.1) @@ -70,31 +85,32 @@ GEM coffee-script (2.4.1) coffee-script-source execjs - coffee-script-source (1.10.0) + coffee-script-source (1.12.2) coffeelint (1.14.0) coffee-script execjs json - concurrent-ruby (1.0.2) - coveralls (0.8.16) + composite_primary_keys (8.1.6) + activerecord (~> 4.2.0) + concurrent-ruby (1.0.5) + coveralls (0.8.21) json (>= 1.8, < 3) - simplecov (~> 0.12.0) - term-ansicolor (~> 1.3.0) - thor (~> 0.19.1) - tins (>= 1.6.0, < 2) + simplecov (~> 0.14.1) + term-ansicolor (~> 1.3) + thor (~> 0.19.4) + tins (~> 1.6) crack (0.4.3) safe_yaml (~> 1.0.0) daemons (1.2.4) - delayed_job (4.1.2) - activesupport (>= 3.0, < 5.1) - delayed_job_active_record (4.1.1) - activerecord (>= 3.0, < 5.1) + delayed_job (4.1.3) + activesupport (>= 3.0, < 5.2) + delayed_job_active_record (4.1.2) + activerecord (>= 3.0, < 5.2) delayed_job (>= 3.0, < 5) diff-lcs (1.2.5) 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) @@ -106,8 +122,6 @@ GEM em-websocket (0.5.1) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) - email_verifier (0.1.0) - dnsruby (>= 1.5) equalizer (0.0.10) erubis (2.7.0) eventmachine (1.2.3) @@ -136,8 +150,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) @@ -153,39 +167,49 @@ GEM guard (~> 2.8) guard-compat (~> 1.0) multi_json (~> 1.8) - guard-symlink (0.1.0) + guard-symlink (0.1.1) + guard guard-compat (~> 1.1) - hashdiff (0.3.2) - hashie (3.4.4) + hashdiff (0.3.5) + hashie (3.5.6) htmlentities (4.3.4) http (1.0.4) addressable (~> 2.3) 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.1) + httpclient (2.8.3) + i18n (0.8.6) icalendar (2.4.1) + icalendar-recurrence (1.1.2) + icalendar (~> 2.0) + ice_cube (~> 0.13) + ice_cube (0.16.2) inflection (1.0.0) json (1.8.6) - jwt (1.5.4) + jwt (1.5.6) kgio (2.11.0) koala (2.4.0) addressable faraday multi_json (>= 1.3.0) - libv8 (3.16.14.15) + libv8 (3.16.14.19) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) ruby_dep (~> 1.2) + little-plugger (1.1.4) + logging (2.2.2) + little-plugger (~> 1.1) + multi_json (~> 1.10) 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) @@ -193,50 +217,54 @@ GEM mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) - mini_portile2 (2.1.0) - minitest (5.10.1) + mini_portile2 (2.2.0) + minitest (5.10.3) multi_json (1.12.1) - multi_xml (0.5.5) + multi_xml (0.6.0) 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) 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) + nori (2.6.0) notiffany (0.1.1) nenv (~> 0.1) shellany (~> 0.0) oauth (0.5.1) - oauth2 (1.2.0) - faraday (>= 0.8, < 0.10) + oauth2 (1.4.0) + faraday (>= 0.8, < 0.13) jwt (~> 1.0) multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) octokit (4.4.1) sawyer (~> 0.7.0, >= 0.5.3) - omniauth (1.3.1) - hashie (>= 1.2, < 4) - rack (>= 1.0, < 3) + omniauth (1.6.1) + hashie (>= 3.4.6, < 3.6.0) + rack (>= 1.6.2, < 3) omniauth-facebook (4.0.0) omniauth-oauth2 (~> 1.2) - omniauth-github (1.1.2) - omniauth (~> 1.0) - omniauth-oauth2 (~> 1.1) + omniauth-github (1.3.0) + omniauth (~> 1.5) + omniauth-oauth2 (>= 1.4.0, < 2.0) omniauth-gitlab (1.0.2) omniauth (~> 1.0) omniauth-oauth2 (~> 1.0) - omniauth-google-oauth2 (0.4.1) - jwt (~> 1.5.2) + omniauth-google-oauth2 (0.5.0) + jwt (~> 1.5) multi_json (~> 1.3) omniauth (>= 1.1.1) omniauth-oauth2 (>= 1.3.1) omniauth-linkedin-oauth2 (0.1.5) omniauth (~> 1.0) omniauth-oauth2 + omniauth-microsoft-office365 (0.0.7) + omniauth + omniauth-oauth2 omniauth-oauth (1.1.0) oauth omniauth (~> 1.0) @@ -248,32 +276,32 @@ GEM omniauth-oauth (~> 1.1) parser (2.3.1.2) ast (~> 2.2) - pg (0.18.4) - pluginator (1.3.0) + pg (0.20.0) + pluginator (1.5.0) power_assert (0.3.1) powerpack (0.1.1) - pre-commit (0.28.0) - pluginator (~> 1.1) + pre-commit (0.35.0) + pluginator (~> 1.5) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) - puma (3.6.0) - rack (1.6.4) + puma (3.9.1) + 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.9) + actionmailer (= 4.2.9) + actionpack (= 4.2.9) + actionview (= 4.2.9) + activejob (= 4.2.9) + activemodel (= 4.2.9) + activerecord (= 4.2.9) + activesupport (= 4.2.9) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.7.1) + railties (= 4.2.9) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) @@ -283,15 +311,16 @@ GEM rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.3) 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) + rails-observers (0.1.5) + activemodel (>= 4.0) + railties (4.2.9) + actionpack (= 4.2.9) + activesupport (= 4.2.9) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - rainbow (2.1.0) - raindrops (0.17.0) + rainbow (2.2.2) + rake + raindrops (0.19.0) rake (12.0.0) rb-fsevent (0.9.7) rb-inotify (0.9.7) @@ -339,7 +368,6 @@ GEM sawyer (0.7.0) addressable (>= 2.3.5, < 2.5) faraday (~> 0.8, < 0.10) - scrub_rb (1.0.1) selenium-webdriver (2.53.4) childprocess (~> 0.5) rubyzip (~> 1.0) @@ -347,19 +375,20 @@ GEM shellany (0.0.1) simple-rss (1.3.1) simple_oauth (0.3.1) - simplecov (0.12.0) + simplecov (0.14.1) docile (~> 1.1.0) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) - simplecov-html (0.10.0) + simplecov-html (0.10.1) simplecov-rcov (0.2.3) simplecov (>= 0.4.1) slack-notifier (1.5.1) slop (3.6.0) - spring (1.7.2) + spring (2.0.2) + activesupport (>= 4.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) @@ -369,18 +398,18 @@ GEM sqlite3 (1.3.11) telegramAPI (1.2.2) rest-client (~> 2.0, >= 1.7.3) - term-ansicolor (1.3.2) + term-ansicolor (1.6.0) tins (~> 1.0) test-unit (3.2.1) power_assert - therubyracer (0.12.2) - libv8 (~> 3.16.14.0) + therubyracer (0.12.3) + libv8 (~> 3.16.14.15) ref - thor (0.19.1) + thor (0.19.4) thread_safe (0.3.6) tilt (2.0.5) - tins (1.13.0) - twitter (5.16.0) + tins (1.15.0) + twitter (5.17.0) addressable (~> 2.3) buftok (~> 0.2.0) equalizer (= 0.0.10) @@ -391,30 +420,37 @@ 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) 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) + unicorn (5.3.0) kgio (~> 2.6) raindrops (~> 0.7) - webmock (2.3.2) + valid_email2 (2.0.0) + activemodel (>= 3.2) + mail (~> 2.5) + viewpoint (1.1.0) + httpclient + logging + nokogiri + rubyntlm + webmock (3.0.1) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff websocket (1.2.3) writeexcel (1.0.5) - zendesk_api (1.14.0) + zendesk_api (1.14.4) faraday (~> 0.9) - hashie (>= 1.2, < 4.0, != 3.3.0) + hashie (>= 3.5.2, < 4.0.0) inflection mime-types multipart-post (~> 2.0) - scrub_rb (~> 1.0.1) PLATFORMS ruby @@ -423,6 +459,7 @@ DEPENDENCIES activerecord-nulldb-adapter activerecord-session_store argon2 + autodiscover! autoprefixer-rails biz browser @@ -430,6 +467,7 @@ DEPENDENCIES coffee-rails coffee-script-source coffeelint + composite_primary_keys coveralls daemons delayed_job_active_record @@ -437,7 +475,6 @@ DEPENDENCIES doorkeeper eco em-websocket - email_verifier eventmachine execjs factory_girl_rails @@ -448,6 +485,7 @@ DEPENDENCIES guard-symlink htmlentities icalendar + icalendar-recurrence json koala libv8 @@ -462,17 +500,19 @@ DEPENDENCIES omniauth-gitlab omniauth-google-oauth2 omniauth-linkedin-oauth2 + omniauth-microsoft-office365 omniauth-oauth2 omniauth-twitter pg pre-commit puma rack-livereload - rails (= 4.2.7.1) + rails (= 4.2.9) rails-observers rb-fsevent rspec-rails rubocop + rubyntlm! sass-rails selenium-webdriver simple-rss @@ -489,12 +529,14 @@ DEPENDENCIES twitter uglifier unicorn + valid_email2 + viewpoint webmock writeexcel zendesk_api RUBY VERSION - ruby 2.3.1p112 + ruby 2.4.1p111 BUNDLED WITH - 1.13.7 + 1.15.3 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) diff --git a/VERSION b/VERSION index a94b39057..22b18172b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.x +2.1.x diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee index 5952bb0bc..c6fc9fa3f 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.coffee @@ -61,13 +61,13 @@ class App.Controller extends Spine.Controller clearDelay: (delay_id) => App.Delay.clear(delay_id, @controllerId) - delay: (callback, timeout, delay_id, queue = true) => + delay: (callback, timeout, delay_id, queue = false) => App.Delay.set(callback, timeout, delay_id, @controllerId, queue) clearInterval: (interval_id) => App.Interval.clear(interval_id, @controllerId) - interval: (callback, interval, interval_id, queue = true) => + interval: (callback, interval, interval_id, queue = false) => App.Interval.set(callback, interval, interval_id, @controllerId, queue) releaseController: => @@ -185,6 +185,17 @@ class App.Controller extends Spine.Controller formValidate: (data) -> App.ControllerForm.validate(data) + # get all query params of the url + queryParam: -> + return if !@query + pairs = @query.split(';') + params = {} + for pair in pairs + result = pair.match('(.+?)=(.*)') + if result && result[1] + params[result[1]] = result[2] + params + # redirectToLogin: (data) -> # @@ -344,7 +355,10 @@ class App.Controller extends Spine.Controller title: -> userId = $(@).data('id') user = App.User.find(userId) - App.Utils.htmlEscape(user.displayName()) + headline = App.Utils.htmlEscape(user.displayName()) + if user.isOutOfOffice() + headline += " (#{App.Utils.htmlEscape(user.outOfOfficeText())})" + headline content: -> userId = $(@).data('id') user = App.User.fullLocal(userId) diff --git a/app/assets/javascripts/app/controllers/_application_controller_form.coffee b/app/assets/javascripts/app/controllers/_application_controller_form.coffee index 8115dcd24..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 + '"' @@ -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/app/controllers/_application_controller_generic.coffee b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee index 6c8dfa547..f764e5c57 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee @@ -309,6 +309,23 @@ class App.ControllerConfirm extends App.ControllerModal if @callback @callback() +class App.ControllerErrorModal extends App.ControllerModal + buttonClose: true + buttonCancel: false + buttonSubmit: 'Close' + #buttonClass: 'btn--danger' + head: 'Error' + #small: true + #shown: true + + content: -> + @message + + onSubmit: => + @close() + if @callback + @callback() + class App.ControllerDrox extends App.Controller constructor: (params) -> super @@ -659,8 +676,9 @@ class App.Sidebar extends App.Controller render: => localEl = $(App.view('generic/sidebar_tabs')( - items: @items + items: @items scrollbarWidth: App.Utils.getScrollBarWidth() + dir: App.i18n.dir() )) # init content callback diff --git a/app/assets/javascripts/app/controllers/_channel/chat.coffee b/app/assets/javascripts/app/controllers/_channel/chat.coffee index a33ee73d7..a1cb273cb 100644 --- a/app/assets/javascripts/app/controllers/_channel/chat.coffee +++ b/app/assets/javascripts/app/controllers/_channel/chat.coffee @@ -21,7 +21,6 @@ class App.ChannelChat extends App.ControllerSubContent '.js-chat-welcome': 'chatWelcome' '.js-testurl-input': 'urlInput' '.js-backgroundColor': 'chatBackground' - '.js-paramsBlock': 'paramsBlock' '.js-code': 'code' '.js-palette': 'palette' '.js-color': 'colorField' @@ -361,7 +360,7 @@ class App.ChannelChat extends App.ControllerSubContent @$('.js-modal-params').html(paramString) # highlight - @paramsBlock.each (i, block) -> + @code.each (i, block) -> hljs.highlightBlock block App.Config.set('Chat', { prio: 4000, name: 'Chat', parent: '#channels', target: '#channels/chat', controller: App.ChannelChat, permission: ['admin.chat'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index 5eb14a75f..5c92bc279 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 = $( '
' + App.i18n.translateContent('New') + '
' ) - 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') @@ -110,7 +108,7 @@ class App.ChannelEmailFilterEdit extends App.ControllerModal # show errors in form if errors @log 'error', errors - @formValidate( form: e.target, errors: errors ) + @formValidate(form: e.target, errors: errors) return false # disable form @@ -120,8 +118,10 @@ class App.ChannelEmailFilterEdit extends App.ControllerModal object.save( done: => @close() - fail: => - @close() + fail: (settings, details) => + @log 'errors', details + @formEnable(e) + @form.showAlert(details.error_human || details.error || 'Unable to create object!') ) class App.ChannelEmailSignature extends App.Controller @@ -203,7 +203,7 @@ class App.ChannelEmailSignatureEdit extends App.ControllerModal # show errors in form if errors @log 'error', errors - @formValidate( form: e.target, errors: errors ) + @formValidate(form: e.target, errors: errors) return false # disable form @@ -213,8 +213,10 @@ class App.ChannelEmailSignatureEdit extends App.ControllerModal object.save( done: => @close() - fail: => + fail: (settings, details) => + @log 'errors', details @formEnable(e) + @form.showAlert(details.error_human || details.error || 'Unable to create object!') ) class App.ChannelEmailAccountOverview extends App.Controller @@ -533,8 +535,8 @@ class App.ChannelEmailAccountWizard extends App.WizardModal # base configureAttributesBase = [ - { name: 'realname', display: 'Organization & Department Name', tag: 'input', type: 'text', limit: 160, null: false, placeholder: 'Organization Support', autocomplete: 'new-password' }, - { name: 'email', display: 'Email', tag: 'input', type: 'email', limit: 120, null: false, placeholder: 'support@example.com', autocapitalize: false, autocomplete: 'new-password' }, + { name: 'realname', display: 'Organization & Department Name', tag: 'input', type: 'text', limit: 160, null: false, placeholder: 'Organization Support', autocomplete: 'off' }, + { name: 'email', display: 'Email', tag: 'input', type: 'email', limit: 120, null: false, placeholder: 'support@example.com', autocapitalize: false, autocomplete: 'off' }, { name: 'password', display: 'Password', tag: 'input', type: 'password', limit: 120, null: false, autocapitalize: false, autocomplete: 'new-password', single: true }, { name: 'group_id', display: 'Destination Group', tag: 'select', null: false, relation: 'Group', nulloption: true }, ] @@ -562,21 +564,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: '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: '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 @@ -608,9 +613,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('') @@ -618,7 +624,7 @@ class App.ChannelEmailAccountWizard extends App.WizardModal 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::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: 'new-password', single: true }, { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false }, ] @@ -672,7 +678,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') @@ -726,7 +732,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') @@ -932,7 +938,7 @@ class App.ChannelEmailNotificationWizard extends App.WizardModal 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::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: 'new-password', single: true }, { name: 'options::port', display: 'Port', tag: 'input', type: 'text', limit: 6, null: true, autocapitalize: false }, ] diff --git a/app/assets/javascripts/app/controllers/_channel/form.coffee b/app/assets/javascripts/app/controllers/_channel/form.coffee index d9697b132..7efd8168b 100644 --- a/app/assets/javascripts/app/controllers/_channel/form.coffee +++ b/app/assets/javascripts/app/controllers/_channel/form.coffee @@ -3,12 +3,14 @@ class App.ChannelForm extends App.ControllerSubContent requiredPermission: 'admin.channel_formular' header: 'Form' events: - 'change form.js-params': 'updateParams' - 'keyup form.js-params': 'updateParams' + 'change form.js-paramsDesigner': 'updateParamsDesigner' + 'keyup form.js-paramsDesigner': 'updateParamsDesigner' 'change .js-formSetting input': 'toggleFormSetting' + 'change .js-paramsSetting select': 'updateGroup' elements: - '.js-paramsBlock': 'paramsBlock' + '.js-code': 'code' + '.js-paramsSetting': 'paramsSetting' '.js-formSetting input': 'formSetting' constructor: -> @@ -20,22 +22,38 @@ class App.ChannelForm extends App.ControllerSubContent render: => setting = App.Setting.get('form_ticket_create') - @html App.view('channel/form')( + + element = $(App.view('channel/form')( baseurl: window.location.origin formSetting: setting - ) + )) - @paramsBlock.each (i, block) -> + group_id = App.Setting.get('form_ticket_create_group_id') + selection = App.UiElement.select.render( + name: 'group_id' + multiple: false + null: false + relation: 'Group' + nulloption: false + value: group_id + #class: 'form-control--small' + ) + console.log('s', element.find('.js-groupSelector'), selection) + element.find('.js-groupSelector').html(selection) + + @html element + + @code.each (i, block) -> hljs.highlightBlock block - @updateParams() + @updateParamsDesigner() - updateParams: -> + updateParamsDesigner: -> quote = (string) -> string = string.replace('\'', '\\\'') .replace(/\/g, '>') - params = @formParam(@$('.js-params')) + params = @formParam(@$('.js-paramsDesigner')) paramString = '' for key, value of params if value != '' @@ -63,4 +81,8 @@ class App.ChannelForm extends App.ControllerSubContent value = @formSetting.prop('checked') App.Setting.set('form_ticket_create', value) + updateGroup: => + value = @paramsSetting.find('[name=group_id]').val() + App.Setting.set('form_ticket_create_group_id', value) + App.Config.set('Form', { prio: 2000, name: 'Form', parent: '#channels', target: '#channels/form', controller: App.ChannelForm, permission: ['admin.formular'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/_integration/check_mk.coffee b/app/assets/javascripts/app/controllers/_integration/check_mk.coffee new file mode 100644 index 000000000..33c58ecf4 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_integration/check_mk.coffee @@ -0,0 +1,46 @@ +class Index extends App.ControllerIntegrationBase + featureIntegration: 'check_mk_integration' + featureName: 'Check_MK' + featureConfig: 'check_mk_config' + description: [ + ['This service receives http requests from %s and creates tickets with host and service.', 'Check_MK'] + ['If the host and service is recovered again, the ticket will be closed automatically.'] + ] + + render: => + super + new App.SettingsForm( + area: 'Integration::CheckMK' + el: @$('.js-form') + ) + + new App.ScriptSnipped( + el: @$('.js-scriptSnipped') + facility: 'check_mk' + style: 'bash' + content: "#!/bin/bash\n\ncurl -X POST -F 'event_id=123' -F 'host=host1' -F 'service=http' -F 'state=down' #{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/integration/check_mk/#{App.Setting.get('check_mk_token')}" + description: [ + ['To enable %s for sending http requests to %s, you need create "%s" in the admin interface if %s.', 'Check_MK', 'Zammad', 'Event Actions', 'Check_MK'] + ] + ) + + new App.HttpLog( + el: @$('.js-log') + facility: 'check_mk' + ) + +class State + @current: -> + App.Setting.get('check_mk_integration') + +App.Config.set( + 'IntegrationCheckMk' + { + name: 'Check_MK' + target: '#system/integration/check_mk' + description: 'An open source monitoring tool.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/_integration/exchange.coffee b/app/assets/javascripts/app/controllers/_integration/exchange.coffee new file mode 100644 index 000000000..5e6cd1843 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_integration/exchange.coffee @@ -0,0 +1,528 @@ +class Index extends App.ControllerIntegrationBase + featureIntegration: 'exchange_integration' + featureName: 'Exchange' + featureConfig: 'exchange_config' + description: [ + ['This service enables Zammad to connect with your Exchange server.'] + ] + events: + 'change .js-switch input': 'switch' + + render: => + super + new Form( + el: @$('.js-form') + ) + + #new App.ImportJob( + # el: @$('.js-importJob') + # facility: 'exchange' + #) + + new App.HttpLog( + el: @$('.js-log') + facility: 'exchange' + ) + + switch: => + super + active = @$('.js-switch input').prop('checked') + if active + job_start = => + @ajax( + id: 'jobs_config' + type: 'POST' + url: "#{@apiPath}/integration/exchange/job_start" + processData: true + success: (data, status, xhr) => + @render(true) + ) + + App.Delay.set( + job_start, + 600, + 'job_start', + ) + +class Form extends App.Controller + elements: + '.js-lastImport': 'lastImport' + '.js-wizard': 'wizardButton' + events: + 'click .js-wizard': 'startWizard' + 'click .js-start-sync': 'startSync' + + constructor: -> + super + @render() + @lastResult() + @activeDryRun() + + currentConfig: -> + App.Setting.get('exchange_config') || {} + + setConfig: (value) => + App.Setting.set('exchange_config', value, {notify: true}) + @startSync() + + render: (top = false) => + @config = @currentConfig() + + folders = [] + if !_.isEmpty(@config.folders) + for folder_id in @config.folders + folders.push @config.wizardData.backend_folders[folder_id] + + @html App.view('integration/exchange')( + config: @config, + folders: folders + ) + if _.isEmpty(@config) + @$('.js-notConfigured').removeClass('hide') + @$('.js-summary').addClass('hide') + else + @$('.js-notConfigured').addClass('hide') + @$('.js-summary').removeClass('hide') + + if top + a = => + @scrollToIfNeeded($('.content.active .page-header')) + @delay(a, 500) + + startSync: => + @ajax( + id: 'jobs_config' + type: 'POST' + url: "#{@apiPath}/integration/exchange/job_start" + processData: true + success: (data, status, xhr) => + @render(true) + @lastResult() + ) + + startWizard: (e) => + e.preventDefault() + new ConnectionWizard( + container: @el.closest('.content') + config: @config + callback: (config) => + @setConfig(config) + ) + + lastResult: => + @ajax( + id: 'jobs_start_index' + type: 'GET' + url: "#{@apiPath}/integration/exchange/job_start" + processData: true + success: (job, status, xhr) => + if !_.isEmpty(job) + if !@lastResultShowJob || @lastResultShowJob.updated_at != job.updated_at + @lastResultShowJob = job + @lastResultShow(job) + if job.finished_at + @wizardButton.attr('disabled', false) + else + @wizardButton.attr('disabled', true) + @delay(@lastResult, 5000) + ) + + lastResultShow: (job) => + if _.isEmpty(job) + @lastImport.html('') + return + countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped + job.result.failed + if !job.result.roles + job.result.roles = {} + for role_id, statistic of job.result.role_ids + role = App.Role.find(role_id) + job.result.roles[role.displayName()] = statistic + el = $(App.view('integration/exchange_last_import')(job: job, countDone: countDone)) + @lastImport.html(el) + + activeDryRun: => + @ajax( + id: 'jobs_try_index' + type: 'GET' + url: "#{@apiPath}/integration/exchange/job_try" + data: + finished: false + processData: true + success: (job, status, xhr) => + return if _.isEmpty(job) + + # show analyzing + new ConnectionWizard( + container: @el.closest('.content') + config: job.payload + start: 'tryLoop' + callback: (config) => + @wizardButton.attr('disabled', false) + @setConfig(config) + ) + @wizardButton.attr('disabled', true) + ) + +class State + @current: -> + App.Setting.get('exchange_integration') + +class ConnectionWizard extends App.WizardModal + wizardConfig: {} + slideMethod: + 'js-folders': 'foldersShow' + 'js-mapping': 'mappingShow' + + events: + 'submit form.js-discover': 'discover' + 'submit form.js-bind': 'folders' + 'submit form.js-folders': 'mapping' + 'click .js-mapping .js-submitTry': 'mappingChange' + 'click .js-try .js-submitSave': 'save' + 'click .js-close': 'hide' + 'click .js-remove': 'removeRow' + 'click .js-userMappingForm .js-add': 'addUserMapping' + 'click .js-goToSlide': 'goToSlide' + + elements: + '.modal-body': 'body' + '.js-foldersSelect': 'foldersSelect' + '.js-folders .js-submitTry': 'foldersSelectSubmit' + '.js-userMappingForm': 'userMappingForm' + '.js-expertForm': 'expertForm' + + constructor: -> + super + + if !_.isEmpty(@config) + @wizardConfig = @config + + if @container + @el.addClass('modal--local') + + @render() + + @el.modal + keyboard: true + show: true + backdrop: true + container: @container + .on + 'show.bs.modal': @onShow + 'shown.bs.modal': @onShown + 'hidden.bs.modal': => + @el.remove() + + if @slide + @showSlide(@slide) + else + @showDiscoverDetails() + + if @start + @[@start]() + + render: => + @html App.view('integration/exchange_wizard')() + + save: (e) => + e.preventDefault() + @callback(@wizardConfig) + @hide(e) + + showSlide: (slide) => + method = @slideMethod[slide] + if method && @[method] + @[method](true) + super + + showDiscoverDetails: => + @$('.js-discover input[name="user"]').val(@wizardConfig.user) + @$('.js-discover input[name="password"]').val(@wizardConfig.password) + + showBindDetails: => + @$('.js-bind input[name="endpoint"]').val(@wizardConfig.endpoint) + @$('.js-bind input[name="user"]').val(@wizardConfig.user) + @$('.js-bind input[name="password"]').val(@wizardConfig.password) + + discover: (e) => + e.preventDefault() + @showSlide('js-connect') + params = @formParam(e.target) + @ajax( + id: 'exchange_discover' + type: 'POST' + url: "#{@apiPath}/integration/exchange/autodiscover" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + if data.result isnt 'ok' + @showSlide('js-discover') + @showAlert('js-discover', data.message) + return + + @wizardConfig.endpoint = data.endpoint + @wizardConfig.user = params.user + @wizardConfig.password = params.password + + @showSlide('js-bind') + @showBindDetails() + + error: (xhr, statusText, error) => + detailsRaw = xhr.responseText + details = {} + if !_.isEmpty(detailsRaw) + details = JSON.parse(detailsRaw) + @showSlide('js-discover') + @showAlert('js-discover', details.error || 'Unable to perform backend.') + ) + + folders: (e) => + e.preventDefault() + @showSlide('js-analyze') + params = @formParam(e.target) + @ajax( + id: 'exchange_folders' + type: 'POST' + url: "#{@apiPath}/integration/exchange/folders" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + if data.result isnt 'ok' + @showSlide('js-bind') + @showAlert('js-bind', data.message) + return + + @wizardConfig.endpoint = params.endpoint + @wizardConfig.user = params.user + @wizardConfig.password = params.password + + # update wizard data + @wizardConfig.wizardData = {} + @wizardConfig.wizardData.backend_folders = data.folders + + @foldersShow() + + error: (xhr, statusText, error) => + detailsRaw = xhr.responseText + details = {} + if !_.isEmpty(detailsRaw) + details = JSON.parse(detailsRaw) + @showSlide('js-bind') + @showAlert('js-bind', details.error || 'Unable to perform backend.') + ) + + foldersShow: (alreadyShown) => + @showSlide('js-folders') if !alreadyShown + @foldersSelect.html(@createColumnSelection('folders', @wizardConfig.wizardData.backend_folders, @wizardConfig.folders)) + + createColumnSelection: (name, options, selected) -> + return App.UiElement.column_select.render( + name: name + null: false + nulloption: false + options: options + value: selected + onChange: (val) => + if val && val.length > 0 + @foldersSelectSubmit.removeClass('is-disabled') + else + @foldersSelectSubmit.addClass('is-disabled') + ) + + mapping: (e) => + e.preventDefault() + @showSlide('js-analyze') + params = @formParam(e.target) + + # folders might be a single selection so we + # have to ensure that is an Array so the + # backend and frontend can handle it properly + if typeof params.folders is 'string' + params.folders = [ params.folders ] + + # add login params + params.endpoint = @wizardConfig.endpoint + params.user = @wizardConfig.user + params.password = @wizardConfig.password + + @ajax( + id: 'exchange_mapping' + type: 'POST' + url: "#{@apiPath}/integration/exchange/mapping" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + if data.result isnt 'ok' + @showSlide('js-folders') + @showAlert('js-folders', data.message) + return + + attributes = {} + for key, value of App.User.attributesGet() + continue if key == 'login' + if (value.tag is 'input' || value.tag is 'richtext' || value.tag is 'textarea') && value.type isnt 'password' + attributes[key] = value.display || key + + @wizardConfig.wizardData.attributes = attributes + @wizardConfig.folders = params.folders + @wizardConfig.wizardData.backend_attributes = data.attributes + + @mappingShow() + + error: (xhr, statusText, error) => + detailsRaw = xhr.responseText + details = {} + if !_.isEmpty(detailsRaw) + details = JSON.parse(detailsRaw) + @showSlide('js-folders') + @showAlert('js-folders', details.error || 'Unable to perform backend.') + ) + + mappingShow: (alreadyShown) => + @showSlide('js-mapping') if !alreadyShown + user_attribute_map = @wizardConfig.attributes + + if _.isEmpty(user_attribute_map) + user_attribute_map = + given_name: 'firstname' + surname: 'lastname' + 'email_addresses.emailaddress1': 'email' + 'phone_numbers.businessphone': 'phone' + + @userMappingForm.find('tbody tr.js-entry').remove() + @userMappingForm.find('tbody tr').before(@buildRowsUserMap(user_attribute_map)) + + mappingChange: (e) => + e.preventDefault() + + # user map + attributes = @formParam(@userMappingForm) + for key in ['source', 'dest'] + if !_.isArray(attributes[key]) + attributes[key] = [attributes[key]] + attributes_local = + item_id: 'login' + length = attributes.source.length-1 + for count in [0..length] + if attributes.source[count] && attributes.dest[count] + attributes_local[attributes.source[count]] = attributes.dest[count] + @wizardConfig.attributes = attributes_local + + @tryShow() + + buildRowsUserMap: (user_attribute_map) => + + # show static login row + userUidDisplayValue = @wizardConfig.wizardData.backend_attributes['item_id'] + el = [ + $(App.view('integration/ldap_user_attribute_row_read_only')( + key: userUidDisplayValue, + value: 'Login' + )) + ] + + for source, dest of user_attribute_map + continue if source == 'item_id' + continue if !(source of @wizardConfig.wizardData.backend_attributes) + el.push @buildRowUserAttribute(source, dest) + el + + buildRowUserAttribute: (source, dest) => + el = $(App.view('integration/exchange_user_attribute_row')()) + el.find('.js-exchangeAttribute').html(@createSelection('source', @wizardConfig.wizardData.backend_attributes, source)) + el.find('.js-userAttribute').html(@createSelection('dest', @wizardConfig.wizardData.attributes, dest)) + el + + createSelection: (name, options, selected, unknown) -> + return App.UiElement.searchable_select.render( + name: name + multiple: false + limit: 100 + null: false + nulloption: false + options: options + value: selected + unknown: unknown + class: 'form-control--small' + ) + + removeRow: (e) -> + e.preventDefault() + $(e.target).closest('tr').remove() + + addUserMapping: (e) => + e.preventDefault() + @userMappingForm.find('tbody tr').last().before(@buildRowUserAttribute()) + + tryShow: (e) => + if e + e.preventDefault() + @showSlide('js-analyze') + + # create import job + @ajax( + id: 'exchange_try' + type: 'POST' + url: "#{@apiPath}/integration/exchange/job_try" + data: JSON.stringify(@wizardConfig) + processData: true + success: (data, status, xhr) => + @tryLoop() + ) + + tryLoop: => + @showSlide('js-dry') + @ajax( + id: 'jobs_try_index' + type: 'GET' + url: "#{@apiPath}/integration/exchange/job_try" + data: + finished: true + processData: true + success: (job, status, xhr) => + if job.result && (job.result.error || job.result.info) + @showSlide('js-error') + @showAlert('js-error', (job.result.error || job.result.info)) + return + + total = 0 + if job.result && _.keys(job.result).length > 0 + @$('.js-preprogress').addClass('hide') + @$('.js-analyzing').removeClass('hide') + + analized = 0 + total = job.result.sum + for action, sum of job.result + continue if action == 'folders' + continue if action == 'sum' + analized += sum + + @$('.js-progress progress').attr('value', analized) + @$('.js-progress progress').attr('max', total) + + if job.finished_at + # reset initial state in case the back button is used + @$('.js-preprogress').removeClass('hide') + @$('.js-analyzing').addClass('hide') + + @tryResult(job, total) + else + @delay(@tryLoop, 4000) + ) + + tryResult: (job, total) => + @showSlide('js-try') + el = $(App.view('integration/exchange_summary')(job: job, countDone: total)) + @el.find('.js-summary').html(el) + +App.Config.set( + 'IntegrationExchange' + { + name: 'Exchange' + target: '#system/integration/exchange' + description: 'Exchange integration for contacts management.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/_integration/idoit.coffee b/app/assets/javascripts/app/controllers/_integration/idoit.coffee new file mode 100644 index 000000000..724207069 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_integration/idoit.coffee @@ -0,0 +1,94 @@ +class Index extends App.ControllerIntegrationBase + featureIntegration: 'idoit_integration' + featureName: 'i-doit' + featureConfig: 'idoit_config' + description: [ + ['This service allows you to connect i-doit objects with Zammad.'] + ] + events: + 'change .js-switch input': 'switch' + + render: => + super + new Form( + el: @$('.js-form') + ) + + new App.HttpLog( + el: @$('.js-log') + facility: 'idoit' + ) + +class Form extends App.Controller + events: + 'submit form': 'update' + + constructor: -> + super + @render() + + currentConfig: -> + App.Setting.get('idoit_config') + + setConfig: (value) -> + App.Setting.set('idoit_config', value, {notify: true}) + + render: => + @config = @currentConfig() + + @html App.view('integration/idoit')( + config: @config + ) + + update: (e) => + e.preventDefault() + @config = @formParam(e.target) + @validateAndSave() + + validateAndSave: => + @ajax( + id: 'idoit' + type: 'POST' + url: "#{@apiPath}/integration/idoit/verify" + data: JSON.stringify( + method: 'cmdb.object_types' + api_token: @config.api_token + endpoint: @config.endpoint + client_id: @config.client_id + ) + success: (data, status, xhr) => + if data.result is 'failed' + new App.ControllerErrorModal( + message: data.message + container: @el.closest('.content') + ) + return + @setConfig(@config) + + error: (data, status) => + + # do not close window if request is aborted + return if status is 'abort' + + details = data.responseJSON || {} + @notify( + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to save!') + ) + ) + +class State + @current: -> + App.Setting.get('idoit_integration') + +App.Config.set( + 'IntegrationIdoit' + { + name: 'i-doit' + target: '#system/integration/idoit' + description: 'CMDB to document complex relations of your network components.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/controllers/_integration/ldap.coffee b/app/assets/javascripts/app/controllers/_integration/ldap.coffee index cda274205..83a61dc98 100644 --- a/app/assets/javascripts/app/controllers/_integration/ldap.coffee +++ b/app/assets/javascripts/app/controllers/_integration/ldap.coffee @@ -28,13 +28,20 @@ class Index extends App.ControllerIntegrationBase super active = @$('.js-switch input').prop('checked') if active - @ajax( - id: 'jobs_config' - type: 'POST' - url: "#{@apiPath}/integration/ldap/job_start" - processData: true - success: (data, status, xhr) => - @render(true) + job_start = => + @ajax( + id: 'jobs_config' + type: 'POST' + url: "#{@apiPath}/integration/ldap/job_start" + processData: true + success: (data, status, xhr) => + @render(true) + ) + + App.Delay.set( + job_start, + 600, + 'job_start', ) class Form extends App.Controller @@ -61,8 +68,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') @@ -84,6 +98,7 @@ class Form extends App.Controller processData: true success: (data, status, xhr) => @render(true) + @lastResult() ) startWizard: (e) => @@ -280,12 +295,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 @@ -419,7 +435,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 +472,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/controllers/_profile/linked_accounts.coffee b/app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee index 671da7abc..5f731803b 100644 --- a/app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee +++ b/app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee @@ -9,43 +9,7 @@ class Index extends App.ControllerSubContent @render() render: => - auth_provider_all = { - facebook: { - url: '/auth/facebook' - name: 'Facebook' - config: 'auth_facebook' - }, - twitter: { - url: '/auth/twitter' - name: 'Twitter' - config: 'auth_twitter' - }, - linkedin: { - url: '/auth/linkedin' - name: 'LinkedIn' - config: 'auth_linkedin' - }, - github: { - url: '/auth/github' - name: 'GitHub' - config: 'auth_github' - }, - gitlab: { - url: '/auth/gitlab' - name: 'GitLab' - config: 'auth_gitlab' - }, - google_oauth2: { - url: '/auth/google_oauth2' - name: 'Google' - config: 'auth_google_oauth2' - }, - oauth2: { - url: '/auth/oauth2' - name: 'OAuth2' - config: 'auth_oauth2' - }, - } + auth_provider_all = App.Config.get('auth_provider_all') auth_providers = {} for key, provider of auth_provider_all if @Config.get(provider.config) is true || @Config.get(provider.config) is 'true' @@ -90,3 +54,45 @@ class Index extends App.ControllerSubContent ) App.Config.set('LinkedAccounts', { prio: 4000, name: 'Linked Accounts', parent: '#profile', target: '#profile/linked', controller: Index, permission: ['user_preferences.linked_accounts'] }, 'NavBarProfile') +App.Config.set('auth_provider_all', { + facebook: + url: '/auth/facebook' + name: 'Facebook' + config: 'auth_facebook' + class: 'facebook' + twitter: + url: '/auth/twitter' + name: 'Twitter' + config: 'auth_twitter' + class: 'twitter' + linkedin: + url: '/auth/linkedin' + name: 'LinkedIn' + config: 'auth_linkedin' + class: 'linkedin' + github: + url: '/auth/github' + name: 'GitHub' + config: 'auth_github' + class: 'github' + gitlab: + url: '/auth/gitlab' + name: 'GitLab' + config: 'auth_gitlab' + class: 'gitlab' + microsoft_office365: + url: '/auth/microsoft_office365' + name: 'Office 365' + config: 'auth_microsoft_office365' + class: 'office365' + google_oauth2: + url: '/auth/google_oauth2' + name: 'Google' + config: 'auth_google_oauth2' + class: 'google' + oauth2: + url: '/auth/oauth2' + name: 'OAuth2' + config: 'auth_oauth2' + class: 'oauth2' +}) 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/_profile/out_of_office.coffee b/app/assets/javascripts/app/controllers/_profile/out_of_office.coffee new file mode 100644 index 000000000..8f796f715 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_profile/out_of_office.coffee @@ -0,0 +1,164 @@ +class Index extends App.ControllerSubContent + requiredPermission: 'user_preferences.out_of_office+ticket.agent' + header: 'Out of Office' + events: + 'submit form': 'submit' + 'click .js-disabled': 'disable' + 'click .js-enable': 'enable' + + constructor: -> + super + @render() + + render: => + user = @Session.get() + if !@localData + @localData = + out_of_office: user.out_of_office + out_of_office_start_at: user.out_of_office_start_at + out_of_office_end_at: user.out_of_office_end_at + out_of_office_replacement_id: user.out_of_office_replacement_id + out_of_office_replacement_id_completion: user.preferences.out_of_office_replacement_id_completion + out_of_office_text: user.preferences.out_of_office_text + form = $(App.view('profile/out_of_office')( + user: user + localData: @localData + placeholder: App.User.outOfOfficeTextPlaceholder() + )) + + dateStart = new App.ControllerForm( + model: + configure_attributes: + [ + name: 'out_of_office_start_at' + display: '' + tag: 'date' + past: false + future: true + null: false + ] + noFieldset: true + params: @localData + ) + form.find('.js-startDate').html(dateStart.form) + + dateEnd = new App.ControllerForm( + model: + configure_attributes: + [ + name: 'out_of_office_end_at' + display: '' + tag: 'date' + past: false + future: true + null: false + ] + noFieldset: true + params: @localData + ) + form.find('.js-endDate').html(dateEnd.form) + + agentList = new App.ControllerForm( + model: + configure_attributes: + [ + name: 'out_of_office_replacement_id' + display: '' + relation: 'User' + tag: 'user_autocompletion' + autocapitalize: false + multiple: false + limit: 30 + minLengt: 2 + placeholder: 'Enter Person or Organization/Company' + null: false + translate: false + disableCreateObject: true + value: @localData + ] + noFieldset: true + params: @localData + ) + form.find('.js-recipientDropdown').html(agentList.form) + if @localData.out_of_office is true + form.find('.js-disabled').removeClass('is-disabled') + #form.find('.js-enable').addClass('is-disabled') + else + form.find('.js-disabled').addClass('is-disabled') + #form.find('.js-enable').removeClass('is-disabled') + @html(form) + + enable: (e) => + e.preventDefault() + params = @formParam(e.target) + params.out_of_office = true + @store(e, params) + + disable: (e) => + e.preventDefault() + params = @formParam(e.target) + params.out_of_office = false + @store(e, params) + + submit: (e, params) => + e.preventDefault() + params = @formParam(e.target) + @store(e, params) + + store: (e, params) => + @formDisable(e) + for key, value of params + @localData[key] = value + App.Ajax.request( + id: 'user_out_of_office' + type: 'PUT' + url: "#{@apiPath}/users/out_of_office" + data: JSON.stringify(params) + processData: true + success: @success + error: @error + ) + + success: (data) => + if data.message is 'ok' + @render() + @notify( + type: 'success' + msg: App.i18n.translateContent('Successfully!') + timeout: 1000 + ) + else + if data.notice + @notify + type: 'error' + msg: App.i18n.translateContent(data.notice[0], data.notice[1]) + removeAll: true + else + @notify + type: 'error' + msg: 'Please contact your administrator.' + removeAll: true + @formEnable( @$('form') ) + + error: (xhr, status, error) => + @formEnable( @$('form') ) + + # do not close window if request is aborted + return if status is 'abort' + data = JSON.parse(xhr.responseText) + + # show error message + if xhr.status is 401 || error is 'Unauthorized' + message = '» ' + App.i18n.translateInline('Unauthorized') + ' «' + else if xhr.status is 404 || error is 'Not Found' + message = '» ' + App.i18n.translateInline('Not Found') + ' «' + else if data.error + message = App.i18n.translateInline(data.error) + else + message = '» ' + App.i18n.translateInline('Error') + ' «' + @notify + type: 'error' + msg: App.i18n.translateContent(message) + removeAll: true + +App.Config.set('OutOfOffice', { prio: 2800, name: 'Out of Office', parent: '#profile', target: '#profile/out_of_office', permission: ['user_preferences.out_of_office+ticket.agent'], controller: Index }, 'NavBarProfile') diff --git a/app/assets/javascripts/app/controllers/_settings/area_proxy.coffee b/app/assets/javascripts/app/controllers/_settings/area_proxy.coffee index 841e23ffc..8d093a776 100644 --- a/app/assets/javascripts/app/controllers/_settings/area_proxy.coffee +++ b/app/assets/javascripts/app/controllers/_settings/area_proxy.coffee @@ -2,7 +2,7 @@ class App.SettingsAreaProxy extends App.Controller events: 'submit form': 'update' 'click .js-submit': 'update' - 'click .js-test': 'test2' + 'click .js-test': 'testConnection' constructor: -> super @@ -14,20 +14,21 @@ class App.SettingsAreaProxy extends App.Controller proxy: App.Setting.get('proxy') proxy_username: App.Setting.get('proxy_username') proxy_password: App.Setting.get('proxy_password') + proxy_no: App.Setting.get('proxy_no') ) update: (e) => e.preventDefault() @formDisable(e) params = @formParam(e) - console.log('params', params) App.Setting.set('proxy', params.proxy) App.Setting.set('proxy_username', params.proxy_username) App.Setting.set('proxy_password', params.proxy_password) + App.Setting.set('proxy_no', params.proxy_no) @formEnable(e) @render() - test2: (e) => + testConnection: (e) => e.preventDefault() params = @formParam(e) @ajax( 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..80d61840b 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' @@ -81,9 +82,9 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi view: shown: true invite_customer: - show: false + shown: false required: false - 'admin.group': + 'admin.user': create: shown: true required: false @@ -93,10 +94,10 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi view: shown: true invite_agent: - show: false + shown: false required: false invite_customer: - show: false + shown: false required: false Organization: 'ticket.customer': @@ -111,7 +112,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 @@ -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/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/postmaster_set.coffee b/app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee index 176030f3f..049bc2735 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee @@ -20,7 +20,7 @@ class App.UiElement.postmaster_set name: 'Customer' relation: 'User' tag: 'user_autocompletion' - disableCreateUser: true + disableCreateObject: true } { value: 'group_id' @@ -32,7 +32,7 @@ class App.UiElement.postmaster_set name: 'Owner' relation: 'User' tag: 'user_autocompletion' - disableCreateUser: true + disableCreateObject: true } ] article: 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/ticket_perform_action.coffee b/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee index e64d6edf4..c5718de48 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee @@ -33,14 +33,15 @@ class App.UiElement.ticket_perform_action elements["#{groupKey}.#{config.name}"] = config # add ticket deletion action - elements['ticket.action'] = - name: 'action' - display: 'Action' - tag: 'select' - null: false - translate: true - options: - delete: 'delete' + if attribute.ticket_delete + elements['ticket.action'] = + name: 'action' + display: 'Action' + tag: 'select' + null: false + translate: true + options: + delete: 'Delete' [defaults, groups, elements] diff --git a/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee b/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee index 72ff6e1b8..91b5ad0b3 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee @@ -156,7 +156,7 @@ class App.UiElement.ticket_selector elementRow = $(e.target).closest('.js-filterElement') groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value') return if !groupAndAttribute - @buildOperator(item, elementRow, groupAndAttribute, elements, {}, attribute, false) + @buildOperator(item, elementRow, groupAndAttribute, elements, {}, attribute) ) # bind for preview @@ -244,9 +244,9 @@ class App.UiElement.ticket_selector if groupAndAttribute elementRow.find('.js-attributeSelector select').val(groupAndAttribute) - @buildOperator(elementFull, elementRow, groupAndAttribute, elements, meta, attribute, true) + @buildOperator(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) - @buildOperator: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute, buildValue) -> + @buildOperator: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) -> currentOperator = elementRow.find('.js-operator option:selected').attr('value') name = "#{attribute.name}::#{groupAndAttribute}::operator" @@ -284,9 +284,9 @@ class App.UiElement.ticket_selector elementRow.find('.js-operator select').replaceWith(selection) - @buildPreCondition(elementFull, elementRow, groupAndAttribute, elements, meta, attribute, buildValue) + @buildPreCondition(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) - @buildPreCondition: (elementFull, elementRow, groupAndAttribute, elements, meta, attributeConfig, buildValue = true) -> + @buildPreCondition: (elementFull, elementRow, groupAndAttribute, elements, meta, attributeConfig) -> currentOperator = elementRow.find('.js-operator option:selected').attr('value') currentPreCondition = elementRow.find('.js-preCondition option:selected').attr('value') @@ -318,7 +318,6 @@ class App.UiElement.ticket_selector if !preCondition elementRow.find('.js-preCondition select').html('') elementRow.find('.js-preCondition').addClass('hide') - return if !buildValue toggleValue() @buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) return @@ -351,7 +350,6 @@ class App.UiElement.ticket_selector toggleValue() ) - return if !buildValue @buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) toggleValue() 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/_ui_element/user_autocompletion_search.coffee b/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee index b8d47b42f..f6dfae836 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee @@ -2,5 +2,5 @@ class App.UiElement.user_autocompletion_search @render: (attributeOrig, params = {}) -> attribute = _.clone(attributeOrig) - attribute.disableCreateUser = true + attribute.disableCreateObject = true new App.UserOrganizationAutocompletion(attribute: attribute, params: params).element() 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..617c83f0c 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create.coffee @@ -9,6 +9,7 @@ class App.TicketCreate extends App.Controller constructor: (params) -> super + @sidebarState = {} # define default type @default_type = 'phone-in' @@ -91,6 +92,8 @@ class App.TicketCreate extends App.Controller else @$('[name="cc"]').closest('.form-group').addClass('hide') + App.TaskManager.touch(@task_key) + meta: => text = '' if @articleAttributes @@ -99,10 +102,10 @@ class App.TicketCreate extends App.Controller if title text = "#{text}: #{title}" meta = - url: @url() - head: text - title: text - id: @id + url: @url() + head: text + title: text + id: @id iconClass: 'pen' url: => @@ -228,7 +231,7 @@ class App.TicketCreate extends App.Controller type = @$('[name="formSenderType"]').val() if signature isnt undefined && signature.body && type is 'email-out' - signatureFinished = App.Utils.replaceTags(signature.body, { user: App.Session.get() }) + signatureFinished = App.Utils.replaceTags(signature.body, { user: App.Session.get(), config: App.Config.all() }) body = @$('[data-name=body]') if App.Utils.signatureCheck(body.html() || '', signatureFinished) @@ -330,27 +333,47 @@ class App.TicketCreate extends App.Controller # show text module UI @textModule = new App.WidgetTextModule( el: @$('[data-name="body"]').parent() - ) - - new Sidebar( - el: @sidebar - params: @formDefault - textModule: @textModule + data: + config: App.Config.all() + user: App.Session.get() ) $('#tags').tokenfield() + @sidebarWidget = new App.TicketCreateSidebar( + el: @sidebar + params: @formDefault + sidebarState: @sidebarState + task_key: @task_key + query: @query + ) + + if @formDefault.customer_id + callback = (customer) => + @localUserInfoCallback(@formDefault, customer) + App.User.full(@formDefault.customer_id, callback) + # update taskbar with new meta data App.TaskManager.touch(@task_key) localUserInfo: (e) => - + return if !@sidebarWidget params = App.ControllerForm.params($(e.target).closest('form')) - new Sidebar( - el: @sidebar - params: params - textModule: @textModule + if params.customer_id + callback = (customer) => + @localUserInfoCallback(params, customer) + App.User.full(params.customer_id, callback) + return + @localUserInfoCallback(params) + + localUserInfoCallback: (params, customer = {}) => + @sidebarWidget.render(params) + @textModule.reload( + config: App.Config.all() + user: App.Session.get() + ticket: + customer: customer ) cancel: (e) -> @@ -475,11 +498,16 @@ class App.TicketCreate extends App.Controller # scroll to top ui.scrollTo() + # add sidebar params + if ui.sidebarWidget + ui.sidebarWidget.commit(ticket_id: @id) + # 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 '#' @@ -494,114 +522,6 @@ class App.TicketCreate extends App.Controller ) ) -class Sidebar extends App.Controller - constructor: -> - super - - # load user - if @params['customer_id'] - App.User.full(@params['customer_id'], @render) - return - - # render ui - @render() - - render: (user) => - - items = [] - if user - - showCustomer = (el) => - # update text module UI - if @textModule - @textModule.reload( - ticket: - customer: user - user: App.Session.get() - ) - - new App.WidgetUser( - el: el - user_id: user.id - ) - - editCustomer = (e, el) => - new App.ControllerGenericEdit( - id: @params.customer_id - genericObject: 'User' - screen: 'edit' - pageData: - title: 'Users' - object: 'User' - objects: 'Users' - container: @el.closest('.content') - ) - items.push { - head: 'Customer' - name: 'customer' - icon: 'person' - actions: [ - { - title: 'Edit Customer' - name: 'Edit Customer' - class: 'glyphicon glyphicon-edit' - callback: editCustomer - }, - ] - callback: showCustomer - } - - if user.organization_id - editOrganization = (e, el) => - new App.ControllerGenericEdit( - id: user.organization_id - genericObject: 'Organization' - pageData: - title: 'Organizations' - object: 'Organization' - objects: 'Organizations' - container: @el.closest('.content') - ) - showOrganization = (el) -> - new App.WidgetOrganization( - el: el - organization_id: user.organization_id - ) - items.push { - head: 'Organization' - name: 'organization' - icon: 'group' - actions: [ - { - title: 'Edit Organization' - name: 'Edit Organization' - class: 'glyphicon glyphicon-edit' - callback: editOrganization - }, - ] - callback: showOrganization - } - - showTemplates = (el) -> - - # show template UI - new App.WidgetTemplate( - el: el - #template_id: template['id'] - ) - - items.push { - head: 'Templates' - name: 'template' - icon: 'templates' - callback: showTemplates - } - - new App.Sidebar( - el: @el - items: items - ) - class Router extends App.ControllerPermanent requiredPermission: 'ticket.agent' constructor: (params) -> @@ -617,6 +537,9 @@ class Router extends App.ControllerPermanent if params.customer_id split = "/customer/#{params.customer_id}" + if params.query + split = "/query/#{params.query}" + id = Math.floor( Math.random() * 99999 ) @navigate "#ticket/create/id/#{id}#{split}" return @@ -627,6 +550,7 @@ class Router extends App.ControllerPermanent article_id: params.article_id type: params.type customer_id: params.customer_id + query: params.query id: params.id App.TaskManager.execute( @@ -642,6 +566,7 @@ App.Config.set('ticket/create/', Router, 'Routes') App.Config.set('ticket/create/id/:id', Router, 'Routes') App.Config.set('ticket/create/customer/:customer_id', Router, 'Routes') App.Config.set('ticket/create/id/:id/customer/:customer_id', Router, 'Routes') +App.Config.set('ticket/create/id/:id/query/:query', Router, 'Routes') # split ticket App.Config.set('ticket/create/:ticket_id/:article_id', Router, 'Routes') diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar.coffee new file mode 100644 index 000000000..88b151449 --- /dev/null +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar.coffee @@ -0,0 +1,43 @@ +class App.TicketCreateSidebar extends App.Controller + constructor: -> + super + @render() + + reload: (args) => + for key, backend of @sidebarBackends + if backend && backend.reload + backend.reload(args) + + commit: (args) => + for key, backend of @sidebarBackends + if backend && backend.commit + backend.commit(args) + + render: (params) => + if params + @params = params + @sidebarBackends ||= {} + @sidebarItems = [] + sidebarBackends = App.Config.get('TicketCreateSidebar') + keys = _.keys(sidebarBackends).sort() + for key in keys + if !@sidebarBackends[key] || !@sidebarBackends[key].reload + @sidebarBackends[key] = new sidebarBackends[key]( + params: @params + query: @query + taskGet: @taskGet + ) + else + @sidebarBackends[key].reload( + params: @params + query: @query + ) + item = @sidebarBackends[key].sidebarItem() + if item + @sidebarItems.push item + + new App.Sidebar( + el: @el + sidebarState: @sidebarState + items: @sidebarItems + ) diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_customer.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_customer.coffee new file mode 100644 index 000000000..cbfd4e7e9 --- /dev/null +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_customer.coffee @@ -0,0 +1,38 @@ +class SidebarCustomer extends App.Controller + sidebarItem: => + return if !@permissionCheck('ticket.agent') + return if !@params.customer_id + { + head: 'Customer' + name: 'customer' + icon: 'person' + actions: [ + { + title: 'Edit Customer' + name: 'customer-edit' + callback: @editCustomer + }, + ] + callback: @showCustomer + } + + showCustomer: (el) => + @el = el + new App.WidgetUser( + el: @el + user_id: @params.customer_id + ) + + editCustomer: => + new App.ControllerGenericEdit( + id: @params.customer_id + genericObject: 'User' + screen: 'edit' + pageData: + title: 'Users' + object: 'User' + objects: 'Users' + container: @el.closest('.content') + ) + +App.Config.set('200-Customer', SidebarCustomer, 'TicketCreateSidebar') diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee new file mode 100644 index 000000000..db2a9239d --- /dev/null +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee @@ -0,0 +1,41 @@ +class SidebarOrganization extends App.Controller + sidebarItem: => + return if !@permissionCheck('ticket.agent') + return if !@params.customer_id + return if !App.User.exists(@params.customer_id) + customer = App.User.find(@params.customer_id) + @organization_id = customer.organization_id + return if !@organization_id + { + head: 'Organization' + name: 'organization' + icon: 'group' + actions: [ + { + title: 'Edit Organization' + name: 'organization-edit' + callback: @editOrganization + }, + ] + callback: @showOrganization + } + + showOrganization: (el) => + @el = el + new App.WidgetOrganization( + el: @el + organization_id: @organization_id + ) + + editOrganization: => + new App.ControllerGenericEdit( + id: @organization_id, + genericObject: 'Organization' + pageData: + title: 'Organizations' + object: 'Organization' + objects: 'Organizations' + container: @el.closest('.content') + ) + +App.Config.set('300-Organization', SidebarOrganization, 'TicketCreateSidebar') diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_template.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_template.coffee new file mode 100644 index 000000000..bb724bc83 --- /dev/null +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_template.coffee @@ -0,0 +1,21 @@ +class SidebarTemplate extends App.Controller + sidebarItem: => + return if !@permissionCheck('ticket.agent') + { + head: 'Templates' + name: 'template' + icon: 'templates' + actions: [] + callback: @showTemplates + } + + showTemplates: (el) => + @el = el + + # show template UI + new App.WidgetTemplate( + el: el + #template_id: template['id'] + ) + +App.Config.set('100-Template', SidebarTemplate, 'TicketCreateSidebar') diff --git a/app/assets/javascripts/app/controllers/agent_ticket_merge.coffee b/app/assets/javascripts/app/controllers/agent_ticket_merge.coffee index aad33ceb4..d71087198 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_merge.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_merge.coffee @@ -98,9 +98,13 @@ class App.TicketMerge extends App.ControllerModal type: 'error' msg: App.i18n.translateContent(data['message']) timeout: 6000 - @formEnable(e) - error: => + error: (data) => + details = data.responseJSON || {} + @notify + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to merge!') + timeout: 6000 @formEnable(e) ) diff --git a/app/assets/javascripts/app/controllers/chat.coffee b/app/assets/javascripts/app/controllers/chat.coffee index f8e969e86..6023b6a8a 100644 --- a/app/assets/javascripts/app/controllers/chat.coffee +++ b/app/assets/javascripts/app/controllers/chat.coffee @@ -462,6 +462,14 @@ class ChatWindow extends App.Controller ) ) + # show text module UI + new App.WidgetTextModule( + el: @input + data: + user: App.Session.get() + config: App.Config.all() + ) + focus: => @input.focus() @@ -473,7 +481,7 @@ class ChatWindow extends App.Controller if event.data and event.data.callback event.data.callback() - @$('.js-customerChatInput').ce({ + @input.ce({ mode: 'richtext' multiline: true maxlength: 40000 @@ -522,7 +530,7 @@ class ChatWindow extends App.Controller switch event.keyCode when TABKEY - allChatInputs = $('.js-customerChatInput').not('[disabled="disabled"]') + allChatInputs = @input.not('[disabled="disabled"]') chatCount = allChatInputs.size() index = allChatInputs.index(@input) @@ -542,7 +550,7 @@ class ChatWindow extends App.Controller allChatInputs.eq(chatCount-1).focus() when ENTERKEY - if !event.shiftKey + if !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey event.preventDefault() @sendMessage() @@ -587,7 +595,7 @@ class ChatWindow extends App.Controller @sounds.message.play() @notifyDesktop( title: @name - body: message + body: App.Utils.html2text(message) url: '#customer_chat' callback: => App.Event.trigger('chat_focus', { session_id: @session.session_id }) 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/controllers/idoit_object_selector.coffee b/app/assets/javascripts/app/controllers/idoit_object_selector.coffee new file mode 100644 index 000000000..d2b7f2065 --- /dev/null +++ b/app/assets/javascripts/app/controllers/idoit_object_selector.coffee @@ -0,0 +1,100 @@ +class App.IdoitObjectSelector extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: true + head: 'i-doit' + + content: -> + @ajax( + id: 'idoit-object-selector' + type: 'POST' + url: "#{@apiPath}/integration/idoit" + data: JSON.stringify(method: 'cmdb.object_types') + success: (data, status, xhr) => + if data.result is 'failed' + @contentInline = data.message + @render() + return + + result = _.sortBy(data.response.result, 'title') + @contentInline = $(App.view('integration/idoit_object_selector')()) + + @contentInline.find('.js-typeSelect').html(@renderTypeSelector(result)) + + @contentInline.filter('.js-search').on('change', 'select, input', (e) => + params = @formParam(e.target) + @search(params) + ) + @contentInline.filter('.js-search').on('keyup', 'input', (e) => + params = @formParam(e.target) + @search(params) + ) + @render() + @$('.js-input').focus() + + error: (xhr, status, error) => + + # do not close window if request is aborted + return if status is 'abort' + + # show error message + @contentInline = 'Unable to load content' + @render() + ) + '' + + search: (filter) => + if _.isEmpty(filter.title) + delete filter.title + else + filter.title = "%#{filter.title}%" + @ajax( + id: 'idoit-object-selector' + type: 'POST' + url: "#{@apiPath}/integration/idoit" + data: JSON.stringify(method: 'cmdb.objects', filter: filter) + success: (data, status, xhr) => + @renderResult(data.response.result) + + error: (xhr, status, error) => + + # do not close window if request is aborted + return if status is 'abort' + + # show error message + @contentInline = 'Unable to load content' + @render() + ) + + renderResult: (items) => + table = App.view('integration/idoit_object_result')( + items: items + ) + @el.find('.js-result').html(table) + + renderTypeSelector: (result) -> + options = {} + for item in result + options[item.id] = item.title + return App.UiElement.searchable_select.render( + name: 'type' + multiple: false + limit: 100 + null: false + nulloption: false + options: options + ) + + onSubmit: (e) => + form = @el.find('.js-result') + params = @formParam(form) + return if _.isEmpty(params.object_id) + + if _.isArray(params.object_id) + object_ids = params.object_id + else + object_ids = [params.object_id] + + @formDisable(form) + @callback(object_ids, @) + diff --git a/app/assets/javascripts/app/controllers/import_zendesk.coffee b/app/assets/javascripts/app/controllers/import_zendesk.coffee index 04ffeee3b..1858adde1 100644 --- a/app/assets/javascripts/app/controllers/import_zendesk.coffee +++ b/app/assets/javascripts/app/controllers/import_zendesk.coffee @@ -10,6 +10,7 @@ class Index extends App.ControllerContent '.zendesk-api-token-error': 'apiTokenErrorMessage' '#zendesk-email': 'zendeskEmail' '#zendesk-api-token': 'zendeskApiToken' + '.js-ticket-count-info': 'ticketCountInfo' updateMigrationDisplayLoop: 0 events: @@ -116,7 +117,8 @@ class Index extends App.ControllerContent showCredentials: (e) => e.preventDefault() @urlStatus.attr('data-state', '') - @zendeskUrlApiToken.attr('href', @zendeskUrl.val() + 'agent/admin/api') + url = @zendeskUrl.val() + '/agent/admin/api' + @zendeskUrlApiToken.attr('href', url.replace(/([^:])\/\/+/g, '$1/')) @zendeskUrlApiToken.val('HERE') @$('[data-slide=zendesk-url]').toggleClass('hide') @$('[data-slide=zendesk-credentials]').toggleClass('hide') @@ -171,6 +173,10 @@ class Index extends App.ControllerContent for key, item of data.data if item.done > item.total item.done = item.total + + if key == 'Ticket' && item.total >= 1000 + @ticketCountInfo.removeClass('hide') + element = @$('.js-' + key.toLowerCase() ) element.find('.js-done').text(item.done) element.find('.js-total').text(item.total) diff --git a/app/assets/javascripts/app/controllers/layout_ref.coffee b/app/assets/javascripts/app/controllers/layout_ref.coffee index 1ae661cfb..f4dc268e7 100644 --- a/app/assets/javascripts/app/controllers/layout_ref.coffee +++ b/app/assets/javascripts/app/controllers/layout_ref.coffee @@ -1499,7 +1499,7 @@ class InputsRef extends App.ControllerContent null: false relation: 'User' autocapitalize: false - disableCreateUser: true + disableCreateObject: true multiple: true @$('.userOrganizationAutocompletePlaceholder').replaceWith( userOrganizationAutocomplete.element() ) diff --git a/app/assets/javascripts/app/controllers/login.coffee b/app/assets/javascripts/app/controllers/login.coffee index 322a518c7..6e7f608d3 100644 --- a/app/assets/javascripts/app/controllers/login.coffee +++ b/app/assets/javascripts/app/controllers/login.coffee @@ -38,50 +38,7 @@ class Index extends App.ControllerContent ) render: (data = {}) -> - auth_provider_all = { - facebook: { - url: '/auth/facebook', - name: 'Facebook', - config: 'auth_facebook', - class: 'facebook' - }, - twitter: { - url: '/auth/twitter' - name: 'Twitter' - config: 'auth_twitter' - class: 'twitter' - }, - linkedin: { - url: '/auth/linkedin' - name: 'LinkedIn' - config: 'auth_linkedin' - class: 'linkedin' - }, - github: { - url: '/auth/github' - name: 'GitHub' - config: 'auth_github' - class: 'github' - }, - gitlab: { - url: '/auth/gitlab' - name: 'GitLab' - config: 'auth_gitlab' - class: 'gitlab' - }, - google_oauth2: { - url: '/auth/google_oauth2' - name: 'Google' - config: 'auth_google_oauth2' - class: 'google' - }, - oauth2: { - url: '/auth/oauth2' - name: 'OAuth2' - config: 'auth_oauth2' - class: 'oauth2' - }, - } + auth_provider_all = App.Config.get('auth_provider_all') auth_providers = [] for key, provider of auth_provider_all if @Config.get(provider.config) is true || @Config.get(provider.config) is 'true' 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/controllers/ticket_customer.coffee b/app/assets/javascripts/app/controllers/ticket_customer.coffee index e23ea954c..edb5df0c0 100644 --- a/app/assets/javascripts/app/controllers/ticket_customer.coffee +++ b/app/assets/javascripts/app/controllers/ticket_customer.coffee @@ -6,7 +6,7 @@ class App.TicketCustomer extends App.ControllerModal content: -> configure_attributes = [ - { name: 'customer_id', display: 'Customer', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateUser: true }, + { name: 'customer_id', display: 'Customer', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateObject: true }, ] controller = new App.ControllerForm( model: diff --git a/app/assets/javascripts/app/controllers/ticket_overview.coffee b/app/assets/javascripts/app/controllers/ticket_overview.coffee index 66c5ec1ca..39f1c4e9a 100644 --- a/app/assets/javascripts/app/controllers/ticket_overview.coffee +++ b/app/assets/javascripts/app/controllers/ticket_overview.coffee @@ -221,7 +221,7 @@ class App.TicketOverview extends App.Controller if @batchCountIndex == @batchCount App.Event.trigger('overview:fetch') ) - return + return if action is 'group_assign' @batchCount = items.length diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.coffee index d523501f2..094a15f30 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.coffee @@ -401,10 +401,11 @@ class App.TicketZoom extends App.Controller nav: @nav isCustomer: @permissionCheck('ticket.customer') scrollbarWidth: App.Utils.getScrollBarWidth() + dir: App.i18n.dir() ) new App.TicketZoomOverviewNavigator( - el: elLocal.find('.overview-navigator') + el: elLocal.find('.js-overviewNavigatorContainer') ticket_id: @ticket_id overview_id: @overview_id ) @@ -412,13 +413,13 @@ class App.TicketZoom extends App.Controller new App.TicketZoomTitle( object_id: @ticket_id overview_id: @overview_id - el: elLocal.find('.ticket-title') + el: elLocal.find('.js-ticketTitleContainer') task_key: @task_key ) new App.TicketZoomMeta( object_id: @ticket_id - el: elLocal.find('.ticket-meta') + el: elLocal.find('.js-ticketMetaContainer') ) @attributeBar = new App.TicketZoomAttributeBar( @@ -445,7 +446,12 @@ class App.TicketZoom extends App.Controller ) @highligher = new App.TicketZoomHighlighter( - el: elLocal.find('.highlighter') + el: elLocal.find('.js-highlighterContainer') + ticket_id: @ticket_id + ) + + new App.TicketZoomSetting( + el: elLocal.find('.js-settingContainer') ticket_id: @ticket_id ) @@ -467,6 +473,7 @@ class App.TicketZoom extends App.Controller sidebarState: @sidebarState object_id: @ticket_id model: 'Ticket' + query: @query taskGet: @taskGet task_key: @task_key formMeta: @formMeta @@ -557,14 +564,16 @@ class App.TicketZoom extends App.Controller return if !@ticket currentStoreTicket = @ticket.attributes() delete currentStoreTicket.article + internal = @Config.get('ui_ticket_zoom_article_note_new_internal') currentStore = ticket: currentStoreTicket article: to: '' cc: '' + subject: '' type: 'note' body: '' - internal: 'true' + internal: internal in_reply_to: '' if @permissionCheck('ticket.customer') @@ -575,7 +584,7 @@ class App.TicketZoom extends App.Controller formCurrent: => currentParams = ticket: @formParam(@el.find('.edit')) - article: @formParam(@el.find('.article-add')) + article: @articleNew.params() # add attachments if exist attachmentCount = @$('.article-add .textBubble .attachments .attachment').length @@ -684,7 +693,7 @@ class App.TicketZoom extends App.Controller tagAdd: (tag) => return if !@sidebar return if !@sidebar.reload - @sidebar.reload(tagAdd: tag) + @sidebar.reload(tagAdd: tag, source: 'macro') tagRemove: (tag) => return if !@sidebar return if !@sidebar.reload @@ -789,6 +798,9 @@ class App.TicketZoom extends App.Controller # reset form after save @reset() + if @sidebar + @sidebar.commit() + if taskAction is 'closeNextInOverview' if @overview_id current_position = 0 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 c1d50f842..cfa5a0ce4 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee @@ -391,25 +391,41 @@ class App.TicketZoomArticleActions extends App.Controller body = @el.closest('.ticketZoom').find('.article-add [data-name="body"]').html() || '' # check if quote need to be added - selectedText = App.ClipBoard.getSelected() - if selectedText + signaturePosition = 'bottom' + selected = App.ClipBoard.getSelected('html') + if selected + selected = App.Utils.htmlCleanup(selected).html() + if !selected + selected = App.ClipBoard.getSelected('text') + if selected + selected = App.Utils.textCleanup(selected) + selected = App.Utils.text2html(selected) - # clean selection - selectedText = App.Utils.textCleanup(selectedText) + # full quote, if needed + if !selected && article && App.Config.get('ui_ticket_zoom_article_email_full_quote') + signaturePosition = 'top' + if article.content_type.match('html') + selected = App.Utils.textCleanup(article.body) + if article.content_type.match('plain') + selected = App.Utils.textCleanup(article.body) + selected = App.Utils.text2html(selected) - # convert to html - selectedText = App.Utils.text2html(selectedText) - if selectedText - selectedText = "


#{selectedText}

" + if selected + selected = "


#{selected}

" - # add selected text to body - body = selectedText + body + # add selected text to body + body = selected + body articleNew.body = body type = App.TicketArticleType.findByAttribute(name:'email') - App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew } ) + App.Event.trigger('ui::ticket::setArticleType', { + ticket: @ticket + type: type + article: articleNew + signaturePosition: signaturePosition + }) telegramPersonalMessageReply: (e) => e.preventDefault() diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee index 0782d2f3f..edb39480d 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee @@ -28,7 +28,62 @@ class App.TicketZoomArticleNew extends App.Controller constructor: -> super - # set possble article types + @internalSelector = true + @type = @defaults['type'] || 'note' + @setPossibleArticleTypes() + + if @permissionCheck('ticket.customer') + @internalSelector = false + + @textareaHeight = + open: 148 + closed: 20 + + @dragEventCounter = 0 + @attachments = [] + + @render() + + if @defaults.body or @isIE10() + @openTextarea(null, true) + + # set article type and expand text area + @bind('ui::ticket::setArticleType', (data) => + return if data.ticket.id.toString() isnt @ticket_id.toString() + + @openTextarea(null, true) + for key, value of data.article + if key is 'body' + @$('[data-name="' + key + '"]').html(value) + else + @$('[name="' + key + '"]').val(value).trigger('change') + + # preselect article type + @setArticleType(data.type.name, data.signaturePosition) + + # set focus at end of field + if data.position is 'end' + @placeCaretAtEnd(@textarea.get(0)) + return + + # set focus into field + @textarea.focus() + ) + + # reset new article screen + @bind('ui::ticket::taskReset', (data) => + return if data.ticket_id.toString() isnt @ticket_id.toString() + @type = 'note' + @defaults = {} + @render() + ) + + # rerender, e. g. on language change + @bind('ui:rerender', => + @render() + ) + + setPossibleArticleTypes: => possibleArticleType = note: true phone: true @@ -50,12 +105,9 @@ class App.TicketZoomArticleNew extends App.Controller possibleArticleType['email'] = true # gets referenced in @setArticleType - @internalSelector = true - @type = @defaults['type'] || 'note' @articleTypes = [] if possibleArticleType.note - internal = @Config.get('ui_ticket_zoom_article_new_internal') - + internal = @Config.get('ui_ticket_zoom_article_note_new_internal') @articleTypes.push { name: 'note' icon: 'note' @@ -64,10 +116,13 @@ class App.TicketZoomArticleNew extends App.Controller features: ['attachment'] } if possibleArticleType.email + attributes = ['to', 'cc', 'subject'] + if !@Config.get('ui_ticket_zoom_article_email_subject') + attributes = ['to', 'cc'] @articleTypes.push { name: 'email' icon: 'email' - attributes: ['to', 'cc'] + attributes: attributes internal: false, features: ['attachment'] } @@ -80,22 +135,28 @@ class App.TicketZoomArticleNew extends App.Controller features: [] } if possibleArticleType['twitter status'] + attributes = ['body:limit', 'body:initials'] + if !@Config.get('ui_ticket_zoom_article_twitter_initials') + attributes = ['body:limit'] @articleTypes.push { name: 'twitter status' icon: 'twitter' attributes: [] internal: false, - features: ['body:limit'] + features: ['body:limit', 'body:initials'] maxTextLength: 140 warningTextLength: 30 } if possibleArticleType['twitter direct-message'] + attributes = ['body:limit', 'body:initials'] + if !@Config.get('ui_ticket_zoom_article_twitter_initials') + attributes = ['body:limit'] @articleTypes.push { name: 'twitter direct-message' icon: 'twitter' attributes: ['to'] internal: false, - features: ['body:limit'] + features: ['body:limit', 'body:initials'] maxTextLength: 10000 warningTextLength: 500 } @@ -130,57 +191,6 @@ class App.TicketZoomArticleNew extends App.Controller }, ] - if @permissionCheck('ticket.customer') - @internalSelector = false - - @textareaHeight = - open: 148 - closed: 20 - - @dragEventCounter = 0 - @attachments = [] - - @render() - - if @defaults.body or @isIE10() - @openTextarea(null, true) - - # set article type and expand text area - @bind('ui::ticket::setArticleType', (data) => - return if data.ticket.id.toString() isnt @ticket_id.toString() - - @openTextarea(null, true) - for key, value of data.article - if key is 'body' - @$('[data-name="' + key + '"]').html(value) - else - @$('[name="' + key + '"]').val(value).trigger('change') - - # preselect article type - @setArticleType(data.type.name) - - # set focus at end of field - if data.position is 'end' - @placeCaretAtEnd(@textarea.get(0)) - return - - # set focus into field - @textarea.focus() - ) - - # reset new article screen - @bind('ui::ticket::taskReset', (data) => - return if data.ticket_id.toString() isnt @ticket_id.toString() - @type = 'note' - @defaults = {} - @render() - ) - - # rerender, e. g. on language change - @bind('ui:rerender', => - @render() - ) - placeCaretAtEnd: (el) -> el.focus() if typeof window.getSelection isnt 'undefined' && typeof document.createRange isnt 'undefined' @@ -229,7 +239,7 @@ class App.TicketZoomArticleNew extends App.Controller ) configure_attributes = [ - { name: 'customer_id', display: 'Recipients', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateUser: false }, + { name: 'customer_id', display: 'Recipients', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateObject: false }, ] controller = new App.ControllerForm( @@ -300,6 +310,7 @@ class App.TicketZoomArticleNew extends App.Controller data: ticket: ticket user: App.Session.get() + config: App.Config.all() ) callback = (ticket) -> textModule.reload( @@ -318,9 +329,6 @@ class App.TicketZoomArticleNew extends App.Controller params.form_id = @form_id params.content_type = 'text/html' - if !params['internal'] - params['internal'] = false - if @permissionCheck('ticket.customer') sender = App.TicketArticleSender.findByAttribute('name', 'Customer') type = App.TicketArticleType.findByAttribute('name', 'web') @@ -332,15 +340,20 @@ class App.TicketZoomArticleNew extends App.Controller params.sender_id = sender.id params.type_id = type.id + if params.internal + params.internal = true + else + params.internal = false + if params.type is 'twitter status' App.Utils.htmlRemoveRichtext(@$('[data-name=body]'), false) params.content_type = 'text/plain' - params.body = "#{App.Utils.html2text(params.body, true)}\n#{@signature.text()}" + params.body = App.Utils.html2text(params.body, true) if params.type is 'twitter direct-message' App.Utils.htmlRemoveRichtext(@$('[data-name=body]'), false) params.content_type = 'text/plain' - params.body = "#{App.Utils.html2text(params.body, true)}\n#{@signature.text()}" + params.body = App.Utils.html2text(params.body, true) if params.type is 'facebook feed comment' App.Utils.htmlRemoveRichtext(@$('[data-name=body]'), false) @@ -352,6 +365,16 @@ class App.TicketZoomArticleNew extends App.Controller params.content_type = 'text/plain' params.body = App.Utils.html2text(params.body, true) + # add initals? + for articleType in @articleTypes + if articleType.name is @type + if _.contains(articleType.features, 'body:initials') + if params.content_type is 'text/html' + params.body = "#{params.body}
#{@signature.text()}" + else + params.body = "#{params.body}\n#{@signature.text()}" + break + params validate: => @@ -411,11 +434,11 @@ class App.TicketZoomArticleNew extends App.Controller return false if params.type is 'twitter status' - textLength = @maxTextLength - params.body.length + textLength = @maxTextLength - App.Utils.textLengthWithUrl(params.body) return false if textLength < 0 if params.type is 'twitter direct-message' - textLength = @maxTextLength - params.body.length + textLength = @maxTextLength - App.Utils.textLengthWithUrl(params.body) return false if textLength < 0 true @@ -461,13 +484,15 @@ class App.TicketZoomArticleNew extends App.Controller @$('[name=internal]').val('') - setArticleType: (type) => + setArticleType: (type, signaturePosition = 'bottom') => wasScrolledToBottom = @isScrolledToBottom() @type = type @$('[name=type]').val(type).trigger('change') @articleNewEdit.attr('data-type', type) @$('.js-selectableTypes').addClass('hide').filter("[data-type='#{type}']").removeClass('hide') + @setPossibleArticleTypes() + # get config config = {} for articleTypeConfig in @articleTypes @@ -500,7 +525,7 @@ class App.TicketZoomArticleNew extends App.Controller @$('[data-name=body] [data-signature="true"]').remove() # apply new signature - signatureFinished = App.Utils.replaceTags(signature.body, { user: App.Session.get(), ticket: ticketCurrent }) + signatureFinished = App.Utils.replaceTags(signature.body, { user: App.Session.get(), ticket: ticketCurrent, config: App.Config.all() }) body = @$('[data-name=body]') if App.Utils.signatureCheck(body.html() || '', signatureFinished) @@ -508,7 +533,10 @@ class App.TicketZoomArticleNew extends App.Controller body.append('

') signature = $("
#{signatureFinished}
") App.Utils.htmlStrip(signature) - body.append(signature) + if signaturePosition is 'top' + body.prepend(signature) + else + body.append(signature) @$('[data-name=body]').replaceWith(body) # remove old signature @@ -534,13 +562,28 @@ class App.TicketZoomArticleNew extends App.Controller for name in articleType.features if name is 'attachment' @$('.article-attachment, .attachments').removeClass('hide') + if name is 'body:initials' + @updateInitials() if name is 'body:limit' @maxTextLength = articleType.maxTextLength @warningTextLength = articleType.warningTextLength @delay(@updateLetterCount, 600) - @updateInitials() @$('.js-textSizeLimit').removeClass('hide') + # convert remote src images to data uri + @$('[data-name=body] img').each( (i,image) -> + $image = $(image) + src = $image.attr('src') + if !_.isEmpty(src) && !src.match(/^data:image/i) + canvas = document.createElement('canvas') + canvas.width = image.width + canvas.height = image.height + ctx = canvas.getContext('2d') + ctx.drawImage(image, 0, 0) + dataURL = canvas.toDataURL() + $image.attr('src', dataURL) + ) + @scrollToBottom() if wasScrolledToBottom isScrolledToBottom: -> @@ -557,7 +600,8 @@ class App.TicketZoomArticleNew extends App.Controller return if !@maxTextLength return if !@warningTextLength params = @params() - textLength = @maxTextLength - params.body.length + textLength = App.Utils.textLengthWithUrl(params.body) + textLength = @maxTextLength - textLength className = switch when textLength < 0 then 'label-danger' when textLength < @warningTextLength then 'label-warning' diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/higlighter.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/highlighter.coffee similarity index 100% rename from app/assets/javascripts/app/controllers/ticket_zoom/higlighter.coffee rename to app/assets/javascripts/app/controllers/ticket_zoom/highlighter.coffee diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/setting.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/setting.coffee new file mode 100644 index 000000000..1103f9e3c --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/setting.coffee @@ -0,0 +1,35 @@ +class App.TicketZoomSetting extends App.Controller + events: + 'click .js-setting': 'show' + + constructor: -> + super + return if !@permissionCheck('admin') + @render() + + render: -> + @html(App.view('ticket_zoom/setting')()) + + show: -> + new Modal() + +class Modal extends App.ControllerModal + buttonClose: true + buttonCancel: true + buttonSubmit: false + head: 'Settings' + + constructor: -> + super + + render: => + super + + post: => + new App.SettingsArea( + area: 'UI::TicketZoom' + el: @el.find('.modal-body') + ) + + content: -> + App.view('generic/page_loading')() diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee index 874922998..1eb4d68ea 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee @@ -9,18 +9,32 @@ class App.TicketZoomSidebar extends App.ObserverController if backend && backend.reload backend.reload(args) + commit: (args) => + for key, backend of @sidebarBackends + if backend && backend.commit + backend.commit(args) + render: (ticket) => - @sidebarBackends = {} + @sidebarBackends ||= {} @sidebarItems = [] sidebarBackends = App.Config.get('TicketZoomSidebar') keys = _.keys(sidebarBackends).sort() for key in keys - @sidebarBackends[key] = new sidebarBackends[key]( - ticket: ticket - taskGet: @taskGet - formMeta: @formMeta - markForm: @markForm - ) + if !@sidebarBackends[key] || !@sidebarBackends[key].reload + @sidebarBackends[key] = new sidebarBackends[key]( + ticket: ticket + query: @query + taskGet: @taskGet + formMeta: @formMeta + markForm: @markForm + ) + else + @sidebarBackends[key].reload( + params: @params + query: @query + formMeta: @formMeta + markForm: @markForm + ) item = @sidebarBackends[key].sidebarItem() if item @sidebarItems.push item diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee index 701a151b6..92bfd8c65 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee @@ -1,7 +1,7 @@ class SidebarCustomer extends App.Controller sidebarItem: => return if !@permissionCheck('ticket.agent') - { + items = { head: 'Customer' name: 'customer' icon: 'person' @@ -11,14 +11,16 @@ class SidebarCustomer extends App.Controller name: 'customer-change' callback: @changeCustomer }, - { - title: 'Edit Customer' - name: 'customer-edit' - callback: @editCustomer - }, ] callback: @showCustomer } + return items if @ticket && @ticket.customer_id == 1 + items.actions.push { + title: 'Edit Customer' + name: 'customer-edit' + callback: @editCustomer + } + items showCustomer: (el) => @el = el diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_idoit.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_idoit.coffee new file mode 100644 index 000000000..5ebce7924 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_idoit.coffee @@ -0,0 +1,132 @@ +class SidebarIdoit extends App.Controller + sidebarItem: => + return if !@Config.get('idoit_integration') + { + head: 'i-doit' + name: 'idoit' + icon: 'printer' + actions: [ + { + title: 'Change Objects' + name: 'objects-change' + callback: @changeObjects + }, + ] + callback: @showObjects + } + + changeObjects: => + new App.IdoitObjectSelector( + task_key: @task_key + container: @el.closest('.content') + callback: (objectIds, objectSelectorUi) => + if @ticket && @ticket.id + @updateTicket(@ticket.id, objectIds, => + objectSelectorUi.close() + @showObjectsContent(objectIds) + ) + return + objectSelectorUi.close() + @showObjectsContent(objectIds) + ) + + showObjects: (el) => + @el = el + + # show placeholder + @objectIds ||= [] + if @ticket && @ticket.preferences && @ticket.preferences.idoit && @ticket.preferences.idoit.object_ids + @objectIds = @ticket.preferences.idoit.object_ids + queryParams = @queryParam() + if queryParams && queryParams.idoit_object_ids + @objectIds.push queryParams.idoit_object_ids + @showObjectsContent() + + showObjectsContent: (objectIds) => + if objectIds + @objectIds = @objectIds.concat(objectIds) + + # show placeholder + if _.isEmpty(@objectIds) + @html("
#{App.i18n.translateInline('none')}
") + return + + # ajax call to show items + @ajax( + id: "idoit-#{@task_key}" + type: 'POST' + url: "#{@apiPath}/integration/idoit" + data: JSON.stringify(method: 'cmdb.objects', filter: ids: @objectIds) + success: (data, status, xhr) => + if data.response + @showList(data.response.result) + return + @showError('Unable to load data...') + + error: (xhr, status, error) => + + # do not close window if request is aborted + return if status is 'abort' + + # show error message + @showError('Unable to load data...') + ) + + showList: (objects) => + list = $(App.view('ticket_zoom/sidebar_idoit')( + objects: objects + )) + list.delegate('.js-delete', 'click', (e) => + e.preventDefault() + objectId = $(e.currentTarget).attr 'data-object-id' + @delete(objectId) + ) + @html(list) + + showError: (message) => + @html App.i18n.translateInline(message) + + delete: (objectId) => + localObjects = [] + for localObjectId in @objectIds + if objectId.toString() isnt localObjectId.toString() + localObjects.push localObjectId + @objectIds = localObjects + if @ticket && @ticket.id + @updateTicket(@ticket.id, @objectIds) + @showObjectsContent() + + commit: (args) => + return if @ticket && @ticket.id + return if !@objectIds + return if _.isEmpty(@objectIds) + return if !args + return if !args.ticket_id + @updateTicket(args.ticket_id, @objectIds) + + updateTicket: (ticket_id, objectIds, callback) => + App.Ajax.request( + id: "idoit-update-#{ticket_id}" + type: 'POST' + url: "#{@apiPath}/integration/idoit_ticket_update" + data: JSON.stringify(ticket_id: ticket_id, object_ids: objectIds) + success: (data, status, xhr) -> + if callback + callback(objectIds) + + error: (xhr, status, details) => + + # do not close window if request is aborted + return if status is 'abort' + + # show error message + @log 'errors', details + @notify( + type: 'error' + msg: App.i18n.translateContent(details.error_human || details.error || 'Unable to update object!') + timeout: 6000 + ) + ) + +App.Config.set('500-Idoit', SidebarIdoit, 'TicketCreateSidebar') +App.Config.set('500-Idoit', SidebarIdoit, 'TicketZoomSidebar') 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' diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee index 52ea11299..cb656f976 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee @@ -69,7 +69,7 @@ class SidebarTicket extends App.Controller if args.tags @tagWidget.reload(args.tags) if args.tagAdd - @tagWidget.add(args.tagAdd) + @tagWidget.add(args.tagAdd, args.source) if args.tagRemove @tagWidget.remove(args.tagRemove) diff --git a/app/assets/javascripts/app/controllers/widget/avatar.coffee b/app/assets/javascripts/app/controllers/widget/avatar.coffee index c2ce3e055..e6bc84d35 100644 --- a/app/assets/javascripts/app/controllers/widget/avatar.coffee +++ b/app/assets/javascripts/app/controllers/widget/avatar.coffee @@ -7,6 +7,12 @@ class App.WidgetAvatar extends App.ObserverController email: true image: true vip: true + out_of_office: true, + out_of_office_start_at: true, + out_of_office_end_at: true, + out_of_office_replacement_id: true, + active: true + globalRerender: false render: (user) => diff --git a/app/assets/javascripts/app/controllers/widget/dev_banner.coffee b/app/assets/javascripts/app/controllers/widget/dev_banner.coffee index a8f7f5c9e..28d534391 100644 --- a/app/assets/javascripts/app/controllers/widget/dev_banner.coffee +++ b/app/assets/javascripts/app/controllers/widget/dev_banner.coffee @@ -5,10 +5,10 @@ class Widget banner = """ | | Welcome Zammad Developer! -| You can enable debugging by the following examples (value is a regex): +| You can enable debugging with the following examples (value is a regex): | | App.Log.config('module', '(websocket|delay|interval)') // enable debugging for websocket, delay and interval class -| App.Log.config('content', 'send') // enable debugging for messages which contains the string 'send' +| App.Log.config('content', 'send') // enable debugging for messages which contain the string 'send' | App.Log.config('banner', false) // disable this banner | | App.Log.config() // current settings diff --git a/app/assets/javascripts/app/controllers/widget/hello_banner.coffee b/app/assets/javascripts/app/controllers/widget/hello_banner.coffee index 966d81055..9c0d9e8fa 100644 --- a/app/assets/javascripts/app/controllers/widget/hello_banner.coffee +++ b/app/assets/javascripts/app/controllers/widget/hello_banner.coffee @@ -10,9 +10,9 @@ class Widget | | Hi there, nice to meet you! | -| Visit %chttps://zammad.org/participate%c and let's make Zammad better. +| Visit %chttp://zammad.com/jobs%c to learn about our current job openings. | -| The Zammad Team. +| Your Zammad Team | """ console.log(banner, 'text-decoration: underline;', 'text-decoration: none;') diff --git a/app/assets/javascripts/app/controllers/widget/http_log.coffee b/app/assets/javascripts/app/controllers/widget/http_log.coffee index 4a625b3bd..43ab07baf 100644 --- a/app/assets/javascripts/app/controllers/widget/http_log.coffee +++ b/app/assets/javascripts/app/controllers/widget/http_log.coffee @@ -25,6 +25,7 @@ class App.HttpLog extends App.Controller render: => @html App.view('widget/http_log')( records: @records + description: @description ) show: (e) => diff --git a/app/assets/javascripts/app/controllers/widget/script_snipped.coffee b/app/assets/javascripts/app/controllers/widget/script_snipped.coffee new file mode 100644 index 000000000..ec2fb1d6e --- /dev/null +++ b/app/assets/javascripts/app/controllers/widget/script_snipped.coffee @@ -0,0 +1,24 @@ +class App.ScriptSnipped extends App.Controller + #events: + # 'click .js-record': 'show' + + elements: + '.js-code': 'code' + + + constructor: -> + super + #@fetch() + @records = [] + @render() + + render: => + @html App.view('widget/script_snipped')( + records: @records + description: @description + style: @style + content: @content + ) + + @code.each (i, block) -> + hljs.highlightBlock block \ No newline at end of file diff --git a/app/assets/javascripts/app/controllers/widget/tag.coffee b/app/assets/javascripts/app/controllers/widget/tag.coffee index f148746fa..ee0f4b455 100644 --- a/app/assets/javascripts/app/controllers/widget/tag.coffee +++ b/app/assets/javascripts/app/controllers/widget/tag.coffee @@ -86,16 +86,16 @@ class App.WidgetTag extends App.Controller return @add(item) - add: (items) => + add: (items, source = '') => for item in items.split(',') item = item.trim() - @addItem(item) + @addItem(item, source) - addItem: (item) => + addItem: (item, source = '') => if _.contains(@localTags, item) @render() return - return if App.Config.get('tag_new') is false && !@possibleTags[item] + return if source != 'macro' && App.Config.get('tag_new') is false && !@possibleTags[item] @localTags.push item @render() App[@object_type].tagAdd(@object.id, item) diff --git a/app/assets/javascripts/app/controllers/widget/text_module.coffee b/app/assets/javascripts/app/controllers/widget/text_module.coffee index b4027bfeb..09dc5468c 100644 --- a/app/assets/javascripts/app/controllers/widget/text_module.coffee +++ b/app/assets/javascripts/app/controllers/widget/text_module.coffee @@ -8,7 +8,7 @@ class App.WidgetTextModule extends App.Controller # remember instances @bindElements = [] if @selector - @bindElements = @$( @selector ).textmodule() + @bindElements = @$(@selector).textmodule() else if @el.attr('contenteditable') @bindElements = @el.textmodule() diff --git a/app/assets/javascripts/app/lib/app_init/queue_manager.coffee b/app/assets/javascripts/app/lib/app_init/queue_manager.coffee new file mode 100644 index 000000000..e560550f0 --- /dev/null +++ b/app/assets/javascripts/app/lib/app_init/queue_manager.coffee @@ -0,0 +1,57 @@ +class App.QueueManager + _instance = undefined + + @init: -> + _instance ?= new _queueSingleton + + @add: (key, data) -> + if _instance == undefined + _instance ?= new _queueSingleton + _instance.add(key, data) + + @pull: (key) -> + if _instance == undefined + _instance ?= new _queueSingleton + _instance.pull(key) + + @all: (key) -> + if _instance == undefined + _instance ?= new _queueSingleton + _instance.all(key) + + @run: (key, callback) -> + if _instance == undefined + _instance ?= new _queueSingleton + _instance.run(key, callback) + +class _queueSingleton + constructor: -> + @queues = {} + @queueRunning = {} + + add: (key, data) -> + if !@queues[key] + @queues[key] = [] + @queues[key].push data + true + + pull: (key) -> + return if !@queues[key] + @queues[key].shift() + + all: (key) -> + @queues[key] + + run: (key, callback) -> + return if !@queues[key] + return if @queueRunning[key] + localQueue = @queues[key] + return if _.isEmpty(localQueue) + @queueRunning[key] = true + loop + callback = localQueue.shift() + callback() + if !localQueue[0] + @queueRunning[key] = false + break + true diff --git a/app/assets/javascripts/app/lib/app_post/_collection_base.coffee b/app/assets/javascripts/app/lib/app_post/_collection_base.coffee index 4fa13b67d..d031dfa11 100644 --- a/app/assets/javascripts/app/lib/app_post/_collection_base.coffee +++ b/app/assets/javascripts/app/lib/app_post/_collection_base.coffee @@ -5,9 +5,9 @@ class App._CollectionSingletonBase constructor: -> @callbacks = {} @counter = 0 - + @key = "collection-#{@event}" # read from cache - cache = App.SessionStorage.get("collection-#{@event}") + cache = App.SessionStorage.get(@key) if cache @set(cache) @@ -73,6 +73,9 @@ class App._CollectionSingletonBase callback: (data) => for counter, attr of @callbacks - attr.callback(data) - if attr.one - delete @callbacks[counter] + callback = -> + attr.callback(data) + if attr.one + delete @callbacks[counter] + App.QueueManager.add(@key, callback) + App.QueueManager.run(@key) 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..011dc2202 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 @@ -113,6 +113,10 @@ class App.ObjectOrganizationAutocompletion extends App.Controller @createToken name, objectId else if object.email + + # quote name for special character + if name.match(/\@|,|;|\^|\+|#|§|\$|%|&|\/|\(|\)|=|\?|!|\*|\[|\]/) + name = "\"#{name}\"" name += " <#{object.email}>" @objectSelect.val(name) @@ -390,14 +394,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 +417,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller properties: translateX: 0 options: - speed: 300 + duration: 240 # reset list height @recipientList.height('') @@ -423,7 +427,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/clipboard.coffee b/app/assets/javascripts/app/lib/app_post/clipboard.coffee index 47d2a2678..03dd63b63 100644 --- a/app/assets/javascripts/app/lib/app_post/clipboard.coffee +++ b/app/assets/javascripts/app/lib/app_post/clipboard.coffee @@ -6,25 +6,25 @@ class App.ClipBoard _instance ?= new _Singleton _instance.bind(el) - @getSelected: -> + @getSelected: (type) -> if _instance == undefined _instance ?= new _Singleton - _instance.getSelected() + _instance.getSelected(type) - @getSelectedLast: -> + @getSelectedLast: (type) -> if _instance == undefined _instance ?= new _Singleton - _instance.getSelectedLast() + _instance.getSelectedLast(type) @getPosition: (el) -> if _instance == undefined _instance ?= new _Singleton _instance.getPosition(el) - @setPosition: ( el, pos ) -> + @setPosition: (el, pos) -> if _instance == undefined _instance ?= new _Singleton - _instance.setPosition( el, pos ) + _instance.setPosition(el, pos) @keycode: (code) -> if _instance == undefined @@ -33,54 +33,68 @@ class App.ClipBoard class _Singleton constructor: -> - @selection = '' - @selectionLast = '' + @selection = + html: '' + text: '' + @selectionLast = + html: '' + text: '' # bind to fill selected text into bind: (el) -> - $(el).bind('mouseup', => - # check selection on mouse up - @selection = @_getSelected() - if @selection - @selectionLast = @selection + # check selection on mouse up + $(el).bind('mouseup', => + @_updateSelection() ) $(el).bind('keyup', (e) => # check selection on sonder key if e.keyCode == 91 - @selection = @_getSelected() - if @selection - @selectionLast = @selection + @_updateSelection() # check selection of arrow keys if e.keyCode == 37 || e.keyCode == 38 || e.keyCode == 39 || e.keyCode == 40 - @selection = @_getSelected() - if @selection - @selectionLast = @selection + @_updateSelection() ) + _updateSelection: => + for key in ['html', 'text'] + @selection[key] = @_getSelected(key) + if @selection[key] + @selectionLast[key] = @selection[key] + # get cross browser selected string - _getSelected: -> + _getSelected: (type) -> text = '' + html = '' if window.getSelection - text = window.getSelection() + sel = window.getSelection() + text = sel.toString() else if document.getSelection - text = document.getSelection() + sel = document.getSelection() + text = sel.toString() else if document.selection - text = document.selection.createRange().text - if text -# text = text.toString().trim() - text = $.trim( text.toString() ) - text + sel = document.selection.createRange() + text = sel.text + if type is 'text' + return $.trim(text.toString()) if text + return '' + + if sel && sel.rangeCount + container = document.createElement('div') + for i in [1..sel.rangeCount] + container.appendChild(sel.getRangeAt(i-1).cloneContents()) + html = container.innerHTML + html # get current selection - getSelected: -> - @selection + getSelected: (type) -> + @selection[type] # get latest selection - getSelectedLast: -> - @selectionLast + getSelectedLast: (type) -> + @selectionLast[type] getPosition: (el) -> pos = 0 @@ -104,13 +118,13 @@ class _Singleton # IE Support if el.setSelectionRange el.focus() - el.setSelectionRange( pos, pos ) + el.setSelectionRange(pos, pos) # Firefox support else if el.createTextRange range = el.createTextRange() range.collapse(true) - range.moveEnd( 'character', pos ) + range.moveEnd('character', pos) range.moveStart('character', pos) range.select() diff --git a/app/assets/javascripts/app/lib/app_post/column_select.coffee b/app/assets/javascripts/app/lib/app_post/column_select.coffee index d58354f5d..728c2d499 100644 --- a/app/assets/javascripts/app/lib/app_post/column_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/column_select.coffee @@ -33,6 +33,11 @@ class App.ColumnSelect extends Spine.Controller @select @pickedValue , 300, {trailing: false} + if @attribute.onChange + @shadow.on('change', => + @attribute.onChange(@shadow.val()) + ) + render: -> @values = [] _.each @options.attribute.options, (option) => diff --git a/app/assets/javascripts/app/lib/app_post/delay.coffee b/app/assets/javascripts/app/lib/app_post/delay.coffee index c68fdd3b9..284848852 100644 --- a/app/assets/javascripts/app/lib/app_post/delay.coffee +++ b/app/assets/javascripts/app/lib/app_post/delay.coffee @@ -1,10 +1,10 @@ class App.Delay _instance = undefined - @set: (callback, timeout, key, level) -> + @set: (callback, timeout, key, level, queue) -> if _instance == undefined _instance ?= new _delaySingleton - _instance.set(callback, timeout, key, level) + _instance.set(callback, timeout, key, level, queue) @clear: (key, level) -> if _instance == undefined @@ -21,6 +21,11 @@ class App.Delay _instance ?= new _delaySingleton _instance.reset() + @count: -> + if _instance == undefined + _instance ?= new _intervalSingleton + _instance.count() + @_all: -> if _instance == undefined _instance ?= new _delaySingleton @@ -32,7 +37,7 @@ class _delaySingleton extends Spine.Module constructor: -> @levelStack = {} - set: (callback, timeout, key, level) => + set: (callback, timeout, key, level, queue) => if !level level = '_all' @@ -44,11 +49,15 @@ class _delaySingleton extends Spine.Module key = Math.floor(Math.random() * 99999) # setTimeout - @log 'debug', 'set', key, timeout, level, callback - call = => + @log 'debug', 'set', key, timeout, level, callback, queue + localCallback = => @clear(key, level) - callback() - delay_id = setTimeout(call, timeout) + if queue + App.QueueManager.add('delay', callback) + App.QueueManager.run('delay') + else + callback() + delay_id = setTimeout(localCallback, timeout) # remember all delays if !@levelStack[level] @@ -93,6 +102,13 @@ class _delaySingleton extends Spine.Module @levelStack[level] = {} true + count: => + return 0 if !@levelStack + count = 0 + for levelName, levelValue of @levelStack + count += Object.keys(levelValue).length + count + _all: => @levelStack diff --git a/app/assets/javascripts/app/lib/app_post/i18n.coffee b/app/assets/javascripts/app/lib/app_post/i18n.coffee index 1b138bf4e..560562269 100644 --- a/app/assets/javascripts/app/lib/app_post/i18n.coffee +++ b/app/assets/javascripts/app/lib/app_post/i18n.coffee @@ -30,6 +30,11 @@ class App.i18n _instance ?= new _i18nSingleton() _instance.date(args, offset) + @dir: -> + if _instance == undefined + _instance ?= new _i18nSingleton() + _instance.dir() + @get: -> if _instance == undefined _instance ?= new _i18nSingleton() @@ -88,6 +93,10 @@ class _i18nSingleton extends Spine.Module @_notTranslated = {} @dateFormat = 'yyyy-mm-dd' @timestampFormat = 'yyyy-mm-dd HH:MM' + @dirToSet = 'ltr' + + dir: -> + @dirToSet get: -> @locale @@ -96,12 +105,15 @@ class _i18nSingleton extends Spine.Module # prepare locale localeToSet = localeToSet.toLowerCase() + @dirToSet = 'ltr' # check if locale exists localeFound = false locales = App.Locale.all() for locale in locales if locale.locale is localeToSet + localeToSet = locale.locale + @dirToSet = locale.dir localeFound = true # try aliases @@ -109,6 +121,8 @@ class _i18nSingleton extends Spine.Module for locale in locales if locale.alias is localeToSet localeToSet = locale.locale + @dirToSet = locale.dir + localeFound = true # if no locale and no alias was found, try to find correct one if !localeFound @@ -118,15 +132,9 @@ class _i18nSingleton extends Spine.Module for locale in locales if locale.alias is localeToSet localeToSet = locale.locale + @dirToSet = locale.dir localeFound = true - # try to find by locale - if !localeFound - for locale in locales - if locale.locale is localeToSet - localeToSet = locale.locale - localeFound = true - # check if locale need to be changed return if localeToSet is @locale @@ -136,8 +144,9 @@ class _i18nSingleton extends Spine.Module # set if not translated should be logged @_notTranslatedLog = @notTranslatedFeatureEnabled(@locale) - # set lang attribute of html tag - $('html').prop('lang', @locale.substr(0, 2) ) + # set lang and dir attribute of html tag + $('html').prop('lang', localeToSet.substr(0, 2)) + $('html').prop('dir', @dirToSet) @mapString = {} App.Ajax.request( diff --git a/app/assets/javascripts/app/lib/app_post/interval.coffee b/app/assets/javascripts/app/lib/app_post/interval.coffee index cc341ec41..74c021f80 100644 --- a/app/assets/javascripts/app/lib/app_post/interval.coffee +++ b/app/assets/javascripts/app/lib/app_post/interval.coffee @@ -1,10 +1,10 @@ class App.Interval _instance = undefined - @set: (callback, timeout, key, level) -> + @set: (callback, timeout, key, level, queue) -> if _instance == undefined _instance ?= new _intervalSingleton - _instance.set(callback, timeout, key, level) + _instance.set(callback, timeout, key, level, queue) @clear: (key, level) -> if _instance == undefined @@ -21,6 +21,11 @@ class App.Interval _instance ?= new _intervalSingleton _instance.reset() + @count: -> + if _instance == undefined + _instance ?= new _intervalSingleton + _instance.count() + @_all: -> if _instance == undefined _instance ?= new _intervalSingleton @@ -32,7 +37,7 @@ class _intervalSingleton extends Spine.Module constructor: -> @levelStack = {} - set: (callback, timeout, key, level) => + set: (callback, timeout, key, level, queue) => if !level level = '_all' @@ -44,9 +49,15 @@ class _intervalSingleton extends Spine.Module key = Math.floor(Math.random() * 99999) # setTimeout - @log 'debug', 'set', key, timeout, level, callback - callback() - interval_id = setInterval(callback, timeout) + @log 'debug', 'set', key, timeout, level, callback, queue + localCallback = -> + if queue + App.QueueManager.add('interval', callback) + App.QueueManager.run('interval') + else + callback() + localCallback() + interval_id = setInterval(localCallback, timeout) # remember all interval if !@levelStack[level] @@ -91,5 +102,12 @@ class _intervalSingleton extends Spine.Module @levelStack[level] = {} true + count: => + return 0 if !@levelStack + count = 0 + for levelName, levelValue of @levelStack + count += Object.keys(levelValue).length + count + _all: => @levelStack diff --git a/app/assets/javascripts/app/lib/app_post/overview_list_collection.coffee b/app/assets/javascripts/app/lib/app_post/overview_list_collection.coffee index 2831aacbc..b605f5503 100644 --- a/app/assets/javascripts/app/lib/app_post/overview_list_collection.coffee +++ b/app/assets/javascripts/app/lib/app_post/overview_list_collection.coffee @@ -71,7 +71,10 @@ class _Singleton callback: (view, data) => for counter, meta of @callbacks if meta.view is view - meta.callback(data) + callback = -> + meta.callback(data) + App.QueueManager.add('ticket_overviews', callback) + App.QueueManager.run('ticket_overviews') class App.OverviewListCollection _instance = new _Singleton diff --git a/app/assets/javascripts/app/lib/app_post/pretty_date.coffee b/app/assets/javascripts/app/lib/app_post/pretty_date.coffee index 9c607eee0..13c6157a2 100644 --- a/app/assets/javascripts/app/lib/app_post/pretty_date.coffee +++ b/app/assets/javascripts/app/lib/app_post/pretty_date.coffee @@ -38,11 +38,16 @@ class App.PrettyDate months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] month = months[created.getMonth()] - # for less than 7 days - if diff < (60 * 60 * 24 * 7) + # for less than 6 days + # weekday HH::MM + if diff < (60 * 60 * 24 * 6) string = "#{App.i18n.translateInline(weekday)} #{created.getHours()}:#{@s(created.getMinutes(), 2)}" - else if diff < (60 * 60 * 24 * 7) * 365 + # if it was this year + # weekday DD. MM HH::MM + else if created.getYear() is current.getYear() string = "#{App.i18n.translateInline(weekday)} #{created.getDate()}. #{App.i18n.translateInline(month)} #{created.getHours()}:#{@s(created.getMinutes(), 2)}" + # if it was the year before + # weekday YYYY-MM-DD HH::MM else string = "#{App.i18n.translateInline(weekday)} #{App.i18n.translateTimestamp(time)}" if escalation 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..905e02885 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,26 @@ 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' + 'keyup .js-input': 'onKeyUp' 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 +34,99 @@ 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 + onKeyUp: => + return if @input.val().trim() isnt '' + @shadowInput.val('') + toggle: => + @currentItem = null @$('[data-toggle="dropdown"]').dropdown('toggle') stopPropagation: (event) -> @@ -62,8 +136,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 +145,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 +166,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 @@ -124,16 +220,101 @@ class App.SearchableSelect extends Spine.Controller @invisiblePart.text('') selectItem: (event) -> + return if !event.currentTarget.textContent @input.val event.currentTarget.textContent.trim() @input.trigger('change') @shadowInput.val event.currentTarget.getAttribute('data-value') @shadowInput.trigger('change') + navigateIn: (event) -> + event.stopPropagation() + @selectItem(event) + @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-back') + return @navigateOut(event) + @clearAutocomplete() if not @isOpen @@ -144,15 +325,22 @@ 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 @input.trigger('change') @shadowInput.trigger('change') + + if @currentItem + if @currentItem.hasClass('js-enter') + @navigateIn(event) + @currentItem = null + return + @currentItem = null + @toggle() onBlur: -> @@ -169,32 +357,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/utils.coffee b/app/assets/javascripts/app/lib/app_post/utils.coffee index 44e4ff560..2c70db01e 100644 --- a/app/assets/javascripts/app/lib/app_post/utils.coffee +++ b/app/assets/javascripts/app/lib/app_post/utils.coffee @@ -1,5 +1,80 @@ # coffeelint: disable=no_unnecessary_double_quotes class App.Utils + @mapTagAttributes: + 'TABLE': ['align', 'bgcolor', 'border', 'cellpadding', 'cellspacing', 'frame', 'rules', 'sortable', 'summary', 'width', 'style'] + 'TD': ['abbr', 'align', 'axis', 'colspan', 'headers', 'rowspan', 'valign', 'width', 'style'] + 'TH': ['abbr', 'align', 'axis', 'colspan', 'headers', 'rowspan', 'scope', 'sorted', 'valign', 'width', 'style'] + 'TR': ['width', 'style'] + + @mapCss: + 'TABLE': [ + 'background', 'background-color', 'color', 'font-size', 'vertical-align', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'text-align', + 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', + + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + ] + 'TH': [ + 'background', 'background-color', 'color', 'font-size', 'vertical-align', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'text-align', + 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', + + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + ] + 'TR': [ + 'background', 'background-color', 'color', 'font-size', 'vertical-align', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'text-align', + 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', + + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + ] + 'TD': [ + 'background', 'background-color', 'color', 'font-size', 'vertical-align', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'text-align', + 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', + + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + ] # textCleand = App.Utils.textCleanup(rawText) @textCleanup: (ascii) -> @@ -49,10 +124,12 @@ class App.Utils @linkify: (string) -> window.linkify(string) - # htmlEscapedAndLinkified = App.Utils.linkify(rawText) + # htmlEscapedAndPhoneified = App.Utils.phoneify(rawText) @phoneify: (string) -> - string = string.replace(/\s+/g, '') - "tel://#{encodeURIComponent(string)}" + return string if _.isEmpty(string) + string = string.replace(/[^0-9,\+,#,\*]+/g, '') + .replace(/(.)\+/, '$1') + "tel:#{string}" # wrappedText = App.Utils.wrap(rawText, maxLineLength) @wrap: (ascii, max = 82) -> @@ -125,6 +202,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 +211,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) @@ -155,12 +234,12 @@ class App.Utils @_removeWordMarkup(html) # remove tags, keep content - html.find('div, span, p, li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, address, table, thead, tbody, tr, td, h1, h2, h3, h4, h5, h6').replaceWith( -> + html.find('div, span, p, li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, address, table, thead, tbody, tr, th, td, h1, h2, h3, h4, h5, h6').replaceWith( -> $(@).contents() ) # remove tags & content - html.find('div, span, p, li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, table, thead, tbody, tr, td, h1, h2, h3, h4, h5, h6, br, hr, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head').remove() + html.find('div, span, p, li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, table, thead, tbody, tr, th, td, h1, h2, h3, h4, h5, h6, br, hr, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head').remove() html @@ -172,20 +251,19 @@ class App.Utils # remove comments @_removeComments(html) - # remove style and class - if parent - @_removeAttributes(html) - # remove work markup @_removeWordMarkup(html) # remove tags, keep content - html.find('li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, address, table, thead, tbody, tr, td, h1, h2, h3, h4, h5, h6').replaceWith( -> + html.find('li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, address, table, thead, tbody, tr, th, td, h1, h2, h3, h4, h5, h6').replaceWith( -> $(@).contents() ) # remove tags & content - html.find('li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, address, table, thead, tbody, tr, td, h1, h2, h3, h4, h5, h6, hr, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head').remove() + html.find('li, ul, ol, a, b, u, i, label, small, strong, strike, pre, code, center, blockquote, form, fieldset, textarea, font, address, table, thead, tbody, tr, th, td, h1, h2, h3, h4, h5, h6, hr, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head').remove() + + # remove style and class + @_removeAttributes(html, parent) html @@ -197,9 +275,6 @@ class App.Utils # remove comments @_removeComments(html) - # remove style and class - @_removeAttributes(html) - # remove work markup @_removeWordMarkup(html) @@ -230,6 +305,9 @@ class App.Utils # remove tags & content html.find('font, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head, fieldset').remove() + # remove style and class + @_cleanAttributes(html) + html @_checkTypeOf: (item) -> @@ -250,26 +328,64 @@ class App.Utils catch err return $("
#{item}
") - @_removeAttributes: (html, parent = true) -> + @_cleanAttribute: (element) -> + return if !element + + if @mapTagAttributes[element.nodeName] + atts = element.attributes + for att in atts + if att && att.name && !_.contains(@mapTagAttributes[element.nodeName], att.name) + element.removeAttribute(att.name) + else + @_removeAttribute(element) + + if @mapCss[element.nodeName] + elementStyle = element.style + styleOld = '' + for prop in elementStyle + styleOld += "#{prop}:#{elementStyle[prop]};" + + if styleOld && styleOld.split + styleNew = '' + for local_pear in styleOld.split(';') + prop = local_pear.split(':') + if prop[0] && prop[0].trim + key = prop[0].trim() + if _.contains(@mapCss[element.nodeName], key) + styleNew += "#{local_pear};" + if styleNew isnt '' + element.setAttribute('style', styleNew) + else + element.removeAttribute('style') + + @_cleanAttributes: (html, parent = true) -> if parent - html.find('*') - .removeAttr('style') - .removeAttr('class') - .removeAttr('title') - .removeAttr('lang') - .removeAttr('type') - .removeAttr('id') - .removeAttr('wrap') - .removeAttrs(/data-/) + html.each((index, element) => @_cleanAttribute(element) ) + html.find('*').each((index, element) => @_cleanAttribute(element) ) html - .removeAttr('style') + + @_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('title') .removeAttr('lang') .removeAttr('type') + .removeAttr('align') .removeAttr('id') .removeAttr('wrap') + .removeAttr('title') .removeAttrs(/data-/) + + @_removeAttributes: (html, parent = true) -> + if parent + html.each((index, element) => @_removeAttribute(element) ) + html.find('*').each((index, element) => @_removeAttribute(element) ) html @_removeComments: (html) -> @@ -535,6 +651,7 @@ class App.Utils # textReplaced = App.Utils.replaceTags( template, { user: { firstname: 'Bob', lastname: 'Smith' } } ) @replaceTags: (template, objects) -> template = template.replace( /#\{\s{0,2}(.+?)\s{0,2}\}/g, (index, key) -> + key = key.replace(/<.+?>/g, '') levels = key.split(/\./) dataRef = objects for level in levels @@ -781,3 +898,10 @@ class App.Utils result = newOrderMethod(a, b, applyOrder) return false if !result applyOrder + + @textLengthWithUrl: (text, url_max_length = 23) -> + length = 0 + return length if !text + placeholder = Array(url_max_length + 1).join('X') + text = text.replace(/http(s|):\/\/[-A-Za-z0-9+&@#\/%?=~_\|!:,.;]+[-A-Za-z0-9+&@#\/%=~_|]/img, placeholder) + text.length 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/lib/base/highlight.pack.js b/app/assets/javascripts/app/lib/base/highlight.pack.js index 302535e3d..fc4c31799 100644 --- a/app/assets/javascripts/app/lib/base/highlight.pack.js +++ b/app/assets/javascripts/app/lib/base/highlight.pack.js @@ -1 +1,2 @@ -!function(e){"undefined"!=typeof exports?e(exports):(window.hljs=e({}),"function"==typeof define&&define.amd&&define("hljs",[],function(){return window.hljs}))}(function(e){function n(e){return e.replace(/&/gm,"&").replace(//gm,">")}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0==t.index}function a(e){return/^(no-?highlight|plain|text)$/i.test(e)}function i(e){var n,t,r,i=e.className+" ";if(i+=e.parentNode?e.parentNode.className:"",t=/\blang(?:uage)?-([\w-]+)\b/i.exec(i))return w(t[1])?t[1]:"no-highlight";for(i=i.split(/\s+/),n=0,r=i.length;r>n;n++)if(w(i[n])||a(i[n]))return i[n]}function o(e,n){var t,r={};for(t in e)r[t]=e[t];if(n)for(t in n)r[t]=n[t];return r}function u(e){var n=[];return function r(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3==i.nodeType?a+=i.nodeValue.length:1==i.nodeType&&(n.push({event:"start",offset:a,node:i}),a=r(i,a),t(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:i}));return a}(e,0),n}function c(e,r,a){function i(){return e.length&&r.length?e[0].offset!=r[0].offset?e[0].offset"}function u(e){l+=""}function c(e){("start"==e.event?o:u)(e.node)}for(var s=0,l="",f=[];e.length||r.length;){var g=i();if(l+=n(a.substr(s,g[0].offset-s)),s=g[0].offset,g==e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=i();while(g==e&&g.length&&g[0].offset==s);f.reverse().forEach(o)}else"start"==g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return l+n(a.substr(s))}function s(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var u={},c=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");u[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?c("keyword",a.k):Object.keys(a.k).forEach(function(e){c(e,a.k[e])}),a.k=u}a.lR=t(a.l||/\b\w+\b/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=t(a.i)),void 0===a.r&&(a.r=1),a.c||(a.c=[]);var s=[];a.c.forEach(function(e){e.v?e.v.forEach(function(n){s.push(o(e,n))}):s.push("self"==e?a:e)}),a.c=s,a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,i);var l=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=l.length?t(l.join("|"),!0):{exec:function(){return null}}}}r(e)}function l(e,t,a,i){function o(e,n){for(var t=0;t";return i+=e+'">',i+n+o}function p(){if(!L.k)return n(M);var e="",t=0;L.lR.lastIndex=0;for(var r=L.lR.exec(M);r;){e+=n(M.substr(t,r.index-t));var a=g(L,r);a?(B+=a[1],e+=h(a[0],n(r[0]))):e+=n(r[0]),t=L.lR.lastIndex,r=L.lR.exec(M)}return e+n(M.substr(t))}function d(){var e="string"==typeof L.sL;if(e&&!x[L.sL])return n(M);var t=e?l(L.sL,M,!0,y[L.sL]):f(M,L.sL.length?L.sL:void 0);return L.r>0&&(B+=t.r),e&&(y[L.sL]=t.top),h(t.language,t.value,!1,!0)}function b(){return void 0!==L.sL?d():p()}function v(e,t){var r=e.cN?h(e.cN,"",!0):"";e.rB?(k+=r,M=""):e.eB?(k+=n(t)+r,M=""):(k+=r,M=t),L=Object.create(e,{parent:{value:L}})}function m(e,t){if(M+=e,void 0===t)return k+=b(),0;var r=o(t,L);if(r)return k+=b(),v(r,t),r.rB?0:t.length;var a=u(L,t);if(a){var i=L;i.rE||i.eE||(M+=t),k+=b();do L.cN&&(k+=""),B+=L.r,L=L.parent;while(L!=a.parent);return i.eE&&(k+=n(t)),M="",a.starts&&v(a.starts,""),i.rE?0:t.length}if(c(t,L))throw new Error('Illegal lexeme "'+t+'" for mode "'+(L.cN||"")+'"');return M+=t,t.length||1}var N=w(e);if(!N)throw new Error('Unknown language: "'+e+'"');s(N);var R,L=i||N,y={},k="";for(R=L;R!=N;R=R.parent)R.cN&&(k=h(R.cN,"",!0)+k);var M="",B=0;try{for(var C,j,I=0;;){if(L.t.lastIndex=I,C=L.t.exec(t),!C)break;j=m(t.substr(I,C.index-I),C[0]),I=C.index+j}for(m(t.substr(I)),R=L;R.parent;R=R.parent)R.cN&&(k+="");return{r:B,value:k,language:e,top:L}}catch(O){if(-1!=O.message.indexOf("Illegal"))return{r:0,value:n(t)};throw O}}function f(e,t){t=t||E.languages||Object.keys(x);var r={r:0,value:n(e)},a=r;return t.forEach(function(n){if(w(n)){var t=l(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}}),a.language&&(r.second_best=a),r}function g(e){return E.tabReplace&&(e=e.replace(/^((<[^>]+>|\t)+)/gm,function(e,n){return n.replace(/\t/g,E.tabReplace)})),E.useBR&&(e=e.replace(/\n/g,"
")),e}function h(e,n,t){var r=n?R[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(r)&&a.push(r),a.join(" ").trim()}function p(e){var n=i(e);if(!a(n)){var t;E.useBR?(t=document.createElementNS("http://www.w3.org/1999/xhtml","div"),t.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):t=e;var r=t.textContent,o=n?l(n,r,!0):f(r),s=u(t);if(s.length){var p=document.createElementNS("http://www.w3.org/1999/xhtml","div");p.innerHTML=o.value,o.value=c(s,u(p),r)}o.value=g(o.value),e.innerHTML=o.value,e.className=h(e.className,n,o.language),e.result={language:o.language,re:o.r},o.second_best&&(e.second_best={language:o.second_best.language,re:o.second_best.r})}}function d(e){E=o(E,e)}function b(){if(!b.called){b.called=!0;var e=document.querySelectorAll("pre code");Array.prototype.forEach.call(e,p)}}function v(){addEventListener("DOMContentLoaded",b,!1),addEventListener("load",b,!1)}function m(n,t){var r=x[n]=t(e);r.aliases&&r.aliases.forEach(function(e){R[e]=n})}function N(){return Object.keys(x)}function w(e){return e=(e||"").toLowerCase(),x[e]||x[R[e]]}var E={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},x={},R={};return e.highlight=l,e.highlightAuto=f,e.fixMarkup=g,e.highlightBlock=p,e.configure=d,e.initHighlighting=b,e.initHighlightingOnLoad=v,e.registerLanguage=m,e.listLanguages=N,e.getLanguage=w,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/},e.C=function(n,t,r){var a=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e});hljs.registerLanguage("css",function(e){var c="[a-zA-Z-][a-zA-Z0-9_-]*",a={cN:"function",b:c+"\\(",rB:!0,eE:!0,e:"\\("},r={cN:"rule",b:/[A-Z\_\.\-]+\s*:/,rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:/\S/,e:":",eE:!0,starts:{cN:"value",eW:!0,eE:!0,c:[a,e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"hexcolor",b:"#[0-9A-Fa-f]+"},{cN:"important",b:"!important"}]}}]};return{cI:!0,i:/[=\/|'\$]/,c:[e.CBCM,{cN:"id",b:/\#[A-Za-z0-9_-]+/},{cN:"class",b:/\.[A-Za-z0-9_-]+/},{cN:"attr_selector",b:/\[/,e:/\]/,i:"$"},{cN:"pseudo",b:/:(:)?[a-zA-Z0-9\_\-\+\(\)"']+/},{cN:"at_rule",b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{cN:"at_rule",b:"@",e:"[{;]",c:[{cN:"keyword",b:/\S+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[a,e.ASM,e.QSM,e.CSSNM]}]},{cN:"tag",b:c,r:0},{cN:"rules",b:"{",e:"}",i:/\S/,c:[e.CBCM,r]}]}});hljs.registerLanguage("scss",function(e){var t="[a-zA-Z-][a-zA-Z0-9_-]*",i={cN:"variable",b:"(\\$"+t+")\\b"},r={cN:"function",b:t+"\\(",rB:!0,eE:!0,e:"\\("},o={cN:"hexcolor",b:"#[0-9A-Fa-f]+"};({cN:"attribute",b:"[A-Z\\_\\.\\-]+",e:":",eE:!0,i:"[^\\s]",starts:{cN:"value",eW:!0,eE:!0,c:[r,o,e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"important",b:"!important"}]}});return{cI:!0,i:"[=/|']",c:[e.CLCM,e.CBCM,r,{cN:"id",b:"\\#[A-Za-z0-9_-]+",r:0},{cN:"class",b:"\\.[A-Za-z0-9_-]+",r:0},{cN:"attr_selector",b:"\\[",e:"\\]",i:"$"},{cN:"tag",b:"\\b(a|abbr|acronym|address|area|article|aside|audio|b|base|big|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|frame|frameset|(h[1-6])|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|map|mark|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|samp|script|section|select|small|span|strike|strong|style|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|ul|var|video)\\b",r:0},{cN:"pseudo",b:":(visited|valid|root|right|required|read-write|read-only|out-range|optional|only-of-type|only-child|nth-of-type|nth-last-of-type|nth-last-child|nth-child|not|link|left|last-of-type|last-child|lang|invalid|indeterminate|in-range|hover|focus|first-of-type|first-line|first-letter|first-child|first|enabled|empty|disabled|default|checked|before|after|active)"},{cN:"pseudo",b:"::(after|before|choices|first-letter|first-line|repeat-index|repeat-item|selection|value)"},i,{cN:"attribute",b:"\\b(z-index|word-wrap|word-spacing|word-break|width|widows|white-space|visibility|vertical-align|unicode-bidi|transition-timing-function|transition-property|transition-duration|transition-delay|transition|transform-style|transform-origin|transform|top|text-underline-position|text-transform|text-shadow|text-rendering|text-overflow|text-indent|text-decoration-style|text-decoration-line|text-decoration-color|text-decoration|text-align-last|text-align|tab-size|table-layout|right|resize|quotes|position|pointer-events|perspective-origin|perspective|page-break-inside|page-break-before|page-break-after|padding-top|padding-right|padding-left|padding-bottom|padding|overflow-y|overflow-x|overflow-wrap|overflow|outline-width|outline-style|outline-offset|outline-color|outline|orphans|order|opacity|object-position|object-fit|normal|none|nav-up|nav-right|nav-left|nav-index|nav-down|min-width|min-height|max-width|max-height|mask|marks|margin-top|margin-right|margin-left|margin-bottom|margin|list-style-type|list-style-position|list-style-image|list-style|line-height|letter-spacing|left|justify-content|initial|inherit|ime-mode|image-orientation|image-resolution|image-rendering|icon|hyphens|height|font-weight|font-variant-ligatures|font-variant|font-style|font-stretch|font-size-adjust|font-size|font-language-override|font-kerning|font-feature-settings|font-family|font|float|flex-wrap|flex-shrink|flex-grow|flex-flow|flex-direction|flex-basis|flex|filter|empty-cells|display|direction|cursor|counter-reset|counter-increment|content|column-width|column-span|column-rule-width|column-rule-style|column-rule-color|column-rule|column-gap|column-fill|column-count|columns|color|clip-path|clip|clear|caption-side|break-inside|break-before|break-after|box-sizing|box-shadow|box-decoration-break|bottom|border-width|border-top-width|border-top-style|border-top-right-radius|border-top-left-radius|border-top-color|border-top|border-style|border-spacing|border-right-width|border-right-style|border-right-color|border-right|border-radius|border-left-width|border-left-style|border-left-color|border-left|border-image-width|border-image-source|border-image-slice|border-image-repeat|border-image-outset|border-image|border-color|border-collapse|border-bottom-width|border-bottom-style|border-bottom-right-radius|border-bottom-left-radius|border-bottom-color|border-bottom|border|background-size|background-repeat|background-position|background-origin|background-image|background-color|background-clip|background-attachment|background-blend-mode|background|backface-visibility|auto|animation-timing-function|animation-play-state|animation-name|animation-iteration-count|animation-fill-mode|animation-duration|animation-direction|animation-delay|animation|align-self|align-items|align-content)\\b",i:"[^\\s]"},{cN:"value",b:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"},{cN:"value",b:":",e:";",c:[r,i,o,e.CSSNM,e.QSM,e.ASM,{cN:"important",b:"!important"}]},{cN:"at_rule",b:"@",e:"[{;]",k:"mixin include extend for if else each while charset import debug media page content font-face namespace warn",c:[r,i,e.QSM,e.ASM,o,e.CSSNM,{cN:"preprocessor",b:"\\s[A-Za-z0-9_.-]+",r:0}]}]}});hljs.registerLanguage("xml",function(t){var s="[A-Za-z0-9\\._:-]+",c={b:/<\?(php)?(?!\w)/,e:/\?>/,sL:"php"},e={eW:!0,i:/]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xsl","plist"],cI:!0,c:[{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},t.C("",{r:10}),{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"|$)",e:">",k:{title:"style"},c:[e],starts:{e:"",rE:!0,sL:"css"}},{cN:"tag",b:"|$)",e:">",k:{title:"script"},c:[e],starts:{e:"",rE:!0,sL:["actionscript","javascript","handlebars"]}},c,{cN:"pi",b:/<\?\w+/,e:/\?>/,r:10},{cN:"tag",b:"",c:[{cN:"title",b:/[^ \/><\n\t]+/,r:0},e]}]}});hljs.registerLanguage("ruby",function(e){var c="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",r="and false then defined module in return redo if BEGIN retry end for true self when next until do begin unless END rescue nil else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",b={cN:"doctag",b:"@[A-Za-z]+"},a={cN:"value",b:"#<",e:">"},n=[e.C("#","$",{c:[b]}),e.C("^\\=begin","^\\=end",{c:[b],r:10}),e.C("^__END__","\\n$")],s={cN:"subst",b:"#\\{",e:"}",k:r},t={cN:"string",c:[e.BE,s],v:[{b:/'/,e:/'/},{b:/"/,e:/"/},{b:/`/,e:/`/},{b:"%[qQwWx]?\\(",e:"\\)"},{b:"%[qQwWx]?\\[",e:"\\]"},{b:"%[qQwWx]?{",e:"}"},{b:"%[qQwWx]?<",e:">"},{b:"%[qQwWx]?/",e:"/"},{b:"%[qQwWx]?%",e:"%"},{b:"%[qQwWx]?-",e:"-"},{b:"%[qQwWx]?\\|",e:"\\|"},{b:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/}]},i={cN:"params",b:"\\(",e:"\\)",k:r},d=[t,a,{cN:"class",bK:"class module",e:"$|;",i:/=/,c:[e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{cN:"inheritance",b:"<\\s*",c:[{cN:"parent",b:"("+e.IR+"::)?"+e.IR}]}].concat(n)},{cN:"function",bK:"def",e:"$|;",c:[e.inherit(e.TM,{b:c}),i].concat(n)},{cN:"constant",b:"(::)?(\\b[A-Z]\\w*(::)?)+",r:0},{cN:"symbol",b:e.UIR+"(\\!|\\?)?:",r:0},{cN:"symbol",b:":",c:[t,{b:c}],r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{cN:"variable",b:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{b:"("+e.RSR+")\\s*",c:[a,{cN:"regexp",c:[e.BE,s],i:/\n/,v:[{b:"/",e:"/[a-z]*"},{b:"%r{",e:"}[a-z]*"},{b:"%r\\(",e:"\\)[a-z]*"},{b:"%r!",e:"![a-z]*"},{b:"%r\\[",e:"\\][a-z]*"}]}].concat(n),r:0}].concat(n);s.c=d,i.c=d;var o="[>?]>",l="[\\w#]+\\(\\w+\\):\\d+:\\d+>",u="(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>",N=[{b:/^\s*=>/,cN:"status",starts:{e:"$",c:d}},{cN:"prompt",b:"^("+o+"|"+l+"|"+u+")",starts:{e:"$",c:d}}];return{aliases:["rb","gemspec","podspec","thor","irb"],k:r,i:/\/\*/,c:n.concat(N).concat(d)}});hljs.registerLanguage("coffeescript",function(e){var c={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger super then unless until loop of by when and or is isnt not",literal:"true false null undefined yes no on off",built_in:"npm require console print module global window document"},n="[A-Za-z$_][0-9A-Za-z$_]*",r={cN:"subst",b:/#\{/,e:/}/,k:c},t=[e.BNM,e.inherit(e.CNM,{starts:{e:"(\\s*/)?",r:0}}),{cN:"string",v:[{b:/'''/,e:/'''/,c:[e.BE]},{b:/'/,e:/'/,c:[e.BE]},{b:/"""/,e:/"""/,c:[e.BE,r]},{b:/"/,e:/"/,c:[e.BE,r]}]},{cN:"regexp",v:[{b:"///",e:"///",c:[r,e.HCM]},{b:"//[gim]*",r:0},{b:/\/(?![ *])(\\\/|.)*?\/[gim]*(?=\W|$)/}]},{cN:"property",b:"@"+n},{b:"`",e:"`",eB:!0,eE:!0,sL:"javascript"}];r.c=t;var s=e.inherit(e.TM,{b:n}),i="(\\(.*\\))?\\s*\\B[-=]>",o={cN:"params",b:"\\([^\\(]",rB:!0,c:[{b:/\(/,e:/\)/,k:c,c:["self"].concat(t)}]};return{aliases:["coffee","cson","iced"],k:c,i:/\/\*/,c:t.concat([e.C("###","###"),e.HCM,{cN:"function",b:"^\\s*"+n+"\\s*=\\s*"+i,e:"[-=]>",rB:!0,c:[s,o]},{b:/[:\(,=]\s*/,r:0,c:[{cN:"function",b:i,e:"[-=]>",rB:!0,c:[o]}]},{cN:"class",bK:"class",e:"$",i:/[:="\[\]]/,c:[{bK:"extends",eW:!0,i:/[:="\[\]]/,c:[s]},s]},{cN:"attribute",b:n+":",e:":",rB:!0,rE:!0,r:0}])}});hljs.registerLanguage("javascript",function(e){return{aliases:["js"],k:{keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},c:[{cN:"pi",r:10,b:/^\s*['"]use (strict|asm)['"]/},e.ASM,e.QSM,{cN:"string",b:"`",e:"`",c:[e.BE,{cN:"subst",b:"\\$\\{",e:"\\}"}]},e.CLCM,e.CBCM,{cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{b:/\s*[);\]]/,r:0,sL:"xml"}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:/[A-Za-z$_][0-9A-Za-z$_]*/}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:[e.CLCM,e.CBCM]}],i:/\[|%/},{b:/\$[(.]/},{b:"\\."+e.IR,r:0},{bK:"import",e:"[;$]",k:"import from as",c:[e.ASM,e.QSM]},{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]}],i:/#/}}); \ No newline at end of file +/*! highlight.js v9.12.0 | BSD3 License | git.io/hljslicense */ +!function(e){var n="object"==typeof window&&window||"object"==typeof self&&self;"undefined"!=typeof exports?e(exports):n&&(n.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return n.hljs}))}(function(e){function n(e){return e.replace(/&/g,"&").replace(//g,">")}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0===t.index}function a(e){return k.test(e)}function i(e){var n,t,r,i,o=e.className+" ";if(o+=e.parentNode?e.parentNode.className:"",t=B.exec(o))return w(t[1])?t[1]:"no-highlight";for(o=o.split(/\s+/),n=0,r=o.length;r>n;n++)if(i=o[n],a(i)||w(i))return i}function o(e){var n,t={},r=Array.prototype.slice.call(arguments,1);for(n in e)t[n]=e[n];return r.forEach(function(e){for(n in e)t[n]=e[n]}),t}function u(e){var n=[];return function r(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3===i.nodeType?a+=i.nodeValue.length:1===i.nodeType&&(n.push({event:"start",offset:a,node:i}),a=r(i,a),t(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:i}));return a}(e,0),n}function c(e,r,a){function i(){return e.length&&r.length?e[0].offset!==r[0].offset?e[0].offset"}function u(e){s+=""}function c(e){("start"===e.event?o:u)(e.node)}for(var l=0,s="",f=[];e.length||r.length;){var g=i();if(s+=n(a.substring(l,g[0].offset)),l=g[0].offset,g===e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=i();while(g===e&&g.length&&g[0].offset===l);f.reverse().forEach(o)}else"start"===g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return s+n(a.substr(l))}function l(e){return e.v&&!e.cached_variants&&(e.cached_variants=e.v.map(function(n){return o(e,{v:null},n)})),e.cached_variants||e.eW&&[o(e)]||[e]}function s(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var o={},u=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");o[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?u("keyword",a.k):x(a.k).forEach(function(e){u(e,a.k[e])}),a.k=o}a.lR=t(a.l||/\w+/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=t(a.i)),null==a.r&&(a.r=1),a.c||(a.c=[]),a.c=Array.prototype.concat.apply([],a.c.map(function(e){return l("self"===e?a:e)})),a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,i);var c=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=c.length?t(c.join("|"),!0):{exec:function(){return null}}}}r(e)}function f(e,t,a,i){function o(e,n){var t,a;for(t=0,a=n.c.length;a>t;t++)if(r(n.c[t].bR,e))return n.c[t]}function u(e,n){if(r(e.eR,n)){for(;e.endsParent&&e.parent;)e=e.parent;return e}return e.eW?u(e.parent,n):void 0}function c(e,n){return!a&&r(n.iR,e)}function l(e,n){var t=N.cI?n[0].toLowerCase():n[0];return e.k.hasOwnProperty(t)&&e.k[t]}function p(e,n,t,r){var a=r?"":I.classPrefix,i='',i+n+o}function h(){var e,t,r,a;if(!E.k)return n(k);for(a="",t=0,E.lR.lastIndex=0,r=E.lR.exec(k);r;)a+=n(k.substring(t,r.index)),e=l(E,r),e?(B+=e[1],a+=p(e[0],n(r[0]))):a+=n(r[0]),t=E.lR.lastIndex,r=E.lR.exec(k);return a+n(k.substr(t))}function d(){var e="string"==typeof E.sL;if(e&&!y[E.sL])return n(k);var t=e?f(E.sL,k,!0,x[E.sL]):g(k,E.sL.length?E.sL:void 0);return E.r>0&&(B+=t.r),e&&(x[E.sL]=t.top),p(t.language,t.value,!1,!0)}function b(){L+=null!=E.sL?d():h(),k=""}function v(e){L+=e.cN?p(e.cN,"",!0):"",E=Object.create(e,{parent:{value:E}})}function m(e,n){if(k+=e,null==n)return b(),0;var t=o(n,E);if(t)return t.skip?k+=n:(t.eB&&(k+=n),b(),t.rB||t.eB||(k=n)),v(t,n),t.rB?0:n.length;var r=u(E,n);if(r){var a=E;a.skip?k+=n:(a.rE||a.eE||(k+=n),b(),a.eE&&(k=n));do E.cN&&(L+=C),E.skip||(B+=E.r),E=E.parent;while(E!==r.parent);return r.starts&&v(r.starts,""),a.rE?0:n.length}if(c(n,E))throw new Error('Illegal lexeme "'+n+'" for mode "'+(E.cN||"")+'"');return k+=n,n.length||1}var N=w(e);if(!N)throw new Error('Unknown language: "'+e+'"');s(N);var R,E=i||N,x={},L="";for(R=E;R!==N;R=R.parent)R.cN&&(L=p(R.cN,"",!0)+L);var k="",B=0;try{for(var M,j,O=0;;){if(E.t.lastIndex=O,M=E.t.exec(t),!M)break;j=m(t.substring(O,M.index),M[0]),O=M.index+j}for(m(t.substr(O)),R=E;R.parent;R=R.parent)R.cN&&(L+=C);return{r:B,value:L,language:e,top:E}}catch(T){if(T.message&&-1!==T.message.indexOf("Illegal"))return{r:0,value:n(t)};throw T}}function g(e,t){t=t||I.languages||x(y);var r={r:0,value:n(e)},a=r;return t.filter(w).forEach(function(n){var t=f(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}),a.language&&(r.second_best=a),r}function p(e){return I.tabReplace||I.useBR?e.replace(M,function(e,n){return I.useBR&&"\n"===e?"
":I.tabReplace?n.replace(/\t/g,I.tabReplace):""}):e}function h(e,n,t){var r=n?L[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(r)&&a.push(r),a.join(" ").trim()}function d(e){var n,t,r,o,l,s=i(e);a(s)||(I.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div"),n.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):n=e,l=n.textContent,r=s?f(s,l,!0):g(l),t=u(n),t.length&&(o=document.createElementNS("http://www.w3.org/1999/xhtml","div"),o.innerHTML=r.value,r.value=c(t,u(o),l)),r.value=p(r.value),e.innerHTML=r.value,e.className=h(e.className,s,r.language),e.result={language:r.language,re:r.r},r.second_best&&(e.second_best={language:r.second_best.language,re:r.second_best.r}))}function b(e){I=o(I,e)}function v(){if(!v.called){v.called=!0;var e=document.querySelectorAll("pre code");E.forEach.call(e,d)}}function m(){addEventListener("DOMContentLoaded",v,!1),addEventListener("load",v,!1)}function N(n,t){var r=y[n]=t(e);r.aliases&&r.aliases.forEach(function(e){L[e]=n})}function R(){return x(y)}function w(e){return e=(e||"").toLowerCase(),y[e]||y[L[e]]}var E=[],x=Object.keys,y={},L={},k=/^(no-?highlight|plain|text)$/i,B=/\blang(?:uage)?-([\w-]+)\b/i,M=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,C="
",I={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};return e.highlight=f,e.highlightAuto=g,e.fixMarkup=p,e.highlightBlock=d,e.configure=b,e.initHighlighting=v,e.initHighlightingOnLoad=m,e.registerLanguage=N,e.listLanguages=R,e.getLanguage=w,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},e.C=function(n,t,r){var a=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.METHOD_GUARD={b:"\\.\\s*"+e.UIR,r:0},e});hljs.registerLanguage("ruby",function(e){var b="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",r={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},c={cN:"doctag",b:"@[A-Za-z]+"},a={b:"#<",e:">"},s=[e.C("#","$",{c:[c]}),e.C("^\\=begin","^\\=end",{c:[c],r:10}),e.C("^__END__","\\n$")],n={cN:"subst",b:"#\\{",e:"}",k:r},t={cN:"string",c:[e.BE,n],v:[{b:/'/,e:/'/},{b:/"/,e:/"/},{b:/`/,e:/`/},{b:"%[qQwWx]?\\(",e:"\\)"},{b:"%[qQwWx]?\\[",e:"\\]"},{b:"%[qQwWx]?{",e:"}"},{b:"%[qQwWx]?<",e:">"},{b:"%[qQwWx]?/",e:"/"},{b:"%[qQwWx]?%",e:"%"},{b:"%[qQwWx]?-",e:"-"},{b:"%[qQwWx]?\\|",e:"\\|"},{b:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},{b:/<<(-?)\w+$/,e:/^\s*\w+$/}]},i={cN:"params",b:"\\(",e:"\\)",endsParent:!0,k:r},d=[t,a,{cN:"class",bK:"class module",e:"$|;",i:/=/,c:[e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{b:"<\\s*",c:[{b:"("+e.IR+"::)?"+e.IR}]}].concat(s)},{cN:"function",bK:"def",e:"$|;",c:[e.inherit(e.TM,{b:b}),i].concat(s)},{b:e.IR+"::"},{cN:"symbol",b:e.UIR+"(\\!|\\?)?:",r:0},{cN:"symbol",b:":(?!\\s)",c:[t,{b:b}],r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{b:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{cN:"params",b:/\|/,e:/\|/,k:r},{b:"("+e.RSR+"|unless)\\s*",k:"unless",c:[a,{cN:"regexp",c:[e.BE,n],i:/\n/,v:[{b:"/",e:"/[a-z]*"},{b:"%r{",e:"}[a-z]*"},{b:"%r\\(",e:"\\)[a-z]*"},{b:"%r!",e:"![a-z]*"},{b:"%r\\[",e:"\\][a-z]*"}]}].concat(s),r:0}].concat(s);n.c=d,i.c=d;var l="[>?]>",o="[\\w#]+\\(\\w+\\):\\d+:\\d+>",u="(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>",w=[{b:/^\s*=>/,starts:{e:"$",c:d}},{cN:"meta",b:"^("+l+"|"+o+"|"+u+")",starts:{e:"$",c:d}}];return{aliases:["rb","gemspec","podspec","thor","irb"],k:r,i:/\/\*/,c:s.concat(w).concat(d)}});hljs.registerLanguage("bash",function(e){var t={cN:"variable",v:[{b:/\$[\w\d#@][\w\d_]*/},{b:/\$\{(.*?)}/}]},s={cN:"string",b:/"/,e:/"/,c:[e.BE,t,{cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]}]},a={cN:"string",b:/'/,e:/'/};return{aliases:["sh","zsh"],l:/\b-?[a-z\._]+\b/,k:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},c:[{cN:"meta",b:/^#![^\n]+sh\s*$/,r:10},{cN:"function",b:/\w[\w\d_]*\s*\(\s*\)\s*\{/,rB:!0,c:[e.inherit(e.TM,{b:/\w[\w\d_]*/})],r:0},e.HCM,s,a,t]}});hljs.registerLanguage("javascript",function(e){var r="[A-Za-z$_][0-9A-Za-z$_]*",t={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},a={cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},n={cN:"subst",b:"\\$\\{",e:"\\}",k:t,c:[]},c={cN:"string",b:"`",e:"`",c:[e.BE,n]};n.c=[e.ASM,e.QSM,c,a,e.RM];var s=n.c.concat([e.CBCM,e.CLCM]);return{aliases:["js","jsx"],k:t,c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,c,e.CLCM,e.CBCM,a,{b:/[{,]\s*/,r:0,c:[{b:r+"\\s*:",rB:!0,r:0,c:[{cN:"attr",b:r,r:0}]}]},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{cN:"function",b:"(\\(.*?\\)|"+r+")\\s*=>",rB:!0,e:"\\s*=>",c:[{cN:"params",v:[{b:r},{b:/\(\s*\)/},{b:/\(/,e:/\)/,eB:!0,eE:!0,k:t,c:s}]}]},{b://,sL:"xml",c:[{b:/<\w+\s*\/>/,skip:!0},{b:/<\w+/,e:/(\/\w+|\w+\/)>/,skip:!0,c:[{b:/<\w+\s*\/>/,skip:!0},"self"]}]}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:r}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:s}],i:/\[|%/},{b:/\$[(.]/},e.METHOD_GUARD,{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}});hljs.registerLanguage("css",function(e){var c="[a-zA-Z-][a-zA-Z0-9_-]*",t={b:/[A-Z\_\.\-]+\s*:/,rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:/\S/,e:":",eE:!0,starts:{eW:!0,eE:!0,c:[{b:/[\w-]+\(/,rB:!0,c:[{cN:"built_in",b:/[\w-]+/},{b:/\(/,e:/\)/,c:[e.ASM,e.QSM]}]},e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"number",b:"#[0-9A-Fa-f]+"},{cN:"meta",b:"!important"}]}}]};return{cI:!0,i:/[=\/|'\$]/,c:[e.CBCM,{cN:"selector-id",b:/#[A-Za-z0-9_-]+/},{cN:"selector-class",b:/\.[A-Za-z0-9_-]+/},{cN:"selector-attr",b:/\[/,e:/\]/,i:"$"},{cN:"selector-pseudo",b:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{b:"@",e:"[{;]",i:/:/,c:[{cN:"keyword",b:/\w+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[e.ASM,e.QSM,e.CSSNM]}]},{cN:"selector-tag",b:c,r:0},{b:"{",e:"}",i:/\S/,c:[e.CBCM,t]}]}});hljs.registerLanguage("xml",function(s){var e="[A-Za-z0-9\\._:-]+",t={eW:!0,i:/`]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist"],cI:!0,c:[{cN:"meta",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},s.C("",{r:10}),{b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{b:/<\?(php)?/,e:/\?>/,sL:"php",c:[{b:"/\\*",e:"\\*/",skip:!0}]},{cN:"tag",b:"|$)",e:">",k:{name:"style"},c:[t],starts:{e:"",rE:!0,sL:["css","xml"]}},{cN:"tag",b:"|$)",e:">",k:{name:"script"},c:[t],starts:{e:"",rE:!0,sL:["actionscript","javascript","handlebars","xml"]}},{cN:"meta",v:[{b:/<\?xml/,e:/\?>/,r:10},{b:/<\?\w+/,e:/\?>/}]},{cN:"tag",b:"",c:[{cN:"name",b:/[^\/><\s]+/,r:0},t]}]}}); \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js index 32b336806..a82a036dc 100644 --- a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js +++ b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js @@ -88,7 +88,7 @@ // handle enter this.$element.on('keydown', function (e) { _this.log('keydown', e.keyCode) - if ( _this.preventInput ) { + if (_this.preventInput) { this.log('preventInput', _this.preventInput) return } @@ -97,18 +97,29 @@ if (e.keyCode === 13) { // disbale multi line - if ( !_this.options.multiline ) { + if (!_this.options.multiline) { e.preventDefault() return } // break
after enter on empty line sel = window.getSelection() - node = $(sel.anchorNode) - if (node.parent().is('blockquote')) { + if (sel) { + node = $(sel.anchorNode) + if (node && node.parent() && node.parent().is('blockquote')) { + e.preventDefault() + document.execCommand('Insertparagraph') + document.execCommand('Outdent') + return + } + } + + // behavior to enter new line on alt+enter + // on alt + enter not realy newline is fired, to make + // it compat. to other systems, do it here + if (!e.shiftKey && e.altKey && !e.ctrlKey && !e.metaKey) { e.preventDefault() - document.execCommand('Insertparagraph') - document.execCommand('Outdent') + _this.paste('

') return } } @@ -237,7 +248,7 @@ // limit check if ( !_this.allowKey(e) ) { - if ( !_this.maxLengthOk( 1 ) ) { + if ( !_this.maxLengthOk(1) ) { e.preventDefault() return } @@ -254,6 +265,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 } @@ -292,7 +306,7 @@ else { img = "" } - document.execCommand('insertHTML', false, img) + _this.paste(img) } // resize if to big @@ -307,15 +321,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 +377,8 @@ // cleanup text = App.Utils.removeEmptyLines(text) _this.log('insert', text) - document.execCommand('insertHTML', false, text) + + _this.paste(text) return true }) @@ -525,7 +548,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, @@ -537,6 +583,9 @@ // get correct val if textbox $.fn.ceg = function() { var plugin = $.data(this[0], 'plugin_' + pluginName) + if (!plugin) { + return + } return plugin.value() } diff --git a/app/assets/javascripts/app/lib/base/jquery.textmodule.js b/app/assets/javascripts/app/lib/base/jquery.textmodule.js index 492229a00..738d87249 100644 --- a/app/assets/javascripts/app/lib/base/jquery.textmodule.js +++ b/app/assets/javascripts/app/lib/base/jquery.textmodule.js @@ -43,17 +43,21 @@ this.$element.on('keydown', function (e) { - // esc - if (e.keyCode === 27) { - _this.close() - } - // navigate through item if (_this.isActive()) { + // esc + if (e.keyCode === 27) { + e.preventDefault() + e.stopPropagation() + _this.close() + return + } + // enter if (e.keyCode === 13) { e.preventDefault() + e.stopPropagation() var id = _this.$widget.find('.dropdown-menu li.is-active').data('id') // as fallback use hovered element @@ -72,12 +76,14 @@ // arrow keys left/right if (e.keyCode === 37 || e.keyCode === 39) { e.preventDefault() + e.stopPropagation() return } // up or down if (e.keyCode === 38 || e.keyCode === 40) { e.preventDefault() + e.stopPropagation() var active = _this.$widget.find('.dropdown-menu li.is-active') active.removeClass('is-active') @@ -92,6 +98,9 @@ var menu = _this.$widget.find('.dropdown-menu') + if (!active.get(0)) { + return + } if (active.position().top < 0) { // scroll up menu.scrollTop( menu.scrollTop() + active.position().top ) @@ -102,7 +111,11 @@ menu.scrollTop( menu.scrollTop() + invisibleHeight ) } } + } + // esc + if (e.keyCode === 27) { + _this.close() } }) @@ -180,14 +193,14 @@ Plugin.prototype.renderBase = function() { this.$element.after('') this.$widget = this.$element.next() - this.$widget.on('click', 'li', $.proxy(this.onEntryClick, this)) + this.$widget.on('mousedown', 'li', $.proxy(this.onEntryClick, this)) this.$widget.on('mouseenter', 'li', $.proxy(this.onMouseEnter, this)) } // set height of widget Plugin.prototype.movePosition = function() { if (!this._position) return - var height = this.$element.height() + 2 + var height = this.$element.outerHeight() + 2 var widgetHeight = this.$widget.find('ul').height() //+ 60 // + height var top = -( widgetHeight + height ) + this._position.top var left = this._position.left - 6 @@ -250,9 +263,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 +320,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(' ') } } } @@ -313,6 +331,7 @@ } Plugin.prototype.onEntryClick = function(event) { + event.preventDefault() var id = $(event.target).data('id') this.take(id) } @@ -325,7 +344,7 @@ } for (var i = 0; i < this.collection.length; i++) { var item = this.collection[i] - if ( item.id == id ) { + if (item.id == id) { var content = item.content this.cutInput() this.paste(content) diff --git a/app/assets/javascripts/app/lib/bootstrap/modal.js b/app/assets/javascripts/app/lib/bootstrap/modal.js index c9ddc93dc..4e202316c 100644 --- a/app/assets/javascripts/app/lib/bootstrap/modal.js +++ b/app/assets/javascripts/app/lib/bootstrap/modal.js @@ -10,6 +10,8 @@ modified by Felix Jan-2014 - add this.$body = $(options.container || document.body) - adjustBackdrop: also adopt left, top and width from $body + modified by Felix Jul-2017 + - add rtl support */ @@ -244,6 +246,10 @@ .css('height', 0) .css('height', this.$element[0].scrollHeight) + if(App.i18n.dir() == 'rtl'){ + this.$backdrop.css('right', 'auto') + } + if(this.scrollbarWidth){ this.$backdrop.css('width', this.$body.width() - this.scrollbarWidth) } @@ -251,14 +257,22 @@ Modal.prototype.adjustDialog = function () { var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight - - this.$element.css({ + var css = { left: this.$body.offset().left, top: this.$body.offset().top, width: this.$body.width(), paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '', paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : '' - }) + } + + if(App.i18n.dir() == 'rtl'){ + css.right = 'auto' + var paddingLeft = css.paddingLeft + css.paddingLeft = css.paddingRight + css.paddingRight = paddingLeft + } + + this.$element.css(css) } Modal.prototype.resetAdjustments = function () { diff --git a/app/assets/javascripts/app/models/_application_model.coffee b/app/assets/javascripts/app/models/_application_model.coffee index f7c708b78..49ad8aac9 100644 --- a/app/assets/javascripts/app/models/_application_model.coffee +++ b/app/assets/javascripts/app/models/_application_model.coffee @@ -32,6 +32,10 @@ class App.Model extends Spine.Model return @title if @subject return @subject + if @phone + return @phone + if @login + return @login return '???' displayNameLong: -> @@ -57,6 +61,12 @@ class App.Model extends Spine.Model return @email if @title return @title + if @subject + return @subject + if @phone + return @phone + if @login + return @login return '???' icon: (user) -> @@ -165,6 +175,31 @@ class App.Model extends Spine.Model ### +set new attributes of model (remove already available attributes) + + App.Model.attributesSet(attributes) + + ### + + @attributesSet: (attributes) -> + + configure_attributes = App[ @.className ].configure_attributes + attributesNew = [] + for localAttribute in configure_attributes + found = false + for attribute in attributes + if attribute.name is localAttribute.name + found = true + break + if !found + attributesNew.push localAttribute + for attribute in attributes + App[@.className].attributes.push attribute.name + attributesNew.push attribute + App[ @.className ].configure_attributes = attributesNew + + ### + attributes = App.Model.attributesGet(optionalScreen, optionalAttributesList) returns 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/job.coffee b/app/assets/javascripts/app/models/job.coffee index 996e3dfc7..d061c2db0 100644 --- a/app/assets/javascripts/app/models/job.coffee +++ b/app/assets/javascripts/app/models/job.coffee @@ -6,7 +6,7 @@ class App.Job extends App.Model { name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, null: false }, { name: 'timeplan', display: 'When should the job run?', tag: 'timer', null: true }, { name: 'condition', display: 'Conditions for effected objects', tag: 'ticket_selector', null: true }, - { name: 'perform', display: 'Execute changes on objects', tag: 'ticket_perform_action', null: true, notification: true }, + { name: 'perform', display: 'Execute changes on objects', tag: 'ticket_perform_action', null: true, notification: true, ticket_delete: true }, { name: 'disable_notification', display: 'Disable Notifications', tag: 'boolean', default: true }, { name: 'note', display: 'Note', tag: 'textarea', note: 'Notes are visible to agents only, never to customers.', limit: 250, null: true }, { name: 'active', display: 'Active', tag: 'active', default: true }, diff --git a/app/assets/javascripts/app/models/overview.coffee b/app/assets/javascripts/app/models/overview.coffee index 144f228f0..dd8c0b2e9 100644 --- a/app/assets/javascripts/app/models/overview.coffee +++ b/app/assets/javascripts/app/models/overview.coffee @@ -8,6 +8,7 @@ class App.Overview extends App.Model { name: 'role_ids', display: 'Available for Role', tag: 'column_select', multiple: true, null: false, relation: 'Role', translate: true }, { name: 'user_ids', display: 'Available for User', tag: 'column_select', multiple: true, null: true, relation: 'User', sortBy: 'firstname' }, { name: 'organization_shared', display: 'Only available for Users with shared Organization', tag: 'select', options: { true: 'yes', false: 'no' }, default: false, null: true }, + { name: 'out_of_office', display: 'Only available for Users which are replacements for other users.', tag: 'select', options: { true: 'yes', false: 'no' }, default: false, null: true }, { name: 'condition', display: 'Conditions for shown Tickets', tag: 'ticket_selector', null: false }, { name: 'prio', display: 'Prio', readonly: 1 }, { @@ -72,4 +73,4 @@ Sie können auch individuelle Übersichten für einzelne Agenten oder agenten Gr ''' uiUrl: -> - '#ticket/view/' + @link + "#ticket/view/#{@link}" diff --git a/app/assets/javascripts/app/models/postmaster_filter.coffee b/app/assets/javascripts/app/models/postmaster_filter.coffee index 30c2fa5b3..2ebddcc10 100644 --- a/app/assets/javascripts/app/models/postmaster_filter.coffee +++ b/app/assets/javascripts/app/models/postmaster_filter.coffee @@ -6,7 +6,7 @@ class App.PostmasterFilter extends App.Model @configure_attributes = [ { name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 250, 'null': false }, { name: 'channel', display: 'Channel', type: 'input', readonly: 1 }, - { name: 'match', display: 'Match all of the following', tag: 'postmaster_match' }, + { name: 'match', display: 'Match all of the following', tag: 'postmaster_match', note: 'You can use regular expression by using "regex:your_reg_exp".' }, { name: 'perform', display: 'Perform action of the following', tag: 'postmaster_set' }, { name: 'note', display: 'Note', tag: 'textarea', limit: 250, null: true }, { name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 }, 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/models/user.coffee b/app/assets/javascripts/app/models/user.coffee index ae3641619..7d357369c 100644 --- a/app/assets/javascripts/app/models/user.coffee +++ b/app/assets/javascripts/app/models/user.coffee @@ -53,8 +53,14 @@ class App.User extends App.Model cssClass += ' ' if cssClass cssClass += "size-#{ size }" + if @active is false + cssClass += ' avatar--inactive' + + if @isOutOfOffice() + cssClass += ' avatar--vacation' + if placement - placement = " data-placement='#{ placement }'" + placement = " data-placement='#{placement}'" if !avatar if type is 'personal' @@ -104,6 +110,19 @@ class App.User extends App.Model vip: vip url: @imageUrl() + isOutOfOffice: -> + return false if @out_of_office isnt true + start_time = @out_of_office_start_at + return false if !start_time + end_time = @out_of_office_end_at + return false if !end_time + start_time = new Date(Date.parse(start_time)) + end_time = new Date(Date.parse(end_time)) + now = new Date((new Date).toDateString()) + if start_time <= now && end_time >= now + return true + false + imageUrl: -> return if !@image # set image url @@ -237,3 +256,16 @@ class App.User extends App.Model break return access if access false + + @outOfOfficeTextPlaceholder: -> + today = new Date() + outOfOfficeText = 'Christmas holiday' + if today.getMonth() < 3 + outOfOfficeText = 'Easter holiday' + else if today.getMonth() < 9 + outOfOfficeText = 'Summer holiday' + outOfOfficeText + + outOfOfficeText: -> + return @preferences.out_of_office_text if !_.isEmpty(@preferences.out_of_office_text) + App.User.outOfOfficeTextPlaceholder() diff --git a/app/assets/javascripts/app/views/channel/chat.jst.eco b/app/assets/javascripts/app/views/channel/chat.jst.eco index cfdd447af..310183074 100644 --- a/app/assets/javascripts/app/views/channel/chat.jst.eco +++ b/app/assets/javascripts/app/views/channel/chat.jst.eco @@ -142,7 +142,7 @@

<%- @T('Automatically show chat') %> (<%- @T('default') %>)

<%- @T('The chat will show up once the connection to the server got established and if there is someone online to chat with.') %>

-
<script src="<%= @baseurl %>/assets/chat/chat.min.js"></script>
+  
<script src="<%= @baseurl %>/assets/chat/chat.min.js"></script>
 <script>
 $(function() {
   new ZammadChat({
@@ -153,7 +153,7 @@ $(function() {
 
   

<%- @T('Manually open chat') %>

<%- @T('If you want to open the chat by the press of a button set the option §show§ to §false§ and add the class §open-zammad-chat§ to the button.') %>

-
<button class="open-zammad-chat">Chat with us</button>
+  
<button class="open-zammad-chat">Chat with us</button>
 
 <script src="<%= @baseurl %>/assets/chat/chat.min.js"></script>
 <script>
diff --git a/app/assets/javascripts/app/views/channel/form.jst.eco b/app/assets/javascripts/app/views/channel/form.jst.eco
index 6f6e891f7..011c10a8c 100644
--- a/app/assets/javascripts/app/views/channel/form.jst.eco
+++ b/app/assets/javascripts/app/views/channel/form.jst.eco
@@ -10,8 +10,20 @@
 

<%- @T('With form you can add a form to your web page which directly generates a ticket for you.') %>

+

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

+
+
+
+
+ +
+
+
+
+
+

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

-
+
@@ -114,6 +126,9 @@
+

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

+

<%- @T("Zammad Forms requires jQuery. If you don't already use it on your website include it like this:") %>

+
<script src="https://code.jquery.com/jquery-2.1.4.min.js"></script>

<%- @T('You need to add the following Javascript code snippet to your web page') %>:

diff --git a/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco b/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco index 8e6710ef7..1fef2ed9b 100644 --- a/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco +++ b/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco @@ -26,6 +26,8 @@
-
+
+
+
<%- @T('Send') %>
\ No newline at end of file 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/checkbox.jst.eco b/app/assets/javascripts/app/views/generic/checkbox.jst.eco index 4576fab6f..8d0412aad 100644 --- a/app/assets/javascripts/app/views/generic/checkbox.jst.eco +++ b/app/assets/javascripts/app/views/generic/checkbox.jst.eco @@ -4,7 +4,7 @@ /> <%- @Icon('checkbox', 'icon-unchecked') %> <%- @Icon('checkbox-checked', 'icon-checked') %> - <%= row.name %> <% if row.note: %>- <%= row.note %><% end %> + <%= row.name %> <% if row.note: %>- <%- @T(row.note) %><% end %> <% end %> \ No newline at end of file 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/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/radio.jst.eco b/app/assets/javascripts/app/views/generic/radio.jst.eco index ea7282ac2..d6576004a 100644 --- a/app/assets/javascripts/app/views/generic/radio.jst.eco +++ b/app/assets/javascripts/app/views/generic/radio.jst.eco @@ -1,7 +1,7 @@
<% for row in @attribute.options: %> 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..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,25 +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: %> -
<% end %> -
+
<% for item in @items: %>
<%- @Icon(item.icon) %> 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..64569a55d 100644 --- a/app/assets/javascripts/app/views/generic/user_permission.jst.eco +++ b/app/assets/javascripts/app/views/generic/user_permission.jst.eco @@ -1,27 +1,59 @@
+<% 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/getting_started/email.jst.eco b/app/assets/javascripts/app/views/getting_started/email.jst.eco index b82d01e8a..1d2d0cb50 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 @@
- +
- +
- +
@@ -63,7 +63,7 @@
-
+

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

diff --git a/app/assets/javascripts/app/views/import/zendesk.jst.eco b/app/assets/javascripts/app/views/import/zendesk.jst.eco index 72d538d19..eeb7f95c9 100644 --- a/app/assets/javascripts/app/views/import/zendesk.jst.eco +++ b/app/assets/javascripts/app/views/import/zendesk.jst.eco @@ -61,6 +61,7 @@

<%- @T('%s Migration', 'Zendesk') %>

+
diff --git a/app/assets/javascripts/app/views/integration/base.jst.eco b/app/assets/javascripts/app/views/integration/base.jst.eco index 272620104..28b3996bb 100644 --- a/app/assets/javascripts/app/views/integration/base.jst.eco +++ b/app/assets/javascripts/app/views/integration/base.jst.eco @@ -10,9 +10,10 @@
<% if @description: %> <% for item in @description: %> -

<%- @T(item[0], item[1], item[2]) %>

+

<%- @T(item...) %>

<% end %> <% end %>
+
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/integration/exchange.jst.eco b/app/assets/javascripts/app/views/integration/exchange.jst.eco new file mode 100644 index 000000000..69385e1cf --- /dev/null +++ b/app/assets/javascripts/app/views/integration/exchange.jst.eco @@ -0,0 +1,71 @@ +
+
+

<%- @T('No %s configured.', 'Exchange') %>

+ +
+
+

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

+
+ + + + + + + + + +
<%- @T('Name') %> + <%- @T('Value') %> +
<%- @T('Endpoint') %> + <%= @config.endpoint %> +
<%- @T('User') %> + <%= @config.user %> +
<%- @T('Password') %> + <%= @M(@config.password) %> +
+ +

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

+ +

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

+ <% if _.isEmpty(@folders): %> + +
<%- @T('No Entries') %> +
+ <% else: %> + + + + + + +
<%- @T('Folder') %> + <% for folder_name in @folders: %> +
<%= folder_name %> + <% end %> +
+ <% end %> + +

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

+ <% if _.isEmpty(@config.attributes): %> + +
<%- @T('No Entries') %> +
+ <% else: %> + + + + + + +
<%- @T('Exchange') %> + <%- @T('Zammad') %> + <% for key, value of @config.attributes: %> +
<%= key %> + <%= value %> + <% end %> +
+ <% end %> + + +
diff --git a/app/assets/javascripts/app/views/integration/exchange_last_import.jst.eco b/app/assets/javascripts/app/views/integration/exchange_last_import.jst.eco new file mode 100644 index 000000000..8323972ad --- /dev/null +++ b/app/assets/javascripts/app/views/integration/exchange_last_import.jst.eco @@ -0,0 +1,54 @@ +
+

<%- @T('Last sync') %>

+ <% if _.isEmpty(@job.started_at): %> + <% if @job.result && @job.result.error: %> + + <% else if @job.result && @job.result.info: %> + + <% else: %> +

<%- @T('Job is waiting to get started...') %>

+ <% end %> + <% else: %> + <% if @job.finished_at: %> +

<%- @Ttimestamp(@job.started_at) %> - <%- @Ttimestamp(@job.finished_at) %>

+ <% if @job.result && @job.result.error: %> + + <% else if @job.result && @job.result.info: %> + + <% end %> + <% else: %> + <% if @job.result && @job.result.error: %> +

<%- @Ttimestamp(@job.started_at) %>

+ + <% else if !@countDone: %> +

<%- @Ttimestamp(@job.started_at) %> - <%- @T('Counting entries. This may take a while.') %>

+ <% else: %> +

<%- @Ttimestamp(@job.started_at) %> - <%- @T('Running...') %>

+
+ +
+ <% end %> + <% end %> + <% if !_.isEmpty(@job.result) && @countDone: %> +
    +
  • <%- @T('%s user to %s user', 'Exchange', 'Zammad') %> (<%= @countDone %>/<%= @job.result.sum %>): +
      +
    • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %> +
    +
  • + <% if !_.isEmpty(@job.result.folders): %> +
  • <%- @T('%s folders', 'Exchange') %>: +
      + <% for folder, result of @job.result.folders: %> +
    • <%- folder %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %> + <% end %> +
    +
  • + <% end %> +
+ <% end %> + <% if @job.finished_at: %> + + <% end %> + <% end %> +
diff --git a/app/assets/javascripts/app/views/integration/exchange_summary.jst.eco b/app/assets/javascripts/app/views/integration/exchange_summary.jst.eco new file mode 100644 index 000000000..0abf47bdf --- /dev/null +++ b/app/assets/javascripts/app/views/integration/exchange_summary.jst.eco @@ -0,0 +1,16 @@ +
    +
  • <%- @T('%s user to %s user', 'Exchange', 'Zammad') %> (<%= @countDone %>): +
      +
    • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %> +
    +
  • + <% if !_.isEmpty(@job.result.folders): %> +
  • <%- @T('%s folders', 'Exchange') %>: +
      + <% for folder, result of @job.result.folders: %> +
    • <%- folder %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %> + <% end %> +
    +
  • + <% end %> +
diff --git a/app/assets/javascripts/app/views/integration/exchange_user_attribute_row.jst.eco b/app/assets/javascripts/app/views/integration/exchange_user_attribute_row.jst.eco new file mode 100644 index 000000000..1d9dd1826 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/exchange_user_attribute_row.jst.eco @@ -0,0 +1,7 @@ + + + + +
+ <%- @Icon('trash') %> <%- @T('Remove') %> +
diff --git a/app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco b/app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco new file mode 100644 index 000000000..d1b5dc25f --- /dev/null +++ b/app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco @@ -0,0 +1,258 @@ + diff --git a/app/assets/javascripts/app/views/integration/idoit.jst.eco b/app/assets/javascripts/app/views/integration/idoit.jst.eco new file mode 100644 index 000000000..deec7f27f --- /dev/null +++ b/app/assets/javascripts/app/views/integration/idoit.jst.eco @@ -0,0 +1,25 @@ +
+

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

+
+ + + + + + + + + +
<%- @T('Name') %> + <%- @T('Value') %> +
<%- @T('API token') %> * + +
<%- @T('Endpoint') %> * + +
<%- @T('Client ID') %> + +
+
+ + +
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/integration/idoit_object_result.jst.eco b/app/assets/javascripts/app/views/integration/idoit_object_result.jst.eco new file mode 100644 index 000000000..e2d4dbe76 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/idoit_object_result.jst.eco @@ -0,0 +1,25 @@ +
+<% if _.isEmpty(@items): %> + <%- @T('none') %> +<% else: %> + + + + + + + + + + <% for item in @items: %> + + + + + + + + <% end %> + +
<%- @T('ID') %><%- @T('Name') %><%- @T('Status') %><%- @T('Link') %>
<%= item.id %><%= item.title %><%= item.cmdb_status_title %>i-doit
+<% end %> diff --git a/app/assets/javascripts/app/views/integration/idoit_object_selector.jst.eco b/app/assets/javascripts/app/views/integration/idoit_object_selector.jst.eco new file mode 100644 index 000000000..fa63a3604 --- /dev/null +++ b/app/assets/javascripts/app/views/integration/idoit_object_selector.jst.eco @@ -0,0 +1,5 @@ + +
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/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco b/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco index 2fb84f854..75c44a713 100644 --- a/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco +++ b/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco @@ -33,13 +33,13 @@
  • <%- @T('%s user to %s user', 'LDAP', 'Zammad') %> (<%= @countDone %>/<%= @job.result.sum %>):
      -
    • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %> +
    • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>, <%= @job.result.deactivated %> <%- @T('deactivated') %>
    <% if !_.isEmpty(@job.result.roles): %>
  • <%- @T('%s groups to %s roles assignments', 'LDAP', 'Zammad') %>:
      <% for role, result of @job.result.roles: %> -
    • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %> +
    • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>, <%= result.deactivated %> <%- @T('deactivated') %> <% end %>
    <% end %> diff --git a/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco b/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco index 2cac7c834..2b79071c1 100644 --- a/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco +++ b/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco @@ -1,14 +1,14 @@
    • <%- @T('%s user to %s user', 'LDAP', 'Zammad') %> (<%= @countDone %>):
        -
      • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %> +
      • <%- @T('Users') %>: <%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>, <%= @job.result.deactivated %> <%- @T('deactivated') %>
    • <% if !_.isEmpty(@job.result.roles): %>
    • <%- @T('%s groups to %s roles assignments', 'LDAP', 'Zammad') %>:
        <% for role, result of @job.result.roles: %> -
      • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %> +
      • <%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>, <%= result.deactivated %> <%- @T('deactivated') %> <% end %>
    • diff --git a/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco b/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco index 1918d3eed..5710a9ad7 100644 --- a/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco +++ b/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco @@ -19,7 +19,7 @@ <%- @T('Host') %> - +
@@ -70,7 +70,7 @@ <%- @T('Bind User') %> - + <%- @T('Bind Password') %> @@ -203,7 +203,7 @@ <%- @T('User filter') %> - + <%- @T('Users without assigned LDAP groups') %> 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/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/login.jst.eco b/app/assets/javascripts/app/views/login.jst.eco index 00b086f48..135c1c859 100644 --- a/app/assets/javascripts/app/views/login.jst.eco +++ b/app/assets/javascripts/app/views/login.jst.eco @@ -24,7 +24,7 @@
- +
diff --git a/app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco b/app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco new file mode 100644 index 000000000..4e72db9fe --- /dev/null +++ b/app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco @@ -0,0 +1,29 @@ +
+ + + + + + +
<%- @T('Key') %> + <%- @T('Action') %> +
+ + + + + + +
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/object_manager/edit.jst.eco b/app/assets/javascripts/app/views/object_manager/edit.jst.eco index 7ba77418e..c48e54894 100644 --- a/app/assets/javascripts/app/views/object_manager/edit.jst.eco +++ b/app/assets/javascripts/app/views/object_manager/edit.jst.eco @@ -6,6 +6,7 @@
date time settings
date settings
select settings
+
tree selection settings
checkbox settings
boolean settings
richtext settings
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/assets/javascripts/app/views/profile/out_of_office.jst.eco b/app/assets/javascripts/app/views/profile/out_of_office.jst.eco new file mode 100644 index 000000000..e2ca357d2 --- /dev/null +++ b/app/assets/javascripts/app/views/profile/out_of_office.jst.eco @@ -0,0 +1,34 @@ + + +
+
+
+
+

+ <% if @localData.out_of_office is true: %> + <%- @Icon('status', 'ok inline') %> + <% else: %> + <%- @Icon('status', 'error inline') %> + <% end %>

+
+
+
+
<%- @T('From') %>
+
+
+
+
<%- @T('Till') %>
+
+
+
+ + +
+
+
<%- @Ti('Disable') %>
+
<%- @Ti('Enable') %>
+
+
+
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 @@

<%- @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/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 @@ +
diff --git a/app/assets/javascripts/app/views/ticket_zoom.jst.eco b/app/assets/javascripts/app/views/ticket_zoom.jst.eco index 36ef86765..f45d5984b 100644 --- a/app/assets/javascripts/app/views/ticket_zoom.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom.jst.eco @@ -1,21 +1,23 @@
-
+
<%- @C('ticket_hook') %> <%- @ticket.number %> -
-
-
+
+
+
-
-
+
+
+
+
-
-
+
+
diff --git a/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco index 4f160381a..7f7142818 100644 --- a/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco @@ -41,13 +41,19 @@
-
+
-
+
+
+
+
+ +
+
diff --git a/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco new file mode 100644 index 000000000..7997f7e8f --- /dev/null +++ b/app/assets/javascripts/app/views/ticket_zoom/setting.jst.eco @@ -0,0 +1,3 @@ +
+ <%- @Icon('cog', 'dropdown-icon') %> +
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/ticket_zoom/sidebar_idoit.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/sidebar_idoit.jst.eco new file mode 100644 index 000000000..3b4dbbb22 --- /dev/null +++ b/app/assets/javascripts/app/views/ticket_zoom/sidebar_idoit.jst.eco @@ -0,0 +1,13 @@ +<% for object in @objects: %> + +<% end %> diff --git a/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco b/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco index 143de9e84..0f395dbf9 100644 --- a/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco +++ b/app/assets/javascripts/app/views/time_accounting/by_ticket.jst.eco @@ -13,6 +13,8 @@ <%- @T('Agent') %> <%- @T('Time Units') %> <%- @T('Time Units Total') %> + <%- @T('Created at') %> + <%- @T('Closed at') %> <% for row in @rows: %> @@ -24,6 +26,8 @@ <%= row.agent %> <%= row.time_unit %> <%= row.ticket.time_unit %> + <%- @humanTime(row.ticket.created_at) %> + <%- @humanTime(row.ticket.close_at) %> <% end %> 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/user.jst.eco b/app/assets/javascripts/app/views/user.jst.eco index 749c7c019..5ba6cb12a 100644 --- a/app/assets/javascripts/app/views/user.jst.eco +++ b/app/assets/javascripts/app/views/user.jst.eco @@ -12,8 +12,8 @@
- <%- @Icon('magnifier') %> + <%- @Icon('magnifier') %>
diff --git a/app/assets/javascripts/app/views/widget/http_log.jst.eco b/app/assets/javascripts/app/views/widget/http_log.jst.eco index a94ff96ee..5682e9f4c 100644 --- a/app/assets/javascripts/app/views/widget/http_log.jst.eco +++ b/app/assets/javascripts/app/views/widget/http_log.jst.eco @@ -1,27 +1,31 @@
- -

<%- @T('Recent logs') %>

-
+

<%- @T('Recent logs') %>

+<% if @description: %> + <% for item in @description: %> +

<%- @T(item...) %>

+ <% end %> +<% end %> +
<% if !@records.length: %> - -
<%- @T('No Entries') %> -
+ +
<%- @T('No Entries') %> +
<% else: %> - - - - - +
<%- @T('Direction') %> - <%- @T('Request') %> - <%- @T('Created at') %> -
+ + + + <% for record in @records: %> - - + -
<%- @T('Direction') %> + <%- @T('Request') %> + <%- @T('Created at') %> +
<%- @T(record.direction) %> - <%= record.status %> <%= record.method %> <%= record.url %> - <%- @humanTime(record.created_at) %> +
<%- @T(record.direction) %> + <%= record.status %> <%= record.method %> <%= record.url %> + <%- @humanTime(record.created_at) %> <% end %> -
+ + <% end %> -
+
diff --git a/app/assets/javascripts/app/views/widget/script_snipped.jst.eco b/app/assets/javascripts/app/views/widget/script_snipped.jst.eco new file mode 100644 index 000000000..8325fc920 --- /dev/null +++ b/app/assets/javascripts/app/views/widget/script_snipped.jst.eco @@ -0,0 +1,8 @@ +
+

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

+<% if @description: %> + <% for item in @description: %> +

<%- @T(item...) %>

+ <% end %> +<% end %> +
<%- @content %>
diff --git a/app/assets/javascripts/app/views/widget/template.jst.eco b/app/assets/javascripts/app/views/widget/template.jst.eco index e2cbc561a..85fef91e7 100644 --- a/app/assets/javascripts/app/views/widget/template.jst.eco +++ b/app/assets/javascripts/app/views/widget/template.jst.eco @@ -4,8 +4,10 @@
- - +
+ + +

@@ -13,8 +15,10 @@
- - +
+ +
+
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' ) %>
--> - + ×
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/app/assets/stylesheets/bootstrap.css b/app/assets/stylesheets/bootstrap.css index b497e60ba..794beac23 100644 --- a/app/assets/stylesheets/bootstrap.css +++ b/app/assets/stylesheets/bootstrap.css @@ -3078,7 +3078,7 @@ fieldset[disabled] .navbar-inverse .btn-link:focus { } .pagination { display: inline-block; - padding-left: 0; + padding: 0; margin: 20px 0; border-radius: 4px; } diff --git a/app/assets/stylesheets/svg-dimensions.css b/app/assets/stylesheets/svg-dimensions.css index 755a6dbed..d86c8b006 100644 --- a/app/assets/stylesheets/svg-dimensions.css +++ b/app/assets/stylesheets/svg-dimensions.css @@ -55,6 +55,7 @@ .icon-mute { width: 16px; height: 16px; } .icon-note { width: 16px; height: 16px; } .icon-oauth2-button { width: 29px; height: 24px; } +.icon-office365-button { width: 29px; height: 24px; } .icon-one-ticket { width: 48px; height: 10px; } .icon-organization { width: 16px; height: 16px; } .icon-outbound-calls { width: 17px; height: 17px; } diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 616fff940..2a55670c1 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -39,6 +39,30 @@ $highlight-color: hsl(205,90%,60%); } } +/* sets LTR and RTL within the same style call */ +@mixin bidi-style($prop, $value, $inverse-prop, $default-value) { + #{$prop}: $value; + + html[dir=rtl] & { + #{$inverse-prop}: $value; + #{$prop}: $default-value; + } +} + +/* adds a property only in RTL */ +@mixin rtl($prop, $value) { + html[dir=rtl] & { + #{$prop}: $value; + } +} + +/* adds a property only in LTR */ +@mixin ltr($prop, $value) { + html[dir=ltr] & { + #{$prop}: $value; + } +} + html { height: 100%; } @@ -130,7 +154,7 @@ blockquote { } ol, ul { - padding-left: 20px; + padding-inline-start: 20px; } #app { @@ -362,7 +386,7 @@ pre code.hljs { } &.btn--icon--last .icon { - margin-left: 5px; // so far only used in ticket_zoom secondaryAction dropup + @include bidi-style(margin-left, 5px, margin-right, 0); // so far only used in ticket_zoom secondaryAction dropup } &:focus { @@ -393,12 +417,13 @@ pre code.hljs { .icon { vertical-align: middle; - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); } } &.is-disabled, - &[disabled] { + &[disabled], + &:disabled { pointer-events: none; cursor: not-allowed; opacity: .33; @@ -415,7 +440,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; @@ -526,7 +551,7 @@ pre code.hljs { border: none; background: none; vertical-align: baseline; - text-align: left; + text-align: start; .icon { fill: currentColor; @@ -575,11 +600,11 @@ pre code.hljs { } &.space-left { - margin-left: 0; + @include bidi-style(margin-left, 0, margin-right, -10px); } &.space-right { - margin-right: 0; + @include bidi-style(margin-right, 0, margin-left, -10px); } } @@ -597,16 +622,17 @@ pre code.hljs { } &.btn--split--first { - border-radius: 3px 0 0 3px; + @include bidi-style(border-radius, 3px 0 0 3px, border-radius, 0 3px 3px 0); } &.btn--split, &.btn--split--last { border-radius: 0; - border-left: none; - margin-left: 0 !important; + @include bidi-style(border-left-width, 0, border-right-width, 1px); + @include ltr(margin-left, 0 !important); + @include rtl(margin-right, 0 !important); } &.btn--split--last { - border-radius: 0 3px 3px 0; + @include bidi-style(border-radius, 0 3px 3px 0, border-radius, 3px 0 0 3px); } &.btn--dropdown { @@ -627,32 +653,34 @@ pre code.hljs { .btn + .btn, .btn + .buttonDropdown, .buttonDropdown + .buttonDropdown { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); } .btn + .btn.align-right { - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); } .btn.align-right ~ .btn { - margin-left: 15px; + @include bidi-style(margin-left, 15px, margin-right, 0); } .vertical > .btn:not(.hidden) + .btn { - margin-left: 0; + @include bidi-style(margin-left, 0, margin-right, 0); margin-top: 10px; } .btn--download .icon-download { margin-right: 6px; + @include rtl(margin-right, -10px); margin-top: 4px; margin-left: -10px; + @include rtl(margin-left, 6px); vertical-align: top; fill: white; } .btn-label { - margin-left: 7px; + @include bidi-style(margin-left, 7px, margin-right, 0); } .visibility-change { @@ -705,7 +733,7 @@ pre code.hljs { } .btn + .btn { - margin-left: 0; + @include bidi-style(margin-left, 0, margin-right, 10px); } .btn--text, .btn--textLarge { @@ -766,15 +794,17 @@ pre code.hljs { } &:not(:last-child):not(:only-child) { - border-right: none; + @include bidi-style(border-right-width, 0, border-left-width, 1px); } &:first-child { border-radius: 5px 0 0 5px; + @include rtl(border-radius, 0 5px 5px 0); } &:last-child { border-radius: 0 5px 5px 0; + @include rtl(border-radius, 5px 0 0 5px); } &:only-child { @@ -782,7 +812,7 @@ pre code.hljs { } .badge { - margin: 0 5px 0 10px; + @include bidi-style(margin, 0 5px 0 10px, margin, 0 10px 0 5px); background: hsla(210,50%,10%,.24); } @@ -840,7 +870,7 @@ pre code.hljs { vertical-align: top; border-radius: 9px; background: hsl(198,18%,86%); - margin-right: 3px; + @include bidi-style(margin-right, 3px, margin-left, 0); flex-shrink: 0; &:empty { @@ -858,6 +888,7 @@ pre code.hljs { min-width: 0; padding: 0; margin-right: 0; + @include bidi-style(margin-right, 0, margin-left, 0); font-size: inherit; font-weight: inherit; text-align: inherit; @@ -871,7 +902,7 @@ pre code.hljs { .key-value { td:first-child { - padding-right: 10px; + @include bidi-style(padding-right, 10px, padding-left, 0); color: #999; } } @@ -934,15 +965,15 @@ table { } .table-column-sortIcon { - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); } th.align-right { .table-column-title { - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); } .table-column-sortIcon { - margin-left: 0; + @include bidi-style(margin-left, 0, margin-right, 0); } } @@ -962,12 +993,12 @@ th.align-right { .table-col-resize { position: absolute; - right: 0; + @include bidi-style(right, 0, left, auto); top: 0; height: 100%; cursor: col-resize; padding: 10px; - margin-right: -10px; + @include bidi-style(margin-right, -10px, margin-left, 0); z-index: 1; &:after { @@ -1018,7 +1049,7 @@ th.align-right { fill: hsl(206,7%,33%); width: 8px; height: 8px; - margin-left: 3px; + @include bidi-style(margin-left, 3px, margin-right, 0); margin-top: -2px; vertical-align: middle; } @@ -1044,7 +1075,7 @@ th.align-right { align-items: center; justify-content: center; position: relative; - + .icon-checked { color: black; } @@ -1074,7 +1105,7 @@ th.align-right { &.checkbox-replacement--inline, &.radio-replacement--inline { display: inline-flex; - margin-right: 3px; + @include bidi-style(margin-right, 3px, margin-left, 0); } input { @@ -1101,7 +1132,7 @@ th.align-right { } + .label-text { - margin-left: 0; + @include bidi-style(margin-left, 0, margin-right, 3px); } } @@ -1204,7 +1235,7 @@ th.align-right { } #task [data-type="close"] { - margin-left: 5px; + @include bidi-style(margin-left, 5px, margin-right, 0); font-size: 13px; top: 1px; } @@ -1213,7 +1244,7 @@ th.align-right { } #task .taskbar-new { - text-align: right; + text-align: end; padding-right: 12px; } @@ -1279,7 +1310,7 @@ label, font-weight: normal; letter-spacing: 0.05em; margin-bottom: 4px; - text-align: left; + text-align: start; padding: 0; /* user-select: none; disabled because of chrome51 https://github.com/martini/zammad/issues/183 */ } @@ -1297,13 +1328,23 @@ label, margin: 0; text-transform: none; display: inline; - white-space: nowrap; /* for labels in tables that might get crushed view: calendar_subscriptions */ } + +table { + .inline-label, + .label-success, + .label-warning, + .label-danger { + white-space: nowrap; /* for labels in tables that might get crushed view: calendar_subscriptions */ + } +} + .inline-label { color: hsl(206,7%,28%); } + .label-text { - margin-left: 3px; + @include bidi-style(margin-left, 3px, margin-right, 0); user-select: none; cursor: pointer; } @@ -1396,6 +1437,7 @@ fieldset > .form-group { .merge-target, .merge-source { flex: 1; + width: 33%; display: flex; flex-direction: column; justify-content: flex-end; @@ -1526,7 +1568,7 @@ fieldset > .form-group { opacity: 0.2; top: -2px; position: relative; - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); .icon-help { display: block; @@ -1622,6 +1664,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] { @@ -1691,7 +1751,7 @@ textarea.form-control { } select.form-control:not([multiple]) { - padding-right: 34px; + @include bidi-style(padding-right, 34px, padding-left, 12px); word-wrap: normal; } @@ -1699,13 +1759,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; @@ -1727,7 +1780,7 @@ select.form-control:not([multiple]) { .form-control + .icon-arrow-down, .dropdown-arrow { position: absolute; - right: 12px; + @include bidi-style(right, 12px, left, auto); top: 50%; margin-top: -3px; fill: black; @@ -1794,9 +1847,9 @@ input.has-error { input, .form-control { flex: 1; - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; + @include bidi-style(border-right-width, 0, border-left-width, 1px); + @include bidi-style(border-top-right-radius, 0, border-top-left-radius, 3px); + @include bidi-style(border-bottom-right-radius, 0, border-bottom-left-radius, 3px); &:focus + .controls-button { .controls-button-inner { @@ -1812,11 +1865,13 @@ input.has-error { content: ""; position: absolute; left: 0; + @include rtl(left, -3px); top: -3px; right: -3px; + @include rtl(right, 0); bottom: -3px; background: hsl(201,62%,90%); - border-radius: 0 7px 7px 0; + @include bidi-style(border-radius, 0 7px 7px 0, border-radius, 7px 0 0 7px); } } } @@ -1839,7 +1894,7 @@ input.has-error { background: white; position: relative; border: 1px solid hsl(0, 0%, 90%); - border-radius: 0 3px 3px 0; + @include bidi-style(border-radius, 0 3px 3px 0, border-radius, 3px 0 0 3px); } .searchfield { @@ -1847,7 +1902,7 @@ input.has-error { margin-bottom: 20px; .icon { - left: 15px; + @include bidi-style(left, 15px, right, auto); top: 12px; width: 17px; height: 17px; @@ -1859,6 +1914,8 @@ input.has-error { appearance: textfield; border-radius: 19px; padding: 0 17px 0 42px; + @include rtl(padding, 0 42px 0 17px); + will-change: transform; &.is-empty + .empty-search { visibility: hidden; @@ -1903,7 +1960,7 @@ input.has-error { svg, .icon { - margin-right: 14px; + @include bidi-style(margin-right, 14px, margin-left, 0); } h2 { @@ -2024,6 +2081,7 @@ kbd { .pagination { margin: 0 0 0 19px; + @includ rtl(margin, 0 19px 0 0); display: flex; } @@ -2033,6 +2091,18 @@ kbd { width: 31px; height: 31px; border-color: #e5e5e5; + @include bidi-style(margin-left, -1px, margin-right, 0); +} + +.pagination > li:first-child > a, +.pagination > li:first-child > span { + @include bidi-style(border-top-left-radius, 4px, border-top-right-radius, 0); + @include bidi-style(border-bottom-left-radius, 4px, border-bottom-right-radius, 0); +} +.pagination > li:last-child > a, +.pagination > li:last-child > span { + @include bidi-style(border-top-right-radius, 4px, border-top-left-radius, 0); + @include bidi-style(border-bottom-right-radius, 4px, border-bottom-left-radius, 0); } .pagination > .active > a, @@ -2047,6 +2117,7 @@ kbd { .pagination-counter { margin: 0 0 0 19px; + @include rtl(margin, 0 19px 0 0); line-height: 33px; color: #9c9c9b; } @@ -2072,7 +2143,7 @@ kbd { align-items: center; .zammad-switch { - margin-right: 9px; + @include bidi-style(margin-right, 9px, margin-left, 0); } h1, @@ -2084,18 +2155,18 @@ kbd { .page-header-center { justify-self: center; - padding-left: 9px; + @include bidi-style(padding-left, 9px, padding-right, 0); margin: 0 auto; & + .page-header-meta { - margin-left: 0; + @include bidi-style(margin-left, 0, margin-right, auto); flex: none; } } .page-header-meta { - margin-left: auto; - padding-left: 20px; + @include bidi-style(margin-left, auto, margin-right, 0); + @include bidi-style(padding-left, 20px, padding-right, 0); display: flex; justify-content: flex-end; flex: 1; @@ -2108,7 +2179,7 @@ kbd { } .btn + .btn { - margin-left: 9px; + @include bidi-style(margin-left, 9px, margin-right, 0); } } @@ -2132,9 +2203,9 @@ kbd { } .page-aside { - padding-right: 20px; - border-right: 1px solid hsl(0,0%,90%); - margin-right: 20px; + @include bidi-style(padding-right, 20px, padding-left, 0); + @include bidi-style(border-right, 1px solid hsl(0,0%,90%), border-left, none); + @include bidi-style(margin-right, 20px, margin-left, 0); width: 240px; flex-shrink: 0; flex-grow: 0; @@ -2152,13 +2223,13 @@ kbd { } .page-loading-label { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); margin-top: 1px; } .dropdown-menu .count { padding-top: 1px; - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); } .help-block { @@ -2291,6 +2362,7 @@ kbd { justify-content: center; color: hsl(233,7%,26%); margin: auto 0 34px -16px; + @include rtl(margin, auto -16px 34px 0); cursor: default; position: absolute; bottom: 0; @@ -2298,12 +2370,12 @@ kbd { right: 0; .icon-logo { - margin-right: 8px; + @include bidi-style(margin-right, 8px, margin-left, 0); margin-top: -11px; } .logotype { - margin-left: 7px; + @include bidi-style(margin-left, 7px, margin-left, 0); margin-top: -3px; fill: hsl(225,9%,27%); } @@ -2328,7 +2400,7 @@ kbd { } .fullscreen-body { - text-align: left; + text-align: start; display: inline-block; } @@ -2387,7 +2459,7 @@ ol.tabs li { justify-content: center; .arrow { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); opacity: 0.75; } @@ -2407,20 +2479,23 @@ ol.tabs li { .tab:first-child { border-radius: 3px 0 0 3px; + @include rtl(border-radius, 0 3px 3px 0); + @include bidi-style(border-right-width, 1px, border-right-width, 0); } .tab:last-child:not(:only-child) { border-radius: 0 3px 3px 0; - border-right: none; + @include bidi-style(border-right-width, 0, border-right-width, 1px); + @include rtl(border-radius, 3px 0 0 3px); } .tab:only-child { border-radius: 3px; - border-right: none; + @include bidi-style(border-right-width, 0, border-right-width, 1px); } .tab-badge { - margin-left: 3px; + @include bidi-style(margin-left, 3px, margin-right, 0); font-size: 0.95em; } @@ -2445,10 +2520,12 @@ ol.tabs li { &:first-child { border-radius: 8px 0 0 8px; + @include rtl(border-radius, 0 8px 8px 0); } &:last-child { border-radius: 0 8px 8px 0; + @include rtl(border-radius, 8px 0 0 8px); } &:only-child { @@ -2459,6 +2536,8 @@ ol.tabs li { .tab-dropdown { padding-left: 18px; padding-right: 15px; + @include rtl(padding-left, 15px); + @include rtl(padding-right, 18px); } } @@ -2549,6 +2628,10 @@ ol.tabs li { background: hsl(0,0%,15%); } + &.auth-provider--office365 { + background: hsl(15,100%,47%); + } + .provider-name { flex: 1; } @@ -2556,8 +2639,7 @@ ol.tabs li { .provider-icon { width: 29px; height: 24px; - margin-right: 10px; - margin-top: 1px; + @include bidi-style(margin-right, 10px, margin-left, 0); } } @@ -2630,7 +2712,7 @@ ol.tabs li { .color-swatch { padding: 2px; - margin: -2px 0; + margin: -2px 0 -4px; cursor: pointer; /* :after technique for bigger click area */ @@ -2706,7 +2788,7 @@ form { margin-top: 10px; .btn + .btn:not(.align-right) { - margin-left: 20px; + @include bidi-style(margin-left, 20px, margin-right, 10px); } } @@ -2716,7 +2798,7 @@ form a.standalone { } form a.standalone.align-right { - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); } footer { @@ -2724,6 +2806,8 @@ footer { padding-top: 10px; padding-left: 10px; padding-right: 22px; + @include rtl(padding-left, 22px); + @include rtl(padding-right, 10px); } .can-move { @@ -2751,7 +2835,7 @@ footer { right: 20px; } .customer_info textarea { - padding-left: 10px; + @include bidi-style(padding-left, 10px, padding-right, 0); width: 100%; border-color: #eee; } @@ -2777,7 +2861,7 @@ footer { .tabsHolder { flex: 1; - margin-right: 20px; + @include bidi-style(margin-right, 20px, margin-left, 0); min-width: 0; /* Firefox bug fix */ } @@ -2838,12 +2922,12 @@ footer { } .bulkAction .btn--text { - margin-right: 0; + @include bidi-style(margin-right, 0, margin-left, 16px); } .bulkAction-controls { margin-top: 10px; - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); } .panel { @@ -2926,7 +3010,7 @@ footer { width: 60px; } .sub_attribute .controls { - margin-left: 80px; + @include bidi-style(margin-left, 80px, margin-right, 0); } .splash { @@ -2943,7 +3027,7 @@ footer { color: hsl(0,0%,45%); .icon { - margin-right: 10px; + @include bidi-style(margin-right, 10px, margin-left, 0); filter: grayscale(90%); } } @@ -2968,7 +3052,7 @@ footer { .menu .badge { background: $ok-color; color: hsl(233,10%,16%); - margin-right: 8px; + @include bidi-style(margin-right, 8px, margin-left, 0); } .menu .zammad-switch { @@ -3029,7 +3113,7 @@ footer { } .menu-item-icon { - margin-right: 15px; + @include bidi-style(margin-right, 15px, margin-left, 0); width: 24px; height: 24px; fill: hsl(206,7%,37%); @@ -3074,11 +3158,11 @@ footer { } .tasks--standalone .icon-task-state { - margin-right: 4px; + @include bidi-style(margin-right, 4px, margin-left, 0); } .nav-tab { - padding: 10px 15px 9px 0; + @include bidi-style(padding, 10px 15px 9px 0, padding, 10px 0 9px 15px); position: relative; color: #808080; display: flex; @@ -3096,7 +3180,7 @@ footer { } .navigation .nav-tab-name { - text-align: left; + text-align: start; } .tasks-navigation .nav-tab-icon .error { @@ -3163,10 +3247,10 @@ footer { .nav-tab-close { position: absolute; - right: 0; + @include bidi-style(right, 0, left, auto); top: 0; height: 100%; - padding: 0 16px 0 0; + @include bidi-style(padding-right, 16px, padding-left, 0); visibility: hidden; @extend .u-clickable; display: flex; @@ -3203,8 +3287,8 @@ footer { } .icon-task-state { - vertical-align: middle; - margin-top: -2px; + margin-top: 1px; + display: block; } .nav-tab-icon .icon-task-state { @@ -3255,12 +3339,13 @@ footer { .search { padding: 11px 5px 4px 10px; + @include rtl(padding, 11px 10px 4px 0px); border-bottom: 1px solid rgba(240,250,255,.05); flex-shrink: 0; display: flex; background-color: inherit; } - + .search-holder { flex: 1; border-radius: 15px; @@ -3315,13 +3400,14 @@ footer { position: absolute; top: 8px; left: 10px; + @include bidi-style(left, 10px, right, auto); z-index: 2; opacity: 0.5; fill: white; } .search.focused .search-holder { - margin-right: -46px; + @include bidi-style(margin-right, -46px, margin-left, 0); } .search.focused .logo { @@ -3387,12 +3473,12 @@ footer { padding: 9px 15px 8px 0; margin-bottom: 7px; height: auto !important; - + .nav-tab-icon { width: 18px; margin-left: 10px; margin-right: 10px; - + .icon { width: 18px; height: 14px; @@ -3437,7 +3523,7 @@ footer { align-items: center; justify-content: center; } - + .user-menu .list-button *:not(.dropdown-nose):not(.icon-crown) { position: relative; } @@ -3553,12 +3639,12 @@ footer { vertical-align: bottom; position: relative; flex-shrink: 0; - + &.size-30 { width: 30px; height: 30px; } - + .icon-crown { position: absolute; width: 28px; @@ -3609,6 +3695,16 @@ footer { opacity: 0.5; } + &--inactive { + filter: grayscale(100%); + opacity: 0.2; + } + + &--vacation { + filter: grayscale(70%); + opacity: 1; + } + &--unique { background-image: image_url("/assets/images/avatar-bg.png"); background-size: 300px 226px; @@ -3644,7 +3740,7 @@ footer { display: flex; align-items: center; justify-content: center; - + .icon-organization { fill: currentColor; } @@ -3659,7 +3755,7 @@ footer { &--group { overflow: hidden; - + .icon { fill: white; position: absolute; @@ -3712,9 +3808,9 @@ footer { padding: 20px; color: hsl(60,1%,34%); background: white; - border-right: 1px solid #e6e6e6; + @include bidi-style(border-right, 1px solid #e6e6e6, border-left, none); overflow: auto; - + @include small-desktop { &.optional { display: none; @@ -3815,8 +3911,10 @@ footer { .nav-pills > li > a > .badge { margin-left: auto; - padding-left: 10px; margin-right: 5px; + @include bidi-style(padding-left, 10px, padding-right, 0); + @include rtl(margin-left, 5px); + @include rtl(margin-right, auto); } a.list-group-item.active > .badge, @@ -3854,6 +3952,7 @@ footer { border: none; color: hsl(206,7%,28%); box-shadow: 0 1px 14px rgba(0,8,14,.25); + @include rtl(text-align, right); } .popover-body { @@ -3871,25 +3970,35 @@ footer { margin-bottom: 21px; } - .popover.right { margin-left: 4px; } + .popover.right { + margin-left: 4px; + } .popover.right > .arrow { border-right: none; left: -9px; } - .popover.top { margin-bottom: 9px; } + .popover.top { + margin-bottom: 9px; + } .popover.top > .arrow { border-top: none; bottom: -9px; } - .popover.left { margin-right: 9px; } + .popover.left { + margin-right: 9px; + margin-left: 0; + } + .popover.left > .arrow { border-left: none; right: -9px; } - .popover.bottom { margin-top: 9px; } + .popover.bottom { + margin-top: 9px; + } .popover.bottom > .arrow { border-bottom: none; top: -9px; @@ -3949,13 +4058,13 @@ footer { .popover--notifications { padding: 0; left: $navigationWidth; + @include rtl(right, $navigationWidth); margin: 8px 2px; max-height: calc(100% - 16px); width: auto; max-width: 400px; min-width: 350px; flex-direction: column; - @extend .zIndex-2; &.is-visible { display: flex; @@ -3968,6 +4077,8 @@ footer { .arrow { top: 23px !important; left: -11px; + @include rtl(left, 408px); + @include rtl(transform, rotate(180deg)); } .popover-content { @@ -4012,7 +4123,7 @@ footer { .popover-notificationsCounter { color: #e25253; - padding-left: 3px; + @include bidi-style(padding-left, 3px, padding-right, 0); } .user-popover, @@ -4053,7 +4164,7 @@ footer { .total-tickets { height: 83px; width: 48px; - margin-right: 4px; + @include bidi-style(margin-right, 4px, margin-left, 0); margin-bottom: -9px; } @@ -4190,20 +4301,20 @@ footer { .stat-legend { margin-top: 30px; - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); display: flex; } .stat-legendEntry { font-size: 11px; line-height: 1; - margin-left: 20px; + @include bidi-style(margin-left, 20px, margin-right, 0); background: none !important; } .stat-circle { margin-bottom: -1px; - margin-right: 3px; + @include bidi-style(margin-right, 3px, margin-left, 0); width: 10px; height: 10px; border-radius: 100%; @@ -4239,6 +4350,8 @@ footer { margin-left: 19px; margin-bottom: 15px; margin-right: 26px; + @include rtl(margin-right, 19px); + @include rtl(margin-left, 26px); } .activity-entries { @@ -4263,7 +4376,7 @@ footer { } &.activity-entry--removeable { - padding-right: 0; + @include bidi-style(padding-right, 0, margin-left, 17px); } &:not(:hover) .activity-remove { @@ -4281,17 +4394,19 @@ footer { &.activity-entry--removeable:not(:last-child) .activity-body:after { right: 17px; + @include bidi-style(right, 17px, left, 0); } } .activity-avatar { padding: 16px 2px 0; - margin-right: 10px; + @include bidi-style(margin-right, 10px, margin-left, 0); flex-shrink: 0; } .activity-body { padding: 16px 0 16px 2px; + @include rtl(padding, 16px 2px 16px 0); position: relative; display: flex; flex: 1; @@ -4321,6 +4436,8 @@ footer { cursor: pointer; padding-left: 10px; padding-right: 27px; + @include rtl(padding-left, 27px); + @include rtl(padding-right, 10px); .activity-remove-icon-holder { width: 19px; @@ -4486,7 +4603,7 @@ footer { justify-content: flex-end; align-items: center; padding: 28px 0 0 0; - margin-right: -40px; + @include bidi-style(margin-right, -40px, margin-left, 0); } .icon-marker { @@ -4506,7 +4623,7 @@ footer { .ticketZoom > .overview-navigator { margin-top: 32px; - padding-left: 20px; + @include bidi-style(padding-left, 20px, padding-right, 0); } .ticket-article, @@ -4528,7 +4645,7 @@ footer { margin-bottom: 8px; padding: 0 7px; text-align: center; - + .ticketZoom-header & { &:hover, &:focus { @@ -4605,13 +4722,14 @@ footer { } .article-meta-value { - margin-left: 8px; + @include bidi-style(margin-left, 8px, margin-right, 0); } .article-meta-icon { fill: white; vertical-align: top; margin: 2px 3px 0 0; + @include rtl(margin, 2px 0 0 3px); } .article-meta .text-muted { @@ -4801,7 +4919,7 @@ footer { } .article-action-icon { - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); vertical-align: top; width: 17px; height: 17px; @@ -4922,11 +5040,11 @@ footer { } .pop-selectable:first-child { - border-radius: 4px 0 0 4px; + @include bidi-style(border-radius, 4px 0 0 4px, border-radius, 0 4px 4px 0); } .pop-selectable:last-child { - border-radius: 0 4px 4px 0; + @include bidi-style(border-radius, 0 4px 4px 0, border-radius, 4px 0 0 4px); } .pop-selectable:only-child { @@ -4965,7 +5083,7 @@ footer { } .recipient-count { - margin-left: 3px; + @include bidi-style(margin-left, 3px, margin-right, 0); margin-top: 1px; line-height: 1; } @@ -5011,7 +5129,7 @@ footer { } .list-entry-name { - margin-left: 18px; + @include bidi-style(margin-left, 18px, margin-right, 0); } .list-entry-type { @@ -5037,11 +5155,13 @@ footer { .list-entry-type div:first-child { border-radius: 3px 0 0 3px; + @include rtl(border-radius, 0 3px 3px 0); } .list-entry-type div:last-child { border-left: none; border-radius: 0 3px 3px 0; + @include rtl(border-radius, 3px 0 0 3px); } .recipient-list input { @@ -5113,6 +5233,7 @@ footer { .attachment { font-size: 13px; padding: 1px 10px 1px 7px; + @include rtl(padding, 1px 7px 1px 10px); cursor: default; position: relative; display: flex; @@ -5123,7 +5244,7 @@ footer { } .attachment-name { - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); min-width: 0; @extend .u-highlight; } @@ -5131,7 +5252,7 @@ footer { .attachment-size { white-space: nowrap; float: right; - margin-right: 10px; + @include bidi-style(margin-right, 10px, margin-left, 0); } .attachment-delete { @@ -5139,7 +5260,7 @@ footer { text-decoration: underline; display: none; white-space: nowrap; - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); cursor: pointer; user-select: none; } @@ -5157,7 +5278,7 @@ footer { fill: hsl(198,18%,72%); width: 9px; height: 9px; - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); } .attachmentPlaceholder-inputHolder { @@ -5175,7 +5296,7 @@ footer { .attachmentUpload-cancel { @extend .u-clickable; - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); text-decoration: underline; } @@ -5183,7 +5304,7 @@ footer { fill: hsl(198,18%,72%); width: 9px; height: 9px; - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); } .attachmentUpload-progressBar { @@ -5195,16 +5316,18 @@ footer { } .tabsSidebar-tabsSpacer { - padding-right: 62px !important; + @include bidi-style(padding-right, 62px !important, padding-left, 0); } .tabsSidebar-sidebarSpacer { - margin-right: $sidebarWidth; + @include bidi-style(margin-right, $sidebarWidth, margin-left, 0); transition: margin-right 500ms; + @include rtl(transition, margin-left 500ms); } .tabsSidebar-sidebarSpacer.is-closed { margin-right: 0; + @include rtl(margin-left, 0); } .tabsSidebar-holder { @@ -5237,16 +5360,17 @@ footer { } .sidebar-header-headline { - padding: 33px 0 17px 25px; + padding: 33px 8px 17px 25px; + @include rtl(padding, 33px 25px 17px 8px); margin: 0 0 0 -20px; + @include rtl(margin, 0 -20px 0 0); line-height: 1; - padding-right: 8px; @extend .u-clickable, .u-textTruncate; } .sidebar-header-actions { flex: 1; - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); display: flex; align-items: center; @@ -5288,7 +5412,7 @@ footer { .tabsSidebar { position: absolute; - right: 0; + @include bidi-style(right, 0, left, auto); top: 0; bottom: 0; transition: 500ms; @@ -5300,6 +5424,7 @@ footer { .tabsSidebar.is-closed { transform: translateX($sidebarWidth); + @include rtl(transform, translateX(-$sidebarWidth)); } .tabsSidebar-tabs { @@ -5307,7 +5432,7 @@ footer { flex-direction: column; justify-content: center; position: absolute; - left: -55px; + @include bidi-style(left, -55px, right, auto); top: 0; bottom: 0; pointer-events: none; @@ -5345,12 +5470,12 @@ footer { } .tabsSidebar-tab:first-child { - border-top-left-radius: 8px; + @include bidi-style(border-top-left-radius, 8px, border-top-right-radius, 0); border-top: none; } .tabsSidebar-tab:last-child { - border-bottom-left-radius: 8px; + @include bidi-style(border-bottom-left-radius, 8px, border-bottom-right-radius, 0); } .tabsSidebar-tab .icon { @@ -5434,7 +5559,7 @@ footer { .list-item-delete { padding: 0 10px; - margin-right: -3px; + @include bidi-style(margin-right, -3px, margin-left, 0); display: flex; align-items: center; justify-content: center; @@ -5458,7 +5583,7 @@ footer { font-size: 11px; border-radius: 7px; padding: 0 5px; - margin-right: 2px; + @include bidi-style(margin-right, 2px, margin-left, 0); } .attributeBar { @@ -5482,8 +5607,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; @@ -5516,7 +5646,7 @@ footer { top: 34px; height: 100%; position: absolute; - margin-left: -34px; + @include bidi-style(margin-left, -34px, margin-right, 0); z-index: 1; } } @@ -5529,7 +5659,7 @@ footer { } .box.box--newTicket { - max-width: 658px; + max-width: 1080px; margin-left: auto; margin-right: auto; } @@ -5606,7 +5736,7 @@ footer { .box-progress-body { flex: 1; - margin-left: 24px; + @include bidi-style(margin-left, 24px, margin-right, 0); display: flex; align-items: center; justify-content: center; @@ -5645,7 +5775,7 @@ footer { height: 16px; fill: #ccc; vertical-align: top; - margin-right: 9px; + @include bidi-style(margin-right, 9px, margin-left, 0); margin-top: 11px; transform: scale(1.2); } @@ -5759,6 +5889,10 @@ footer { padding-bottom: 14px; } +.templates-manage fieldset { + margin: 0; +} + .template-attributes { margin: 17px 0 19px; } @@ -5766,7 +5900,7 @@ footer { .template-attribute { height: 24px; line-height: 25px; - padding-left: 10px; + @include bidi-style(padding-left, 10px, padding-right, 0); margin-bottom: 2px; font-size: 13px; color: hsl(198,11%,59%); @@ -5777,11 +5911,11 @@ footer { .template-attribute .key { text-transform: uppercase; - margin-right: 3px; + @include bidi-style(margin-right, 3px, margin-left, 0); } .template-attribute .value { - margin-left: 3px; + @include bidi-style(margin-left, 3px, margin-right, 0); } .template-attribute .delete { @@ -5842,12 +5976,12 @@ footer { height: 18px; } .switchBackToUser-text { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); } .switchBackToUser-close { width: 40px; height: 40px; - margin-right: -10px; + @include bidi-style(margin-right, -10px, margin-left, 0); display: flex; align-items: center; justify-content: center; @@ -5867,6 +6001,10 @@ footer { max-width: 660px; margin-top: 35px; margin-bottom: 35px; + + &.wizard { + margin: 35px auto; + } } .modal--large .modal-dialog { max-width: 740px; @@ -5902,6 +6040,8 @@ footer { padding: 23px; position: absolute; right: 0; + @include rtl(right, auto); + @include rtl(left, 0); top: 0; @extend .u-clickable; } @@ -5931,6 +6071,7 @@ footer { padding: 23px 23px 20px; border: none; display: flex; + @include rtl(text-align, left); } .modal-leftFooter, @@ -5943,7 +6084,7 @@ footer { .modal.modal--local { display: block; - padding-left: 40px; + @include bidi-style(padding-left, 40px, padding-right, 0); .modal-backdrop { background: hsla(210,17%,93%,.55); @@ -5963,6 +6104,8 @@ footer { } .dropdown-menu { + @extend .zIndex-5; // has to be behind modal windows and beneath notifications (popover) + position: absolute; margin: 0; padding: 0; min-width: 100%; @@ -5974,13 +6117,14 @@ footer { border: none; box-shadow: none; overflow: hidden; + @include rtl(text-align, right); } .dropdown-menu kbd { background: none; color: inherit; padding: 2px 5px; - margin-left: 7px; + @include bidi-style(margin-left, 7px, margin-right, 0); line-height: 1; vertical-align: baseline; opacity: 0.5; @@ -6072,7 +6216,7 @@ footer { } .dropdown-menu .badge { - padding-left: 10px; + @include bidi-style(padding-left, 10px, padding-right, 0); } .dropdown.dropdown--actions { @@ -6097,7 +6241,7 @@ footer { .dropdown-selectedSpacer { width: 34px; - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); justify-content: flex-end; opacity: 0; @@ -6130,6 +6274,48 @@ footer { height: 30px; } + .dropdown-menu--has-submenu { + overflow: hidden; + background: none; + + ul { + background: hsl(234,10%,19%); + } + } + + .dropdown-submenu { + position: absolute; + top: 0; + left: 0; + width: 100%; + } + + .dropdown.dropdown--actions .dropdown-controls { + @extend .u-clickable; + display: flex; + + &:not(:hover):not(.is-active) { + background: hsl(206,7%,28%); + } + + .icon { + fill: white; + @include bidi-style(margin-right, 10px, margin-left, 0); + flex-shrink: 0; + } + } + + .dropdown-title { + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dropdown-detail { + opacity: 0.5; + } + .recipientList, .recipientList-organizationMembers { list-style: none; @@ -6144,7 +6330,7 @@ footer { .recipientList-entry .recipientList-iconSpacer { width: 20px; - margin-left: -5px; + @include bidi-style(margin-left, -5px, margin-right, 0); display: flex; align-items: center; justify-content: center; @@ -6168,7 +6354,7 @@ footer { .recipientList-name { color: white; - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); flex: 1; @extend .u-textTruncate; } @@ -6187,7 +6373,7 @@ footer { } .recipientList-icon.plus { - margin-left: 13px; + @include bidi-style(margin-left, 13px, margin-right, 0); } .recipientList--new { @@ -6218,12 +6404,13 @@ footer { .userInfo-avatar { float: right; + @include rtl(float, left); position: relative; } .organizationInfo-avatar { @extend .userInfo-avatar; - padding: 18px 0 0 18px; + @include bidi-style(padding, 18px 0 0 18px, padding, 18px 18px 0 0); } .userList { @@ -6242,7 +6429,7 @@ footer { .userList-name { @extend .u-textTruncate; - margin-left: 7px; + @include bidi-style(margin-left, 7px, margin-right, 0); } } @@ -6260,19 +6447,23 @@ 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 { - margin: 11px 10px 0 0; + margin-top: 11px; + @include bidi-style(margin-right, 10px, margin-left, 0); } .userSearch .tab:not(.active) { @@ -6301,6 +6492,7 @@ footer { display: flex; margin: 10px -20px 20px; padding: 0 20px 0 21px; /* margin-left: -1px */ + @include rtl(padding, 0 21px 0 20px); position: relative; } @@ -6314,10 +6506,12 @@ footer { &:first-child { border-radius: 7px 0 0 7px; + @include rtl(border-radius, 0 7px 7px 0); } &:last-child { border-radius: 0 7px 7px 0; + @include rtl(border-radius, 7px 0 0 7px); } &:only-child { @@ -6362,7 +6556,7 @@ footer { } .form-item + .btn { - margin-left: 23px; + @include bidi-style(margin-left, 23px, margin-right, 0); } .scrollPageHeader { @@ -6397,10 +6591,13 @@ footer { @extend .u-textTruncate; } +.wizard { + margin: auto; // makes sure that the wizard is scrollable +} .wizard-logo { fill: white; - margin-left: -25px; + @include bidi-style(margin-left, -25px, margin-right, 0); margin-bottom: 5px; } @@ -6411,6 +6608,10 @@ footer { width: 400px; padding-bottom: 18px; margin-bottom: 20px; + + &.wizard-slide--large { + width: 460px; + } } .wizard h2 { @@ -6456,7 +6657,7 @@ label + .wizard-buttonList { .wizard-loadingText .loading { vertical-align: middle; - margin-right: 10px; + @include bidi-style(margin-right, 10px, margin-left, 0); } .wizard-aside { @@ -6534,7 +6735,7 @@ label + .wizard-buttonList { .input-feedback { position: absolute; - padding-left: 10px; + @include bidi-style(padding-left, 10px, padding-right, 0); right: 1px; top: 1px; bottom: 1px; @@ -6569,7 +6770,7 @@ label + .wizard-buttonList { } .progressTable td:first-child { - text-align: right; + text-align: end; } .progressTable progress { @@ -6579,7 +6780,7 @@ label + .wizard-buttonList { } .progressTable .icon-checkmark { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); opacity: 0; } @@ -6820,11 +7021,11 @@ label + .wizard-buttonList { } .profile-action { - margin-right: -20px; + @include bidi-style(margin-right, -20px, margin-left, 0); .dropdown-toggle { margin-top: -20px; - margin-right: -30px; + @include bidi-style(margin-right, -30px, margin-left, 0); margin-bottom: 8px; padding: 26px 40px 2px; } @@ -6842,7 +7043,7 @@ label + .wizard-buttonList { } .profile-details { - margin-left: -50px; + @include bidi-style(margin-left, -50px, margin-right, 0); } .profile-ticketsPlaceholder { @@ -6857,7 +7058,7 @@ label + .wizard-buttonList { .profile-detailsEntry { margin: 8px 0; - padding-left: 50px; + @include bidi-style(padding-left, 50px, padding-right, 0); width: 50%; } @@ -6873,7 +7074,7 @@ label + .wizard-buttonList { align-items: center; .avatar { - margin-right: 10px; + @include bidi-style(margin-right, 10px, margin-left, 0); } } @@ -6962,11 +7163,12 @@ label + .wizard-buttonList { } .overview-navigator { - @extend .horizontal; + display: flex; } .overview-navigator .pagination { margin: 0 0 0 10px; + @include rtl(margin, 0 10px 0 0); } .empty-space { @@ -6997,10 +7199,12 @@ label + .wizard-buttonList { &:first-child { border-radius: 4px 4px 0 0; + @include rtl(border-radius, 0 0 4px 4px); } &:last-child { border-radius: 0 0 4px 4px; + @include rtl(border-radius, 4px 4px 0 0); } &:only-child { @@ -7019,12 +7223,14 @@ label + .wizard-buttonList { .controls, input { - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); } .controls-label { margin-left: 0; margin-right: 5px; + @include rtl(margin-left, 5px); + @include rtl(margin-right, 0); } select, @@ -7129,12 +7335,19 @@ output { 0 0 0 1px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.2); background: white; + @include rtl(transform, translateX(70%)); } } .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%); @@ -7147,6 +7360,7 @@ output { &:checked + label:after { transform: translateX(70%); + @include rtl(transform, none); } } @@ -7155,7 +7369,7 @@ output { } .horizontal-filter-text { - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); } .filter-controls { @@ -7188,7 +7402,7 @@ output { } &:not(:last-child) { - margin-right: 7px; + @include bidi-style(margin-right, 7px, margin-left, 0); } .icon { @@ -7299,7 +7513,7 @@ output { } .settings-list-separator { - border-left-width: 3px; + @include bidi-style(border-left-width, 3px, border-right-width, 1px); } .text-muted { @@ -7320,7 +7534,7 @@ output { th:not(:last-child), td:not(:last-child) { - border-right: none; + @include bidi-style(border-right-width, 0, border-left-width, 1px); } tr:not(:last-child) td, @@ -7334,21 +7548,21 @@ output { } & > thead > tr > th:first-child { - border-top-left-radius: 4px; + @include bidi-style(border-top-left-radius, 4px, border-top-right-radius, 0); } & > thead > tr > th:last-child { - border-top-right-radius: 4px; + @include bidi-style(border-top-right-radius, 4px, border-top-left-radius, 0); } & > tbody:last-child > tr:last-child > td:first-child, & > tfoot:last-child > tr:last-child > td:first-child { - border-bottom-left-radius: 4px; + @include bidi-style(border-bottom-left-radius, 4px, border-bottom-right-radius, 0); } & > tbody:last-child > tr:last-child > td:last-child, & > tfoot:last-child > tr:last-child > td:last-child { - border-bottom-right-radius: 4px; + @include bidi-style(border-bottom-right-radius, 4px, border-bottom-left-radius, 0); } p { @@ -7413,7 +7627,7 @@ output { margin: 0 14px; background: white; color: hsl(60,1%,34%); - border-right: 1px solid hsl(198,18%,86%); + @include bidi-style(border-right, 1px solid hsl(198,18%,86%), border-left, none); border-bottom: 1px solid hsl(198,18%,86%); border-radius: 3px 3px 0 0; @@ -7439,7 +7653,7 @@ output { flex-basis: 100%; white-space: nowrap; background: hsl(197, 20%, 93%); - border-left: 1px solid hsl(198,18%,86%); + @include bidi-style(border-left, 1px solid hsl(198,18%,86%), border-right, none); border-top: 1px solid hsl(198,18%,86%); border-radius: 3px 3px 0 0; } @@ -7463,11 +7677,11 @@ output { } &:first-child { - margin-left: 0; + @include bidi-style(margin-left, 0, margin-left, 14px); } &:last-child { - margin-right: 0; + @include bidi-style(margin-right, 0, margin-left, 14px); } } @@ -7476,10 +7690,12 @@ output { .form-control { padding-right: 37px; + @include bidi-style(padding-right, 37px, padding-left, 12px); } .searchableSelect-main { position: relative; + line-height: 19px; &.form-control--small ~ .searchableSelect-autocomplete { top: 7px; @@ -7493,14 +7709,30 @@ output { .dropdown-menu { margin-top: -3px; + max-width: 100%; } - &.dropdown li:hover:not(.is-active) { - background: none; + &-option-text { + flex: 1 1 0%; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + display: block; + + & + .icon { + @include bidi-style(margin-left, 10px, margin-right, 0); + } } - &.dropdown li.is-hidden { - display: none; + &.dropdown li { + + &:hover:not(.is-active) { + background: none; + } + + &.is-hidden { + display: none; + } } li:not(.is-active):hover + li { @@ -7517,6 +7749,7 @@ output { display: flex; pointer-events: none; white-space: pre; + line-height: 19px; } .searchableSelect-autocomplete-invisible { @@ -7593,6 +7826,10 @@ output { h2 { margin-bottom: 0; + + .action-form-status .icon { + margin-top: 0; + } } .action-block, @@ -7622,14 +7859,12 @@ output { } .action-label { - margin-left: auto; background: hsl(197,20%,93%); border: 1px solid hsl(197,20%,88%); align-self: flex-start; padding: 5px 10px; - margin-right: -25px; - margin-top: -4px; - margin-bottom: -5px; + margin: -4px -25px -5px auto; + @include rtl(margin, -4px auto -5px -25px); color: hsl(197,16%,65%); cursor: default; } @@ -7645,7 +7880,7 @@ output { .action-controls { display: flex; - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); align-self: flex-end; .btn { @@ -7794,7 +8029,7 @@ output { .icon { vertical-align: middle; margin-top: -3px; - margin-right: 5px; + @include bidi-style(margin-right, 5px, margin-left, 0); } a { @@ -7847,7 +8082,7 @@ output { height: 133px; .loading-text { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); } } @@ -7912,14 +8147,14 @@ output { white-space: nowrap; .status-badge { - margin-left: 2px; + @include bidi-style(margin-left, 2px, margin-right, 0); vertical-align: middle; height: 100%; } } .chat-status { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); &[data-status='online'] .icon { fill: $supergood-color; @@ -7982,7 +8217,7 @@ output { .icon { fill: hsl(210,5%,78%); - margin-right: 8px; + @include bidi-style(margin-right, 8px, margin-left, 0); } } @@ -7992,7 +8227,7 @@ output { font-size: 13px; line-height: 18px; overflow: auto; - border-right: 1px solid hsl(0,0%,91%); + @include bidi-style(border-right, 1px solid hsl(0,0%,91%), border-left, none); border-left: 1px solid hsl(0,0%,91%); position: relative; } @@ -8038,7 +8273,7 @@ output { } .chat-message--agent { - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); background: hsl(199,44%,93%); align-self: flex-end; } @@ -8057,13 +8292,13 @@ output { } .chat-loader { - margin-right: -4px; + @include bidi-style(margin-right, -4px, margin-left, 0); .icon { width: 12px; height: 12px; fill: hsl(0,0%,90%); - margin-left: -4px; + @include bidi-style(margin-left, -4px, margin-right, 0); vertical-align: middle; animation: ease-in-out load-fade 600ms infinite alternate; } @@ -8101,9 +8336,14 @@ output { } .chat-input { - margin-right: 10px; - max-height: 50vh; - overflow: auto; + @include bidi-style(margin-right, 10px, margin-left, 0); + flex-grow: 1; + position: relative; + + .form-control { + overflow: auto; + max-height: 50vh; + } } .browser { @@ -8145,11 +8385,11 @@ output { .browser-input { position: relative; flex: 1; - margin-right: 10px; + @include bidi-style(margin-right, 10px, margin-left, 0); input { min-width: 0; - padding-right: 40px; + @include bidi-style(padding-right, 40px, padding-left, 12px); &.is-loading + .loading.icon { display: block; @@ -8158,7 +8398,7 @@ output { .loading.icon { position: absolute; - right: 11px; + @include bidi-style(right, 11px, left, auto); top: 10px; display: none; } @@ -8477,18 +8717,18 @@ output { cursor: grabbing; overflow: hidden; user-select: none; - + &.is-visible { display: block; } - + &-backdrop { @extend .fit; background: hsla(231,20%,8%,.8); opacity: 0; will-change: opacity; } - + &-circle { margin: 35px auto; background: hsl(207,7%,29%); @@ -8506,11 +8746,11 @@ output { right: 0; will-change: transform, opacity; visibility: hidden; - + &--top { top: 0; } - + &--bottom { bottom: 0; } @@ -8519,7 +8759,7 @@ output { fill: currentColor; opacity: 1; } - + &-label { width: 50%; margin: 10px 0; @@ -8550,7 +8790,7 @@ output { position: absolute; visibility: hidden; will-change: opacity, transition; - + &-inner { margin: 37px 25px; display: flex; @@ -8563,10 +8803,10 @@ output { &-assign { padding-bottom: 50px; bottom: -50px; // extra space for bounce animation - + .batch-overlay-box-inner { max-height: 310px; - + @media screen and (min-height: 1000px) { max-height: 465px; } @@ -8574,7 +8814,7 @@ output { &-group { box-shadow: 0 0 35px hsla(0,0%,0%,.5); - + .batch-overlay-box-inner { margin-top: 42px; margin-bottom: 10px; @@ -8596,7 +8836,7 @@ output { padding: 13px; width: 116px; height: 155px; - + &.is-hovered { .avatar { border-color: $highlight-color; @@ -8636,12 +8876,12 @@ output { .batch-overlay-box-inner { max-height: 146px; margin: 24px 12px; - + @media screen and (min-height: 800px) { max-height: 292px; } } - + &-entry { margin: 13px; border: 4px solid hsl(231,5%,30%); @@ -8654,7 +8894,7 @@ output { align-items: center; justify-content: center; font-size: 0.9em; - + &.is-hovered { border-color: $highlight-color; transform: scale(1.05); @@ -8671,7 +8911,7 @@ output { width: 250px; height: 40px; will-change: transform; - + &-item { position: absolute; left: 0; @@ -8683,11 +8923,11 @@ output { padding: 11px 0 9px 11px; box-shadow: 0 0 10px hsla(0,0%,0%,.28); will-change: transform; - + a { color: inherit; } - + td { display: block; padding: 0 12px; @@ -8695,11 +8935,11 @@ output { text-overflow: ellipsis; overflow: hidden; flex-shrink: 0; - + &:nth-child(3) { flex-shrink: 1; } - + &:nth-child(n+4) { display: none; } @@ -8721,7 +8961,7 @@ output { justify-content: center; box-shadow: 0 0 10px hsla(0,0%,0%,.28); will-change: transform; - + &:empty { display: none; } @@ -8897,19 +9137,19 @@ body.fit { } .align-left { - margin-right: auto; + @include bidi-style(margin-right, auto, margin-left, 0); } .align-right { - margin-left: auto; + @include bidi-style(margin-left, auto, margin-right, 0); } .space-left { - margin-left: 10px; + @include bidi-style(margin-left, 10px, margin-right, 0); } .space-right { - margin-right: 10px; + @include bidi-style(margin-right, 10px, margin-left, 0); } .align-center { 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/application_controller/handles_errors.rb b/app/controllers/application_controller/handles_errors.rb index 10a5b5141..5ada95335 100644 --- a/app/controllers/application_controller/handles_errors.rb +++ b/app/controllers/application_controller/handles_errors.rb @@ -15,22 +15,26 @@ module ApplicationController::HandlesErrors def not_found(e) logger.error e respond_to_exception(e, :not_found) + http_log end def unprocessable_entity(e) logger.error e respond_to_exception(e, :unprocessable_entity) + http_log end def internal_server_error(e) logger.error e respond_to_exception(e, :internal_server_error) + http_log end def unauthorized(e) error = humanize_error(e.message) response.headers['X-Failure'] = error.fetch(:error_human, error[:error]) respond_to_exception(e, :unauthorized) + http_log end private 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/concerns/integration/import_job_base.rb b/app/controllers/concerns/integration/import_job_base.rb new file mode 100644 index 000000000..493351ec7 --- /dev/null +++ b/app/controllers/concerns/integration/import_job_base.rb @@ -0,0 +1,89 @@ +module Integration::ImportJobBase + extend ActiveSupport::Concern + + def job_try_index + job_index( + dry_run: true, + take_finished: params[:finished] == 'true' + ) + end + + def job_try_create + ImportJob.dry_run(name: import_backend_namespace, payload: payload_dry_run) + render json: { + result: 'ok', + } + end + + def job_start_index + job_index(dry_run: false) + end + + def job_start_create + if !ImportJob.exists?(name: import_backend_namespace, finished_at: nil) + job = ImportJob.create(name: import_backend_namespace, payload: payload_import) + job.delay.start + end + render json: { + result: 'ok', + } + end + + def payload_dry_run + params + end + + def payload_import + import_setting + end + + private + + def answer_with + result = yield + render json: result.merge(result: 'ok') + rescue => e + logger.error(e) + render json: { + result: 'failed', + message: e.message, + } + end + + def import_setting + Setting.get(import_setting_name) + end + + def import_setting_name + "#{import_backend_name.downcase}_config" + end + + def import_backend_namespace + "Import::#{import_backend_name}" + end + + def import_backend_name + self.class.name.split('::').last.sub('Controller', '') + end + + def job_index(dry_run:, take_finished: true) + job = ImportJob.find_by( + name: import_backend_namespace, + dry_run: dry_run, + finished_at: nil + ) + if !job && take_finished + job = ImportJob.where( + name: import_backend_namespace, + dry_run: dry_run + ).order(created_at: :desc).limit(1).first + end + + if job + model_show_render_item(job) + else + render json: {} + end + end + +end 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..9e1328748 100644 --- a/app/controllers/form_controller.rb +++ b/app/controllers/form_controller.rb @@ -5,8 +5,10 @@ class FormController < ApplicationController before_action :cors_preflight_check_execute after_action :set_access_control_headers_execute - def config + def configuration return if !enabled? + return if !fingerprint_exists? + return if limit_reached? api_path = Rails.configuration.api_path http_type = Setting.get('http_type') @@ -14,49 +16,50 @@ class FormController < ApplicationController endpoint = "#{http_type}://#{fqdn}#{api_path}/form_submit" - config = { + result = { enabled: Setting.get('form_ticket_create'), endpoint: endpoint, + token: token_gen(params[:fingerprint]) } if params[:test] && current_user && current_user.permissions?('admin.channel_formular') - config[:enabled] = true + result[:enabled] = true end - render json: config, status: :ok + render json: result, status: :ok end def submit return if !enabled? + return if !fingerprint_exists? + return if !token_valid?(params[:token], params[:fingerprint]) + return if limit_reached? # validate input errors = {} - if !params[:name] || params[:name].empty? + if params[:name].blank? errors['name'] = 'required' end - if !params[:email] || params[:email].empty? + if params[:email].blank? errors['email'] = 'required' - end - if params[:email] !~ /@/ + elsif params[:email] !~ /@/ + errors['email'] = 'invalid' + elsif params[:email] =~ /(>|<|\||\!|"|§|'|\$|%|&|\(|\)|\?|\s|\.\.)/ errors['email'] = 'invalid' end - if params[:email] =~ /(>|<|\||\!|"|§|'|\$|%|&|\(|\)|\?|\s)/ - errors['email'] = 'invalid' - end - if !params[:title] || params[:title].empty? + if params[:title].blank? errors['title'] = 'required' end - if !params[:body] || params[:body].empty? + if params[:body].blank? errors['body'] = 'required' end # realtime verify - if !errors['email'] + if errors['email'].blank? begin - checker = EmailVerifier::Checker.new(params[:email]) - checker.connect - if !checker.verify - errors['email'] = "Unable to send to '#{params[:email]}'" + address = ValidEmail2::Address.new(params[:email]) + if !address || !address.valid? || !address.valid_mx? + errors['email'] = 'invalid' end rescue => e message = e.to_s @@ -69,7 +72,7 @@ class FormController < ApplicationController end end - if errors && !errors.empty? + if errors.present? render json: { errors: errors }, status: :ok @@ -86,7 +89,6 @@ class FormController < ApplicationController firstname: name, lastname: '', email: email, - password: '', active: true, role_ids: role_ids, updated_by_id: 1, @@ -97,12 +99,25 @@ class FormController < ApplicationController # set current user UserInfo.current_user_id = customer.id - ticket = Ticket.create( - group_id: 1, + group = Group.find_by(id: Setting.get('form_ticket_create_group_id')) + if !group + group = Group.where(active: true).first + if !group + group = Group.first + end + end + ticket = Ticket.create!( + group_id: group.id, customer_id: customer.id, title: params[:title], + preferences: { + form: { + remote_ip: request.remote_ip, + fingerprint_md5: Digest::MD5.hexdigest(params[:fingerprint]), + } + } ) - 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, @@ -112,11 +127,12 @@ class FormController < ApplicationController ) if params[:file] + params[:file].each { |file| Store.add( object: 'Ticket::Article', o_id: article.id, - data: File.read(file.tempfile), + data: file.read, filename: file.original_filename, preferences: { 'Mime-Type' => file.content_type, @@ -138,6 +154,91 @@ class FormController < ApplicationController private + def token_gen(fingerprint) + crypt = ActiveSupport::MessageEncryptor.new(Setting.get('application_secret')[0, 32]) + fingerprint = "#{Base64.strict_encode64(Setting.get('fqdn'))}:#{Time.zone.now.to_i}:#{Base64.strict_encode64(fingerprint)}" + Base64.strict_encode64(crypt.encrypt_and_sign(fingerprint)) + end + + def token_valid?(token, fingerprint) + if token.blank? + Rails.logger.info 'No token for form!' + response_access_deny + return false + end + begin + crypt = ActiveSupport::MessageEncryptor.new(Setting.get('application_secret')[0, 32]) + result = crypt.decrypt_and_verify(Base64.decode64(token)) + rescue + Rails.logger.info 'Invalid token for form!' + response_access_deny + return false + end + if result.blank? + Rails.logger.info 'Invalid token for form!' + response_access_deny + return false + end + parts = result.split(/:/) + if parts.count != 3 + Rails.logger.info "Invalid token for form (need to have 3 parts, only #{parts.count} found)!" + response_access_deny + return false + end + fqdn_local = Base64.decode64(parts[0]) + if fqdn_local != Setting.get('fqdn') + Rails.logger.info "Invalid token for form (invalid fqdn found #{fqdn_local} != #{Setting.get('fqdn')})!" + response_access_deny + return false + end + fingerprint_local = Base64.decode64(parts[2]) + if fingerprint_local != fingerprint + Rails.logger.info "Invalid token for form (invalid fingerprint found #{fingerprint_local} != #{fingerprint})!" + response_access_deny + return false + end + if parts[1].to_i < (Time.zone.now.to_i - 60 * 60 * 24) + Rails.logger.info 'Invalid token for form (token expired})!' + response_access_deny + return false + end + true + end + + def limit_reached? + return false if !SearchIndexBackend.enabled? + + form_limit_by_ip_per_hour = Setting.get('form_ticket_create_by_ip_per_hour') || 20 + result = SearchIndexBackend.search("preferences.form.remote_ip:'#{request.remote_ip}' AND created_at:>now-1h", form_limit_by_ip_per_hour, 'Ticket') + if result.count >= form_limit_by_ip_per_hour.to_i + response_access_deny + return true + end + + form_limit_by_ip_per_day = Setting.get('form_ticket_create_by_ip_per_day') || 240 + result = SearchIndexBackend.search("preferences.form.remote_ip:'#{request.remote_ip}' AND created_at:>now-1d", form_limit_by_ip_per_day, 'Ticket') + if result.count >= form_limit_by_ip_per_day.to_i + response_access_deny + return true + end + + form_limit_per_day = Setting.get('form_ticket_create_per_day') || 5000 + result = SearchIndexBackend.search('preferences.form.remote_ip:* AND created_at:>now-1d', form_limit_per_day, 'Ticket') + if result.count >= form_limit_per_day.to_i + response_access_deny + return true + end + + false + end + + def fingerprint_exists? + return true if params[:fingerprint].present? && params[:fingerprint].length > 30 + Rails.logger.info 'No fingerprint given!' + response_access_deny + false + end + def enabled? return true if params[:test] && current_user && current_user.permissions?('admin.channel_formular') return true if Setting.get('form_ticket_create') diff --git a/app/controllers/import_zendesk_controller.rb b/app/controllers/import_zendesk_controller.rb index e0ab2c006..62d56093e 100644 --- a/app/controllers/import_zendesk_controller.rb +++ b/app/controllers/import_zendesk_controller.rb @@ -7,7 +7,7 @@ class ImportZendeskController < ApplicationController return if setup_done_response # validate - if !params[:url] || params[:url] !~ %r{^(http|https)://.+?$} + if params[:url].blank? || params[:url] !~ %r{^(http|https)://.+?$} render json: { result: 'invalid', message: 'Invalid URL!', @@ -40,8 +40,8 @@ class ImportZendeskController < ApplicationController return end - # since 2016-10-15 a redirect to a signup page has been implemented - if response.body =~ /(Take it for a risk-free|Take it for a risk-free)/i + # since 2016-10-15 a redirect to a marketing page has been implemented + if response.body !~ /#{params[:url]}/ render json: { result: 'invalid', message_human: 'Hostname not found!', @@ -49,7 +49,9 @@ class ImportZendeskController < ApplicationController return end - Setting.set('import_zendesk_endpoint', "#{params[:url]}api/v2") + endpoint = "#{params[:url]}/api/v2" + endpoint.gsub!(%r{([^:])//+}, '\\1/') + Setting.set('import_zendesk_endpoint', endpoint) render json: { result: 'ok', @@ -92,6 +94,7 @@ class ImportZendeskController < ApplicationController def import_start return if setup_done_response Setting.set('import_mode', true) + Setting.set('import_backend', 'zendesk') # start migration Import::Zendesk.delay.start_bg diff --git a/app/controllers/integration/check_mk_controller.rb b/app/controllers/integration/check_mk_controller.rb new file mode 100644 index 000000000..3d9fb6373 --- /dev/null +++ b/app/controllers/integration/check_mk_controller.rb @@ -0,0 +1,138 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Integration::CheckMkController < ApplicationController + skip_before_action :verify_csrf_token + before_action :check_configured + + def update + + # check params + raise Exceptions::UnprocessableEntity, 'event_id is missing!' if params[:event_id].blank? + raise Exceptions::UnprocessableEntity, 'state is missing!' if params[:state].blank? + raise Exceptions::UnprocessableEntity, 'host is missing!' if params[:host].blank? + + # search for open ticket + auto_close = Setting.get('check_mk_auto_close') + auto_close_state_id = Setting.get('check_mk_auto_close_state_id') + group_id = Setting.get('check_mk_group_id') + state_recovery_match = '(OK|UP)' + + # check if ticket with host is open + customer = User.lookup(id: 1) + + # follow up detection by meta data + integration = 'check_mk' + open_states = Ticket::State.by_category(:open) + ticket_ids = Ticket.where(state: open_states).order(created_at: :desc).limit(5000).pluck(:id) + ticket_ids_found = [] + ticket_ids.each { |ticket_id| + ticket = Ticket.find_by(id: ticket_id) + next if !ticket + next if !ticket.preferences + next if !ticket.preferences[integration] + next if !ticket.preferences[integration]['host'] + next if ticket.preferences[integration]['host'] != params[:host] + next if ticket.preferences[integration]['service'] != params[:service] + + # found open ticket for service+host + ticket_ids_found.push ticket.id + } + + # new ticket, set meta data + title = "#{params[:host]} is #{params[:state]}" + body = "EventID: #{params[:event_id]} +Host: #{params[:host]} +Service: #{params[:service]} +State: #{params[:state]} +Text: #{params[:text]} +RemoteIP: #{request.remote_ip} +UserAgent: #{request.env['HTTP_USER_AGENT']} +" + + # add article + if params[:state].present? && ticket_ids_found.present? + ticket_ids_found.each { |ticket_id| + ticket = Ticket.find_by(id: ticket_id) + next if !ticket + 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, + body: body, + subject: title, + internal: false, + ) + } + if (!auto_close && params[:state].match(/#{state_recovery_match}/i)) || !params[:state].match(/#{state_recovery_match}/i) + render json: { + result: 'ticket already open, added note', + ticket_ids: ticket_ids_found, + } + return + end + end + + # check if service is recovered + if auto_close && params[:state].present? && params[:state].match(/#{state_recovery_match}/i) + if ticket_ids_found.blank? + render json: { + result: 'no open tickets found, ignore action', + } + return + end + state = Ticket::State.lookup(id: auto_close_state_id) + ticket_ids_found.each { |ticket_id| + ticket = Ticket.find_by(id: ticket_id) + next if !ticket + ticket.state_id = auto_close_state_id + ticket.save! + } + render json: { + result: "closed tickets with ids #{ticket_ids_found.join(',')}", + ticket_ids: ticket_ids_found, + } + return + end + + ticket = Ticket.create!( + group_id: group_id, + customer_id: customer.id, + title: title, + preferences: { + check_mk: { + host: params[:host], + service: params[:service], + }, + } + ) + 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, + body: body, + subject: title, + internal: false, + ) + + render json: { + result: "new ticket created (ticket id: #{ticket.id})", + ticket_id: ticket.id, + ticket_number: ticket.number, + } + end + + private + + def check_configured + http_log_config facility: 'check_mk' + + if !Setting.get('check_mk_integration') + raise Exceptions::UnprocessableEntity, 'Feature is disable, please contact your admin to enable it!' + end + + if Setting.get('check_mk_token') != params[:token] + raise Exceptions::UnprocessableEntity, 'Invalid token!' + end + end + +end diff --git a/app/controllers/integration/exchange_controller.rb b/app/controllers/integration/exchange_controller.rb new file mode 100644 index 000000000..74d7f178c --- /dev/null +++ b/app/controllers/integration/exchange_controller.rb @@ -0,0 +1,71 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Integration::ExchangeController < ApplicationController + include Integration::ImportJobBase + + prepend_before_action { authentication_check(permission: 'admin.integration.exchange') } + + def autodiscover + answer_with do + client = Autodiscover::Client.new( + email: params[:user], + password: params[:password], + ) + + { + endpoint: client.autodiscover.ews_url, + } + end + end + + def folders + answer_with do + Sequencer.process('Import::Exchange::AvailableFolders', + parameters: { + ews_config: { + endpoint: params[:endpoint], + user: params[:user], + password: params[:password], + } + }) + end + end + + def mapping + answer_with do + raise 'Please select at least one folder.' if params[:folders].blank? + + examples = Sequencer.process('Import::Exchange::AttributesExamples', + parameters: { + ews_folder_ids: params[:folders], + ews_config: { + endpoint: params[:endpoint], + user: params[:user], + password: params[:password], + } + }) + examples.tap do |result| + raise 'No entries found in selected folder(s).' if result[:attributes].blank? + end + end + end + + private + + # currently a workaround till LDAP is migrated to Sequencer + def payload_dry_run + { + ews_attributes: params[:attributes], + ews_folder_ids: params[:folders], + ews_config: { + endpoint: params[:endpoint], + user: params[:user], + password: params[:password], + } + } + end + + def payload_import + nil + end +end diff --git a/app/controllers/integration/idoit_controller.rb b/app/controllers/integration/idoit_controller.rb new file mode 100644 index 000000000..ee4960c5e --- /dev/null +++ b/app/controllers/integration/idoit_controller.rb @@ -0,0 +1,51 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Integration::IdoitController < ApplicationController + prepend_before_action -> { authentication_check(permission: ['agent.integration.idoit', 'admin.integration.idoit']) }, except: [:verify] + prepend_before_action -> { authentication_check(permission: ['admin.integration.idoit']) }, only: [:verify] + + def verify + response = ::Idoit.verify(params[:api_token], params[:endpoint], params[:client_id]) + render json: { + result: 'ok', + response: response, + } + rescue => e + logger.error e + + render json: { + result: 'failed', + message: e.message, + } + end + + def query + response = ::Idoit.query(params[:method], params[:filter]) + render json: { + result: 'ok', + response: response, + } + rescue => e + logger.error e + + render json: { + result: 'failed', + message: e.message, + } + end + + def update + params[:object_ids] ||= [] + ticket = Ticket.find(params[:ticket_id]) + access!(ticket, 'read') + ticket.preferences[:idoit] ||= {} + ticket.preferences[:idoit][:object_ids] ||= [] + ticket.preferences[:idoit][:object_ids].concat(params[:object_ids]) + ticket.save! + + render json: { + result: 'ok', + } + end + +end diff --git a/app/controllers/integration/ldap_controller.rb b/app/controllers/integration/ldap_controller.rb index ff91b292f..cbf30654c 100644 --- a/app/controllers/integration/ldap_controller.rb +++ b/app/controllers/integration/ldap_controller.rb @@ -4,6 +4,8 @@ require 'ldap/user' require 'ldap/group' class Integration::LdapController < ApplicationController + include Integration::ImportJobBase + prepend_before_action { authentication_check(permission: 'admin.integration.ldap') } def discover @@ -14,12 +16,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 @@ -51,48 +62,4 @@ class Integration::LdapController < ApplicationController message: e.message, } end - - def job_try_index - job_index( - dry_run: true, - take_finished: params[:finished] == 'true' - ) - end - - def job_try_create - ImportJob.dry_run(name: 'Import::Ldap', payload: params) - render json: { - result: 'ok', - } - end - - def job_start_index - job_index(dry_run: false) - end - - def job_start_create - backend = 'Import::Ldap' - if !ImportJob.exists?(name: backend, finished_at: nil) - job = ImportJob.create(name: backend, payload: Setting.get('ldap_config')) - job.delay.start - end - render json: { - result: 'ok', - } - end - - private - - def job_index(dry_run:, take_finished: true) - job = ImportJob.find_by(name: 'Import::Ldap', dry_run: dry_run, finished_at: nil) - if !job && take_finished - job = ImportJob.where(name: 'Import::Ldap', dry_run: dry_run).order(created_at: :desc).limit(1).first - end - - if job - model_show_render_item(job) - else - render json: {} - end - end end diff --git a/app/controllers/object_manager_attributes_controller.rb b/app/controllers/object_manager_attributes_controller.rb index 5dbf0d281..e1f785c7d 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 @@ -108,7 +108,7 @@ class ObjectManagerAttributesController < ApplicationController end end if params[:data_option] && !params[:data_option].key?(:default) - params[:data_option][:default] = if params[:data_type] =~ /^(input|select)$/ + params[:data_option][:default] = if params[:data_type] =~ /^(input|select|tree_select)$/ '' end end 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' } diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 2c9bbbf8e..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' @@ -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 diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index affee2e97..e0db600da 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -62,6 +62,7 @@ class SearchController < ApplicationController items.each { |item| require item[:type].to_filename record = Kernel.const_get(item[:type]).lookup(id: item[:id]) + next if !record assets = record.assets(assets) result.push item } diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 81b1244c1..7f5e1acf6 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -282,12 +282,12 @@ class SessionsController < ApplicationController assets = {} sessions_clean = [] SessionHelper.list.each { |session| - next if !session.data['user_id'] + next if session.data['user_id'].blank? sessions_clean.push session - if session.data['user_id'] - user = User.lookup(id: session.data['user_id']) - assets = user.assets(assets) - end + next if session.data['user_id'] + user = User.lookup(id: session.data['user_id']) + next if !user + assets = user.assets(assets) } render json: { sessions: sessions_clean, diff --git a/app/controllers/taskbar_controller.rb b/app/controllers/taskbar_controller.rb index dd4cd7b61..ade959ade 100644 --- a/app/controllers/taskbar_controller.rb +++ b/app/controllers/taskbar_controller.rb @@ -21,14 +21,18 @@ class TaskbarController < ApplicationController def update taskbar = Taskbar.find(params[:id]) access(taskbar) - taskbar.update_attributes!(Taskbar.param_cleanup(params)) + taskbar.with_lock do + taskbar.update_attributes!(Taskbar.param_cleanup(params)) + end model_update_render_item(taskbar) end def destroy taskbar = Taskbar.find(params[:id]) access(taskbar) - taskbar.destroy + taskbar.with_lock do + taskbar.destroy + end model_destroy_render_item() 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..b6c7a6f07 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,18 +330,7 @@ class TicketsController < ApplicationController } return end - - # permission check - ticket_permission(ticket_slave) - - # check diffetent ticket ids - if ticket_slave.id == ticket_master.id - render json: { - result: 'failed', - message: 'Can\'t merge ticket with it self!', - } - return - end + access!(ticket_slave, 'full') # merge ticket ticket_slave.merge_to( @@ -370,10 +348,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 +366,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 +459,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 +554,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/time_accountings_controller.rb b/app/controllers/time_accountings_controller.rb index f2acb1a09..e393b1c51 100644 --- a/app/controllers/time_accountings_controller.rb +++ b/app/controllers/time_accountings_controller.rb @@ -8,7 +8,7 @@ class TimeAccountingsController < ApplicationController year = params[:year] || Time.zone.now.year month = params[:month] || Time.zone.now.month - start_periode = Date.parse("#{year}-#{month}-01") + start_periode = Time.zone.parse("#{year}-#{month}-01") end_periode = start_periode.end_of_month time_unit = {} @@ -93,9 +93,84 @@ class TimeAccountingsController < ApplicationController name: 'Time Units Total', width: 10, }, + { + name: 'Created at', + width: 10, + }, + { + name: 'Closed at', + width: 10, + }, + { + name: 'Close Escalation At', + width: 10, + }, + { + name: 'Close In Min', + width: 10, + }, + { + name: 'Close Diff In Min', + width: 10, + }, + { + name: 'First Response At', + width: 10, + }, + { + name: 'First Response Escalation At', + width: 10, + }, + { + name: 'First Response In Min', + width: 10, + }, + { + name: 'First Response Diff In Min', + width: 10, + }, + { + name: 'Update Escalation At', + width: 10, + }, + { + name: 'Update In Min', + width: 10, + }, + { + name: 'Update Diff In Min', + width: 10, + }, + { + name: 'Last Contact At', + width: 10, + }, + { + name: 'Last Contact Agent At', + width: 10, + }, + { + name: 'Last Contact Customer At', + width: 10, + }, + { + name: 'Article Count', + width: 10, + }, + { + name: 'Escalation At', + width: 10, + }, ] result = [] results.each { |row| + row[:ticket].keys.each { |field| + next if row[:ticket][field].blank? + next if !row[:ticket][field].is_a?(ActiveSupport::TimeWithZone) + + row[:ticket][field] = row[:ticket][field].iso8601 + } + result_row = [ row[:ticket]['number'], row[:ticket]['title'], @@ -104,6 +179,23 @@ class TimeAccountingsController < ApplicationController row[:agent], row[:time_unit], row[:ticket]['time_unit'], + row[:ticket]['created_at'], + row[:ticket]['close_at'], + row[:ticket]['close_escalation_at'], + row[:ticket]['close_in_min'], + row[:ticket]['close_diff_in_min'], + row[:ticket]['first_response_at'], + row[:ticket]['first_response_escalation_at'], + row[:ticket]['first_response_in_min'], + row[:ticket]['first_response_diff_in_min'], + row[:ticket]['update_escalation_at'], + row[:ticket]['update_in_min'], + row[:ticket]['update_diff_in_min'], + row[:ticket]['last_contact_at'], + row[:ticket]['last_contact_agent_at'], + row[:ticket]['last_contact_customer_at'], + row[:ticket]['article_count'], + row[:ticket]['escalation_at'], ] result.push result_row } @@ -125,7 +217,7 @@ class TimeAccountingsController < ApplicationController year = params[:year] || Time.zone.now.year month = params[:month] || Time.zone.now.month - start_periode = Date.parse("#{year}-#{month}-01") + start_periode = Time.zone.parse("#{year}-#{month}-01") end_periode = start_periode.end_of_month time_unit = {} @@ -205,7 +297,7 @@ class TimeAccountingsController < ApplicationController year = params[:year] || Time.zone.now.year month = params[:month] || Time.zone.now.month - start_periode = Date.parse("#{year}-#{month}-01") + start_periode = Time.zone.parse("#{year}-#{month}-01") end_periode = start_periode.end_of_month time_unit = {} diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c29663960..1ff08c32c 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,18 @@ class UsersController < ApplicationController if admin_account_exists && !params[:signup] raise Exceptions::UnprocessableEntity, 'Only signup with not authenticate user possible!' end + + # check if user already exists + if clean_params[:email].blank? + raise Exceptions::UnprocessableEntity, 'Attribute \'email\' required!' + end + + # check if user already exists + exists = User.find_by(email: clean_params[:email].downcase.strip) + raise Exceptions::UnprocessableEntity, 'Email address is already used for other user.' if exists + + user = User.new(clean_params) + user.associations_from_param(params) user.updated_by_id = 1 user.created_by_id = 1 @@ -164,19 +171,10 @@ 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 - if !user.email.empty? - exists = User.where(email: user.email.downcase).first - raise Exceptions::UnprocessableEntity, 'User already exists!' if exists - end user.save! # if first user was added, set system init done @@ -184,7 +182,7 @@ class UsersController < ApplicationController Setting.set('system_init_done', true) # fetch org logo - if !user.email.empty? + if user.email.present? Service::Image.organization_suggest(user.email) end @@ -245,34 +243,31 @@ 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] }) + 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] }) + user.associations_from_param(group_ids: params[:group_ids], groups: params[:groups]) end # only allow Admin's and Agent's if current_user.permissions?(['admin.user', 'ticket.agent']) && (params[:organization_ids] || params[:organizations]) - user.associations_from_param({ organization_ids: params[:organization_ids], organizations: params[:organizations] }) + user.associations_from_param(organization_ids: params[:organization_ids], organizations: params[:organizations]) end if params[:expand] @@ -298,7 +293,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 @@ -371,7 +368,7 @@ class UsersController < ApplicationController limit: params[:limit], current_user: current_user, } - if params[:role_ids] && !params[:role_ids].empty? + if params[:role_ids].present? query_params[:role_ids] = params[:role_ids] end @@ -457,10 +454,10 @@ class UsersController < ApplicationController end # do query - user_all = if params[:role_ids] && !params[:role_ids].empty? - User.joins(:roles).where( 'roles.id' => params[:role_ids] ).where('users.id != 1').order('users.created_at DESC').limit( params[:limit] || 20 ) + user_all = if params[:role_ids].present? + User.joins(:roles).where('roles.id' => params[:role_ids]).where('users.id != 1').order('users.created_at DESC').limit(params[:limit] || 20) else - User.where('id != 1').order('created_at DESC').limit( params[:limit] || 20 ) + User.where('id != 1').order('created_at DESC').limit(params[:limit] || 20) end # build result list @@ -541,7 +538,7 @@ Response: } Test: -curl http://localhost/api/v1/users/email_verify.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN"}' +curl http://localhost/api/v1/users/email_verify -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN"}' =end @@ -570,7 +567,7 @@ Response: } Test: -curl http://localhost/api/v1/users/email_verify_send.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"email": "some_email@example.com"}' +curl http://localhost/api/v1/users/email_verify_send -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"email": "some_email@example.com"}' =end @@ -629,7 +626,7 @@ Response: } Test: -curl http://localhost/api/v1/users/password_reset.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"username": "some_username"}' +curl http://localhost/api/v1/users/password_reset -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"username": "some_username"}' =end @@ -681,7 +678,7 @@ Response: } Test: -curl http://localhost/api/v1/users/password_reset_verify.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN", "password" "new_password"}' +curl http://localhost/api/v1/users/password_reset_verify -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"token": "SoMeToKeN", "password" "new_password"}' =end @@ -737,7 +734,7 @@ Response: } Test: -curl http://localhost/api/v1/users/password_change.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"password_old": "password_old", "password_new": "password_new"}' +curl http://localhost/api/v1/users/password_change -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"password_old": "password_old", "password_new": "password_new"}' =end @@ -784,7 +781,7 @@ curl http://localhost/api/v1/users/password_change.json -v -u #{login}:#{passwor =begin Resource: -PUT /api/v1/users/preferences.json +PUT /api/v1/users/preferences Payload: { @@ -798,7 +795,7 @@ Response: } Test: -curl http://localhost/api/v1/users/preferences.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"language": "de", "notifications": true}' +curl http://localhost/api/v1/users/preferences -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"language": "de", "notifications": true}' =end @@ -811,7 +808,7 @@ curl http://localhost/api/v1/users/preferences.json -v -u #{login}:#{password} - params[:user].each { |key, value| user.preferences[key.to_sym] = value } - user.save + user.save! end end render json: { message: 'ok' }, status: :ok @@ -820,7 +817,47 @@ curl http://localhost/api/v1/users/preferences.json -v -u #{login}:#{password} - =begin Resource: -DELETE /api/v1/users/account.json +PUT /api/v1/users/out_of_office + +Payload: +{ + "out_of_office": true, + "out_of_office_start_at": true, + "out_of_office_end_at": true, + "out_of_office_replacement_id": 123, + "out_of_office_text": 'honeymoon' +} + +Response: +{ + :message => 'ok' +} + +Test: +curl http://localhost/api/v1/users/out_of_office -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"out_of_office": true, "out_of_office_replacement_id": 123}' + +=end + + def out_of_office + raise Exceptions::UnprocessableEntity, 'No current user!' if !current_user + user = User.find(current_user.id) + user.with_lock do + user.assign_attributes( + out_of_office: params[:out_of_office], + out_of_office_start_at: params[:out_of_office_start_at], + out_of_office_end_at: params[:out_of_office_end_at], + out_of_office_replacement_id: params[:out_of_office_replacement_id], + ) + user.preferences[:out_of_office_text] = params[:out_of_office_text] + user.save! + end + render json: { message: 'ok' }, status: :ok + end + +=begin + +Resource: +DELETE /api/v1/users/account Payload: { @@ -834,7 +871,7 @@ Response: } Test: -curl http://localhost/api/v1/users/account.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"provider": "twitter", "uid": 581482342942}' +curl http://localhost/api/v1/users/account -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"provider": "twitter", "uid": 581482342942}' =end @@ -1006,30 +1043,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 +1071,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.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/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/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 deleted file mode 100644 index 5e49f4fe7..000000000 --- a/app/models/application_model/checks_attribute_length.rb +++ /dev/null @@ -1,38 +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 - } - 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..bedd15cca --- /dev/null +++ b/app/models/application_model/checks_attribute_values_and_length.rb @@ -0,0 +1,58 @@ +# 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 + + if column.type == :binary + self[name].force_encoding('BINARY') + end + + # strip null byte chars (postgresql will complain about it) + if column.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/app/models/application_model/checks_import.rb b/app/models/application_model/checks_import.rb index 2e5e65868..feb1def4f 100644 --- a/app/models/application_model/checks_import.rb +++ b/app/models/application_model/checks_import.rb @@ -13,7 +13,8 @@ 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 + 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_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/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..1ac32dc09 100644 --- a/app/models/calendar.rb +++ b/app/models/calendar.rb @@ -150,14 +150,16 @@ returns return if !ical_url # only sync every 5 days - cache_key = "CalendarIcal::#{id}" - cache = Cache.get(cache_key) - return if !last_log && cache && cache[:ical_url] == ical_url + if id + cache_key = "CalendarIcal::#{id}" + cache = Cache.get(cache_key) + return if !last_log && cache && cache[:ical_url] == ical_url + end begin events = {} - if ical_url && !ical_url.empty? - events = Calendar.parse(ical_url) + if ical_url.present? + events = Calendar.fetch_parse(ical_url) end # sync with public_holidays @@ -189,11 +191,13 @@ returns } } self.last_log = nil - cache = Cache.write( - cache_key, - { public_holidays: public_holidays, ical_url: ical_url }, - { expires_in: 5.days }, - ) + if id + Cache.write( + cache_key, + { public_holidays: public_holidays, ical_url: ical_url }, + { expires_in: 1.day }, + ) + end rescue => e self.last_log = e.inspect end @@ -205,7 +209,7 @@ returns true end - def self.parse(location) + def self.fetch_parse(location) if location =~ /^http/i result = UserAgent.get(location) if !result.success? @@ -220,39 +224,62 @@ returns cal = cals.first events = {} cal.events.each { |event| + if event.rrule + + # loop till days + interval_frame_start = Date.parse("#{Time.zone.now - 1.year}-01-01") + interval_frame_end = Date.parse("#{Time.zone.now + 3.years}-12-31") + occurrences = event.occurrences_between(interval_frame_start, interval_frame_end) + if occurrences.present? + occurrences.each { |occurrence| + result = Calendar.day_and_comment_by_event(event, occurrence.start_time) + next if !result + events[result[0]] = result[1] + } + end + end next if event.dtstart < Time.zone.now - 1.year next if event.dtstart > Time.zone.now + 3.years - day = "#{event.dtstart.year}-#{format('%02d', event.dtstart.month)}-#{format('%02d', event.dtstart.day)}" - comment = event.summary || event.description - comment = Encode.conv( 'utf8', comment.to_s.force_encoding('utf-8') ) - if !comment.valid_encoding? - comment = comment.encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?') - end - - # ignore daylight saving time entries - next if comment =~ /(daylight saving|sommerzeit|summertime)/i - events[day] = comment + result = Calendar.day_and_comment_by_event(event, event.dtstart) + next if !result + events[result[0]] = result[1] } events.sort.to_h end + # get day and comment by event + def self.day_and_comment_by_event(event, start_time) + day = "#{start_time.year}-#{format('%02d', start_time.month)}-#{format('%02d', start_time.day)}" + comment = event.summary || event.description + comment = Encode.conv( 'utf8', comment.to_s.force_encoding('utf-8') ) + if !comment.valid_encoding? + comment = comment.encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?') + end + + # ignore daylight saving time entries + return if comment =~ /(daylight saving|sommerzeit|summertime)/i + [day, comment] + end + private # 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 def min_one_check if !Calendar.find_by(default: true) first = Calendar.order(:created_at, :id).limit(1).first + return true if !first first.default = true first.save end @@ -270,11 +297,13 @@ returns sla.save! end } + true end # fetch ical feed def fetch_ical sync(true) + true end # validate format of public holidays @@ -292,6 +321,6 @@ returns false end } - + true end end diff --git a/app/models/channel.rb b/app/models/channel.rb index b56a8e68e..3a13312a6 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 @@ -211,12 +223,6 @@ send via account def deliver(mail_params, notification = false) - # ignore notifications in developer mode - if notification == true && Setting.get('developer_mode') == true - logger.info "Do not send notification #{mail_params.inspect} because of enabled developer_mode" - return - end - adapter = options[:adapter] adapter_options = options if options[:outbound] && options[:outbound][:adapter] 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/app/models/channel/driver/twitter.rb b/app/models/channel/driver/twitter.rb index 349b76031..dbb6b2ee2 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'] != '' @@ -285,11 +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) @@ -307,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 @@ -327,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 @@ -351,4 +372,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/app/models/channel/email_build.rb b/app/models/channel/email_build.rb index 3629bfe3a..b0692648f 100644 --- a/app/models/channel/email_build.rb +++ b/app/models/channel/email_build.rb @@ -156,7 +156,9 @@ Check if string is a complete html document. If not, add head and css styles. return html if html =~ //i - Rails.configuration.html_email_body.sub('###html###', html) + # use block form because variable html could contain backslashes and e. g. '\1' that + # must not be handled as back-references for regular expressions + Rails.configuration.html_email_body.sub('###html###') { html } end =begin diff --git a/app/models/channel/email_parser.rb b/app/models/channel/email_parser.rb index 2ddfec7bc..98d3edddd 100644 --- a/app/models/channel/email_parser.rb +++ b/app/models/channel/email_parser.rb @@ -98,14 +98,24 @@ class Channel::EmailParser data[field.to_sym] = '' } - # get sender + # get sender with @ / email address from = nil ['from', 'reply-to', 'return-path'].each { |item| next if data[item.to_sym].blank? + next if data[item.to_sym] !~ /@/ from = data[item.to_sym] break if from } + # in case of no sender with email address - get sender + if !from + ['from', 'reply-to', 'return-path'].each { |item| + next if data[item.to_sym].blank? + from = data[item.to_sym] + break if from + } + end + # set x-any-recipient data['x-any-recipient'.to_sym] = '' ['to', 'cc', 'delivered-to', 'x-original-to', 'envelope-to'].each { |item| @@ -117,32 +127,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? @@ -176,6 +161,7 @@ class Channel::EmailParser if data[:body].empty? && mail.text_part data[:body] = mail.text_part.body.decoded data[:body] = Encode.conv(mail.text_part.charset, data[:body]) + data[:body] = data[:body].to_s.force_encoding('utf-8') if !data[:body].valid_encoding? data[:body] = data[:body].encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?') @@ -328,7 +314,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 @@ -339,15 +335,29 @@ 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' 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 @@ -638,6 +648,49 @@ returns true end + def self.sender_properties(from) + data = {} + + begin + 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}" + 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/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/channel/filter/database.rb b/app/models/channel/filter/database.rb index aea04d4f6..06077abe7 100644 --- a/app/models/channel/filter/database.rb +++ b/app/models/channel/filter/database.rb @@ -13,27 +13,28 @@ module Channel::Filter::Database min_one_rule_exists = false filter[:match].each { |key, meta| begin - next if !meta || !meta['value'] || meta['value'].empty? + next if meta.blank? || meta['value'].blank? + value = mail[ key.downcase.to_sym ] + match_rule = meta['value'] min_one_rule_exists = true - scan = [] - if mail - scan = mail[ key.downcase.to_sym ].scan(/#{meta['value']}/i) - end - if scan[0] - if meta[:operator] == 'contains not' + if meta[:operator] == 'contains not' + if value.present? && match(value, match_rule, false) all_matches_ok = false + Rails.logger.info " matching #{key.downcase}:'#{value}' on #{match_rule}, but shoud not" + end + elsif meta[:operator] == 'contains' + if value.blank? || !match(value, match_rule, true) + all_matches_ok = false + Rails.logger.info " not matching #{key.downcase}:'#{value}' on #{match_rule}, but should" end - Rails.logger.info " matching #{key.downcase}:'#{mail[ key.downcase.to_sym ]}' on #{meta['value']}" else - if meta[:operator] == 'contains' - all_matches_ok = false - end - Rails.logger.info " not matching #{key.downcase}:'#{mail[ key.downcase.to_sym ]}' on #{meta['value']}" + all_matches_ok = false + Rails.logger.info " Invalid operator in match #{meta.inspect}" end break if !all_matches_ok rescue => e all_matches_ok = false - Rails.logger.error "can't use match rule #{meta['value']} on #{mail[ key.to_sym ]}" + Rails.logger.error "can't use match rule #{match_rule} on #{value}" Rails.logger.error e.inspect end } @@ -49,4 +50,31 @@ module Channel::Filter::Database } end + + def self.match(value, match_rule, _should_match, check_mode = false) + + regexp = false + if match_rule =~ /^(regex:)(.+?)$/ + regexp = true + match_rule = $2 + end + + if regexp == false + match_rule_quoted = Regexp.quote(match_rule).gsub(/\\\*/, '.*') + return true if value =~ /#{match_rule_quoted}/i + return false + end + + begin + return true if value =~ /#{match_rule}/i + return false + rescue => e + message = "Can't use regex '#{match_rule}' on '#{value}': #{e.message}" + Rails.logger.error message + raise message if check_mode == true + end + + false + end + end diff --git a/app/models/channel/filter/identify_sender.rb b/app/models/channel/filter/identify_sender.rb index e8594c3cc..e408ab10e 100644 --- a/app/models/channel/filter/identify_sender.rb +++ b/app/models/channel/filter/identify_sender.rb @@ -35,7 +35,7 @@ module Channel::Filter::IdentifySender items.each { |item| # skip if recipient is system email - next if EmailAddress.find_by(email: item.address) + next if EmailAddress.find_by(email: item.address.downcase) customer_user = user_create( login: item.address, @@ -103,13 +103,14 @@ module Channel::Filter::IdentifySender rescue => e # parse not parseable fields by mail gem like # - Max Kohl | [example.com] + # - Max Kohl Rails.logger.error 'ERROR: ' + e.inspect - Rails.logger.error 'ERROR: try it by my self' + Rails.logger.error "ERROR: try it by my self (#{item}): #{mail[item.to_sym]}" recipients = mail[item.to_sym].to_s.split(',') recipients.each { |recipient| address = nil display_name = nil - if recipient =~ /<(.+?)>/ + if recipient =~ /.*<(.+?)>/ address = $1 end if recipient =~ /^(.+?)<(.+?)>/ diff --git a/app/models/channel/filter/monitoring_base.rb b/app/models/channel/filter/monitoring_base.rb index 08eecf563..c1370d02b 100644 --- a/app/models/channel/filter/monitoring_base.rb +++ b/app/models/channel/filter/monitoring_base.rb @@ -20,17 +20,17 @@ class Channel::Filter::MonitoringBase auto_close_state_id = Setting.get("#{integration}_auto_close_state_id") state_recovery_match = '(OK|UP)' - return if !mail[:from] - return if !mail[:body] + return if mail[:from].blank? + return if mail[:body].blank? session_user_id = mail[ 'x-zammad-session-user-id'.to_sym ] return if !session_user_id # check if sender is monitoring - return if !mail[:from].match(/#{sender}/i) + return if !mail[:from].match(/#{Regexp.quote(sender)}/i) # get mail attibutes like host and state result = {} - mail[:body].gsub(%r{(Service|Host|State|Address|Date/Time|Additional\sInfo):(.+?)\n}i) { |_match| + mail[:body].gsub(%r{(Service|Host|State|Address|Date/Time|Additional\sInfo|Info):(.+?)\n}i) { |_match| key = $1 if key key = key.downcase @@ -42,15 +42,26 @@ class Channel::Filter::MonitoringBase result[key] = value } + # check min. params + return if result['host'].blank? + + # get state from body + if result['state'].blank? + if mail[:body] =~ /==>.*\sis\s(.+?)\!\s+?<==/ + result['state'] = $1 + end + end + # check if ticket with host is open customer = User.lookup(id: session_user_id) # follow up detection by meta data open_states = Ticket::State.by_category(:open) - Ticket.where(state: open_states).each { |ticket| + ticket_ids = Ticket.where(state: open_states).order(created_at: :desc).limit(5000).pluck(:id) + ticket_ids.each { |ticket_id| + ticket = Ticket.find_by(id: ticket_id) + next if !ticket next if !ticket.preferences - next if !ticket.preferences['integration'] - next if ticket.preferences['integration'] != integration next if !ticket.preferences[integration] next if !ticket.preferences[integration]['host'] next if ticket.preferences[integration]['host'] != result['host'] @@ -60,7 +71,7 @@ class Channel::Filter::MonitoringBase mail[ 'x-zammad-ticket-id'.to_sym ] = ticket.id # check if service is recovered - if auto_close && result['state'].match(/#{state_recovery_match}/i) + if auto_close && result['state'].present? && result['state'].match(/#{state_recovery_match}/i) state = Ticket::State.lookup(id: auto_close_state_id) if state mail[ 'x-zammad-ticket-followup-state'.to_sym ] = state.name @@ -75,7 +86,6 @@ class Channel::Filter::MonitoringBase mail[ 'x-zammad-ticket-preferences'.to_sym ] = {} end preferences = {} - preferences['integration'] = integration preferences[integration] = result preferences.each { |key, value| mail[ 'x-zammad-ticket-preferences'.to_sym ][key] = value 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/app/models/channel/filter/sender_is_system_address.rb b/app/models/channel/filter/sender_is_system_address.rb index 1321edd3f..8e58fe51a 100644 --- a/app/models/channel/filter/sender_is_system_address.rb +++ b/app/models/channel/filter/sender_is_system_address.rb @@ -5,13 +5,13 @@ module Channel::Filter::SenderIsSystemAddress def self.run(_channel, mail) # if attributes already set by header - return if mail[ 'x-zammad-ticket-create-article-sender'.to_sym ] - return if mail[ 'x-zammad-article-sender'.to_sym ] + return if mail['x-zammad-ticket-create-article-sender'.to_sym] + return if mail['x-zammad-article-sender'.to_sym] # check if sender address is system form = 'raw-from'.to_sym - return if !mail[form] - return if !mail[:to] + return if mail[form].blank? + return if mail[:to].blank? # in case, set sender begin @@ -19,8 +19,8 @@ module Channel::Filter::SenderIsSystemAddress items = mail[form].addrs items.each { |item| next if !EmailAddress.find_by(email: item.address.downcase) - mail[ 'x-zammad-ticket-create-article-sender'.to_sym ] = 'Agent' - mail[ 'x-zammad-article-sender'.to_sym ] = 'Agent' + mail['x-zammad-ticket-create-article-sender'.to_sym] = 'Agent' + mail['x-zammad-article-sender'.to_sym] = 'Agent' return true } rescue => e @@ -28,13 +28,13 @@ module Channel::Filter::SenderIsSystemAddress end # check if sender is agent - return if mail[:from_email].empty? + return if mail[:from_email].blank? begin - user = User.find_by(email: mail[:from_email]) + user = User.find_by(email: mail[:from_email].downcase) return if !user return if !user.permissions?('ticket.agent') - mail[ 'x-zammad-ticket-create-article-sender'.to_sym ] = 'Agent' - mail[ 'x-zammad-article-sender'.to_sym ] = 'Agent' + mail['x-zammad-ticket-create-article-sender'.to_sym] = 'Agent' + mail['x-zammad-article-sender'.to_sym] = 'Agent' return true rescue => e Rails.logger.error 'ERROR: SenderIsSystemAddress: ' + e.inspect 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_groups.rb b/app/models/concerns/has_groups.rb new file mode 100644 index 000000000..b65c6b372 --- /dev/null +++ b/app/models/concerns/has_groups.rb @@ -0,0 +1,352 @@ +# 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) + return false if !active? + return false if !groups_access_permission? + + 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) + return [] if !active? + return [] if !groups_access_permission? + + 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) + return [] if !active? + return [] if !groups_access_permission? + 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 + + # Checks if the instance has general permission to Group access. + # + # @example + # customer_user.groups_access_permission? + # #=> false + # + # @return [Boolean] + def groups_access_permission? + return true if !respond_to?(:permissions?) + permissions?('ticket.agent') + end + + private + + def groups_access_map(key) + return {} if !active? + return {} if !groups_access_permission? + + {}.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 + ) + 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_access(group_id, access).collect(&:id) + 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) + 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 ||= [] + + # get instances and check for required permission + instances = where(id: ids).select(&:groups_access_permission?) + + # check indirect access through roles if possible + return instances if !respond_to?(:role_access) + + # combines and removes duplicates + # and returns them in one statement + instances | role_access(group_id, access) + 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_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_roles.rb b/app/models/concerns/has_roles.rb new file mode 100644 index 000000000..42377e6a2 --- /dev/null +++ b/app/models/concerns/has_roles.rb @@ -0,0 +1,96 @@ +# 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) + return false if !groups_access_permission? + + 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 instances having the given access(es) to the given Group through Roles. + # + # @example Group ID param + # User.role_access(1, 'read') + # #=> [1, 3, ...] + # + # @example Group param + # User.role_access(group, 'read') + # #=> [1, 3, ...] + # + # @example Access list + # User.role_access(group, ['read', 'create']) + # #=> [1, 3, ...] + # + # @return [Array] + def role_access(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 + joins(:roles).where(active: true, join_table => { role_id: role_ids }).distinct.select(&:groups_access_permission?) + end + + # 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) + role_access(group_id, access).collect(&: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/concerns/has_search_index_backend.rb b/app/models/concerns/has_search_index_backend.rb index 4ab81a4ab..c7a656493 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 @@ -123,14 +126,16 @@ 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 rescue => e - logger.error "Unable to send #{item.class}.find(#{item.id}) backend: #{e.inspect}" + logger.error "Unable to send #{item.class}.find(#{item.id}).search_index_update_backend backend: #{e.inspect}" tolerance_count += 1 - raise "Unable to send #{item.class}.find(#{item.id}) backend: #{e.inspect}" if tolerance_count == tolerance + raise "Unable to send #{item.class}.find(#{item.id}).search_index_update_backend backend: #{e.inspect}" if tolerance_count == tolerance end } end 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/cti/caller_id.rb b/app/models/cti/caller_id.rb index a34a2d8e3..bbfaa26a2 100644 --- a/app/models/cti/caller_id.rb +++ b/app/models/cti/caller_id.rb @@ -54,8 +54,10 @@ returns search_params[:level] = level end - result = Cti::CallerId.where(search_params).group(:user_id, :id).order(id: 'DESC').limit(20) - + caller_ids = Cti::CallerId.select('MAX(id) as caller_id').where(search_params).group(:user_id).order('caller_id DESC').limit(20).map(&:caller_id) + Cti::CallerId.where(id: caller_ids).order(id: :desc).each { |record| + result.push record + } break if result.present? } result diff --git a/app/models/cti/log.rb b/app/models/cti/log.rb index c5cdc7519..0d23718d7 100644 --- a/app/models/cti/log.rb +++ b/app/models/cti/log.rb @@ -266,7 +266,22 @@ returns } end - # processes a incoming event +=begin + +processes a incoming event + +Cti::Log.process( + 'cause' => '', + 'event' => 'newCall', + 'user' => 'user 1', + 'from' => '4912347114711', + 'to' => '4930600000000', + 'callId' => '4991155921769858278-1', + 'direction' => 'in', +) + +=end + def self.process(params) comment = params['cause'] event = params['event'] @@ -358,5 +373,23 @@ returns ) } end + +=begin + +cleanup caller logs + + Cti::Log.cleanup + +optional you can put the max oldest chat entries as argument + + Cti::Log.cleanup(12.months) + +=end + + def self.cleanup(diff = 12.months) + Cti::Log.where('created_at < ?', Time.zone.now - diff).delete_all + true + end + end end diff --git a/app/models/email_address.rb b/app/models/email_address.rb index b2918ebbc..3c9985d30 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -8,11 +8,12 @@ class EmailAddress < ApplicationModel validates :realname, presence: true validates :email, presence: true + before_validation :check_email before_create :check_if_channel_exists_set_inactive 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 @@ -41,6 +42,15 @@ check and if channel not exists reset configured channels for email addresses private + def check_email + return true if Setting.get('import_mode') + return true if email.blank? + self.email = email.downcase.strip + raise Exceptions::UnprocessableEntity, 'Invalid email' if email !~ /@/ + raise Exceptions::UnprocessableEntity, 'Invalid email' if email =~ /\s/ + true + end + # set email address to inactive/active if channel exists or not def check_if_channel_exists_set_inactive @@ -59,7 +69,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/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/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/object_manager/attribute.rb b/app/models/object_manager/attribute.rb index 750128734..3985f9f7e 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, @@ -115,6 +115,57 @@ possible types note: 'some additional comment', # optional }, +# tree_select + + data_type: 'tree_select', + data_option: { + default: 'aa', + options: [ + { + 'value' => 'aa', + 'name' => 'aa (comment)', + 'children' => [ + { + 'value' => 'aaa', + 'name' => 'aaa (comment)', + }, + { + 'value' => 'aab', + 'name' => 'aab (comment)', + }, + { + 'value' => 'aac', + 'name' => 'aac (comment)', + }, + ] + }, + { + 'value' => 'bb', + 'name' => 'bb (comment)', + 'children' => [ + { + 'value' => 'bba', + 'name' => 'aaa (comment)', + }, + { + 'value' => 'bbb', + 'name' => 'bbb (comment)', + }, + { + 'value' => 'bbc', + 'name' => 'bbc (comment)', + }, + ] + }, + ], + maxlength: 200, + nulloption: true, + null: false, + multiple: false, # currently only "false" supported + translate: true, # optional + note: 'some additional comment', # optional + }, + # checkbox data_type: 'checkbox', @@ -550,7 +601,7 @@ to send no browser reload event, pass false end data_type = nil - if attribute.data_type =~ /^input|select|richtext|textarea|checkbox$/ + if attribute.data_type =~ /^input|select|tree_select|richtext|textarea|checkbox$/ data_type = :string elsif attribute.data_type =~ /^integer|user_autocompletion$/ data_type = :integer @@ -564,7 +615,7 @@ to send no browser reload event, pass false # change field if model.column_names.include?(attribute.name) - if attribute.data_type =~ /^input|select|richtext|textarea|checkbox$/ + if attribute.data_type =~ /^input|select|tree_select|richtext|textarea|checkbox$/ ActiveRecord::Migration.change_column( model.table_name, attribute.name, @@ -603,7 +654,7 @@ to send no browser reload event, pass false end # create field - if attribute.data_type =~ /^input|select|richtext|textarea|checkbox$/ + if attribute.data_type =~ /^input|select|tree_select|richtext|textarea|checkbox$/ ActiveRecord::Migration.add_column( model.table_name, attribute.name, @@ -669,6 +720,9 @@ to send no browser reload event, pass false def self.reset_database_info(model) model.connection.schema_cache.clear! model.reset_column_information + # rebuild columns cache to reduce the risk of + # race conditions in re-setting it with outdated data + model.columns end def check_name @@ -704,7 +758,7 @@ to send no browser reload event, pass false if !data_type raise 'Need data_type param' end - if data_type !~ /^(input|user_autocompletion|checkbox|select|datetime|date|tag|richtext|textarea|integer|autocompletion_ajax|boolean|user_permission|active)$/ + if data_type !~ /^(input|user_autocompletion|checkbox|select|tree_select|datetime|date|tag|richtext|textarea|integer|autocompletion_ajax|boolean|user_permission|active)$/ raise "Invalid data_type param '#{data_type}'" end @@ -735,7 +789,7 @@ to send no browser reload event, pass false } end - if data_type == 'select' || data_type == 'checkbox' + if data_type == 'select' || data_type == 'tree_select' || data_type == 'checkbox' raise 'Need data_option[:default] param' if !data_option.key?(:default) raise 'Invalid data_option[:options] or data_option[:relation] param' if data_option[:options].nil? && data_option[:relation].nil? if !data_option.key?(:maxlength) diff --git a/app/models/observer/organization/ref_object_touch.rb b/app/models/observer/organization/ref_object_touch.rb index 8f97798db..beec37fd4 100644 --- a/app/models/observer/organization/ref_object_touch.rb +++ b/app/models/observer/organization/ref_object_touch.rb @@ -20,12 +20,23 @@ 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).pluck(:id).each { |ticket_id| + ticket = Ticket.find(ticket_id) + ticket.with_lock do + ticket.touch + end + } # touch current members record.member_ids.uniq.each { |user_id| - User.find(user_id).touch + user = User.find(user_id) + user.with_lock do + user.touch + end } end end diff --git a/app/models/observer/ticket/article/communicate_email/background_job.rb b/app/models/observer/ticket/article/communicate_email/background_job.rb index 7a3b3ce88..6e3c51454 100644 --- a/app/models/observer/ticket/article/communicate_email/background_job.rb +++ b/app/models/observer/ticket/article/communicate_email/background_job.rb @@ -124,7 +124,7 @@ class Observer::Ticket::Article::CommunicateEmail::BackgroundJob local_record.preferences['delivery_status'] = 'fail' local_record.preferences['delivery_status_message'] = message local_record.preferences['delivery_status_date'] = Time.zone.now - local_record.save + local_record.save! Rails.logger.error message if local_record.preferences['delivery_retry'] > 3 @@ -141,7 +141,10 @@ class Observer::Ticket::Article::CommunicateEmail::BackgroundJob recipient_list += local_record[key] } - Ticket::Article.create( + # reopen ticket and notify agent + Observer::Transaction.reset + UserInfo.current_user_id = 1 + Ticket::Article.create!( ticket_id: local_record.ticket_id, content_type: 'text/plain', body: "Unable to send email to '#{recipient_list}': #{message}", @@ -151,10 +154,14 @@ class Observer::Ticket::Article::CommunicateEmail::BackgroundJob preferences: { delivery_article_id_related: local_record.id, delivery_message: true, + notification: true, }, - updated_by_id: 1, - created_by_id: 1, ) + ticket = Ticket.find(local_record.ticket_id) + ticket.state = Ticket::State.find_by(default_follow_up: true) + ticket.save! + Observer::Transaction.commit + UserInfo.current_user_id = nil end raise message @@ -166,7 +173,7 @@ class Observer::Ticket::Article::CommunicateEmail::BackgroundJob def reschedule_at(current_time, attempts) if Rails.env.production? - return current_time + attempts * 20.seconds + return current_time + attempts * 25.seconds end current_time + 5.seconds end 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/article_changes.rb b/app/models/observer/ticket/article_changes.rb index 2ae9294db..85e721803 100644 --- a/app/models/observer/ticket/article_changes.rb +++ b/app/models/observer/ticket/article_changes.rb @@ -41,7 +41,7 @@ class Observer::Ticket::ArticleChanges < ActiveRecord::Observer record.ticket.touch return end - record.ticket.save + record.ticket.save! end # get article count 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/ticket/last_owner_update.rb b/app/models/observer/ticket/last_owner_update.rb new file mode 100644 index 000000000..ab100ba50 --- /dev/null +++ b/app/models/observer/ticket/last_owner_update.rb @@ -0,0 +1,34 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Observer::Ticket::LastOwnerUpdate < ActiveRecord::Observer + observe 'ticket' + + def before_create(record) + _check('create', record) + end + + def before_update(record) + _check('update', record) + end + + private + + def _check(type, record) + + # return if we run import mode + return true if Setting.get('import_mode') + + # check if owner has changed + if type == 'update' + return true if record.changes['owner_id'].blank? + end + + # check if owner is nobody + if record.owner_id.blank? || record.owner_id == 1 + record.last_owner_update_at = nil + return true + end + + record.last_owner_update_at = Time.zone.now + end +end diff --git a/app/models/observer/ticket/online_notification_seen.rb b/app/models/observer/ticket/online_notification_seen.rb index ec8883d02..dee1774ef 100644 --- a/app/models/observer/ticket/online_notification_seen.rb +++ b/app/models/observer/ticket/online_notification_seen.rb @@ -16,12 +16,11 @@ class Observer::Ticket::OnlineNotificationSeen < ActiveRecord::Observer def _check(record) # return if we run import mode - return if Setting.get('import_mode') + return false if Setting.get('import_mode') # set seen only if state has changes - return if !record.changes - return if record.changes.empty? - return if !record.changes['state_id'] + return false if record.changes.blank? + return false if record.changes['state_id'].blank? # check if existing online notifications for this ticket should be set to seen return true if !record.online_notification_seen_state diff --git a/app/models/observer/ticket/online_notification_seen/background_job.rb b/app/models/observer/ticket/online_notification_seen/background_job.rb index 90e931ff4..237c3adf9 100644 --- a/app/models/observer/ticket/online_notification_seen/background_job.rb +++ b/app/models/observer/ticket/online_notification_seen/background_job.rb @@ -16,7 +16,7 @@ class Observer::Ticket::OnlineNotificationSeen::BackgroundJob next if seen == notification.seen notification.seen = true notification.updated_by_id = @user_id - notification.save + notification.save! } end end diff --git a/app/models/observer/ticket/user_ticket_counter/background_job.rb b/app/models/observer/ticket/user_ticket_counter/background_job.rb index 0dc6521e0..a09904d06 100644 --- a/app/models/observer/ticket/user_ticket_counter/background_job.rb +++ b/app/models/observer/ticket/user_ticket_counter/background_job.rb @@ -7,18 +7,22 @@ class Observer::Ticket::UserTicketCounter::BackgroundJob def perform # open ticket count - state_open = Ticket::State.by_category(:open) - tickets_open = Ticket.where( - customer_id: @customer_id, - state_id: state_open, - ).count() + tickets_open = 0 + tickets_closed = 0 + if @customer_id != 1 + state_open = Ticket::State.by_category(:open) + tickets_open = Ticket.where( + customer_id: @customer_id, + state_id: state_open, + ).count() - # closed ticket count - state_closed = Ticket::State.by_category(:closed) - tickets_closed = Ticket.where( - customer_id: @customer_id, - state_id: state_closed, - ).count() + # closed ticket count + state_closed = Ticket::State.by_category(:closed) + tickets_closed = Ticket.where( + customer_id: @customer_id, + state_id: state_closed, + ).count() + end # check if update is needed customer = User.lookup(id: @customer_id) diff --git a/app/models/observer/transaction.rb b/app/models/observer/transaction.rb index be7092504..97651de8f 100644 --- a/app/models/observer/transaction.rb +++ b/app/models/observer/transaction.rb @@ -3,6 +3,10 @@ class Observer::Transaction < ActiveRecord::Observer observe :ticket, 'ticket::_article', :user, :organization, :tag + def self.reset + EventBuffer.reset('transaction') + end + def self.commit(params = {}) # add attribute of interface handle (e. g. to send (no) notifications if a agent @@ -176,7 +180,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 +191,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 +215,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 +234,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/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.rb b/app/models/organization.rb index 18cb20eba..967e72558 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 @@ -26,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/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/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/overview.rb b/app/models/overview.rb index aa1213b4b..57a3490f9 100644 --- a/app/models/overview.rb +++ b/app/models/overview.rb @@ -24,25 +24,41 @@ 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? + return true if link.present? self.link = link_name(name) + true end def fill_link_on_update - return true if link.empty? return true if !changes['name'] + return true if changes['link'] self.link = link_name(name) + true end def link_name(name) - link = name.downcase - link.gsub!(/\s/, '_') - link.gsub!(/[^0-9a-z]/i, '_') - link.gsub!(/_+/, '_') - link + local_link = name.downcase + local_link = local_link.parameterize('_') + local_link.gsub!(/\s/, '_') + local_link.gsub!(/_+/, '_') + local_link = URI.escape(local_link) + if local_link.blank? + local_link = id || rand(999) + end + check = true + while check + exists = Overview.find_by(link: local_link) + if exists && exists.id != id + local_link = "#{local_link}_#{rand(999)}" + else + check = false + end + end + local_link end end diff --git a/app/models/postmaster_filter.rb b/app/models/postmaster_filter.rb index f56bf98ea..51ffff1df 100644 --- a/app/models/postmaster_filter.rb +++ b/app/models/postmaster_filter.rb @@ -4,4 +4,26 @@ class PostmasterFilter < ApplicationModel store :perform store :match validates :name, presence: true + + before_create :validate_condition + before_update :validate_condition + + def validate_condition + raise Exceptions::UnprocessableEntity, 'Min. one match rule needed!' if match.blank? + match.each { |_key, meta| + raise Exceptions::UnprocessableEntity, 'operator invalid, ony "contains" and "contains not" is supported' if meta['operator'].blank? || meta['operator'] !~ /^(contains|contains not)$/ + raise Exceptions::UnprocessableEntity, 'value invalid/empty' if meta['value'].blank? + begin + if meta['operator'] == 'contains not' + Channel::Filter::Database.match('test content', meta['value'], false, true) + else + Channel::Filter::Database.match('test content', meta['value'], true, true) + end + rescue => e + raise Exceptions::UnprocessableEntity, e.message + end + } + true + 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..2a362a4c0 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' @@ -121,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 @@ -133,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/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/setting.rb b/app/models/setting.rb index 8dfaeffb0..3734cb848 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 @@ -61,18 +60,19 @@ 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 + setting.save! logger.info "Setting.reset(#{name}, #{setting.state_current.inspect})" - load - @@current[:settings_config][name] end =begin @@ -84,6 +84,7 @@ reload config settings =end def self.reload + @@last_changed_at = nil # rubocop:disable Style/ClassVars load(true) end @@ -93,27 +94,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 @@ -121,53 +131,46 @@ reload config settings # set initial value in state_initial def set_initial self.state_initial = state_current + true 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 + true 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? # 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/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/store.rb b/app/models/store.rb index a5ea0520c..51a84ed5b 100644 --- a/app/models/store.rb +++ b/app/models/store.rb @@ -118,27 +118,37 @@ 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 + +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 +157,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 diff --git a/app/models/store/file.rb b/app/models/store/file.rb index c430e3610..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})" @@ -116,7 +118,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 diff --git a/app/models/tag.rb b/app/models/tag.rb index 529114905..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 @@ -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/taskbar.rb b/app/models/taskbar.rb index f4614a15c..63c64d860 100644 --- a/app/models/taskbar.rb +++ b/app/models/taskbar.rb @@ -88,9 +88,11 @@ class Taskbar < ApplicationModel # update other taskbars Taskbar.where(key: key).order(:created_at, :id).each { |taskbar| next if taskbar.id == id - taskbar.preferences = preferences - taskbar.local_update = true - taskbar.save! + taskbar.with_lock do + taskbar.preferences = preferences + taskbar.local_update = true + taskbar.save! + end } return true if destroyed? 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 diff --git a/app/models/ticket.rb b/app/models/ticket.rb index d34688823..ac7933b4f 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' @@ -49,6 +48,7 @@ class Ticket < ApplicationModel :last_contact_at, :last_contact_agent_at, :last_contact_customer_at, + :last_owner_update_at, :preferences history_attributes_ignored :create_article_type_id, @@ -75,33 +75,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 +85,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 @@ -146,7 +114,7 @@ returns pending_action = Ticket::StateType.find_by(name: 'pending action') ticket_states_pending_action = Ticket::State.where(state_type_id: pending_action) .where.not(next_state_id: nil) - if !ticket_states_pending_action.empty? + if ticket_states_pending_action.present? next_state_map = {} ticket_states_pending_action.each { |state| next_state_map[state.id] = state.next_state_id @@ -170,7 +138,7 @@ returns pending_reminder = Ticket::StateType.find_by(name: 'pending reminder') ticket_states_pending_reminder = Ticket::State.where(state_type_id: pending_reminder) - if !ticket_states_pending_reminder.empty? + if ticket_states_pending_reminder.present? reminder_state_map = {} ticket_states_pending_reminder.each { |state| reminder_state_map[state.id] = state.next_state_id @@ -261,6 +229,47 @@ returns =begin +processes tickets which auto unassign time has reached + + processed_tickets = Ticket.process_auto_unassign + +returns + + processed_tickets = [, ...] + +=end + + def self.process_auto_unassign + + # process pending action tickets + state_ids = Ticket::State.by_category(:work_on).pluck(:id) + return [] if state_ids.blank? + result = [] + groups = Group.where(active: true).where('assignment_timeout IS NOT NULL AND groups.assignment_timeout != 0') + return [] if groups.blank? + groups.each { |group| + next if group.assignment_timeout.blank? + ticket_ids = Ticket.where('state_id IN (?) AND owner_id != 1 AND group_id = ?', state_ids, group.id).limit(600).pluck(:id) + ticket_ids.each { |ticket_id| + ticket = Ticket.find_by(id: ticket_id) + next if !ticket + minutes_since_last_assignment = Time.zone.now - ticket.last_owner_update_at + next if (minutes_since_last_assignment / 60) <= group.assignment_timeout + Transaction.execute do + ticket.owner_id = 1 + ticket.updated_at = Time.zone.now + ticket.updated_by_id = 1 + ticket.save! + end + result.push ticket + } + } + + result + end + +=begin + merge tickets ticket = Ticket.find(123) @@ -277,6 +286,14 @@ returns def merge_to(data) + # prevent cross merging tickets + target_ticket = Ticket.find_by(id: data[:ticket_id]) + raise 'no target ticket given' if !target_ticket + raise Exceptions::UnprocessableEntity, 'ticket already merged, no merge into merged ticket possible' if target_ticket.state.state_type.name == 'merged' + + # check different ticket ids + raise Exceptions::UnprocessableEntity, 'Can\'t merge ticket with it self!' if id == target_ticket.id + # update articles Transaction.execute do @@ -329,7 +346,7 @@ returns save! # touch new ticket (to broadcast change) - Ticket.find(data[:ticket_id]).touch + target_ticket.touch end true end @@ -360,7 +377,7 @@ returns state = Ticket::State.lookup(id: state_id) state_type = Ticket::StateType.lookup(id: state.state_type_id) - # always to set unseen for ticket owner + # always to set unseen for ticket owner and users which did not the update if state_type.name != 'merged' if user_id_check return false if user_id_check == owner_id && user_id_check != updated_by_id @@ -393,26 +410,35 @@ 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 - if !current_user - ticket_count = Ticket.where(query, *bind_params).joins(tables).count - tickets = Ticket.where(query, *bind_params).joins(tables).limit(limit) - return [ticket_count, tickets] + ActiveRecord::Base.transaction(requires_new: true) do + begin + if !current_user + ticket_count = Ticket.where(query, *bind_params).joins(tables).count + tickets = Ticket.where(query, *bind_params).joins(tables).limit(limit) + return [ticket_count, tickets] + end + + 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) + + return [ticket_count, tickets] + rescue ActiveRecord::StatementInvalid => e + Rails.logger.error e.inspect + Rails.logger.error e.backtrace + raise ActiveRecord::Rollback + end end - - access_condition = Ticket.access_condition(current_user) - 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) - - [ticket_count, tickets] + [] end =begin @@ -537,7 +563,7 @@ condition example query += "#{attribute} IN (?)" bind_params.push 1 else - query += "#{attribute} IS NOT NULL" + query += "#{attribute} IS NULL" end elsif selector['pre_condition'] == 'current_user.id' raise "Use current_user.id in selector, but no current_user is set #{selector.inspect}" if !current_user_id @@ -551,7 +577,7 @@ condition example else # rubocop:disable Style/IfInsideElse if selector['value'].nil? - query += "#{attribute} IS NOT NULL" + query += "#{attribute} IS NULL" else query += "#{attribute} IN (?)" bind_params.push selector['value'] @@ -564,7 +590,7 @@ condition example query += "#{attribute} NOT IN (?)" bind_params.push 1 else - query += "#{attribute} IS NULL" + query += "#{attribute} IS NOT NULL" end elsif selector['pre_condition'] == 'current_user.id' query += "#{attribute} NOT IN (?)" @@ -801,9 +827,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').sort_by(&:login).each do |user| recipients_raw.push(user.email) - } + end else logger.error "Unknown email notification recipient '#{recipient}'" next @@ -813,6 +839,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 !~ /@/ @@ -838,11 +877,52 @@ 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 = { + 10 => 10, + 30 => 15, + 60 => 25, + 180 => 50, + 600 => 100, + } + 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 = { + 10 => 30, + 30 => 60, + 60 => 120, + 180 => 240, + 600 => 360, + } + skip = false + map.each { |minutes, 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 - minutes.minutes).count + next if already_sent < count + logger.info "Send no trigger based notification to #{recipient_email} because already sent #{count} in total within last #{minutes} minutes (loop protection)" + skip = true + break + } + next if skip + email = recipient_email.downcase.strip next if recipients_checked.include?(email) recipients_checked.push(email) @@ -1028,42 +1108,46 @@ 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'] + + # ignore if new state is blank and + # let handle ActiveRecord the error + return if state_id.blank? # 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 @@ -1072,20 +1156,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 738cf112f..8bc557bfe 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 @@ -36,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 @@ -54,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'] !~ / 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/escalation.rb b/app/models/ticket/escalation.rb index 68b645469..4f2f22585 100644 --- a/app/models/ticket/escalation.rb +++ b/app/models/ticket/escalation.rb @@ -322,23 +322,23 @@ returns =end def destination_time(start_time, move_minutes, biz, history_data) - destination_time = biz.time(move_minutes, :minutes).after(start_time) + local_destination_time = biz.time(move_minutes, :minutes).after(start_time) # go step by step to end of move_minutes until move_minutes is 0 200.times.each { |_count| # check if we have pending time in the range to the destination time - working_minutes = period_working_minutes(start_time, destination_time, biz, history_data, true) + working_minutes = period_working_minutes(start_time, local_destination_time, biz, history_data, true) move_minutes -= working_minutes # skip if no pending time is given break if move_minutes <= 0 # set pending destination to start time and add pending time to destination time - start_time = destination_time - destination_time = biz.time(move_minutes, :minutes).after(start_time) + start_time = local_destination_time + local_destination_time = biz.time(move_minutes, :minutes).after(start_time) } - destination_time + local_destination_time end # get period working minutes time in minutes diff --git a/app/models/ticket/overviews.rb b/app/models/ticket/overviews.rb index 6f9e0b4fd..51e894643 100644 --- a/app/models/ticket/overviews.rb +++ b/app/models/ticket/overviews.rb @@ -5,9 +5,7 @@ module Ticket::Overviews all overviews by user - result = Ticket::Overviews.all( - current_user: User.find(123), - ) + result = Ticket::Overviews.all(current_user: User.find(123)) returns @@ -21,11 +19,11 @@ returns # get customer overviews role_ids = User.joins(:roles).where(users: { id: current_user.id, active: true }, roles: { active: true }).pluck('roles.id') if current_user.permissions?('ticket.customer') - overviews = if current_user.organization_id && current_user.organization.shared - Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: { active: true }).distinct('overview.id').order(:prio) - else - Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: { active: true, organization_shared: false }).distinct('overview.id').order(:prio) - end + overview_filter = { active: true, organization_shared: false } + if current_user.organization_id && current_user.organization.shared + overview_filter.delete(:organization_shared) + end + overviews = Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: overview_filter).distinct('overview.id').order(:prio) overviews_list = [] overviews.each { |overview| user_ids = overview.user_ids @@ -37,7 +35,12 @@ returns # get agent overviews return [] if !current_user.permissions?('ticket.agent') - overviews = Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: { active: true }).distinct('overview.id').order(:prio) + overview_filter = { active: true } + overview_filter_not = { out_of_office: true } + if User.where('out_of_office = ? AND out_of_office_start_at <= ? AND out_of_office_end_at >= ? AND out_of_office_replacement_id = ? AND active = ?', true, Time.zone.today, Time.zone.today, current_user.id, true).count.positive? + overview_filter_not = {} + end + overviews = Overview.joins(:roles).where(overviews_roles: { role_id: role_ids }, overviews: overview_filter).where.not(overview_filter_not).distinct('overview.id').order(:prio) overviews_list = [] overviews.each { |overview| user_ids = overview.user_ids @@ -89,7 +92,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/ticket/subject.rb b/app/models/ticket/subject.rb index 53fc18349..3fa3fdd71 100644 --- a/app/models/ticket/subject.rb +++ b/app/models/ticket/subject.rb @@ -22,22 +22,28 @@ returns ticket_hook = Setting.get('ticket_hook') ticket_hook_divider = Setting.get('ticket_hook_divider') ticket_subject_re = Setting.get('ticket_subject_re') - if is_reply && !ticket_subject_re.empty? - subject = "#{ticket_subject_re}: #{subject}" - end # none position if Setting.get('ticket_hook_position') == 'none' + if is_reply && ticket_subject_re.present? + subject = "#{ticket_subject_re}: #{subject}" + end return subject end # right position if Setting.get('ticket_hook_position') == 'right' - return subject + " [#{ticket_hook}#{ticket_hook_divider}#{number}]" + if is_reply && ticket_subject_re.present? + subject = "#{ticket_subject_re}: #{subject}" + end + return "#{subject} [#{ticket_hook}#{ticket_hook_divider}#{number}]" end # left position - "[#{ticket_hook}#{ticket_hook_divider}#{number}] " + subject + if is_reply && ticket_subject_re.present? + return "#{ticket_subject_re}: [#{ticket_hook}#{ticket_hook_divider}#{number}] #{subject}" + end + "[#{ticket_hook}#{ticket_hook_divider}#{number}] #{subject}" end =begin diff --git a/app/models/token.rb b/app/models/token.rb index de70a3d5d..edd83bd6c 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -119,10 +119,10 @@ cleanup old token private def generate_token - loop do self.name = SecureRandom.urlsafe_base64(48) break if !Token.exists?(name: name) end + true end end diff --git a/app/models/transaction/karma.rb b/app/models/transaction/karma.rb index c90d8be8c..ef32bdf2b 100644 --- a/app/models/transaction/karma.rb +++ b/app/models/transaction/karma.rb @@ -54,7 +54,7 @@ class Transaction::Karma if @item[:type] == 'reminder_reached' return if ticket.owner_id == 1 - return if ticket.pending_time > Time.zone.now - 2.days + return if ticket.pending_time && ticket.pending_time > Time.zone.now - 2.days Karma::ActivityLog.add('ticket reminder overdue (+2 days)', ticket.owner, 'Ticket', ticket.id) return end diff --git a/app/models/transaction/notification.rb b/app/models/transaction/notification.rb index 417e98fa9..1893a28b6 100644 --- a/app/models/transaction/notification.rb +++ b/app/models/transaction/notification.rb @@ -26,66 +26,61 @@ class Transaction::Notification # return if we run import mode return if Setting.get('import_mode') - return if @item[:object] != 'Ticket' - return if @params[:disable_notification] - ticket = Ticket.find(@item[:object_id]) + ticket = Ticket.find_by(id: @item[:object_id]) + return if !ticket if @item[:article_id] article = Ticket::Article.find(@item[:article_id]) # ignore notifications sender = Ticket::Article::Sender.lookup(id: article.sender_id) if sender && sender.name == 'System' - return if @item[:changes].empty? - article = nil + return if @item[:changes].blank? && article.preferences[:notification] != true + if article.preferences[:notification] != true + article = nil + end end end # 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 + recipients_reason = {} # loop through all users - possible_recipients = ticket.agent_of_group - if ticket.owner_id == 1 + possible_recipients = User.group_access(ticket.group_id, 'full').sort_by(&:login) + + # apply owner + if ticket.owner_id != 1 possible_recipients.push ticket.owner + recipients_reason[ticket.owner_id] = 'are assigned' end + + # apply out of office agents + possible_recipients_additions = Set.new + possible_recipients.each { |user| + recursive_ooo_replacements( + user: user, + replacements: possible_recipients_additions, + reasons: recipients_reason, + ) + } + + if possible_recipients_additions.present? + # join unique entries + possible_recipients = possible_recipients | possible_recipients_additions.to_a + end + already_checked_recipient_ids = {} possible_recipients.each { |user| result = NotificationFactory::Mailer.notification_settings(user, ticket, @item[:type]) next if !result - next if already_checked_recipient_ids[result[:user].id] - already_checked_recipient_ids[result[:user].id] = true + next if already_checked_recipient_ids[user.id] + already_checked_recipient_ids[user.id] = true recipients_and_channels.push result + next if recipients_reason[user.id] + recipients_reason[user.id] = 'are in group' } # send notifications @@ -101,11 +96,11 @@ class Transaction::Notification end # ignore inactive users - next if !user.active + next if !user.active? # ignore if no changes has been done changes = human_changes(user, ticket) - next if @item[:type] == 'update' && !article && (!changes || changes.empty?) + next if @item[:type] == 'update' && !article && changes.blank? # check if today already notified if @item[:type] == 'reminder_reached' || @item[:type] == 'escalation' || @item[:type] == 'escalation_warning' @@ -144,7 +139,7 @@ class Transaction::Notification OnlineNotification.remove_by_type('Ticket', ticket.id, 'escalation_warning', user) # on updates without state changes create unseen messages - elsif @item[:type] != 'create' && (!@item[:changes] || @item[:changes].empty? || !@item[:changes]['state_id']) + elsif @item[:type] != 'create' && (@item[:changes].blank? || @item[:changes]['state_id'].blank?) seen = false else seen = ticket.online_notification_seen_state(user.id) @@ -205,6 +200,7 @@ class Transaction::Notification recipient: user, current_user: current_user, changes: changes, + reason: recipients_reason[user.id], }, message_id: "", references: ticket.get_references, @@ -321,4 +317,27 @@ class Transaction::Notification changes end + private + + def recursive_ooo_replacements(user:, replacements:, reasons:, level: 0) + if level == 10 + Rails.logger.warn("Found more than 10 replacement levels for agent #{user}.") + return + end + + replacement = user.out_of_office_agent + return if !replacement + # return for already found, added and checked users + # to prevent re-doing complete lookup paths + return if !replacements.add?(replacement) + reasons[replacement.id] = 'are the out-of-office replacement of the owner' + + recursive_ooo_replacements( + user: replacement, + replacements: replacements, + reasons: reasons, + level: level + 1 + ) + end + end diff --git a/app/models/translation.rb b/app/models/translation.rb index bc04c7f51..9f536b32d 100644 --- a/app/models/translation.rb +++ b/app/models/translation.rb @@ -1,4 +1,5 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +require 'csv' class Translation < ApplicationModel before_create :set_initial @@ -212,7 +213,7 @@ translate strings in ruby context, e. g. for notifications =begin -load locales from local +load translations from local all: @@ -282,6 +283,65 @@ all: true end +=begin + +load translations from csv file + +all: + + Translation.load_from_csv + + or + + Translation.load_from_csv(locale, file_location, file_charset) # e. g. 'en-us' or 'de-de' and /path/to/translation_list.csv + + e. g. + + Translation.load_from_csv('he-il', '/Users/me/Downloads/Hebrew_translation_list-1.csv', 'Windows-1255') + +Get source file at https://i18n.zammad.com/api/v1/translations_empty_translation_list + +=end + + def self.load_from_csv(locale_name, location, charset = 'UTF8') + locale = Locale.find_by(locale: locale_name) + if !locale + raise "No such locale: #{locale_name}" + end + + if !::File.exist?(location) + raise "No such file: #{location}" + end + + content = ::File.open(location, "r:#{charset}").read + params = { + col_sep: ',', + } + rows = ::CSV.parse(content, params) + header = rows.shift + + translation_raw = [] + rows.each { |row| + raise "Can't import translation, source is missing" if row[0].blank? + if row[1].blank? + warn "Skipped #{row[0]}, because translation is blank" + next + end + raise "Can't import translation, format is missing" if row[2].blank? + raise "Can't import translation, format is invalid (#{row[2]})" if row[2] !~ /^(time|string)$/ + item = { + 'locale' => locale.locale, + 'source' => row[0], + 'target' => row[1], + 'target_initial' => '', + 'format' => row[2], + } + translation_raw.push item + } + to_database(locale.name, translation_raw) + true + end + private_class_method def self.to_database(locale, data) translations = Translation.where(locale: locale).all ActiveRecord::Base.transaction do @@ -341,13 +401,15 @@ all: private def set_initial - return if target_initial - return if target_initial == '' + return true if target_initial + return true if target_initial == '' self.target_initial = target + true end def cache_clear Cache.delete('TranslationMapOnlyContent::' + locale.downcase) + true end def self.cache_set(locale, data) 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/app/models/user.rb b/app/models/user.rb index 8b01e0afa..90528a231 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,23 +28,23 @@ 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 load 'user/search_index.rb' include User::SearchIndex - before_validation :check_name, :check_email, :check_login, :ensure_password - before_create :check_preferences_default, :validate_roles, :domain_based_assignment, :set_locale - before_update :check_preferences_default, :validate_roles, :reset_login_failed + before_validation :check_name, :check_email, :check_login, :ensure_uniq_email, :ensure_password, :ensure_roles, :ensure_identifier + before_create :check_preferences_default, :validate_roles, :validate_ooo, :domain_based_assignment, :set_locale + before_update :check_preferences_default, :validate_roles, :validate_ooo, :reset_login_failed after_create :avatar_for_email_check after_update :avatar_for_email_check - after_destroy :avatar_destroy + after_destroy :avatar_destroy, :user_device_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' @@ -160,6 +160,45 @@ returns =begin +check if user is in role + + user = User.find(123) + result = user.out_of_office? + +returns + + result = true|false + +=end + + def out_of_office? + return false if out_of_office != true + return false if out_of_office_start_at.blank? + return false if out_of_office_end_at.blank? + Time.zone.today.between?(out_of_office_start_at, out_of_office_end_at) + end + +=begin + +check if user is in role + + user = User.find(123) + result = user.out_of_office_agent + +returns + + result = user_model + +=end + + def out_of_office_agent + return if !out_of_office? + return if out_of_office_replacement_id.blank? + User.find_by(id: out_of_office_replacement_id) + end + +=begin + get users activity stream user = User.find(123) @@ -746,7 +785,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 +801,7 @@ returns return true if !has_changed - if id + if id && shouldSave save! return true end @@ -772,8 +811,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 @@ -795,7 +840,7 @@ returns end def check_name - return if !firstname.empty? && !lastname.empty? + return true if !firstname.empty? && !lastname.empty? if !firstname.empty? && lastname.empty? @@ -809,7 +854,7 @@ returns if !name[1].nil? self.firstname = name[1] end - return + return true end # "Firstname Lastname" @@ -820,7 +865,7 @@ returns if !name[1].nil? self.lastname = name[1] end - return + return true # -no name- "firstname.lastname@example.com" elsif firstname.empty? && lastname.empty? && !email.empty? @@ -834,21 +879,23 @@ returns end end end + true end def check_email - return if Setting.get('import_mode') - return if email.empty? + return true if Setting.get('import_mode') + return true if email.blank? self.email = email.downcase.strip - return if id == 1 + return true if id == 1 raise Exceptions::UnprocessableEntity, 'Invalid email' if email !~ /@/ raise Exceptions::UnprocessableEntity, 'Invalid email' if email =~ /\s/ + true end def check_login # use email as login if not given - if login.empty? && !email.empty? + if login.blank? self.login = email end @@ -859,9 +906,9 @@ returns end end - # if no email, complain about missing login - if id != 1 && login.empty? && email.empty? - raise Exceptions::UnprocessableEntity, 'Attribute \'login\' required!' + # generate auto login + if login.blank? + self.login = "auto-#{Time.zone.now.to_i}-#{rand(999_999)}" end # check if login already exists @@ -870,15 +917,37 @@ returns while check exists = User.find_by(login: login) if exists && exists.id != id - self.login = login + rand(999).to_s + self.login = "#{login}#{rand(999)}" else check = false end end + true + end + + def ensure_roles + return true if role_ids.present? + self.role_ids = Role.signup_role_ids + end + + def ensure_identifier + return true if email.present? || firstname.present? || lastname.present? || phone.present? + return true if login.present? && !login.start_with?('auto-') + raise Exceptions::UnprocessableEntity, 'Minimum one identifier (login, firstname, lastname, phone or email) for user is required.' + end + + def ensure_uniq_email + return true if Setting.get('user_email_multiple_use') + return true if Setting.get('import_mode') + return true if email.blank? + return true if !changes + return true if !changes['email'] + return true if !User.find_by(email: email.downcase.strip) + raise Exceptions::UnprocessableEntity, 'Email address is already used for other user.' end def validate_roles - return if !role_ids + return true if !role_ids role_ids.each { |role_id| role = Role.lookup(id: role_id) raise "Unable to find role for id #{role_id}" if !role @@ -889,8 +958,18 @@ returns raise "Role #{role.name} conflicts with #{local_role.name}" if role_ids.include?(local_role.id) } } + true end + def validate_ooo + return true if out_of_office != true + raise Exceptions::UnprocessableEntity, 'out of office start is required' if out_of_office_start_at.blank? + raise Exceptions::UnprocessableEntity, 'out of office end is required' if out_of_office_end_at.blank? + raise Exceptions::UnprocessableEntity, 'out of office end is before start' if out_of_office_start_at > out_of_office_end_at + raise Exceptions::UnprocessableEntity, 'out of office replacement user is required' if out_of_office_replacement_id.blank? + raise Exceptions::UnprocessableEntity, 'out of office no such replacement user' if !User.find_by(id: out_of_office_replacement_id) + true + end =begin checks if the current user is the last one @@ -903,7 +982,7 @@ raise 'Minimum one user need to have admin permissions' =end def last_admin_check(role) - return if Setting.get('import_mode') + return true if Setting.get('import_mode') ticket_admin_role_ids = Role.joins(:permissions).where(permissions: { name: ['admin', 'admin.user'] }).pluck(:id) count = User.joins(:roles).where(roles: { id: ticket_admin_role_ids }, users: { active: true }).count @@ -912,10 +991,11 @@ raise 'Minimum one user need to have admin permissions' end raise Exceptions::UnprocessableEntity, 'Minimum one user needs to have admin permissions.' if count < 1 + true end def validate_agent_limit(role) - return if !Setting.get('system_agent_limit') + return true if !Setting.get('system_agent_limit') ticket_agent_role_ids = Role.joins(:permissions).where(permissions: { name: 'ticket.agent' }).pluck(:id) count = User.joins(:roles).where(roles: { id: ticket_agent_role_ids }, users: { active: true }).count @@ -924,38 +1004,41 @@ raise 'Minimum one user need to have admin permissions' end raise Exceptions::UnprocessableEntity, 'Agent limit exceeded, please check your account settings.' if count > 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 - user = User.find_by( id: UserInfo.current_user_id ) - return if !user - return if !user.preferences[:locale] + return true if !UserInfo.current_user_id + user = User.find_by(id: UserInfo.current_user_id) + 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( @@ -968,20 +1051,26 @@ 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 Avatar.remove('User', id) end + def user_device_destroy + UserDevice.remove(id) + 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? @@ -1000,8 +1089,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/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_device.rb b/app/models/user_device.rb index 15d7b3698..3085fe24b 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -21,8 +21,9 @@ store new device for user if device not already known def self.add(user_agent, ip, user_id, fingerprint, type) - # since gem browser 2 is not handling nil for user_agent, set it to '' - user_agent ||= '' + if user_agent.blank? + user_agent = 'unknown' + end # get location info location_details = Service::GeoIp.location(ip) @@ -60,23 +61,26 @@ store new device for user if device not already known end # get browser details - browser = Browser.new(user_agent, accept_language: 'en-us') - browser = { - plattform: browser.platform.to_s.camelize, - name: browser.name, - version: browser.version, - full_version: browser.full_version, - } + browser = {} + if user_agent != 'unknown' + browser = Browser.new(user_agent, accept_language: 'en-us') + browser = { + plattform: browser.platform.to_s.camelize, + name: browser.name, + version: browser.version, + full_version: browser.full_version, + } + end # generate device name if browser[:name] == 'Generic Browser' browser[:name] = user_agent end name = '' - if browser[:plattform] && browser[:plattform] != 'Other' + if browser[:plattform].present? && browser[:plattform] != 'Other' name = browser[:plattform] end - if browser[:name] && browser[:name] != 'Other' + if browser[:name].present? && browser[:name] != 'Other' if name.present? name += ', ' end @@ -84,7 +88,7 @@ store new device for user if device not already known end # if not identified, use user agent - if !name || name == '' || name == 'Other, Other' || name == 'Other' + if name.blank? || name == 'Other, Other' || name == 'Other' name = user_agent browser[:name] = user_agent end @@ -103,7 +107,7 @@ store new device for user if device not already known end # create new device - user_device = create( + user_device = create!( user_id: user_id, name: name, os: browser[:plattform], @@ -206,4 +210,15 @@ send user notification about new device or new location for device ) end +=begin + +delete device devices of user + + user_devices = UserDevice.remove(user.id) + +=end + + def self.remove(user_id) + UserDevice.where(user_id: user_id).destroy_all + 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/app/views/mailer/application.html.erb b/app/views/mailer/application.html.erb index 913224ad3..858bdd5d3 100644 --- a/app/views/mailer/application.html.erb +++ b/app/views/mailer/application.html.erb @@ -16,6 +16,7 @@ font-size: 16px; } .footer { + font-size: 10px; color: #aaaaaa; border-top-style:solid; border-top-width:1px; @@ -40,6 +41,13 @@ <% if @objects[:standalone] != true %> <% end %> diff --git a/app/views/tests/form_tree_select.html.erb b/app/views/tests/form_tree_select.html.erb new file mode 100644 index 000000000..15fadf932 --- /dev/null +++ b/app/views/tests/form_tree_select.html.erb @@ -0,0 +1,22 @@ + + + + + + + + + +
+ +
+
+
+ +
+
diff --git a/bin/setup b/bin/setup index 00ce4d30a..016034e94 100755 --- a/bin/setup +++ b/bin/setup @@ -10,7 +10,7 @@ Dir.chdir APP_ROOT do puts '== Installing dependencies ==' system 'gem install bundler --conservative' - system 'bundle check || bundle install' + system 'bundle check || bundle install --jobs 8' # puts "\n== Copying sample files ==" # unless File.exist?("config/database.yml") diff --git a/config/application.rb b/config/application.rb index 28aaa09c4..3721b5df5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,6 +23,7 @@ module Zammad config.active_record.observers = 'observer::_session', 'observer::_ticket::_close_time', + 'observer::_ticket::_last_owner_update', 'observer::_ticket::_user_ticket_counter', 'observer::_ticket::_article_changes', 'observer::_ticket::_article::_fillup_from_general', 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/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/config/initializers/html_sanitizer.rb b/config/initializers/html_sanitizer.rb index b1d4a5b9c..387fd5fdd 100644 --- a/config/initializers/html_sanitizer.rb +++ b/config/initializers/html_sanitizer.rb @@ -40,6 +40,7 @@ Rails.application.config.html_sanitizer_attributes_whitelist = { 'table' => %w(align bgcolor border cellpadding cellspacing frame rules sortable summary width style), 'td' => %w(abbr align axis colspan headers rowspan valign width style), 'th' => %w(abbr align axis colspan headers rowspan scope sorted valign width style), + 'tr' => %w(width style), 'ul' => %w(type), 'q' => %w(cite), 'span' => %w(style), diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 6436d6aad..ea2793497 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -32,9 +32,11 @@ Rails.application.config.middleware.use OmniAuth::Builder do authorize_url: '/oauth/authorize', token_url: '/oauth/token' }, - scope: 'read_user', } + # microsoft_office365 database connect + provider :microsoft_office365_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database' + # oauth2 database connect provider :oauth2_database, 'not_change_will_be_set_by_database', 'not_change_will_be_set_by_database', { client_options: { diff --git a/config/routes/form.rb b/config/routes/form.rb index 70fac4fde..fbe08b640 100644 --- a/config/routes/form.rb +++ b/config/routes/form.rb @@ -2,7 +2,7 @@ Zammad::Application.routes.draw do api_path = Rails.configuration.api_path # forms - match api_path + '/form_submit', to: 'form#submit', via: :post - match api_path + '/form_config', to: 'form#config', via: :get + match api_path + '/form_submit', to: 'form#submit', via: :post + match api_path + '/form_config', to: 'form#configuration', via: :post end diff --git a/config/routes/integration_check_mk.rb b/config/routes/integration_check_mk.rb new file mode 100644 index 000000000..323201145 --- /dev/null +++ b/config/routes/integration_check_mk.rb @@ -0,0 +1,5 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/integration/check_mk/:token', to: 'integration/check_mk#update', via: :post, defaults: { format: 'json' } +end diff --git a/config/routes/integration_exchange.rb b/config/routes/integration_exchange.rb new file mode 100644 index 000000000..0c84a90f8 --- /dev/null +++ b/config/routes/integration_exchange.rb @@ -0,0 +1,11 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/integration/exchange/autodiscover', to: 'integration/exchange#autodiscover', via: :post + match api_path + '/integration/exchange/folders', to: 'integration/exchange#folders', via: :post + match api_path + '/integration/exchange/mapping', to: 'integration/exchange#mapping', via: :post + match api_path + '/integration/exchange/job_try', to: 'integration/exchange#job_try_index', via: :get + match api_path + '/integration/exchange/job_try', to: 'integration/exchange#job_try_create', via: :post + match api_path + '/integration/exchange/job_start', to: 'integration/exchange#job_start_index', via: :get + match api_path + '/integration/exchange/job_start', to: 'integration/exchange#job_start_create', via: :post +end diff --git a/config/routes/integration_idoit.rb b/config/routes/integration_idoit.rb new file mode 100644 index 000000000..455599307 --- /dev/null +++ b/config/routes/integration_idoit.rb @@ -0,0 +1,9 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/integration/idoit', to: 'integration/idoit#query', via: :post + match api_path + '/integration/idoit', to: 'integration/idoit#query', via: :get + match api_path + '/integration/idoit/verify', to: 'integration/idoit#verify', via: :post + match api_path + '/integration/idoit_ticket_update', to: 'integration/idoit#update', via: :post + +end diff --git a/config/routes/test.rb b/config/routes/test.rb index 4298d13da..91d738b21 100644 --- a/config/routes/test.rb +++ b/config/routes/test.rb @@ -5,6 +5,7 @@ Zammad::Application.routes.draw do match '/tests_model', to: 'tests#model', via: :get match '/tests_model_ui', to: 'tests#model_ui', via: :get match '/tests_form', to: 'tests#form', via: :get + match '/tests_form_tree_select', to: 'tests#form_tree_select', via: :get match '/tests_form_find', to: 'tests#form_find', via: :get match '/tests_form_trim', to: 'tests#form_trim', via: :get match '/tests_form_extended', to: 'tests#form_extended', via: :get diff --git a/config/routes/user.rb b/config/routes/user.rb index 54f8dba73..0f66db41c 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -8,6 +8,7 @@ Zammad::Application.routes.draw do match api_path + '/users/password_reset_verify', to: 'users#password_reset_verify', via: :post match api_path + '/users/password_change', to: 'users#password_change', via: :post match api_path + '/users/preferences', to: 'users#preferences', via: :put + match api_path + '/users/out_of_office', to: 'users#out_of_office', via: :put match api_path + '/users/account', to: 'users#account_remove', via: :delete match api_path + '/users/avatar', to: 'users#avatar_new', via: :post diff --git a/contrib/cleanup.sh b/contrib/cleanup.sh index c057c0dbb..10efac1e9 100755 --- a/contrib/cleanup.sh +++ b/contrib/cleanup.sh @@ -7,3 +7,5 @@ rm -rf app/assets/javascripts/app/views/layout_ref/ rm app/assets/javascripts/app/controllers/karma.coffee rm app/assets/javascripts/app/controllers/report.coffee rm app/assets/javascripts/app/controllers/report_profile.coffee +rm app/assets/javascripts/app/controllers/_integration/check_mk.coffee +rm app/assets/javascripts/app/controllers/_integration/idoit.coffee diff --git a/contrib/icon-sprite.sketch b/contrib/icon-sprite.sketch index 6ec311680..2a6135cec 100644 Binary files a/contrib/icon-sprite.sketch and b/contrib/icon-sprite.sketch differ diff --git a/contrib/nginx/zammad.conf b/contrib/nginx/zammad.conf index 7bb4fcc25..279d35f51 100644 --- a/contrib/nginx/zammad.conf +++ b/contrib/nginx/zammad.conf @@ -33,6 +33,7 @@ server { proxy_set_header Connection "Upgrade"; proxy_set_header CLIENT_IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; proxy_pass http://zammad-websocket; } @@ -41,6 +42,7 @@ server { proxy_set_header Host $http_host; proxy_set_header CLIENT_IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 180; proxy_pass http://zammad; diff --git a/contrib/nginx/zammad_ssl.conf b/contrib/nginx/zammad_ssl.conf index 248790af3..de55f36ed 100644 --- a/contrib/nginx/zammad_ssl.conf +++ b/contrib/nginx/zammad_ssl.conf @@ -124,6 +124,7 @@ server { proxy_set_header Connection "Upgrade"; proxy_set_header CLIENT_IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; proxy_pass http://zammad-websocket; } @@ -132,6 +133,7 @@ server { proxy_set_header Host $http_host; proxy_set_header CLIENT_IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 180; proxy_pass http://zammad; diff --git a/contrib/packager.io/functions b/contrib/packager.io/functions index 48d1b3378..1d10e9c6b 100644 --- a/contrib/packager.io/functions +++ b/contrib/packager.io/functions @@ -79,7 +79,7 @@ function detect_webserver () { WEBSERVER="nginx" WEBSERVER_CMD="nginx" if [ "${OS}" == "DEBIAN" ]; then - WEBSERVER_CONF="/etc/nginx/sites-enabled/zammad.conf" + WEBSERVER_CONF="/etc/nginx/sites-available/zammad.conf" elif [ "${OS}" == "REDHAT" ]; then WEBSERVER_CONF="/etc/nginx/conf.d/zammad.conf" elif [ "${OS}" == "SUSE" ]; then @@ -89,7 +89,7 @@ function detect_webserver () { WEBSERVER="apache2" WEBSERVER_CMD="apache2" if [ "${OS}" == "DEBIAN" ]; then - WEBSERVER_CONF="/etc/apache2/sites-enabled/zammad.conf" + WEBSERVER_CONF="/etc/apache2/sites-available/zammad.conf" fi elif [ -n "$(which httpd 2> /dev/null)" ]; then WEBSERVER="apache2" @@ -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 } @@ -195,8 +195,14 @@ function update_database () { function create_webserver_config () { if [ "${OS}" == "DEBIAN" ]; then - test -f /etc/${WEBSERVER}/sites-available/zammad.conf || cp ${ZAMMAD_DIR}/contrib/${WEBSERVER}/zammad.conf /etc/${WEBSERVER}/sites-available/zammad.conf - test -h ${WEBSERVER_CONF} || ln -s /etc/${WEBSERVER}/sites-available/zammad.conf ${WEBSERVER_CONF} + if [ ! -f "${WEBSERVER_CONF}" ]; then + if [ -f "/etc/${WEBSERVER}/sites-enabled/zammad.conf" ]; then + mv /etc/${WEBSERVER}/sites-enabled/zammad.conf ${WEBSERVER_CONF} + else + cp ${ZAMMAD_DIR}/contrib/${WEBSERVER}/zammad.conf ${WEBSERVER_CONF} + fi + ln -s ${WEBSERVER_CONF} /etc/${WEBSERVER}/sites-enabled/zammad.conf + fi if [ "${WEBSERVER}" == "apache2" ]; then a2enmod proxy a2enmod proxy_http @@ -233,6 +239,13 @@ function update_or_install () { fi } +function set_env_vars () { + 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 () { 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 diff --git a/contrib/travis-ci.org/trigger-docker-compose-build.sh b/contrib/travis-ci.org/trigger-docker-compose-build.sh new file mode 100755 index 000000000..cc7288b49 --- /dev/null +++ b/contrib/travis-ci.org/trigger-docker-compose-build.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# +# trigger build of https://github.com/zammad/zammad-docker-compose on https://travis-ci.org/zammad/zammad-docker-compose and upload it to https://hub.docker.com/r/zammad/zammad-docker-compose/ +# + +REPO_USER="zammad" +REPO="zammad-docker-compose" +BRANCH="master" + +if [ ${TRAVIS_BRANCH} == 'stable' ]; then + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Travis-API-Version: 3" \ + -H "Accept: application/json" \ + -H "Authorization: token ${TRAVIS_API_TOKEN}" \ + -d '{"request": {"branch":"'${BRANCH}'"}}' \ + "https://api.travis-ci.org/repo/${REPO_USER}%2F${REPO}/requests" +fi diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb index 722e5c517..71547ebb9 100644 --- a/db/migrate/20120101000001_create_base.rb +++ b/db/migrate/20120101000001_create_base.rb @@ -40,6 +40,10 @@ class CreateBase < ActiveRecord::Migration t.timestamp :last_login, limit: 3, null: true t.string :source, limit: 200, null: true t.integer :login_failed, null: false, default: 0 + t.boolean :out_of_office, null: false, default: false + t.date :out_of_office_start_at, null: true + t.date :out_of_office_end_at, null: true + t.integer :out_of_office_replacement_id, null: true t.string :preferences, limit: 8000, null: true t.integer :updated_by_id, null: false t.integer :created_by_id, null: false @@ -54,8 +58,13 @@ class CreateBase < ActiveRecord::Migration add_index :users, [:phone] add_index :users, [:fax] add_index :users, [:mobile] + add_index :users, [:out_of_office, :out_of_office_start_at, :out_of_office_end_at], name: 'index_out_of_office' + add_index :users, [:out_of_office_replacement_id] 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 + add_foreign_key :users, :users, column: :out_of_office_replacement_id create_table :signatures do |t| t.string :name, limit: 100, null: false @@ -67,6 +76,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 +91,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 +108,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 +124,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 +154,49 @@ 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, 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.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,11 +210,13 @@ 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 t.string :alias, limit: 20, null: true t.string :name, limit: 255, null: false + t.string :dir, limit: 9, null: false, default: 'ltr' t.boolean :active, null: false, default: true t.timestamps limit: 3, null: false end @@ -192,6 +235,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 +265,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 +276,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 +286,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 +302,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 +317,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 +340,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 +359,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 +408,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 +429,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 +444,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 +485,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 +502,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 +520,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 +537,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 +558,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 @@ -495,8 +571,8 @@ class CreateBase < ActiveRecord::Migration t.string :name, limit: 200, null: false t.string :display, limit: 200, null: false t.string :data_type, limit: 100, null: false - t.string :data_option, limit: 8000, null: true - t.string :data_option_new, limit: 8000, null: true + t.text :data_option, limit: 800.kilobytes + 1, null: true + t.text :data_option_new, limit: 800.kilobytes + 1, null: true t.boolean :editable, null: false, default: true t.boolean :active, null: false, default: true t.string :screens, limit: 2000, null: true @@ -511,6 +587,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 +652,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 +682,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 +700,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..ab36bcfce 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 @@ -62,6 +69,7 @@ class CreateTicket < ActiveRecord::Migration t.column :last_contact_at, :timestamp, limit: 3, null: true t.column :last_contact_agent_at, :timestamp, limit: 3, null: true t.column :last_contact_customer_at, :timestamp, limit: 3, null: true + t.column :last_owner_update_at, :timestamp, limit: 3, null: true t.column :create_article_type_id, :integer, null: true t.column :create_article_sender_id, :integer, null: true t.column :article_count, :integer, null: true @@ -96,36 +104,35 @@ class CreateTicket < ActiveRecord::Migration add_index :tickets, [:last_contact_at] add_index :tickets, [:last_contact_agent_at] add_index :tickets, [:last_contact_customer_at] + add_index :tickets, [:last_owner_update_at] add_index :tickets, [:create_article_type_id] add_index :tickets, [:create_article_sender_id] add_index :tickets, [:created_by_id] 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 +144,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 +155,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 +188,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 @@ -204,6 +238,7 @@ class CreateTicket < ActiveRecord::Migration t.column :order, :string, limit: 2500, null: false t.column :group_by, :string, limit: 250, null: true t.column :organization_shared, :boolean, null: false, default: false + t.column :out_of_office, :boolean, null: false, default: false t.column :view, :string, limit: 1000, null: false t.column :active, :boolean, null: false, default: true t.column :updated_by_id, :integer, null: false @@ -211,27 +246,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 +288,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 +310,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 +328,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 +347,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 +361,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 +378,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 +401,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 +429,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 +445,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 +458,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 +473,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 +485,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 +503,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 +528,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 +540,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 +564,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 +573,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/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/20170516000001_trigger_recipient_update.rb b/db/migrate/20170516000001_trigger_recipient_update.rb index 2b359d0ae..a2ed60e87 100644 --- a/db/migrate/20170516000001_trigger_recipient_update.rb +++ b/db/migrate/20170516000001_trigger_recipient_update.rb @@ -5,14 +5,18 @@ class TriggerRecipientUpdate < ActiveRecord::Migration return if !Setting.find_by(name: 'system_init_done') ['auto reply (on new tickets)', 'auto reply (on follow up of tickets)'].each { |name| - trigger = Trigger.find_by(name: name) - next if !trigger - next if !trigger.perform - next if !trigger.perform['notification.email'] - next if !trigger.perform['notification.email']['recipient'] - next if trigger.perform['notification.email']['recipient'] != 'ticket_customer' - trigger.perform['notification.email']['recipient'] = 'article_last_sender' - trigger.save + begin + trigger = Trigger.find_by(name: name) + next if trigger.blank? + next if trigger.perform.blank? + next if trigger.perform['notification.email'].blank? + next if trigger.perform['notification.email']['recipient'].blank? + next if trigger.perform['notification.email']['recipient'] != 'ticket_customer' + trigger.perform['notification.email']['recipient'] = 'article_last_sender' + trigger.save! + rescue => e + Rails.logger.error "Unable to update Trigger.find(#{trigger.id}) '#{trigger.inspect}': #{e.message}" + 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..ace1d8d5f --- /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/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/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/db/migrate/20170531144425_foreign_keys.rb b/db/migrate/20170531144425_foreign_keys.rb new file mode 100644 index 000000000..64bcedab0 --- /dev/null +++ b/db/migrate/20170531144425_foreign_keys.rb @@ -0,0 +1,231 @@ +class ForeignKeys < ActiveRecord::Migration + disable_ddl_transaction! + + 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| + ActiveRecord::Base.transaction do + begin + 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 +end diff --git a/db/migrate/20170608151442_enhanced_permissions.rb b/db/migrate/20170608151442_enhanced_permissions.rb new file mode 100644 index 000000000..558741b10 --- /dev/null +++ b/db/migrate/20170608151442_enhanced_permissions.rb @@ -0,0 +1,25 @@ +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] + + Cache.clear + end +end diff --git a/db/migrate/20170619000001_tree_select.rb b/db/migrate/20170619000001_tree_select.rb new file mode 100644 index 000000000..69dddaad7 --- /dev/null +++ b/db/migrate/20170619000001_tree_select.rb @@ -0,0 +1,10 @@ +class TreeSelect < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + change_column :object_manager_attributes, :data_option, :text, limit: 800.kilobytes + 1, null: true + change_column :object_manager_attributes, :data_option_new, :text, limit: 800.kilobytes + 1, null: true + end +end diff --git a/db/migrate/20170626000001_locale_add_direction.rb b/db/migrate/20170626000001_locale_add_direction.rb new file mode 100644 index 000000000..66fd8583c --- /dev/null +++ b/db/migrate/20170626000001_locale_add_direction.rb @@ -0,0 +1,9 @@ +class LocaleAddDirection < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + add_column :locales, :dir, :string, limit: 9, null: false, default: 'ltr' + end +end diff --git a/db/migrate/20170628000001_form_group_selection.rb b/db/migrate/20170628000001_form_group_selection.rb new file mode 100644 index 000000000..a7a1b6c60 --- /dev/null +++ b/db/migrate/20170628000001_form_group_selection.rb @@ -0,0 +1,103 @@ +class FormGroupSelection < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + group = Group.where(active: true).first + if !group + group = Group.first + end + group_id = 1 + if group + group_id = group.id + end + Setting.create_if_not_exists( + title: 'Group selection for Ticket creation', + name: 'form_ticket_create_group_id', + area: 'Form::Base', + description: 'Defines if group of created tickets via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: group_id, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + + Setting.create_if_not_exists( + title: 'Limit tickets by ip per hour', + name: 'form_ticket_create_by_ip_per_hour', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per hour via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_hour', + tag: 'input', + }, + ], + }, + state: 20, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + Setting.create_if_not_exists( + title: 'Limit tickets by ip per day', + name: 'form_ticket_create_by_ip_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_day', + tag: 'input', + }, + ], + }, + state: 240, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + Setting.create_if_not_exists( + title: 'Limit tickets per day', + name: 'form_ticket_create_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_per_day', + tag: 'input', + }, + ], + }, + state: 5000, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, + ) + + end +end diff --git a/db/migrate/20170629000001_exchange_integration.rb b/db/migrate/20170629000001_exchange_integration.rb new file mode 100644 index 000000000..88447f2c6 --- /dev/null +++ b/db/migrate/20170629000001_exchange_integration.rb @@ -0,0 +1,51 @@ +class ExchangeIntegration < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + Setting.set('import_backends', ['Import::Ldap', 'Import::Exchange']) + + Setting.create_if_not_exists( + title: 'Exchange config', + name: 'exchange_config', + area: 'Integration::Exchange', + description: 'Defines the Exchange config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, + ) + Setting.create_if_not_exists( + title: 'Exchange integration', + name: 'exchange_integration', + area: 'Integration::Switch', + description: 'Defines if Exchange is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'exchange_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true + ) + end + +end diff --git a/db/migrate/20170713000001_omniauth_office365_setting.rb b/db/migrate/20170713000001_omniauth_office365_setting.rb new file mode 100644 index 000000000..147dbd508 --- /dev/null +++ b/db/migrate/20170713000001_omniauth_office365_setting.rb @@ -0,0 +1,63 @@ +class OmniauthOffice365Setting < 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: 'Authentication via %s', + name: 'auth_microsoft_office365', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_microsoft_office365', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_microsoft_office365_credentials'], + title_i18n: ['Office 365'], + description_i18n: ['Office 365', 'Microsoft Application Registration Portal', 'https://apps.dev.microsoft.com'], + permission: ['admin.security'], + }, + state: false, + frontend: true + ) + Setting.create_if_not_exists( + title: 'Office 365 App Credentials', + name: 'auth_microsoft_office365_credentials', + area: 'Security::ThirdPartyAuthentication::Office365', + description: 'Enables user authentication via Office 365.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'app_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'app_secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false + ) + end +end diff --git a/db/migrate/20170713000002_ticket_zoom_setting2.rb b/db/migrate/20170713000002_ticket_zoom_setting2.rb new file mode 100644 index 000000000..8147edc1f --- /dev/null +++ b/db/migrate/20170713000002_ticket_zoom_setting2.rb @@ -0,0 +1,126 @@ +class TicketZoomSetting2 < 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: 'ui_ticket_zoom_article_new_internal') + if setting + setting.title = 'Note - default visibility' + setting.name = 'ui_ticket_zoom_article_note_new_internal' + setting.description = 'Default visibility for new articles.' + setting.preferences[:prio] = 100 + setting.options[:form][0][:name] = 'ui_ticket_zoom_article_note_new_internal' + setting.save! + end + Setting.create_if_not_exists( + title: 'Note - default visibility', + name: 'ui_ticket_zoom_article_note_new_internal', + area: 'UI::TicketZoom', + description: 'Default visibility for new articles.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_note_new_internal', + tag: 'boolean', + translate: true, + options: { + true => 'internal', + false => 'public', + }, + }, + ], + }, + state: true, + preferences: { + prio: 100, + permission: ['admin.ui'], + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'Email - subject field', + name: 'ui_ticket_zoom_article_email_subject', + area: 'UI::TicketZoom', + description: 'Use subject field for emails. If disabled, the ticket title will be used as subject.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_subject', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 200, + permission: ['admin.ui'], + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'Email - full quote', + name: 'ui_ticket_zoom_article_email_full_quote', + area: 'UI::TicketZoom', + description: 'Enable if you want to quote the full email in your answer. The quoted email will be put at the end of your answer. If you just want to quote a certain phrase, just mark the text and press reply (this feature is always available).', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_full_quote', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 220, + permission: ['admin.ui'], + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'Twitter - tweet initials', + name: 'ui_ticket_zoom_article_twitter_initials', + area: 'UI::TicketZoom', + description: 'Add sender initials to end of a tweet.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_twitter_initials', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 300, + permission: ['admin.ui'], + }, + frontend: true + ) + end + +end diff --git a/db/migrate/20170714000001_object_manager_user_email_optional.rb b/db/migrate/20170714000001_object_manager_user_email_optional.rb new file mode 100644 index 000000000..4536130b6 --- /dev/null +++ b/db/migrate/20170714000001_object_manager_user_email_optional.rb @@ -0,0 +1,54 @@ +class ObjectManagerUserEmailOptional < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + ObjectManager::Attribute.add( + force: true, + object: 'User', + name: 'email', + display: 'Email', + data_type: 'input', + data_option: { + type: 'email', + maxlength: 150, + null: true, + item_class: 'formGroup--halfSize', + }, + editable: false, + active: true, + screens: { + signup: { + '-all-' => { + null: false, + }, + }, + invite_agent: { + '-all-' => { + null: false, + }, + }, + invite_customer: { + '-all-' => { + null: false, + }, + }, + edit: { + '-all-' => { + null: true, + }, + }, + view: { + '-all-' => { + shown: true, + }, + }, + }, + to_create: false, + to_migrate: false, + to_delete: false, + position: 400, + ) + end +end diff --git a/db/migrate/20170714000002_user_email_multiple_use.rb b/db/migrate/20170714000002_user_email_multiple_use.rb new file mode 100644 index 000000000..5d89d8d59 --- /dev/null +++ b/db/migrate/20170714000002_user_email_multiple_use.rb @@ -0,0 +1,34 @@ +class UserEmailMultipleUse < 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: 'User email for muliple users', + name: 'user_email_multiple_use', + area: 'Model::User', + description: 'Allow to use email address for muliple users.', + options: { + form: [ + { + display: '', + null: true, + name: 'user_email_multiple_use', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin'], + }, + frontend: false + ) + end + +end diff --git a/db/migrate/20170714000003_cleanup_cti_log.rb b/db/migrate/20170714000003_cleanup_cti_log.rb new file mode 100644 index 000000000..4b9e59cbe --- /dev/null +++ b/db/migrate/20170714000003_cleanup_cti_log.rb @@ -0,0 +1,18 @@ +class CleanupCtiLog < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + Scheduler.create_if_not_exists( + name: 'Cleanup Cti::Log', + method: 'Cti::Log.cleanup', + period: 1.month, + prio: 2, + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + end + +end diff --git a/db/migrate/20170314000002_fixed_twitter_ticket_article_preferences.rb b/db/migrate/20170725000001_fixed_twitter_ticket_article_preferences3.rb similarity index 59% rename from db/migrate/20170314000002_fixed_twitter_ticket_article_preferences.rb rename to db/migrate/20170725000001_fixed_twitter_ticket_article_preferences3.rb index ce2cff6d0..23e21acdb 100644 --- a/db/migrate/20170314000002_fixed_twitter_ticket_article_preferences.rb +++ b/db/migrate/20170725000001_fixed_twitter_ticket_article_preferences3.rb @@ -1,4 +1,4 @@ -class FixedTwitterTicketArticlePreferences < ActiveRecord::Migration +class FixedTwitterTicketArticlePreferences3 < ActiveRecord::Migration def up # return if it's a new setup @@ -6,12 +6,23 @@ class FixedTwitterTicketArticlePreferences < ActiveRecord::Migration # find article preferences with Twitter::NullObject and replace it with nill to prevent elasticsearch index issue article_type = Ticket::Article::Type.find_by(name: 'twitter status') - Ticket::Article.where(type_id: article_type.id).each { |article| + article_ids = Ticket::Article.where(type_id: article_type.id).pluck(:id) + article_ids.each { |article_id| + article = Ticket::Article.find(article_id) next if !article.preferences changed = false article.preferences.each { |_key, value| next if value.class != ActiveSupport::HashWithIndifferentAccess value.each { |sub_key, sub_level| + if sub_level.class == NilClass + value[sub_key] = nil + next + end + 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/db/migrate/20170115000001_add_proxy_settings_439.rb b/db/migrate/20170727000001_setting_proxy.rb similarity index 62% rename from db/migrate/20170115000001_add_proxy_settings_439.rb rename to db/migrate/20170727000001_setting_proxy.rb index a6c0467f4..2634c3beb 100644 --- a/db/migrate/20170115000001_add_proxy_settings_439.rb +++ b/db/migrate/20170727000001_setting_proxy.rb @@ -1,4 +1,4 @@ -class AddProxySettings439 < ActiveRecord::Migration +class SettingProxy < ActiveRecord::Migration def up # return if it's a new setup @@ -53,30 +53,61 @@ class AddProxySettings439 < ActiveRecord::Migration }, frontend: false ) + # fix typo + setting = Setting.find_by(name: 'proxy_password') + if setting + setting.options[:form][0][:name] = 'proxy_password' + setting.save! + else + Setting.create_if_not_exists( + title: 'Proxy Password', + name: 'proxy_password', + area: 'System::Network', + description: 'Password for proxy connection.', + options: { + form: [ + { + display: '', + null: false, + name: 'proxy_password', + tag: 'input', + }, + ], + }, + state: '', + preferences: { + disabled: true, + online_service_disable: true, + prio: 3, + permission: ['admin.system'], + }, + frontend: false + ) + end Setting.create_if_not_exists( - title: 'Proxy Password', - name: 'proxy_password', + title: 'No Proxy', + name: 'proxy_no', area: 'System::Network', - description: 'Password for proxy connection.', + description: 'No proxy for the following hosts.', options: { form: [ { display: '', null: false, - name: 'proxy_passowrd', + name: 'proxy_no', tag: 'input', }, ], }, - state: '', + state: 'localhost,127.0.0.0,::1', preferences: { disabled: true, online_service_disable: true, - prio: 3, + prio: 4, permission: ['admin.system'], }, frontend: false ) - end + end diff --git a/db/migrate/20170816000001_idoit_support.rb b/db/migrate/20170816000001_idoit_support.rb new file mode 100644 index 000000000..8a8185b3e --- /dev/null +++ b/db/migrate/20170816000001_idoit_support.rb @@ -0,0 +1,49 @@ +class IdoitSupport < 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: 'i-doit integration', + name: 'idoit_integration', + area: 'Integration::Switch', + description: 'Defines if i-doit (http://www.i-doit) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'idoit_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true + ) + Setting.create_if_not_exists( + title: 'i-doit config', + name: 'idoit_config', + area: 'Integration::Idoit', + description: 'Defines the i-doit config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, + ) + end + +end diff --git a/db/migrate/20170820000001_check_mk_integration.rb b/db/migrate/20170820000001_check_mk_integration.rb new file mode 100644 index 000000000..8952108b6 --- /dev/null +++ b/db/migrate/20170820000001_check_mk_integration.rb @@ -0,0 +1,119 @@ +class CheckMkIntegration < 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: 'Check_MK integration', + name: 'check_mk_integration', + area: 'Integration::Switch', + description: 'Defines if Check_MK (http://mathias-kettner.com/check_mk.html) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'check_mk_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false + ) + Setting.create_if_not_exists( + title: 'Group', + name: 'check_mk_group_id', + area: 'Integration::CheckMK', + description: 'Defines the group of created tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'check_mk_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: 1, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false + ) + Setting.create_if_not_exists( + title: 'Auto close', + name: 'check_mk_auto_close', + area: 'Integration::CheckMK', + description: 'Defines if tickets should be closed if service is recovered.', + options: { + form: [ + { + display: '', + null: true, + name: 'check_mk_auto_close', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 3, + permission: ['admin.integration'], + }, + frontend: false + ) + Setting.create_if_not_exists( + title: 'Auto close state', + name: 'check_mk_auto_close_state_id', + area: 'Integration::CheckMK', + description: 'Defines the state of auto closed tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'check_mk_auto_close_state_id', + tag: 'select', + relation: 'TicketState', + }, + ], + }, + state: 4, + preferences: { + prio: 4, + permission: ['admin.integration'], + }, + frontend: false + ) + Setting.create_if_not_exists( + title: 'Check_MK tolen', + name: 'check_mk_token', + area: 'Core', + description: 'Defines the Check_MK token for allowing updates.', + options: {}, + state: SecureRandom.hex(16), + preferences: { + permission: ['admin.integration'], + }, + frontend: false + ) + end + +end diff --git a/db/migrate/20170822000001_agend_based_sender_issue_1351.rb b/db/migrate/20170822000001_agend_based_sender_issue_1351.rb new file mode 100644 index 000000000..8095b238e --- /dev/null +++ b/db/migrate/20170822000001_agend_based_sender_issue_1351.rb @@ -0,0 +1,14 @@ +class AgendBasedSenderIssue1351 < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + EmailAddress.all.each { |email_address| + begin + email_address.save! + rescue => e + Rails.logger.error "Unable to update EmailAddress.find(#{email_address.id}) '#{email_address.inspect}': #{e.message}" + end + } + end +end diff --git a/db/migrate/20170826000001_out_of_office.rb b/db/migrate/20170826000001_out_of_office.rb new file mode 100644 index 000000000..133771c67 --- /dev/null +++ b/db/migrate/20170826000001_out_of_office.rb @@ -0,0 +1,53 @@ +class OutOfOffice < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + add_column :overviews, :out_of_office, :boolean, null: false, default: false + Overview.reset_column_information + + role_ids = Role.with_permissions(['ticket.agent']).map(&:id) + overview_role = Role.find_by(name: 'Agent') + Overview.create_if_not_exists( + name: 'My replacement Tickets', + link: 'my_replacement_tickets', + prio: 1080, + role_ids: role_ids, + out_of_office: true, + condition: { + 'ticket.state_id' => { + operator: 'is', + value: Ticket::State.by_category(:open).pluck(:id), + }, + #'ticket.out_of_office_replacement_id' => { + # operator: 'is', + # pre_condition: 'current_user.organization_id', + #}, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer group owner escalation_at), + s: %w(title customer group owner escalation_at), + m: %w(number title customer group owner escalation_at), + view_mode_default: 's', + }, + updated_by_id: 1, + created_by_id: 1, + ) + + add_column :users, :out_of_office, :boolean, null: false, default: false + add_column :users, :out_of_office_start_at, :date, null: true + add_column :users, :out_of_office_end_at, :date, null: true + add_column :users, :out_of_office_replacement_id, :integer, null: true + + add_index :users, [:out_of_office, :out_of_office_start_at, :out_of_office_end_at], name: 'index_out_of_office' + add_index :users, [:out_of_office_replacement_id] + add_foreign_key :users, :users, column: :out_of_office_replacement_id + + Cache.clear + end +end diff --git a/db/migrate/20170830000001_last_owner_update.rb b/db/migrate/20170830000001_last_owner_update.rb new file mode 100644 index 000000000..004e12daf --- /dev/null +++ b/db/migrate/20170830000001_last_owner_update.rb @@ -0,0 +1,57 @@ +class LastOwnerUpdate < ActiveRecord::Migration + def up + + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + # reset assignment_timeout to prevent unwanted things happen + Group.all.each { |group| + group.assignment_timeout = nil + group.save! + } + + add_column :tickets, :last_owner_update_at, :timestamp, limit: 3, null: true + add_index :tickets, [:last_owner_update_at] + Ticket.reset_column_information + + Scheduler.create_if_not_exists( + name: 'Process auto unassign tickets', + method: 'Ticket.process_auto_unassign', + period: 10.minutes, + prio: 1, + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + + state_ids = Ticket::State.by_category(:work_on).pluck(:id) + if state_ids.present? + ticket_ids = Ticket.where('tickets.state_id IN (?) AND tickets.owner_id != 1', state_ids).order(created_at: :desc).limit(1000).pluck(:id) + ticket_ids.each { |ticket_id| + ticket = Ticket.find_by(id: ticket_id) + next if !ticket + ticket.last_owner_update_at = last_owner_update_at(ticket) + ticket.save! + } + end + end + + def last_owner_update_at(ticket) + type = History::Type.lookup(name: 'updated') + if type + object = History::Object.lookup(name: 'Ticket') + if object + attribute = History::Attribute.lookup(name: 'owner') + if attribute + history = History.where(o_id: ticket.id, history_type_id: type.id, history_object_id: object.id, history_attribute_id: attribute.id).where.not(id_to: 1).order(created_at: :desc).limit(1) + if history.present? + return history.first.created_at + end + end + end + end + return nil if ticket.owner_id == 1 + ticket.created_at + end + +end 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/db/seeds/object_manager_attributes.rb b/db/seeds/object_manager_attributes.rb index 6e70e8ed6..e622a82e8 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, @@ -604,7 +604,7 @@ ObjectManager::Attribute.add( data_option: { type: 'email', maxlength: 150, - null: false, + null: true, item_class: 'formGroup--halfSize', }, editable: false, @@ -627,7 +627,7 @@ ObjectManager::Attribute.add( }, edit: { '-all-' => { - null: false, + null: true, }, }, view: { diff --git a/db/seeds/overviews.rb b/db/seeds/overviews.rb index 80d66a00b..ce1f052f6 100644 --- a/db/seeds/overviews.rb +++ b/db/seeds/overviews.rb @@ -160,6 +160,34 @@ Overview.create_if_not_exists( }, ) +Overview.create_if_not_exists( + name: 'My replacement Tickets', + link: 'my_replacement_tickets', + prio: 1080, + role_ids: [overview_role.id], + out_of_office: true, + condition: { + 'ticket.state_id' => { + operator: 'is', + value: Ticket::State.by_category(:open).pluck(:id), + }, + #'ticket.out_of_office_replacement_id' => { + # operator: 'is', + # pre_condition: 'current_user.organization_id', + #}, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer group owner escalation_at), + s: %w(title customer group owner escalation_at), + m: %w(number title customer group owner escalation_at), + view_mode_default: 's', + }, +) + overview_role = Role.find_by(name: 'Customer') Overview.create_if_not_exists( name: 'My Tickets', diff --git a/db/seeds/permissions.rb b/db/seeds/permissions.rb index fbd8e60fa..699e28f29 100644 --- a/db/seeds/permissions.rb +++ b/db/seeds/permissions.rb @@ -306,7 +306,7 @@ Permission.create_if_not_exists( ) Permission.create_if_not_exists( name: 'ticket.customer', - note: 'Access to Customer Tickets based on current_user.id and current_user.organization_id', + note: 'Access to Customer Tickets based on current_user and organization', preferences: { not: ['ticket.agent'], }, diff --git a/db/seeds/schedulers.rb b/db/seeds/schedulers.rb index 94fbe49f6..05bf45568 100644 --- a/db/seeds/schedulers.rb +++ b/db/seeds/schedulers.rb @@ -12,6 +12,13 @@ Scheduler.create_if_not_exists( prio: 1, active: true, ) +Scheduler.create_if_not_exists( + name: 'Process auto unassign tickets', + method: 'Ticket.process_auto_unassign', + period: 10.minutes, + prio: 1, + active: true, +) Scheduler.create_if_not_exists( name: 'Import OTRS diff load', method: 'Import::OTRS.diff_worker', @@ -156,6 +163,15 @@ Scheduler.create_if_not_exists( updated_by_id: 1, created_by_id: 1, ) +Scheduler.create_if_not_exists( + name: 'Cleanup Cti::Log', + method: 'Cti::Log.cleanup', + period: 1.month, + prio: 2, + active: true, + updated_by_id: 1, + created_by_id: 1, +) Scheduler.create_if_not_exists( name: 'Import Jobs', method: 'ImportJob.start_registered', diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 99c5305c8..20e84d629 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -479,7 +479,7 @@ Setting.create_if_not_exists( { display: '', null: false, - name: 'proxy_passowrd', + name: 'proxy_password', tag: 'input', }, ], @@ -493,6 +493,30 @@ Setting.create_if_not_exists( }, frontend: false ) +Setting.create_if_not_exists( + title: 'No Proxy', + name: 'proxy_no', + area: 'System::Network', + description: 'No proxy for the following hosts.', + options: { + form: [ + { + display: '', + null: false, + name: 'proxy_no', + tag: 'input', + }, + ], + }, + state: 'localhost,127.0.0.0,::1', + preferences: { + disabled: true, + online_service_disable: true, + prio: 4, + permission: ['admin.system'], + }, + frontend: false +) Setting.create_if_not_exists( title: 'Send client stats', @@ -546,18 +570,17 @@ Setting.create_if_not_exists( }, frontend: true ) - Setting.create_if_not_exists( - title: 'Define default visibility of new a new article', - name: 'ui_ticket_zoom_article_new_internal', + title: 'Note - default visibility', + name: 'ui_ticket_zoom_article_note_new_internal', area: 'UI::TicketZoom', - description: 'Set default visibility of new a new article.', + description: 'Default visibility for new note.', options: { form: [ { display: '', null: true, - name: 'ui_ticket_zoom_article_new_internal', + name: 'ui_ticket_zoom_article_note_new_internal', tag: 'boolean', translate: true, options: { @@ -569,7 +592,88 @@ Setting.create_if_not_exists( }, state: true, preferences: { - prio: 1, + prio: 100, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Email - subject field', + name: 'ui_ticket_zoom_article_email_subject', + area: 'UI::TicketZoom', + description: 'Use subject field for emails. If disabled, the ticket title will be used as subject.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_subject', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 200, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Email - full quote', + name: 'ui_ticket_zoom_article_email_full_quote', + area: 'UI::TicketZoom', + description: 'Enable if you want to quote the full email in your answer. The quoted email will be put at the end of your answer. If you just want to quote a certain phrase, just mark the text and press reply (this feature is always available).', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_email_full_quote', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 220, + permission: ['admin.ui'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'Twitter - tweet initials', + name: 'ui_ticket_zoom_article_twitter_initials', + area: 'UI::TicketZoom', + description: 'Add sender initials to end of a tweet.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_article_twitter_initials', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 300, permission: ['admin.ui'], }, frontend: true @@ -625,6 +729,31 @@ Setting.create_if_not_exists( }, frontend: true ) +Setting.create_if_not_exists( + title: 'User email for muliple users', + name: 'user_email_multiple_use', + area: 'Model::User', + description: 'Allow to use email address for muliple users.', + options: { + form: [ + { + display: '', + null: true, + name: 'user_email_multiple_use', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + permission: ['admin'], + }, + frontend: false +) Setting.create_if_not_exists( title: 'Authentication via %s', name: 'auth_ldap', @@ -1004,6 +1133,63 @@ Setting.create_if_not_exists( frontend: false ) +Setting.create_if_not_exists( + title: 'Authentication via %s', + name: 'auth_microsoft_office365', + area: 'Security::ThirdPartyAuthentication', + description: 'Enables user authentication via %s. Register your app first at [%s](%s).', + options: { + form: [ + { + display: '', + null: true, + name: 'auth_microsoft_office365', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + preferences: { + controller: 'SettingsAreaSwitch', + sub: ['auth_microsoft_office365_credentials'], + title_i18n: ['Office 365'], + description_i18n: ['Office 365', 'Microsoft Application Registration Portal', 'https://apps.dev.microsoft.com'], + permission: ['admin.security'], + }, + state: false, + frontend: true +) +Setting.create_if_not_exists( + title: 'Office 365 App Credentials', + name: 'auth_microsoft_office365_credentials', + area: 'Security::ThirdPartyAuthentication::Office365', + description: 'Enables user authentication via Office 365.', + options: { + form: [ + { + display: 'App ID', + null: true, + name: 'app_id', + tag: 'input', + }, + { + display: 'App Secret', + null: true, + name: 'app_secret', + tag: 'input', + }, + ], + }, + state: {}, + preferences: { + permission: ['admin.security'], + }, + frontend: false +) + Setting.create_if_not_exists( title: 'Authentication via %s', name: 'auth_oauth2', @@ -1063,7 +1249,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', @@ -1489,6 +1675,101 @@ Setting.create_if_not_exists( frontend: false, ) +group = Group.where(active: true).first +if !group + group = Group.first +end +group_id = 1 +if group + group_id = group.id +end +Setting.create_if_not_exists( + title: 'Group selection for Ticket creation', + name: 'form_ticket_create_group_id', + area: 'Form::Base', + description: 'Defines if group of created tickets via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: group_id, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) + +Setting.create_if_not_exists( + title: 'Limit tickets by ip per hour', + name: 'form_ticket_create_by_ip_per_hour', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per hour via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_hour', + tag: 'input', + }, + ], + }, + state: 20, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Limit tickets by ip per day', + name: 'form_ticket_create_by_ip_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets by ip per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_by_ip_per_day', + tag: 'input', + }, + ], + }, + state: 240, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Limit tickets per day', + name: 'form_ticket_create_per_day', + area: 'Form::Base', + description: 'Defines limit of tickets per day via web form.', + options: { + form: [ + { + display: '', + null: true, + name: 'form_ticket_create_per_day', + tag: 'input', + }, + ], + }, + state: 5000, + preferences: { + permission: ['admin.channel_formular'], + }, + frontend: false, +) + Setting.create_if_not_exists( title: 'Ticket Subject Size', name: 'ticket_subject_size', @@ -1657,6 +1938,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', @@ -2093,7 +2401,7 @@ Setting.create_if_not_exists( area: 'Import', description: 'A list of active import backends that get scheduled automatically.', options: {}, - state: ['Import::Ldap'], + state: ['Import::Ldap', 'Import::Exchange'], preferences: { permission: ['admin'], }, @@ -2217,6 +2525,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', @@ -2291,11 +2608,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( @@ -2521,6 +2847,117 @@ Setting.create_if_not_exists( }, frontend: false ) +Setting.create_if_not_exists( + title: 'Check_MK integration', + name: 'check_mk_integration', + area: 'Integration::Switch', + description: 'Defines if Check_MK (http://mathias-kettner.com/check_mk.html) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'check_mk_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Group', + name: 'check_mk_group_id', + area: 'Integration::CheckMK', + description: 'Defines the group of created tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'check_mk_group_id', + tag: 'select', + relation: 'Group', + }, + ], + }, + state: 1, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Auto close', + name: 'check_mk_auto_close', + area: 'Integration::CheckMK', + description: 'Defines if tickets should be closed if service is recovered.', + options: { + form: [ + { + display: '', + null: true, + name: 'check_mk_auto_close', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 3, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Auto close state', + name: 'check_mk_auto_close_state_id', + area: 'Integration::CheckMK', + description: 'Defines the state of auto closed tickets.', + options: { + form: [ + { + display: '', + null: false, + name: 'check_mk_auto_close_state_id', + tag: 'select', + relation: 'TicketState', + }, + ], + }, + state: 4, + preferences: { + prio: 4, + permission: ['admin.integration'], + }, + frontend: false +) +Setting.create_if_not_exists( + title: 'Check_MK tolen', + name: 'check_mk_token', + area: 'Core', + description: 'Defines the Check_MK token for allowing updates.', + options: {}, + state: SecureRandom.hex(16), + preferences: { + permission: ['admin.integration'], + }, + frontend: false +) + Setting.create_if_not_exists( title: 'LDAP integration', name: 'ldap_integration', @@ -2548,6 +2985,46 @@ Setting.create_if_not_exists( }, frontend: true ) +Setting.create_if_not_exists( + title: 'Exchange config', + name: 'exchange_config', + area: 'Integration::Exchange', + description: 'Defines the Exchange config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) +Setting.create_if_not_exists( + title: 'Exchange integration', + name: 'exchange_integration', + area: 'Integration::Switch', + description: 'Defines if Exchange is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'exchange_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) Setting.create_if_not_exists( title: 'LDAP config', name: 'ldap_config', @@ -2561,6 +3038,46 @@ Setting.create_if_not_exists( }, frontend: false, ) +Setting.create_if_not_exists( + title: 'i-doit integration', + name: 'idoit_integration', + area: 'Integration::Switch', + description: 'Defines if i-doit (http://www.i-doit) is enabled or not.', + options: { + form: [ + { + display: '', + null: true, + name: 'idoit_integration', + tag: 'boolean', + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: false, + preferences: { + prio: 1, + authentication: true, + permission: ['admin.integration'], + }, + frontend: true +) +Setting.create_if_not_exists( + title: 'i-doit config', + name: 'idoit_config', + area: 'Integration::Idoit', + description: 'Defines the i-doit config.', + options: {}, + state: {}, + preferences: { + prio: 2, + permission: ['admin.integration'], + }, + frontend: false, +) Setting.create_if_not_exists( title: 'Defines sync transaction backend.', name: '0100_trigger', diff --git a/lib/core_ext/fixnum.rb b/lib/core_ext/integer.rb similarity index 86% rename from lib/core_ext/fixnum.rb rename to lib/core_ext/integer.rb index 71e6456c8..b3363b5f4 100644 --- a/lib/core_ext/fixnum.rb +++ b/lib/core_ext/integer.rb @@ -1,4 +1,4 @@ -class Fixnum +class Integer =begin diff --git a/lib/core_ext/open-uri.rb b/lib/core_ext/open-uri.rb new file mode 100644 index 000000000..703b20594 --- /dev/null +++ b/lib/core_ext/open-uri.rb @@ -0,0 +1,20 @@ +# rubocop:disable Style/FileName +if Kernel.respond_to?(:open_uri_original_open) + module Kernel + private + + # see: https://github.com/ruby/ruby/pull/1675 + def open(name, *rest, &block) # :doc: + if name.respond_to?(:open) && !name.method(:open).parameters.empty? + name.open(*rest, &block) + elsif name.respond_to?(:to_str) && + %r{\A[A-Za-z][A-Za-z0-9+\-\.]*://} =~ name && + (uri = URI.parse(name)).respond_to?(:open) + uri.open(*rest, &block) + else + open_uri_original_open(name, *rest, &block) + end + end + module_function :open + end +end diff --git a/lib/core_ext/string.rb b/lib/core_ext/string.rb index 6552f3e99..3db6fe1fb 100644 --- a/lib/core_ext/string.rb +++ b/lib/core_ext/string.rb @@ -81,7 +81,7 @@ class String def html2text(string_only = false, strict = false) string = "#{self}" # rubocop:disable Style/UnneededInterpolation - # in case of invalid encodeing, strip invalid chars + # in case of invalid encoding, strip invalid chars # see also test/fixtures/mail21.box # note: string.encode!('UTF-8', 'UTF-8', :invalid => :replace, :replace => '?') was not detecting invalid chars if !string.valid_encoding? @@ -306,7 +306,7 @@ class String string.gsub!(%r\
[[:space:]]*(([[:space:]]*)){2,}\im, '

\3') string.gsub!(%r\[[:space:]]*(
[[:space:]]*){3,}[[:space:]]*
\im, '

') string.gsub!(%r\
[[:space:]]*(
[[:space:]]*){1,}[[:space:]]*
\im, '
 
') - string.gsub!(%r\
[[:space:]]*(
[[:space:]]*{1,}
[[:space:]]*){2,}
\im, '
 
') + string.gsub!(%r\
[[:space:]]*(
[[:space:]]*
[[:space:]]*){2,}
\im, '
 
') string.gsub!(%r\

[[:space:]]*

([[:space:]]*){2,}[[:space:]]*\im, '


') string.gsub!(%r{

[[:space:]]*

([[:space:]]*)+

[[:space:]]*

}im, '

') string.gsub!(%r\(
[[:space:]]*
[[:space:]]*){2,}\im, '
') diff --git a/lib/email_helper/verify.rb b/lib/email_helper/verify.rb index c872cce61..729b98027 100644 --- a/lib/email_helper/verify.rb +++ b/lib/email_helper/verify.rb @@ -56,7 +56,7 @@ or def self.email(params) # send verify email - subject = if !params[:subject] || params[:subject].empty? + subject = if params[:subject].blank? '#' + rand(99_999_999_999).to_s else params[:subject] diff --git a/lib/idoit.rb b/lib/idoit.rb new file mode 100644 index 000000000..f1fe7f379 --- /dev/null +++ b/lib/idoit.rb @@ -0,0 +1,149 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class Idoit + +=begin + +get list ob types + + result = Idoit.verify(api_token, endpoint, client_id) + +returns + + array with cmdb.object_types or an exeption if no data was able to retrive + +=end + + def self.verify(api_token, endpoint, _client_id = nil) + raise 'api_token required' if api_token.blank? + raise 'endpoint required' if endpoint.blank? + + params = { + apikey: api_token, + } + + _query('cmdb.object_types', params, _url_cleanup(endpoint)) + end + +=begin + +get list ob types + + result = Idoit.query(method, filter) + + result = Idoit.query(method, { type: '59' }) + +returns + + result = [ + { + "id": "1", + "title": "System service", + "container": "0", + "const": "C__OBJTYPE__SERVICE", + "color": "987384", + "image": "https://demo.panic.at/i-doit/images/objecttypes/service.jpg", + "icon": "images/icons/silk/application_osx_terminal.png", + "cats": "4", + "tree_group": "1", + "status": "2", + "type_group": "1", + "type_group_title": "Software" + }, + { + "id": "2", + "title": "Application", + "container": "0", + "const": "C__OBJTYPE__APPLICATION", + "color": "E4B9D7", + "image": "https://demo.panic.at/i-doit/images/objecttypes/application.jpg", + "icon": "images/icons/silk/application_xp.png", + "cats": "20", + "tree_group": "1", + "status": "2", + "type_group": "1", + "type_group_title": "Software" + }, + ] + +or with filter: + + "result": [ + { + "id": "26", + "title": "demo.panic.at", + "sysid": "SYSID_1485512390", + "type": "59", + "created": "2017-01-27 11:19:24", + "updated": "2017-01-27 11:19:49", + "type_title": "Virtual server", + "type_group_title": "Infrastructure", + "status": "2", + "cmdb_status": "6", + "cmdb_status_title": "in operation", + "image": "https://demo.panic.at/i-doit/images/objecttypes/empty.png" + }, + ], + +=end + + def self.query(method, filter = {}) + setting = Setting.get('idoit_config') + raise 'Unable for find api_token in config' if setting[:api_token].blank? + raise 'Unable for find endpoint in config' if setting[:endpoint].blank? + + #translator_key = Setting.get('translator_key') + params = { + apikey: setting[:api_token], + } + if filter.present? + params[:filter] = filter + end + _query(method, params, _url_cleanup(setting[:endpoint])) + end + + def self._query(method, params, url) + result = UserAgent.post( + url, + { + method: method, + params: params, + version: '2.0', + }, + { + json: true, + open_timeout: 6, + read_timeout: 16, + log: { + facility: 'idoit', + }, + }, + ) + + raise "Can't fetch objects from #{url}: Unable to parse response from server. Invalid JSON response." if !result.success? && result.error =~ /JSON::ParserError:.+?\s+unexpected\s+token\s+at\s+'<\!DOCTYPE\s+html/i + raise "Can't fetch objects from #{url}: #{result.error}" if !result.success? + + # add link to idoit + if result.data['result'].class == Array + result.data['result'].each { |item| + next if !item['id'] + item['link'] = "#{_url_cleanup_baseurl(url)}/?objID=#{item['id']}" + item['link'].gsub!(%r{([^:])//+}, '\\1/') + } + end + result.data + end + + def self._url_cleanup(url) + raise "Invalid endpoint '#{url}', need to start with http:// or https://" if url !~ %r{^http(s|)://}i + url = _url_cleanup_baseurl(url) + url = "#{url}/src/jsonrpc.php" + url.gsub(%r{([^:])//+}, '\\1/') + end + + def self._url_cleanup_baseurl(url) + raise "Invalid endpoint '#{url}', need to start with http:// or https://" if url !~ %r{^http(s|)://}i + url.gsub!(%r{src/jsonrpc.php}, '') + url.gsub(%r{([^:])//+}, '\\1/') + end +end diff --git a/lib/import/base_resource.rb b/lib/import/base_resource.rb index 7b70d305a..70761c75b 100644 --- a/lib/import/base_resource.rb +++ b/lib/import/base_resource.rb @@ -16,7 +16,7 @@ module Import end def source - import_class_namespace + self.class.source end def remote_id(resource, *_args) @@ -57,6 +57,14 @@ module Import changes end + def self.source + import_class_namespace + end + + def self.import_class_namespace + @import_class_namespace ||= name.to_s.sub('Import::', '') + end + private def initialize_associations_states @@ -83,31 +91,35 @@ module Import @resource = lookup_existing(resource, *args) return false if !@resource - # delete since we have an update and - # the record is already created - resource.delete(:created_by_id) + # lock the current resource for write access + @resource.with_lock do - # store the current state of the associations - # from the resource hash because if we assign - # them to the instance some (e.g. has_many) - # will get stored even in the dry run :/ - store_associations(:after, resource) + # delete since we have an update and + # the record is already created + resource.delete(:created_by_id) - associations = tracked_associations - @resource.assign_attributes(resource.except(*associations)) + # store the current state of the associations + # from the resource hash because if we assign + # them to the instance some (e.g. has_many) + # will get stored even in the dry run :/ + store_associations(:after, resource) - # the return value here is kind of misleading - # and should not be trusted to indicate if a - # resource was actually updated. - # Use .action instead - return true if !attributes_changed? + associations = tracked_associations + @resource.assign_attributes(resource.except(*associations)) - @action = :updated + # the return value here is kind of misleading + # and should not be trusted to indicate if a + # resource was actually updated. + # Use .action instead + return true if !attributes_changed? - return true if @dry_run - @resource.assign_attributes(resource.slice(*associations)) - @resource.save! - true + @action = :updated + + return true if @dry_run + @resource.assign_attributes(resource.slice(*associations)) + @resource.save! + true + end end def lookup_existing(resource, *_args) @@ -214,11 +226,7 @@ module Import end def mapping_config(*_args) - import_class_namespace.gsub('::', '_').underscore + '_mapping' - end - - def import_class_namespace - self.class.name.to_s.sub('Import::', '') + self.class.import_class_namespace.gsub('::', '_').underscore + '_mapping' end def handle_args(_resource, *args) diff --git a/lib/import/exchange.rb b/lib/import/exchange.rb new file mode 100644 index 000000000..cd8d307cb --- /dev/null +++ b/lib/import/exchange.rb @@ -0,0 +1,13 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +module Import + class Exchange < Import::IntegrationBase + include Import::Mixin::Sequence + + private + + def sequence_name + 'Import::Exchange::FolderContacts' + end + end +end diff --git a/lib/import/exchange/folder.rb b/lib/import/exchange/folder.rb new file mode 100644 index 000000000..7a4559f0a --- /dev/null +++ b/lib/import/exchange/folder.rb @@ -0,0 +1,76 @@ +require 'mixin/rails_logger' + +module Import + class Exchange + class Folder + include ::Mixin::RailsLogger + + def initialize(connection) + @connection = connection + @lookup_map = {} + end + + def id_folder_map + @id_folder_map ||= all.collect do |folder| + [folder.id, folder] + end.to_h + + # duplicate object to avoid errors where keys get + # added via #get_folder while iterating over + # the result of this method + @lookup_map = @id_folder_map.dup + @id_folder_map + end + + def find(id) + @lookup_map[id] ||= @connection.get_folder(id) + end + + def all + # request folders only if neccessary and store the result + @all ||= children(%i(root msgfolderroot publicfoldersroot)) + end + + def children(parent_identifiers) + parent_identifiers.each_with_object([]) do |parent_identifier, result| + + child_folders = request_children(parent_identifier) + + next if child_folders.blank? + + child_folder_ids = child_folders.collect(&:id) + child_folders += children(child_folder_ids) + + result.concat(child_folders) + end + end + + def display_path(folder) + display_name = folder.display_name + return display_name if !folder.parent_folder_id + + parent_folder = find(folder.parent_folder_id) + return display_name if !parent_folder + + parent_folder = id_folder_map[folder.parent_folder_id] + return display_name if !parent_folder + + # recursive + parent_folder_path = display_path(parent_folder) + + "#{parent_folder_path} -> #{display_name}" + rescue Viewpoint::EWS::EwsError + folder.display_name + end + + private + + def request_children(parent_identifier) + @connection.folders(root: parent_identifier) + rescue Viewpoint::EWS::EwsFolderNotFound => e + logger.warn(e) + nil + end + end + end +end diff --git a/lib/import/exchange/item_attributes.rb b/lib/import/exchange/item_attributes.rb new file mode 100644 index 000000000..bc0d19ec0 --- /dev/null +++ b/lib/import/exchange/item_attributes.rb @@ -0,0 +1,111 @@ +module Import + class Exchange + class ItemAttributes + + def self.extract(resource) + new(resource).extract + end + + def initialize(resource) + @resource = resource + end + + def extract + @attributes ||= begin + properties = @resource.get_all_properties! + result = normalize(properties) + flattened = flatten(result) + booleanized = booleanize_values(flattened) + end + end + + private + + def booleanize_values(properties) + properties.each do |key, value| + if value.is_a?(String) + next if !%w(true false).include?(value) + properties[key] = value == 'true' + elsif value.is_a?(Hash) + properties[key] = booleanize_values(value) + end + end + end + + def normalize(properties) + result = {} + properties.each do |key, value| + + next if key == :body + + if value[:text] + result[key] = value[:text] + elsif value[:attribs] + result[key] = value[:attribs] + elsif value[:elems] + result[key] = sub_elems(value[:elems]) + end + end + + result + end + + def sub_elems(elems) + result = {} + elems.each do |elem| + if elem[:entry] + result.merge!( sub_elem_entry( elem[:entry] ) ) + else + result.merge!( normalize(elem) ) + end + end + result + end + + def sub_elem_entry(entry) + entry_value = {} + if entry[:elems] + entry_value = sub_elems(entry[:elems]) + end + + if entry[:text] + entry_value[:text] = entry[:text] + end + + if entry[:attribs].present? + entry_value.merge!(entry[:attribs]) + end + + entry_key = entry_value.delete(:key) + { + entry_key => entry_value + } + end + + def flatten(properties, prefix: nil) + + result = {} + properties.each do |key, value| + + result_key = key + if prefix + result_key = if %i(text id).include?(key) && ( !result[result_key] || result[result_key] == value ) + prefix + else + "#{prefix}.#{key}".to_sym + end + end + result_key = result_key.to_s.downcase + + if value.is_a?(Hash) + sub_result = flatten(value, prefix: result_key) + result.merge!(sub_result) + else + result[result_key] = value.to_s + end + end + result + end + end + end +end diff --git a/lib/import/helper/attributes_examples.rb b/lib/import/helper/attributes_examples.rb new file mode 100644 index 000000000..1ae3c00a9 --- /dev/null +++ b/lib/import/helper/attributes_examples.rb @@ -0,0 +1,70 @@ +module Import + module Helper + class AttributesExamples + attr_reader :examples, :enough, :max_unkown + + def initialize(&block) + @max_unkown = 50 + @no_new_counter = 1 + @examples = {} + @known = [] + + # Support both builder styles: + # + # Import::Helper::AttributesExamples.new do + # extract(attributes) + # end + # + # and + # + # Import::Helper::AttributesExamples.new do |extractor| + # extractor.extract(attributes) + # end + return if !block_given? + if block.arity.zero? + instance_eval(&block) + else + yield self + end + end + + def extract(attributes) + unknown = attributes.keys - @known + + return if !unknown?(unknown) + + store(attributes, unknown) + + @known.concat(unknown) + @no_new_counter = 0 + end + + private + + def unknown?(unknown) + return true if unknown.present? + + @no_new_counter += 1 + + # check max 50 entries with no or no new attributes in a row + @enough_examples = @no_new_counter != 50 + + false + end + + def store(attributes, unknown) + unknown.each do |attribute| + value = attributes[attribute] + + next if value.nil? + + example = value.to_s.force_encoding('UTF-8').encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?') + example.gsub!(/^(.{20,}?).*$/m, '\1...') + + @examples[attribute] = "#{attribute} (e. g. #{example})" + end + end + + end + end +end diff --git a/lib/import/integration_base.rb b/lib/import/integration_base.rb new file mode 100644 index 000000000..ac995f9dd --- /dev/null +++ b/lib/import/integration_base.rb @@ -0,0 +1,143 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +module Import + + # This base class handles regular integrations. + # It provides generic interfaces for settings and active state. + # It ensures that all requirements for a regular integration are met before a import can start. + # It handles the case of an Scheduler interruption. + # + # It's required to implement the +start_import+ method which only has to start the import. + class IntegrationBase < Import::Base + + def self.inherited(subclass) + subclass.extend(Forwardable) + + # delegate instance methods to the generic class implementations + subclass.delegate [:identifier, :active?, :config, :display_name] => subclass + end + + # Defines the integration identifier used for + # automatic config lookup and error message generation. + # + # @example + # Import::Ldap.identifier + # #=> "Ldap" + # + # return [String] + def self.identifier + name.split('::').last + end + + # Provides the name that is used in texts visible to the user. + # + # @example + # Import::Exchange.display_name + # #=> "Exchange" + # + # return [String] + def self.display_name + identifier + end + + # Checks if the integration is active. + # + # @example + # Import::Ldap.active? + # #=> true + # + # return [Boolean] + def self.active? + Setting.get("#{identifier.downcase}_integration") || false + end + + # Provides the integration configuration. + # + # @example + # Import::Ldap.config + # #=> {"ssl_verify"=>true, "host_url"=>"ldaps://192...", ...} + # + # return [Hash] the configuration + def self.config + Setting.get("#{identifier.downcase}_config") || {} + end + + # Stores the integration configuration. + # + # @example + # Import::Ldap.config = {"ssl_verify"=>true, "host_url"=>"ldaps://192...", ...} + # + # return [nil] + def self.config=(value) + Setting.set("#{identifier.downcase}_config", value) + end + + # Checks if the integration is activated and configured. + # Otherwise it won't get queued since it will display + # an error which is confusing and wrong. + # + # @example + # Import::Ldap.queueable? + # #=> true + # + # return [Boolean] + def self.queueable? + active? && config.present? + end + + # Starts a live or dry run import. + # + # @example + # instance = Import::Ldap.new(import_job) + # + # @raise [RuntimeError] Raised if an import should start but the integration is disabled + # + # return [nil] + def start + return if !requirements_completed? + start_import + end + + # Gets called when the Scheduler gets (re-)started and an ImportJob was still + # in the queue. The job will always get restarted to avoid the gap till the next + # run triggered by the Scheduler. The result will get updated to inform the user + # in the agent interface result view. + # + # @example + # instance = Import::Ldap.new(import_job) + # instance.reschedule?(delayed_job) + # #=> true + # + # return [true] + def reschedule?(_delayed_job) + inform('Restarting due to scheduler restart.') + true + end + + private + + def start_import + raise "Missing implementation of method '#{__method__}' for #{self.class.name}" + end + + def requirements_completed? + return true if @import_job.dry_run + + if !active? + message = "Sync cancelled. #{display_name} integration deactivated. Activate via the switch." + elsif config.blank? && @import_job.payload.blank? + message = "Sync cancelled. #{display_name} configration or ImportJob payload missing." + end + + return true if !message + inform(message) + false + end + + def inform(message) + @import_job.update_attribute(:result, { + info: message + }) + end + end +end diff --git a/lib/import/ldap.rb b/lib/import/ldap.rb index 5b82e60a4..882981bb6 100644 --- a/lib/import/ldap.rb +++ b/lib/import/ldap.rb @@ -4,50 +4,17 @@ require 'ldap' require 'ldap/group' module Import - class Ldap < Import::Base + class Ldap < Import::IntegrationBase - # Checks if the integration is activated and configured. - # Otherwise it won't get queued since it will display - # an error which is confusing and wrong. + # Provides the name that is used in texts visible to the user. # # @example - # Import::LDAP.queueable? - # #=> true + # Import::Ldap.display_name + # #=> "LDAP" # - # return [Boolean] - def self.queueable? - Setting.get('ldap_integration') && Setting.get('ldap_config').present? - end - - # Starts a live or dry run LDAP import. - # - # @example - # instance = Import::LDAP.new(import_job) - # - # @raise [RuntimeError] Raised if an import should start but the ldap integration is disabled - # - # return [nil] - def start - return if !requirements_completed? - start_import - end - - # Gets called when the Scheduler gets (re-)started and a LDAP ImportJob was still - # in the queue. The job will always get restarted to avoid the gap till the next - # run triggered by the Scheduler. The result will get updated to inform the user - # in the agent interface result view. - # - # @example - # instance = Import::LDAP.new(import_job) - # instance.reschedule?(delayed_job) - # #=> true - # - # return [true] - def reschedule?(_delayed_job) - @import_job.update_attribute(:result, { - info: 'Restarting due to scheduler restart.' - }) - true + # return [String] + def self.display_name + identifier.upcase end private @@ -63,23 +30,5 @@ module Import @import_job.result = Import::Ldap::UserFactory.statistics end - - def requirements_completed? - return true if @import_job.dry_run - - if !Setting.get('ldap_integration') - message = 'Sync cancelled. LDAP integration deactivated. Activate via the switch.' - elsif Setting.get('ldap_config').blank? && @import_job.payload.blank? - message = 'Sync cancelled. LDAP configration or ImportJob payload missing.' - end - - return true if !message - - @import_job.update_attribute(:result, { - info: message - }) - - false - end end end diff --git a/lib/import/ldap/user.rb b/lib/import/ldap/user.rb index a70f85c50..19eaba32f 100644 --- a/lib/import/ldap/user.rb +++ b/lib/import/ldap/user.rb @@ -6,6 +6,34 @@ module Import @remote_id end + def self.lost_map(found_remote_ids) + ExternalSync.joins('INNER JOIN users ON (users.id = external_syncs.o_id)') + .where( + source: source, + object: import_class.name, + users: { + active: true + } + ) + .pluck(:source_id, :o_id) + .to_h + .except(*found_remote_ids) + end + + def self.deactivate_lost(lost_ids) + # we need to update in slices since some DBs + # have a limit for IN length + lost_ids.each_slice(5000) do |slice| + + # we need to instanciate every entry and set + # the active state this way to send notifications + # to the client + ::User.where(id: slice).each do |user| + user.update_attribute(:active, false) + end + end + end + private def import(resource, *args) @@ -58,7 +86,7 @@ module Import return true if resource[:login].blank? # skip resource if only ignored attributes are set - ignored_attributes = %i(login dn created_by_id updated_by_id) + ignored_attributes = %i(login dn created_by_id updated_by_id active) !resource.except(*ignored_attributes).values.any?(&:present?) end @@ -181,6 +209,11 @@ module Import mapped[attribute] = mapped[attribute].downcase end + # we have to add the active state manually + # because otherwise disabled instances won't get + # re-activated if they should get synced again + mapped[:active] = true + mapped end diff --git a/lib/import/ldap/user_factory.rb b/lib/import/ldap/user_factory.rb index 8b6c80aba..3ed6f40ee 100644 --- a/lib/import/ldap/user_factory.rb +++ b/lib/import/ldap/user_factory.rb @@ -33,10 +33,14 @@ module Import relevant_attributes = config[:user_attributes].keys relevant_attributes.push('dn') + @found_lost_remote_ids = [] + @found_remote_ids = [] @ldap.search(config[:user_filter], attributes: relevant_attributes) do |entry| backend_instance = create_instance(entry, config, user_roles, signup_role_ids, kargs) post_import_hook(entry, backend_instance, config, user_roles, signup_role_ids, kargs) + track_found_remote_ids(backend_instance) + next if import_job.blank? import_job_count += 1 next if import_job_count < 100 @@ -47,6 +51,7 @@ module Import import_job_count = 0 end + handle_lost end def self.pre_import_hook(_records, *_args) @@ -77,18 +82,25 @@ module Import action = backend_instance.action + add_resource_role_ids_to_statistics(resource.role_ids, action) + + action + end + + def self.add_resource_role_ids_to_statistics(role_ids, action) + return if role_ids.blank? + known_actions = { - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } - if !@statistics[:role_ids] - @statistics[:role_ids] = {} - end + @statistics[:role_ids] ||= {} - resource.role_ids.each do |role_id| + role_ids.each do |role_id| next if !known_actions.key?(action) @@ -99,8 +111,6 @@ module Import @statistics[:role_ids][role_id][action] += 1 end - - action end def self.user_roles(ldap:, config:) @@ -111,6 +121,46 @@ module Import ldap_group = ::Ldap::Group.new(group_config, ldap: ldap) ldap_group.user_roles(config[:group_role_map]) end + + def self.track_found_remote_ids(backend_instance) + remote_id = backend_instance.remote_id(nil) + @deactivation_actions ||= %i(skipped failed) + if @deactivation_actions.include?(backend_instance.action) + @found_lost_remote_ids.push(remote_id) + else + @found_remote_ids.push(remote_id) + end + end + + def self.handle_lost + backend_class = backend_class(nil) + lost_map = backend_class.lost_map(@found_remote_ids) + + # disabled count is tracked as a separate number + # since they don't have to be in the sum (e.g. deleted in LDAP) + @statistics[:deactivated] = lost_map.size + + # skipped deactivated are those who + # were found, skipped and will get deactivated + skipped_deactivated = @found_lost_remote_ids & lost_map.keys + @statistics[:skipped] -= skipped_deactivated.size + + # loop over every lost user ID and add the + # deactivated count to the statistics + lost_ids = lost_map.values + + lost_ids.each do |user_id| + role_ids = ::User.joins(:roles) + .where(id: user_id) + .pluck(:'roles_users.role_id') + + add_resource_role_ids_to_statistics(role_ids, :deactivated) + end + + # deactivate entries only on live syncs + return if @dry_run + backend_class.deactivate_lost(lost_ids) + end end end end diff --git a/lib/import/mixin/sequence.rb b/lib/import/mixin/sequence.rb new file mode 100644 index 000000000..262933e44 --- /dev/null +++ b/lib/import/mixin/sequence.rb @@ -0,0 +1,19 @@ +module Import + module Mixin + module Sequence + private + + def sequence_name + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + + def process + Sequencer.process(sequence_name, + parameters: { + import_job: @import_job + }) + end + alias start_import process + end + end +end diff --git a/lib/import/model_resource.rb b/lib/import/model_resource.rb index 3313d2535..0f35aad00 100644 --- a/lib/import/model_resource.rb +++ b/lib/import/model_resource.rb @@ -2,11 +2,19 @@ module Import class ModelResource < Import::BaseResource def import_class - model_name.constantize + self.class.import_class end def model_name - @model_name ||= self.class.name.split('::').last + self.class.model_name + end + + def self.import_class + model_name.constantize + end + + def self.model_name + @model_name ||= name.split('::').last end private diff --git a/lib/import/otrs/article/attachment_factory.rb b/lib/import/otrs/article/attachment_factory.rb index ad44e663d..c68956b4c 100644 --- a/lib/import/otrs/article/attachment_factory.rb +++ b/lib/import/otrs/article/attachment_factory.rb @@ -38,7 +38,7 @@ module Import Store.add( object: 'Ticket::Article', o_id: local_article.id, - filename: decoded_filename, + filename: decoded_filename.force_encoding('utf-8'), data: decoded_content, preferences: { 'Mime-Type' => attachment['ContentType'], @@ -54,6 +54,9 @@ module Import sleep rand 3 retry if !(retries -= 1).zero? raise + rescue => e + log "Ticket #{local_article.ticket_id} - #{sha} - #{e}: #{attachment.inspect}" + raise ensure queue_cleanup(sha) end diff --git a/lib/import/otrs/dynamic_field/checkbox.rb b/lib/import/otrs/dynamic_field/checkbox.rb index 8666986ac..33044c8c6 100644 --- a/lib/import/otrs/dynamic_field/checkbox.rb +++ b/lib/import/otrs/dynamic_field/checkbox.rb @@ -11,7 +11,7 @@ module Import true => 'Yes', false => 'No', }, - null: false, + null: true, translate: true, } ) diff --git a/lib/import/otrs/dynamic_field/date.rb b/lib/import/otrs/dynamic_field/date.rb index 6be4508bd..7d8d71133 100644 --- a/lib/import/otrs/dynamic_field/date.rb +++ b/lib/import/otrs/dynamic_field/date.rb @@ -14,7 +14,7 @@ module Import future: dynamic_field['Config']['YearsInFuture'] != '0', past: dynamic_field['Config']['YearsInPast'] != '0', diff: dynamic_field['Config']['DefaultValue'].to_i / 60 / 60 / 24, - null: false, + null: true, } ) end diff --git a/lib/import/otrs/dynamic_field/date_time.rb b/lib/import/otrs/dynamic_field/date_time.rb index 997a4ac49..d9a7761b1 100644 --- a/lib/import/otrs/dynamic_field/date_time.rb +++ b/lib/import/otrs/dynamic_field/date_time.rb @@ -14,7 +14,7 @@ module Import future: dynamic_field['Config']['YearsInFuture'] != '0', past: dynamic_field['Config']['YearsInPast'] != '0', diff: dynamic_field['Config']['DefaultValue'].to_i / 60 / 60, - null: false, + null: true, } ) end diff --git a/lib/import/otrs/dynamic_field/dropdown.rb b/lib/import/otrs/dynamic_field/dropdown.rb index a6d0d8c5a..6af21498b 100644 --- a/lib/import/otrs/dynamic_field/dropdown.rb +++ b/lib/import/otrs/dynamic_field/dropdown.rb @@ -6,11 +6,12 @@ module Import @attribute_config.merge!( data_type: 'select', data_option: { - default: '', - multiple: false, - options: dynamic_field['Config']['PossibleValues'], - null: dynamic_field['Config']['PossibleNone'] == '1', - translate: dynamic_field['Config']['TranslatableValues'] == '1', + default: '', + multiple: false, + options: dynamic_field['Config']['PossibleValues'], + nulloption: dynamic_field['Config']['PossibleNone'] == '1', + null: true, + translate: dynamic_field['Config']['TranslatableValues'] == '1', } ) end diff --git a/lib/import/otrs/dynamic_field/multiselect.rb b/lib/import/otrs/dynamic_field/multiselect.rb index 3710fc7d0..f1c13ef06 100644 --- a/lib/import/otrs/dynamic_field/multiselect.rb +++ b/lib/import/otrs/dynamic_field/multiselect.rb @@ -6,11 +6,12 @@ module Import @attribute_config.merge!( data_type: 'select', data_option: { - default: '', - multiple: true, - options: dynamic_field['Config']['PossibleValues'], - null: dynamic_field['Config']['PossibleNone'] == '1', - translate: dynamic_field['Config']['TranslatableValues'] == '1', + default: '', + multiple: true, + options: dynamic_field['Config']['PossibleValues'], + nulloption: dynamic_field['Config']['PossibleNone'] == '1', + null: true, + translate: dynamic_field['Config']['TranslatableValues'] == '1', } ) end diff --git a/lib/import/otrs/dynamic_field/text.rb b/lib/import/otrs/dynamic_field/text.rb index cafcb7f41..a5067ba02 100644 --- a/lib/import/otrs/dynamic_field/text.rb +++ b/lib/import/otrs/dynamic_field/text.rb @@ -9,7 +9,7 @@ module Import default: dynamic_field['Config']['DefaultValue'], type: 'text', maxlength: 255, - null: false, + null: true, } ) end diff --git a/lib/import/otrs/dynamic_field/text_area.rb b/lib/import/otrs/dynamic_field/text_area.rb index e4b71ce72..b25d54ce2 100644 --- a/lib/import/otrs/dynamic_field/text_area.rb +++ b/lib/import/otrs/dynamic_field/text_area.rb @@ -8,7 +8,7 @@ module Import data_option: { default: dynamic_field['Config']['DefaultValue'], rows: dynamic_field['Config']['Rows'], - null: false, + null: true, } ) end diff --git a/lib/import/otrs/requester.rb b/lib/import/otrs/requester.rb index c3dbbf049..ef504cf71 100644 --- a/lib/import/otrs/requester.rb +++ b/lib/import/otrs/requester.rb @@ -95,6 +95,8 @@ module Import def handle_response(response) encoded_body = Encode.conv('utf8', response.body.to_s) + # remove null bytes otherwise PostgreSQL will fail + encoded_body.gsub!('\u0000', '') JSON.parse(encoded_body) end diff --git a/lib/import/otrs/state_factory.rb b/lib/import/otrs/state_factory.rb index b86501824..dd40f914e 100644 --- a/lib/import/otrs/state_factory.rb +++ b/lib/import/otrs/state_factory.rb @@ -43,9 +43,7 @@ module Import return if !state state.default_create = true - state.callback_loop = true - - state.save + state.save! end def update_default_follow_up @@ -56,9 +54,7 @@ module Import return if !state state.default_follow_up = true - state.callback_loop = true - - state.save + state.save! end def update_ticket_attributes diff --git a/lib/import/statistical_factory.rb b/lib/import/statistical_factory.rb index 1b882d87c..695c78d4e 100644 --- a/lib/import/statistical_factory.rb +++ b/lib/import/statistical_factory.rb @@ -13,11 +13,12 @@ module Import def reset_statistics @statistics = { - skipped: 0, - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 0, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } end 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/lib/import/zendesk/object_field.rb b/lib/import/zendesk/object_field.rb index ac0024271..814a8e913 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,}/, '_').gsub(/_id(s?)$/, '_no\1') 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/lib/import/zendesk/state.rb b/lib/import/zendesk/state.rb index 25fabd497..30dfd4895 100644 --- a/lib/import/zendesk/state.rb +++ b/lib/import/zendesk/state.rb @@ -5,6 +5,7 @@ module Import MAPPING = { 'pending' => 'pending reminder', 'solved' => 'closed', + 'deleted' => 'removed', }.freeze class << self diff --git a/lib/import/zendesk/ticket.rb b/lib/import/zendesk/ticket.rb index 5e36420ef..615e32ffe 100644 --- a/lib/import/zendesk/ticket.rb +++ b/lib/import/zendesk/ticket.rb @@ -39,7 +39,7 @@ module Import { id: ticket.id, - title: ticket.subject, + title: ticket.subject || ticket.description || '-', owner_id: Import::Zendesk::UserFactory.local_id( ticket.assignee ) || 1, note: ticket.description, group_id: Import::Zendesk::GroupFactory.local_id( ticket.group_id ) || 1, diff --git a/lib/import/zendesk/ticket/comment/attachment.rb b/lib/import/zendesk/ticket/comment/attachment.rb index 9c1ec687a..15728baf4 100644 --- a/lib/import/zendesk/ticket/comment/attachment.rb +++ b/lib/import/zendesk/ticket/comment/attachment.rb @@ -3,7 +3,7 @@ module Import class Ticket class Comment class Attachment - extend Import::Helper + include Import::Helper def initialize(attachment, local_article) @@ -20,6 +20,8 @@ module Import }, created_by_id: 1 ) + rescue => e + log e.message end private @@ -35,6 +37,7 @@ module Import ) return response if response.success? log response.error + nil end end end diff --git a/lib/import/zendesk/ticket_factory.rb b/lib/import/zendesk/ticket_factory.rb index 0362f1f41..fbba1fb22 100644 --- a/lib/import/zendesk/ticket_factory.rb +++ b/lib/import/zendesk/ticket_factory.rb @@ -2,6 +2,45 @@ module Import module Zendesk module TicketFactory extend Import::Zendesk::BaseFactory + + # rubocop:disable Style/ModuleFunction + extend self + + private + + def import_loop(records, *args) + + count_update_hook = proc { |record| + yield(record) + update_ticket_count(records) + } + + super(records, *args, &count_update_hook) + end + + def update_ticket_count(collection) + + cache_key = 'import_zendesk_stats' + count_variable = :@count + page_variable = :@next_page + + next_page = collection.instance_variable_get(page_variable) + @last_page ||= next_page + + return if @last_page == next_page + return if !collection.instance_variable_get(count_variable) + + @last_page = next_page + + # check cache + cache = Cache.get(cache_key) + return if !cache + + cache['Tickets'] ||= 0 + cache['Tickets'] += collection.instance_variable_get(count_variable) + + Cache.write(cache_key, cache) + end end end end 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 diff --git a/lib/import/zendesk/user/role.rb b/lib/import/zendesk/user/role.rb index e5b389b47..fdc960c37 100644 --- a/lib/import/zendesk/user/role.rb +++ b/lib/import/zendesk/user/role.rb @@ -43,19 +43,21 @@ module Import end def role_admin - return @role_admin if @role_admin - @role_admin = ::Role.lookup(name: 'Admin') + @role_admin ||= lookup('Admin') end def role_agent - return @role_agent if @role_agent - @role_agent = ::Role.lookup(name: 'Agent') + @role_agent ||= lookup('Agent') end def role_customer - return @role_customer if @role_customer - @role_customer = ::Role.lookup(name: 'Customer') + @role_customer ||= lookup('Customer') end + + def lookup(role_name) + ::Role.lookup(name: role_name) + end + end end end 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 eb3a10cc7..87bd051ea 100644 --- a/lib/ldap/group.rb +++ b/lib/ldap/group.rb @@ -80,21 +80,24 @@ class Ldap filter ||= filter() result = {} - @ldap.search(filter, attributes: %w(dn member)) do |entry| + @ldap.search(filter, attributes: %w(dn member memberuid)) do |entry| - members = entry[:member] + roles = mapping[entry.dn.downcase] + next if roles.blank? + + members = group_user_dns(entry) next if members.blank? - role = mapping[entry.dn.downcase] - next if role.blank? - role = role.to_i - 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 @@ -109,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)', '(objectClass=organization)']) end # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed. @@ -130,5 +133,18 @@ class Ldap @uid_attribute = config[:uid_attribute] @filter = config[:filter] end + + def group_user_dns(entry) + return entry[:member] if entry[:member].present? + return if entry[:memberuid].blank? + + entry[:memberuid].collect do |uid| + dn = nil + @ldap.search("(uid=#{uid})", attributes: %w(dn)) do |user| + dn = user.dn + end + dn + end.compact + end end end diff --git a/lib/ldap/user.rb b/lib/ldap/user.rb index 9cb25bdbd..681fe9028 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)', '(objectClass=person)']) end # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed. diff --git a/lib/mixin/instance_wrapper.rb b/lib/mixin/instance_wrapper.rb new file mode 100644 index 000000000..96e8ff8e3 --- /dev/null +++ b/lib/mixin/instance_wrapper.rb @@ -0,0 +1,43 @@ +module Mixin + # This modules enables to redirect all calls to methods that are + # not defined to the declared instance variable. This comes handy + # when you wan't extend a Ruby core class like Hash. + # To inherit directly from such classes is a bad idea and should be avoided. + # This way allows it indirectly. + module InstanceWrapper + module ClassMethods + # Creates the class macro `wrap` that activates + # the wrapping for the given instance variable name. + # + # @param [Symbol] variable the name of the instance variable to wrap around + # + # @example + # wrap :@some_hash + # + # @return [nil] + def wrap(variable) + define_method(:instance) { + instance_variable_get(variable) + } + end + end + + def self.included(base) + base.extend(ClassMethods) + end + + private + + def method_missing(method, *args, &block) + if instance.respond_to?(method) + instance.send(method, *args, &block) + else + super + end + end + + def respond_to_missing?(method_sym, include_all) + instance.respond_to?(method_sym, include_all) + end + end +end diff --git a/lib/mixin/rails_logger.rb b/lib/mixin/rails_logger.rb new file mode 100644 index 000000000..e014f1d08 --- /dev/null +++ b/lib/mixin/rails_logger.rb @@ -0,0 +1,9 @@ +module Mixin + module RailsLogger + extend Forwardable + extend SingleForwardable + + instance_delegate [:logger] => self + single_delegate [:logger] => :Rails + end +end diff --git a/lib/mixin/required_sub_paths.rb b/lib/mixin/required_sub_paths.rb new file mode 100644 index 000000000..fa436749e --- /dev/null +++ b/lib/mixin/required_sub_paths.rb @@ -0,0 +1,44 @@ +module Mixin + module RequiredSubPaths + + def self.included(_base) + path = caller_locations.first.path + sub_path = File.join(File.dirname(path), File.basename(path, '.rb')) + eager_load_recursive(sub_path) + end + + # Loads a directory recursivly. + # The specialty of this method is that it will first load all + # files in a directory and then start with the sub directories. + # This is needed since otherwise some parent namespaces might not + # be initialized yet. + # + # The cause of this is that Rails autoload doesn't work properly + # for same named classes or modules in different namespaces. + # Here is a good description how autoload works: + # http://urbanautomaton.com/blog/2013/08/27/rails-autoloading-hell/ + # + # This avoids a) Rails autoloading issues and b) require '...' workarounds + def self.eager_load_recursive(path) + + excluded = ['.', '..'] + sub_paths = [] + Dir.entries(path).each do |entry| + next if excluded.include?(entry) + + sub_path = File.join(path, entry) + + if File.directory?(sub_path) + sub_paths.push(sub_path) + elsif sub_path =~ /\A(.*)\.rb\z/ + require_path = $1 + require(require_path) + end + end + + sub_paths.each do |sub_path| + eager_load_recursive(sub_path) + end + end + end +end diff --git a/lib/mixin/start_finish_logger.rb b/lib/mixin/start_finish_logger.rb new file mode 100644 index 000000000..7bee862ac --- /dev/null +++ b/lib/mixin/start_finish_logger.rb @@ -0,0 +1,11 @@ +module Mixin + module StartFinishLogger + include ::Mixin::RailsLogger + + def log_start_finish(level, prefix) + logger.public_send(level, "#{prefix} started.") + yield + logger.public_send(level, "#{prefix} finished.") + end + end +end diff --git a/lib/models.rb b/lib/models.rb index ce4dcffca..a7a1140fa 100644 --- a/lib/models.rb +++ b/lib/models.rb @@ -242,7 +242,7 @@ returns # update items ActiveRecord::Base.transaction do items_to_update.each { |_id, item| - item.save + item.save! } end } diff --git a/lib/notification_factory/mailer.rb b/lib/notification_factory/mailer.rb index a55127afe..12f810e12 100644 --- a/lib/notification_factory/mailer.rb +++ b/lib/notification_factory/mailer.rb @@ -35,27 +35,47 @@ returns matrix = user.preferences['notification_config']['matrix'] return if !matrix - # check if group is in selecd groups - if ticket.owner_id != user.id + owned_by_nobody = false + owned_by_me = false + if ticket.owner_id == 1 + owned_by_nobody = true + elsif ticket.owner_id == user.id + owned_by_me = true + else + # check the replacement chain of max 10 + # if the current user is in it + check_for = ticket.owner + 10.times do + replacement = check_for.out_of_office_agent + break if !replacement + + check_for = replacement + next if replacement.id != user.id + + owned_by_me = true + break + end + end + + # check if group is in selected groups + if !owned_by_me selected_group_ids = user.preferences['notification_config']['group_ids'] - if selected_group_ids - if selected_group_ids.class == Array - hit = nil - if selected_group_ids.empty? - hit = true - elsif selected_group_ids[0] == '-' && selected_group_ids.count == 1 - hit = true - else - hit = false - selected_group_ids.each { |selected_group_id| - if selected_group_id.to_s == ticket.group_id.to_s - hit = true - break - end - } - end - return if !hit + if selected_group_ids.is_a?(Array) + hit = nil + if selected_group_ids.empty? + hit = true + elsif selected_group_ids[0] == '-' && selected_group_ids.count == 1 + hit = true + else + hit = false + selected_group_ids.each { |selected_group_id| + if selected_group_id.to_s == ticket.group_id.to_s + hit = true + break + end + } end + return if !hit # no group access end end return if !matrix[type] @@ -64,13 +84,13 @@ returns return if !data['criteria'] channels = data['channel'] return if !channels - if data['criteria']['owned_by_me'] && ticket.owner_id == user.id + if data['criteria']['owned_by_me'] && owned_by_me return { user: user, channels: channels } end - if data['criteria']['owned_by_nobody'] && ticket.owner_id == 1 + if data['criteria']['owned_by_nobody'] && owned_by_nobody return { user: user, channels: channels diff --git a/lib/notification_factory/renderer.rb b/lib/notification_factory/renderer.rb index f15179d96..e0c9f1229 100644 --- a/lib/notification_factory/renderer.rb +++ b/lib/notification_factory/renderer.rb @@ -72,7 +72,7 @@ examples how to use return "\#{#{object_name} / no such object}" if !object_refs # if content of method is a complex datatype, just return - if object_methods.empty? && object_refs.class != String && object_refs.class != Float && object_refs.class != Fixnum + if object_methods.empty? && object_refs.class != String && object_refs.class != Float && object_refs.class != Integer return "\#{#{key} / no such method}" end object_methods_s = '' 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]})!" diff --git a/lib/search_index_backend.rb b/lib/search_index_backend.rb index f511abd16..065724f79 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' } } } } @@ -198,9 +198,12 @@ return search result data['query']['bool']['must'] = [] end - # add * on simple query search - if query && query =~ /^\w+$/ - query += '*' + # add * on simple query like "somephrase23" or "attribute: somephrase23" + if query.present? + query.strip! + if query =~ /^([[:alpha:],0-9]+|[[:alpha:],0-9]+\:\s+[[:alpha:],0-9]+)$/ + query += '*' + end end # real search condition @@ -427,8 +430,7 @@ return true if backend is configured =end def self.enabled? - return if !Setting.get('es_url') - return if Setting.get('es_url').empty? + return false if Setting.get('es_url').blank? true end diff --git a/lib/sequencer.rb b/lib/sequencer.rb new file mode 100644 index 000000000..7a606eed1 --- /dev/null +++ b/lib/sequencer.rb @@ -0,0 +1,83 @@ +require 'mixin/rails_logger' +require 'mixin/start_finish_logger' + +class Sequencer + include ::Mixin::RailsLogger + include ::Mixin::StartFinishLogger + + attr_reader :sequence + + # Convenience wrapper for instant processing with the given attributes. + # + # @example + # Sequencer.process('Example::Sequence') + # + # @example + # Sequencer.process('Example::Sequence', + # parameters: { + # some: 'value', + # }, + # expecting: [:result, :answer] + # ) + # + # @return [Hash{Symbol => Object}] the final result state attributes and values + def self.process(sequence, *args) + new(sequence, *args).process + end + + # Initializes a new Sequencer instance for the given Sequence with parameters and expecting result. + # + # @example + # Sequencer.new('Example::Sequence') + # + # @example + # Sequencer.new('Example::Sequence', + # parameters: { + # some: 'value', + # }, + # expecting: [:result, :answer] + # ) + def initialize(sequence, parameters: {}, expecting: nil) + @sequence = Sequencer::Sequence.constantize(sequence) + @parameters = parameters + @expecting = expecting + + # fall back to sequence default expecting if no explicit + # expecting was given for this sequence + return if !@expecting.nil? + @expecting = @sequence.expecting + end + + # Processes the Sequence the instance was initialized with. + # + # @example + # sequence.process + # + # @return [Hash{Symbol => Object}] the final result state attributes and values + def process + log_start_finish(:info, "Sequence '#{@sequence.name}'") do + + sequence.units.each_with_index do |unit, index| + + state.process do + + log_start_finish(:info, "Sequence '#{sequence.name}' Unit '#{unit.name}' (index: #{index})") do + unit.process(state) + end + end + end + end + + state.to_h.tap do |result| + logger.debug("Returning Sequence '#{@sequence.name}' result: #{result.inspect}") + end + end + + private + + def state + @state ||= Sequencer::State.new(sequence, + parameters: @parameters, + expecting: @expecting) + end +end diff --git a/lib/sequencer/mixin/exchange/folder.rb b/lib/sequencer/mixin/exchange/folder.rb new file mode 100644 index 000000000..0740da383 --- /dev/null +++ b/lib/sequencer/mixin/exchange/folder.rb @@ -0,0 +1,18 @@ +class Sequencer + module Mixin + module Exchange + module Folder + + def self.included(base) + base.uses :ews_connection + end + + private + + def ews_folder + @ews_folder ||= ::Import::Exchange::Folder.new(ews_connection) + end + end + end + end +end diff --git a/lib/sequencer/mixin/import_job/resource_loop.rb b/lib/sequencer/mixin/import_job/resource_loop.rb new file mode 100644 index 000000000..a27e7e670 --- /dev/null +++ b/lib/sequencer/mixin/import_job/resource_loop.rb @@ -0,0 +1,37 @@ +require 'sequencer/mixin/sub_sequence' + +class Sequencer + module Mixin + module ImportJob + module ResourceLoop + include ::Sequencer::Mixin::SubSequence + + private + + def resource_sequence(sequence_name, items) + default_params = { + dry_run: import_job.dry_run, + import_job: import_job, + } + + items.each do |item| + resource_params = {} + if block_given? + resource_params = yield item + else + resource_params[:resource] = item + end + + resource_params[:resource] = resource_params[:resource].with_indifferent_access + + sub_sequence(sequence_name, + parameters: default_params.merge(resource_params)) + end + + # store possible unsaved values in result e.g. statistics + import_job.save! + end + end + end + end +end diff --git a/lib/sequencer/mixin/prefixed_constantize.rb b/lib/sequencer/mixin/prefixed_constantize.rb new file mode 100644 index 000000000..243bee274 --- /dev/null +++ b/lib/sequencer/mixin/prefixed_constantize.rb @@ -0,0 +1,43 @@ +class Sequencer + module Mixin + # Classes that extend this module need a PREFIX constant. + module PrefixedConstantize + # Returns the class for a given name String independend of the prefix. + # + # @param [String] sequence the name String for the requested class + # + # @example + # Sequencer::Sequence.constantize('ExampleSequence') + # #=> Sequencer::Sequence::ExampleSequence + # + # @example + # Sequencer::Unit.constantize('Sequencer::Unit::Example::Unit') + # #=> Sequencer::Unit::Example::Unit + # + # @return [Object] the class for the given String + def constantize(name_string) + namespace(name_string).constantize + end + + # Returns the complete class namespace for a given name String + # independend of the prefix. + # + # @param [String] sequence the name String for the requested class namespace + # + # @example + # Sequencer::Sequence.namespace('ExampleSequence') + # #=> 'Sequencer::Sequence::ExampleSequence' + # + # @example + # Sequencer::Unit.namespace('Sequencer::Unit::Example::Unit') + # #=> 'Sequencer::Unit::Example::Unit' + # + # @return [String] the class namespace for the given String + def namespace(name_string) + prefix = const_get(:PREFIX) + return name_string if name_string.start_with?(prefix) + "#{prefix}#{name_string}" + end + end + end +end diff --git a/lib/sequencer/mixin/sub_sequence.rb b/lib/sequencer/mixin/sub_sequence.rb new file mode 100644 index 000000000..75348641c --- /dev/null +++ b/lib/sequencer/mixin/sub_sequence.rb @@ -0,0 +1,9 @@ +class Sequencer + module Mixin + module SubSequence + def sub_sequence(sequence, args = {}) + Sequencer.process(sequence, args) + end + end + end +end diff --git a/lib/sequencer/sequence.rb b/lib/sequencer/sequence.rb new file mode 100644 index 000000000..906ee2ba3 --- /dev/null +++ b/lib/sequencer/sequence.rb @@ -0,0 +1,16 @@ +require 'sequencer/mixin/prefixed_constantize' + +class Sequencer + class Sequence + extend ::Sequencer::Mixin::PrefixedConstantize + + PREFIX = 'Sequencer::Sequence::'.freeze + + attr_reader :units, :expecting + + def initialize(units:, expecting: []) + @units = units + @expecting = expecting + end + end +end diff --git a/lib/sequencer/sequence/base.rb b/lib/sequencer/sequence/base.rb new file mode 100644 index 000000000..382d47baa --- /dev/null +++ b/lib/sequencer/sequence/base.rb @@ -0,0 +1,48 @@ +class Sequencer + class Sequence + class Base + + # Defines the default attributes that will get returned for the sequence. + # These can be overwritten by giving the :expecting key to a sequence process call. + # + # @example + # Sequencer::Sequence::Example.expecting + # # => [:result, :list] + # + # @return [Array] the list of expected result keys. + def self.expecting + [] + end + + # Defines the list of Units that form the sequence. The units will get + # processed in the order they are defined in. The namespaces can be + # absolute or without the `Sequencer::Unit` prefix. + # + # @example + # Sequencer::Sequence::Example.sequence + # # => ['Import::Example::Resource', 'Sequencer::Unit::Import::Model::Create', ...] + # + # @return [Array] the list of units forming the sequence. + def self.sequence + raise "Missing implementation of '#{__method__}' method for '#{name}'" + end + + # This is an internally used method that converts the defined sequence to a + # Sequencer::Units instance which has special methods. + # + # @example + # Sequencer::Sequence::Example.units + # # => + # + # @return [Object] + def self.units + Sequencer::Units.new(*sequence) + end + + # @see .units + def units + self.class.units + end + end + end +end diff --git a/lib/sequencer/sequence/exchange/folder/attributes.rb b/lib/sequencer/sequence/exchange/folder/attributes.rb new file mode 100644 index 000000000..547d86e4f --- /dev/null +++ b/lib/sequencer/sequence/exchange/folder/attributes.rb @@ -0,0 +1,17 @@ +class Sequencer + class Sequence + module Exchange + module Folder + class Attributes < Sequencer::Sequence::Base + + def self.sequence + [ + 'Exchange::Connection', + 'Exchange::Folder::Attributes', + ] + end + end + end + end + end +end diff --git a/lib/sequencer/sequence/import/exchange/attributes_examples.rb b/lib/sequencer/sequence/import/exchange/attributes_examples.rb new file mode 100644 index 000000000..6c5cdab2b --- /dev/null +++ b/lib/sequencer/sequence/import/exchange/attributes_examples.rb @@ -0,0 +1,23 @@ +class Sequencer + class Sequence + module Import + module Exchange + class AttributesExamples < Sequencer::Sequence::Base + + def self.expecting + [:attributes] + end + + def self.sequence + [ + 'Exchange::Connection', + 'Exchange::Folders::ByIds', + 'Import::Exchange::AttributeExamples', + 'Import::Exchange::AttributeMapper::AttributeExamples', + ] + end + end + end + end + end +end diff --git a/lib/sequencer/sequence/import/exchange/available_folders.rb b/lib/sequencer/sequence/import/exchange/available_folders.rb new file mode 100644 index 000000000..63c37b927 --- /dev/null +++ b/lib/sequencer/sequence/import/exchange/available_folders.rb @@ -0,0 +1,22 @@ +class Sequencer + class Sequence + module Import + module Exchange + class AvailableFolders < Sequencer::Sequence::Base + + def self.expecting + [:folders] + end + + def self.sequence + [ + 'Exchange::Connection', + 'Exchange::Folders::IdPathMap', + 'Import::Exchange::AttributeMapper::AvailableFolders', + ] + end + end + end + end + end +end diff --git a/lib/sequencer/sequence/import/exchange/folder_contact.rb b/lib/sequencer/sequence/import/exchange/folder_contact.rb new file mode 100644 index 000000000..d475920ca --- /dev/null +++ b/lib/sequencer/sequence/import/exchange/folder_contact.rb @@ -0,0 +1,33 @@ +class Sequencer + class Sequence + module Import + module Exchange + class FolderContact < Sequencer::Sequence::Base + + def self.sequence + [ + 'Import::Exchange::FolderContact::RemoteId', + 'Import::Exchange::FolderContact::Mapping', + 'Import::Common::Model::Skip::Blank::Mapped', + 'Import::Exchange::FolderContact::StaticAttributes', + 'Import::Common::Model::ExternalSync::Lookup', + 'Import::Common::Model::Associations::Extract', + 'Import::Common::User::Attributes::Downcase', + 'Import::Common::User::Email::CheckValidity', + 'Import::Common::Model::Attributes::AddByIds', + 'Import::Common::Model::Update', + 'Import::Common::Model::Create', + 'Import::Common::Model::Associations::Assign', + 'Import::Common::Model::Save', + 'Import::Common::Model::ExternalSync::Create', + 'Import::Exchange::FolderContact::HttpLog', + 'Import::Exchange::FolderContact::Statistics::Diff', + 'Import::Common::ImportJob::Statistics::Update', + 'Import::Common::ImportJob::Statistics::Store', + ] + end + end + end + end + end +end diff --git a/lib/sequencer/sequence/import/exchange/folder_contacts.rb b/lib/sequencer/sequence/import/exchange/folder_contacts.rb new file mode 100644 index 000000000..9d07f75cd --- /dev/null +++ b/lib/sequencer/sequence/import/exchange/folder_contacts.rb @@ -0,0 +1,22 @@ +class Sequencer + class Sequence + module Import + module Exchange + class FolderContacts < Sequencer::Sequence::Base + + def self.sequence + [ + 'Import::Exchange::FolderContacts::DryRunPayload', + 'Exchange::Connection', + 'Import::Exchange::FolderContacts::FolderIds', + 'Import::Exchange::FolderContacts::Sum', + 'Import::Common::ImportJob::Statistics::Update', + 'Import::Common::ImportJob::Statistics::Store', + 'Import::Exchange::FolderContacts::SubSequence', + ] + end + end + end + end + end +end diff --git a/lib/sequencer/state.rb b/lib/sequencer/state.rb new file mode 100644 index 000000000..39a7107a6 --- /dev/null +++ b/lib/sequencer/state.rb @@ -0,0 +1,272 @@ +require 'mixin/rails_logger' +require 'mixin/start_finish_logger' + +class Sequencer + class State + include ::Mixin::RailsLogger + include ::Mixin::StartFinishLogger + + def initialize(sequence, parameters: {}, expecting: nil) + @index = -1 + @units = sequence.units + @result_index = @units.count + @values = {} + + initialize_attributes(sequence.units) + initialize_parameters(parameters.with_indifferent_access) + initialize_expectations(expecting || sequence.expecting) + end + + # Stores a value for the given attribute. Value can be a regular object + # or the result of a given code block. + # The attribute gets validated against the .provides list of attributes. + # In the case than an attribute gets provided that is not declared to + # be provided an exception will be raised. + # + # @param [Symbol] attribute the attribute for which the value gets provided. + # @param [Object] value the value that should get stored for the given attribute. + # @yield [] executes the given block and takes the result as the value. + # @yieldreturn [Object] the value for the given attribute. + # + # @example + # state.provide(:sum, 3) + # + # @example + # state.provide(:sum) do + # some_value = rand(100) + # some_value * 3 + # end + # + # @raise [RuntimeError] if the attribute is not provideable from the calling Unit + # + # @return [nil] + def provide(attribute, value = nil) + if provideable?(attribute) + value = yield if block_given? + set(attribute, value) + else + value = "UNEXECUTED BLOCK: #{caller(1..1).first}" if block_given? + unprovideable_setter(attribute, value) + end + end + + # Returns the value of the given attribute. + # The attribute gets validated against the .uses list of attributes. In the + # case than an attribute gets used that is not declared to be used + # an exception will be raised. + # + # @param [Symbol] attribute the attribute for which the value is requested. + # + # @example + # state.use(:answer) + # #=> 42 + # + # @raise [RuntimeError] if the attribute is not useable from the calling Unit + # + # @return [nil] + def use(attribute) + if useable?(attribute) + get(attribute) + else + unaccessable_getter(attribute) + end + end + + # Returns the value of the given attribute. + # The attribute DOES NOT get validated against the .uses list of attributes. + # + # @param [Symbol] attribute the attribute for which the value is requested. + # + # @example + # state.optional(:answer) + # #=> 42 + # + # @example + # state.optional(:unknown) + # #=> nil + # + # @return [Object, nil] + def optional(attribute) + return get(attribute) if @attributes.known?(attribute) + logger.debug("Access to unknown optional attribute '#{attribute}'.") + nil + end + + # Checks if a value for the given attribute is provided. + # The attribute DOES NOT get validated against the .uses list of attributes. + # + # @param [Symbol] attribute the attribute which should get checked. + # + # @example + # state.provided?(:answer) + # #=> true + # + # @example + # state.provided?(:unknown) + # #=> false + # + # @return [Boolean] + def provided?(attribute) + optional(attribute) != nil + end + + # Unsets the value for the given attribute. + # The attribute gets validated against the .uses list of attributes. + # In the case than an attribute gets unset that is not declared + # to be used an exception will be raised. + # + # @param [Symbol] attribute the attribute for which the value gets unset. + # + # @example + # state.unset(:answer) + # + # @raise [RuntimeError] if the attribute is not useable from the calling Unit + # + # @return [nil] + def unset(attribute) + value = nil + if useable?(attribute) + set(attribute, value) + else + unprovideable_setter(attribute, value) + end + end + + # Handles state processing of the next Unit in the Sequence while executing + # the given block. After the Unit is processed the state will get cleaned up + # and no longer needed attribute values will get discarded. + # + # @yield [] executes the given block and handles the state changes before and afterwards. + # + # @example + # state.process do + # unit.process + # end + # + # @return [nil] + def process + @index += 1 + yield + cleanup + end + + # Handles state processing of the next Unit in the Sequence while executing + # the given block. After the Unit is processed the state will get cleaned up + # and no longer needed attribute values will get discarded. + # + # @example + # state.to_h + # #=> {"ssl_verify"=>true, "host_url"=>"ldaps://192...", ...} + # + # @return [Hash{Symbol => Object}] + def to_h + available.map { |identifier| [identifier, @values[identifier]] }.to_h + end + + private + + def available + @attributes.select do |_identifier, attribute| + @index.between?(attribute.from, attribute.to) + end.keys + end + + def unit(index = nil) + @units[index || @index] + end + + def provideable?(attribute) + unit.provides.include?(attribute) + end + + def useable?(attribute) + unit.uses.include?(attribute) + end + + def set(attribute, value) + logger.debug("Setting '#{attribute}' value (#{value.class.name}): #{value.inspect}") + @values[attribute] = value + end + + def get(attribute) + value = @values[attribute] + logger.debug("Getting '#{attribute}' value (#{value.class.name}): #{value.inspect}") + value + end + + def unprovideable_setter(attribute, value) + message = "Unprovideable attribute '#{attribute}' set with value (#{value.class.name}): '#{value}'" + logger.error(message) + raise message + end + + def unaccessable_getter(attribute) + message = "Unaccessable getter used for attribute '#{attribute}'" + logger.error(message) + raise message + end + + def initialize_attributes(units) + log_start_finish(:debug, 'Attributes lifespan initialization') do + @attributes = Sequencer::Units::Attributes.new(units.declarations) + logger.debug("Attributes lifespan: #{@attributes.inspect}") + end + end + + def initialize_parameters(parameters) + logger.debug("Initializing Sequencer::State with initial parameters: #{parameters.inspect}") + + log_start_finish(:debug, 'Attribute value provisioning check and initialization') do + + @attributes.each do |identifier, attribute| + + if !attribute.will_be_used? + logger.debug("Attribute '#{identifier}' is provided by Unit(s) but never used.") + next + end + + init_param = parameters.key?(identifier) + provided_attr = attribute.will_be_provided? + + if !init_param && !provided_attr + message = "Attribute '#{identifier}' is used in Unit '#{unit(attribute.to).name}' (index: #{attribute.to}) but is not provided or given via initial parameters." + logger.error(message) + raise message + end + + # skip if attribute is provided by an Unit but not + # an initial parameter + next if !init_param + + # update 'from' lifespan information for attribute + # since it's provided via the initial parameter + attribute.from = @index + + # set initial value + set(identifier, parameters[identifier]) + end + end + end + + def initialize_expectations(expected_attributes) + expected_attributes.each do |identifier| + logger.debug("Adding attribute '#{identifier}' to the list of expected result attributes.") + @attributes[identifier].to = @result_index + end + end + + def cleanup + log_start_finish(:info, "State cleanup of Unit #{unit.name} (index: #{@index})") do + + @attributes.delete_if do |identifier, attribute| + remove = !attribute.will_be_used? + remove ||= attribute.to <= @index + if remove && attribute.will_be_used? + logger.debug("Removing unneeded attribute '#{identifier}': #{@values[identifier]}") + end + remove + end + end + end + end +end diff --git a/lib/sequencer/unit.rb b/lib/sequencer/unit.rb new file mode 100644 index 000000000..723ef4a2e --- /dev/null +++ b/lib/sequencer/unit.rb @@ -0,0 +1,71 @@ +require 'sequencer/mixin/prefixed_constantize' + +class Sequencer + class Unit + include ::Mixin::RequiredSubPaths + extend ::Sequencer::Mixin::PrefixedConstantize + + PREFIX = 'Sequencer::Unit::'.freeze + + # Convenience wrapper for processing a single Unit. + # + # ATTENTION: This should only be used for development, testing or debugging purposes. + # There might be a check in the future to prevent using this method in other scopes. + # + # @see #initialize + # @see #process + def self.process(unit, parameters, &block) + new(unit).process(parameters, &block) + end + + # Initializes a new Sequencer::Unit for processing it. + # + # ATTENTION: This should only be used for development, testing or debugging purposes. + # There might be a check in the future to prevent using this method in other scopes. + # + # @param [String] unit the name String for the Unit that should get processed + def initialize(unit) + @unit = self.class.constantize(unit) + end + + # Processes the Sequencer::Unit that the instance was initialized with. + # + # ATTENTION: This should only be used for development, testing or debugging purposes. + # There might be a check in the future to prevent using this method in other scopes. + # + # @param [Hash{Symbol => Object}] parameters the parameters for initializing the Sequencer::State + # @yield [instance] optional block to access the Unit instance + # @yieldparam instance [Object] the Unit instance for e.g. adding expectations + def process(parameters) + @parameters = parameters + instance = @unit.new(state) + + # yield instance to apply expectations + yield instance if block_given? + + state.process do + instance.process + end + + state.to_h + end + + private + + def state + @state ||= begin + units = Sequencer::Units.new( + @unit.name + ) + + sequence = Sequencer::Sequence.new( + units: units, + expecting: @unit.provides, + ) + + Sequencer::State.new(sequence, + parameters: @parameters) + end + end + end +end diff --git a/lib/sequencer/unit/base.rb b/lib/sequencer/unit/base.rb new file mode 100644 index 000000000..cd97afe82 --- /dev/null +++ b/lib/sequencer/unit/base.rb @@ -0,0 +1,212 @@ +require 'mixin/rails_logger' + +class Sequencer + class Unit + class Base + include ::Mixin::RailsLogger + + attr_reader :state + + # Creates the class macro `uses` that allows a Unit to + # declare the attributes it will use via parameter or block. + # On the other hand it returns the declared attributes if + # called without parameters. + # + # This method can be called multiple times and will add the + # given attributes to the list. It takes care of handling + # duplicates so no uniq check is required. It's safe to use + # for inheritance structures and modules. + # + # It additionally creates a getter instance method for each declared + # attribute like e.g. attr_reader does. This allows direct access + # to an attribute via `attribute_name`. See examples. + # + # @param [Array] attributes an optional list of attributes that the Unit uses + # + # @yield [] A block returning a list of attributes + # + # @example Via regular Array parameter + # uses :instance, :action, :connection + # + # @example Via block + # uses do + # additional = method(parameter) + # [:some, additional] + # end + # + # @example Listing declared attributes + # Unit::Name.uses + # # => [:instance, :action, :connection, :some, :suprise] + # + # @example Using declared attribute in the Unit via state object + # state.use(:instance).id + # + # @example Using declared attribute in the Unit via getter + # instance.id + # + # @return [Array] the list of all declared uses of a Unit. + def self.uses(*attributes, &block) + declaration_accessor( + key: __method__, + attributes: attributes(*attributes, &block) + ) do |attribute| + use_getter(attribute) + end + end + + # Creates the class macro `provides` that allows a Unit to + # declare the attributes it will provided via parameter or block. + # On the other hand it returns the declared attributes if + # called without parameters. + # + # This method can be called multiple times and will add the + # given attributes to the list. It takes care of handling + # duplicates so no uniq check is required. It's safe to use + # for inheritance structures and modules. + # + # It additionally creates a setter instance method for each declared + # attribute like e.g. attr_writer does. This allows direct access + # to an attribute via `self.attribute_name = `. See examples. + # + # A Unit should usually not provide more than one or two attributes. + # If your Unit provides it's doing to much and should be splitted + # into multiple Units. + # + # @param [Array] attributes an optional list of attributes that the Unit provides + # + # @yield [] A block returning a list of attributes + # + # @example Via regular Array parameter + # provides :instance, :action, :connection + # + # @example Via block + # provides do + # additional = method(parameter) + # [:some, additional] + # end + # + # @example Listing declared attributes + # Unit::Name.provides + # # => [:instance, :action, :connection, :some, :suprise] + # + # @example Providing declared attribute in the Unit via state object parameter + # state.provide(:action, :created) + # + # @example Providing declared attribute in the Unit via state object block + # state.provide(:instance) do + # # ... + # instance + # end + # + # @example Providing declared attribute in the Unit via setter + # self.action = :created + # + # @return [Array] the list of all declared provides of a Unit. + def self.provides(*attributes, &block) + declaration_accessor( + key: __method__, + attributes: attributes(*attributes, &block) + ) do |attribute| + provide_setter(attribute) + end + end + + def self.attributes(*attributes) + # exectute block if given and add + # the result to the (possibly empty) + # list of given attributes + attributes.concat(yield) if block_given? + attributes + end + + # This method is the heart of the #uses and #provides method. + # It takes the declaration key and decides based on the given + # parameters if the given attributes should get stored or + # the stored values returned. + def self.declaration_accessor(key:, attributes:) + + # if no attributes were given (storing) + # return the already stored list of attributes + return declarations(key).to_a if attributes.blank? + + # loop over all given attributes and + # add them to the list of already stored + # attributes for the given declaration key + attributes.each do |attribute| + next if !declarations(key).add?(attribute) + + # yield callback if given to create + # getter or setter or whatever + yield(attribute) if block_given? + end + end + + # This method creates the convenience method + # getter for the given attribute. + def self.use_getter(attribute) + define_method(attribute) do + instance_variable_cached(attribute) do + state.use(attribute) + end + end + end + + # This method creates the convenience method + # setter for the given attribute. + def self.provide_setter(attribute) + define_method("#{attribute}=") do |value| + state.provide(attribute, value) + end + end + + # This method is the attribute store for the given declaration key. + def self.declarations(key) + instance_variable_cached("#{key}_declarations") do + declarations_initial(key) + end + end + + # This method initializes the attribute store for the given declaration key. + # It checks if a parent class already has an existing store and duplicates it + # for independent usage. Otherwise it creates a new one. + def self.declarations_initial(key) + return Set.new([]) if !superclass.respond_to?(:declarations) + superclass.send(:declarations, key).dup + end + + # This method creates an accessor to a cached instance variable for the given scope. + # It will create a new variable with the result of the given block as an initial value. + # On later calls it will return the already initialized, cached variable state. + # The variable will be created by default as a class variable. If a instance scope is + # passed it will create an instance variable instead. + def self.instance_variable_cached(key, scope: self) + cache = "@#{key}" + value = scope.instance_variable_get(cache) + return value if value + value = yield + scope.instance_variable_set(cache, value) + end + + # This method is an instance wrapper around the class method .instance_variable_cached. + # It will behave the same but passed the instance scope to create an + # cached instance variable. + def instance_variable_cached(key, &block) + self.class.instance_variable_cached(key, scope: self, &block) + end + + # This method is an convenience wrapper to create an instance + # and then directly processing it. + def self.process(*args) + new(*args).process + end + + def initialize(state) + @state = state + end + + def process + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + end + end +end diff --git a/lib/sequencer/unit/common/attribute_mapper.rb b/lib/sequencer/unit/common/attribute_mapper.rb new file mode 100644 index 000000000..8922dd190 --- /dev/null +++ b/lib/sequencer/unit/common/attribute_mapper.rb @@ -0,0 +1,28 @@ +class Sequencer + class Unit + module Common + class AttributeMapper < Sequencer::Unit::Base + + def self.map + raise "Missing implementation of '#{__method__}' method for '#{name}'" + end + + def self.uses + map.keys + end + + def self.provides + map.values + end + + def process + self.class.map.each do |original, renamed| + state.provide(renamed) do + state.use(original) + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/exchange/connection.rb b/lib/sequencer/unit/exchange/connection.rb new file mode 100644 index 000000000..fddcba8c0 --- /dev/null +++ b/lib/sequencer/unit/exchange/connection.rb @@ -0,0 +1,23 @@ +class Sequencer + class Unit + module Exchange + class Connection < Sequencer::Unit::Base + + uses :ews_config + provides :ews_connection + + def process + # check if EWS connection is already given (sub sequence) + return if state.provided?(:ews_connection) + + state.provide(:ews_connection) do + config = ews_config + config ||= ::Import::Exchange.config + + Viewpoint::EWSClient.new(config[:endpoint], config[:user], config[:password]) + end + end + end + end + end +end diff --git a/lib/sequencer/unit/exchange/folders/by_ids.rb b/lib/sequencer/unit/exchange/folders/by_ids.rb new file mode 100644 index 000000000..8a08d530f --- /dev/null +++ b/lib/sequencer/unit/exchange/folders/by_ids.rb @@ -0,0 +1,24 @@ +require 'sequencer/mixin/exchange/folder' + +class Sequencer + class Unit + module Exchange + module Folders + class ByIds < Sequencer::Unit::Base + include ::Sequencer::Mixin::Exchange::Folder + + uses :ews_folder_ids + provides :ews_folders + + def process + state.provide(:ews_folders) do + ews_folder_ids.collect do |folder_id| + ews_folder.find(folder_id) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/exchange/folders/id_path_map.rb b/lib/sequencer/unit/exchange/folders/id_path_map.rb new file mode 100644 index 000000000..9b6d48847 --- /dev/null +++ b/lib/sequencer/unit/exchange/folders/id_path_map.rb @@ -0,0 +1,31 @@ +require 'sequencer/mixin/exchange/folder' + +class Sequencer + class Unit + module Exchange + module Folders + class IdPathMap < Sequencer::Unit::Base + include ::Sequencer::Mixin::Exchange::Folder + + provides :ews_folder_id_path_map + + def process + state.provide(:ews_folder_id_path_map) do + + ids = state.optional(:ews_folder_ids) + ids ||= [] + + ews_folder.id_folder_map.collect do |id, folder| + next if ids.present? && ids.exclude?(id) + next if folder.total_count.blank? + next if folder.total_count.zero? + + [id, ews_folder.display_path(folder)] + end.compact.to_h + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/import_job/payload/to_state.rb b/lib/sequencer/unit/import/common/import_job/payload/to_state.rb new file mode 100644 index 000000000..0c0d17324 --- /dev/null +++ b/lib/sequencer/unit/import/common/import_job/payload/to_state.rb @@ -0,0 +1,25 @@ +class Sequencer + class Unit + module Import + module Common + module ImportJob + module Payload + class ToState < Sequencer::Unit::Base + + uses :import_job + + def process + provides = self.class.provides + raise "Can't find any provides for #{self.class.name}" if provides.blank? + + provides.each do |attribute| + state.provide(attribute, import_job.payload[attribute]) + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/import_job/statistics/store.rb b/lib/sequencer/unit/import/common/import_job/statistics/store.rb new file mode 100644 index 000000000..33aa8a9e8 --- /dev/null +++ b/lib/sequencer/unit/import/common/import_job/statistics/store.rb @@ -0,0 +1,35 @@ +class Sequencer + class Unit + module Import + module Common + module ImportJob + module Statistics + class Store < Sequencer::Unit::Base + + uses :import_job, :statistics + + def process + # update the attribute temporarily so we can update it when: + # - the last update is more than 10 seconds in the past + # - all instances are processed but the last statistics entry is not written here. + # This will be done in the calling Unit of the executed sub sequence + import_job.result = statistics + + return if !store? + import_job.save! + end + + private + + def store? + return true if import_job.updated_at.blank? + # update every 10 seconds to reduce DB load + import_job.updated_at > Time.zone.now + 10.seconds + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/import_job/statistics/update.rb b/lib/sequencer/unit/import/common/import_job/statistics/update.rb new file mode 100644 index 000000000..57af16340 --- /dev/null +++ b/lib/sequencer/unit/import/common/import_job/statistics/update.rb @@ -0,0 +1,50 @@ +class Sequencer + class Unit + module Import + module Common + module ImportJob + module Statistics + class Update < Sequencer::Unit::Base + + uses :statistics_diff + provides :statistics + + def process + state.provide(:statistics) do + sum_deeply( + existing: statistics, + additions: statistics_diff + ) + end + + # reset diff to avoid situations where old diff gets added multiple times + state.unset(:statistics_diff) + end + + private + + def statistics + import_job = state.optional(:import_job) + return {} if import_job.nil? + import_job.result + end + + def sum_deeply(existing:, additions:) + existing.merge(additions) do |_key, oldval, newval| + if oldval.is_a?(Hash) || newval.is_a?(Hash) + sum_deeply( + existing: oldval, + additions: newval + ) + else + oldval + newval + end + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/import_job/sub_sequence/general.rb b/lib/sequencer/unit/import/common/import_job/sub_sequence/general.rb new file mode 100644 index 000000000..e581e1782 --- /dev/null +++ b/lib/sequencer/unit/import/common/import_job/sub_sequence/general.rb @@ -0,0 +1,66 @@ +require 'sequencer/mixin/sub_sequence' + +class Sequencer + class Unit + module Import + module Common + module ImportJob + module SubSequence + class General < Sequencer::Unit::Base + include ::Sequencer::Mixin::SubSequence + + uses :import_job + + def process + resource_sequence + end + + private + + # INFO: Cache results via `@sequence ||= ...`, if needed + def sequence + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + + # INFO: Cache results via `@resources ||= ...`, if needed + def resources + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + + def default_parameters + { + dry_run: import_job.dry_run, + import_job: import_job, + } + end + + def resource_sequence + return if resources.blank? + + defaults = default_parameters + + resources.each do |resource| + + arguments = { + parameters: defaults.merge(resource: resource) + } + + yield resource, arguments if block_given? + + arguments[:parameters][:resource] = arguments[:parameters][:resource].with_indifferent_access + + result = sub_sequence(sequence, arguments) + + processed(result) + end + end + + def processed(_result) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/import_job/sub_sequence/resource.rb b/lib/sequencer/unit/import/common/import_job/sub_sequence/resource.rb new file mode 100644 index 000000000..7037d6e07 --- /dev/null +++ b/lib/sequencer/unit/import/common/import_job/sub_sequence/resource.rb @@ -0,0 +1,15 @@ +class Sequencer + class Unit + module Import + module Common + module ImportJob + module SubSequence + class Resource < Sequencer::Unit::Import::Common::ImportJob::SubSequence::General + uses :resource + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/mapping/flat_keys.rb b/lib/sequencer/unit/import/common/mapping/flat_keys.rb new file mode 100644 index 000000000..302671ced --- /dev/null +++ b/lib/sequencer/unit/import/common/mapping/flat_keys.rb @@ -0,0 +1,35 @@ +class Sequencer + class Unit + module Import + module Common + module Mapping + class FlatKeys < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped + + uses :resource + provides :mapped + + def process + provide_mapped do + mapped + end + end + + private + + def mapped + resource_with_indifferent_access = resource.with_indifferent_access + mapping.symbolize_keys.collect do |source, local| + [local, resource_with_indifferent_access[source]] + end.to_h.with_indifferent_access + end + + def mapping + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/mapping/mixin/provide_mapped.rb b/lib/sequencer/unit/import/common/mapping/mixin/provide_mapped.rb new file mode 100644 index 000000000..544dc9727 --- /dev/null +++ b/lib/sequencer/unit/import/common/mapping/mixin/provide_mapped.rb @@ -0,0 +1,30 @@ +class Sequencer + class Unit + module Import + module Common + module Mapping + module Mixin + module ProvideMapped + + def self.included(base) + base.provides :mapped + end + + private + + def existing_mapped + @existing_mapped ||= state.optional(:mapped) || ActiveSupport::HashWithIndifferentAccess.new + end + + def provide_mapped + state.provide(:mapped) do + existing_mapped.merge(yield) + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/associations/assign.rb b/lib/sequencer/unit/import/common/model/associations/assign.rb new file mode 100644 index 000000000..00752ad1b --- /dev/null +++ b/lib/sequencer/unit/import/common/model/associations/assign.rb @@ -0,0 +1,59 @@ +require 'sequencer/unit/import/common/model/mixin/handle_failure' + +class Sequencer + class Unit + module Import + module Common + module Model + module Associations + class Assign < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure + + uses :instance, :associations, :instance_action, :dry_run + provides :instance_action + + def process + return if dry_run + return if instance.blank? + + instance.assign_attributes(associations) + + # execute associations check only if needed for performance reasons + return if instance_action != :unchanged + return if !changed? + state.provide(:instance_action, :changed) + rescue => e + handle_failure(e) + end + + private + + def changed? + logger.debug("Changed instance associations: #{changes.inspect}") + changes.present? + end + + def changes + @changes ||= begin + return {} if associations.blank? + associations.collect do |association, value| + before = compareable(instance.send(association)) + after = compareable(value) + next if before == after + [association, [before, after]] + end.compact.to_h.with_indifferent_access + end + end + + def compareable(value) + return nil if value.blank? + return value.sort if value.respond_to(:sort) + value + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/associations/extract.rb b/lib/sequencer/unit/import/common/model/associations/extract.rb new file mode 100644 index 000000000..40142b08e --- /dev/null +++ b/lib/sequencer/unit/import/common/model/associations/extract.rb @@ -0,0 +1,65 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Associations + class Extract < Sequencer::Unit::Base + + uses :model_class, :mapped + provides :associations + + def process + state.provide(:associations) do + associations.collect do |association| + next if !mapped.key?(association) + + # remove from the mapped values if it's an association + value = mapped.delete(association) + + # skip if we don't track them + next if tracked_associations.exclude?(association) + + [association, value] + end.compact.to_h + end + end + + private + + def associations + @associations ||= begin + associations = [] + # loop over all reflections + model_class.reflect_on_all_associations.each do |reflection| + + # refection name is something like groups or organization (singular/plural) + associations.push(reflection.name) + + # key is something like group_id or organization_id (singular) + key = reflection.klass.name.foreign_key + + # add trailing 's' to get pluralized key + reflection_name = reflection.name.to_s + if reflection_name.singularize == reflection_name + key = "#{key}s" + end + + # store _id/_ids name + associations.push(key.to_sym) + end + associations + end + end + + def tracked_associations + # track all associations by default + associations + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/attributes/add_by_ids.rb b/lib/sequencer/unit/import/common/model/attributes/add_by_ids.rb new file mode 100644 index 000000000..23ab42192 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/attributes/add_by_ids.rb @@ -0,0 +1,24 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Attributes + class AddByIds < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Mapping::Mixin::ProvideMapped + + def process + provide_mapped do + { + created_by_id: 1, + updated_by_id: 1, + } + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/attributes/check_mandatory.rb b/lib/sequencer/unit/import/common/model/attributes/check_mandatory.rb new file mode 100644 index 000000000..dbb1ba529 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/attributes/check_mandatory.rb @@ -0,0 +1,32 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Attributes + class CheckMandatory < Sequencer::Unit::Base + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction + + uses :mapped + provides :instance_action + + def process + mandatory.each do |mapped_attribute| + next if mapped[mapped_attribute].present? + state.provide(:instance_action, :skipped) + break + end + end + + private + + def mandatory + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/attributes/remote_id.rb b/lib/sequencer/unit/import/common/model/attributes/remote_id.rb new file mode 100644 index 000000000..412fbb773 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/attributes/remote_id.rb @@ -0,0 +1,32 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Attributes + class RemoteId < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure + + uses :resource + provides :remote_id + + def process + state.provide(:remote_id) do + resource.fetch(attribute) + end + rescue KeyError => e + handle_failure(e) + end + + private + + def attribute + :id + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/create.rb b/lib/sequencer/unit/import/common/model/create.rb new file mode 100644 index 000000000..8aab65fb5 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/create.rb @@ -0,0 +1,25 @@ +class Sequencer + class Unit + module Import + module Common + module Model + class Create < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction + + uses :mapped, :model_class + provides :instance, :instance_action + + def process + instance = model_class.new(mapped) + state.provide(:instance, instance) + state.provide(:instance_action, :created) + rescue => e + handle_failure(e) + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/external_sync/create.rb b/lib/sequencer/unit/import/common/model/external_sync/create.rb new file mode 100644 index 000000000..b1354a454 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/external_sync/create.rb @@ -0,0 +1,29 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module ExternalSync + class Create < Sequencer::Unit::Base + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance + + uses :instance, :instance_action, :remote_id, :dry_run, :external_sync_source, :model_class + + def process + return if dry_run + return if instance_action != :created + + ::ExternalSync.create( + source: external_sync_source, + source_id: remote_id, + object: model_class.name, + o_id: instance.id + ) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/external_sync/local.rb b/lib/sequencer/unit/import/common/model/external_sync/local.rb new file mode 100644 index 000000000..5d4c6f1f8 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/external_sync/local.rb @@ -0,0 +1,63 @@ +require 'sequencer/unit/import/common/model/mixin/handle_failure' + +class Sequencer + class Unit + module Import + module Common + module Model + module ExternalSync + class Local < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance + + uses :mapped, :remote_id, :model_class, :external_sync_source, :instance_action + provides :instance + + def process + return if state.provided?(:instance) + + return if value.blank? + return if instance.blank? + + create_external_sync + + state.provide(:instance, instance) + end + + private + + def attribute + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + + def value + mapped[attribute] + end + + def instance + @instance ||= begin + model_class.where(attribute => value).find do |local| + !ExternalSync.exists?( + source: external_sync_source, + object: model_class.name, + o_id: local.id + ) + end + end + end + + def create_external_sync + ExternalSync.create( + source: external_sync_source, + source_id: remote_id, + object: import_class.name, + o_id: instance.id + ) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/external_sync/lookup.rb b/lib/sequencer/unit/import/common/model/external_sync/lookup.rb new file mode 100644 index 000000000..e9894d794 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/external_sync/lookup.rb @@ -0,0 +1,34 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module ExternalSync + class Lookup < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance + + uses :remote_id, :model_class, :external_sync_source + provides :instance + + def process + synced_instance = ::ExternalSync.find_by( + source: external_sync_source, + source_id: remote_id, + object: model_class.name, + ) + return if !synced_instance + + state.provide(:instance) do + model_class.find(synced_instance.o_id) + end + rescue => e + handle_failure(e) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/http_log.rb b/lib/sequencer/unit/import/common/model/http_log.rb new file mode 100644 index 000000000..811e4f383 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/http_log.rb @@ -0,0 +1,50 @@ +class Sequencer + class Unit + module Import + module Common + module Model + class HttpLog < Sequencer::Unit::Base + + uses :dry_run, :instance_action, :remote_id, :mapped, :exception + + def process + return if dry_run + ::HttpLog.create( + direction: 'out', + facility: facility, + method: 'tcp', + url: "#{instance_action} -> #{remote_id}", + status: status, + ip: nil, + request: { + content: mapped, + }, + response: { + message: response + }, + created_by_id: 1, + updated_by_id: 1, + ) + end + + private + + def status + @status ||= begin + instance_action == :failed ? :failed : :success + end + end + + def response + exception ? exception.message : status + end + + def facility + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/mixin/handle_failure.rb b/lib/sequencer/unit/import/common/model/mixin/handle_failure.rb new file mode 100644 index 000000000..d2502de14 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/mixin/handle_failure.rb @@ -0,0 +1,24 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Mixin + module HandleFailure + + def self.included(base) + base.provides :exception, :instance_action + end + + def handle_failure(e) + logger.error(e) + state.provide(:exception, e) + state.provide(:instance_action, :failed) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/mixin/skip_on_provided_instance_action.rb b/lib/sequencer/unit/import/common/model/mixin/skip_on_provided_instance_action.rb new file mode 100644 index 000000000..2e9964d69 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/mixin/skip_on_provided_instance_action.rb @@ -0,0 +1,22 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Mixin + module SkipOnProvidedInstanceAction + + def process + if state.provided?(:instance_action) + logger.debug("Skipping. Attribute 'instance_action' already provided.") + else + super + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/mixin/skip_on_skipped_instance.rb b/lib/sequencer/unit/import/common/model/mixin/skip_on_skipped_instance.rb new file mode 100644 index 000000000..8d849c2e9 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/mixin/skip_on_skipped_instance.rb @@ -0,0 +1,26 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Mixin + module SkipOnSkippedInstance + + def self.prepended(base) + base.uses :instance_action + end + + def process + if instance_action == :skipped + logger.debug("Skipping. Attribute 'instance_action' is set to :skipped.") + else + super + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/save.rb b/lib/sequencer/unit/import/common/model/save.rb new file mode 100644 index 000000000..8dec6b8ab --- /dev/null +++ b/lib/sequencer/unit/import/common/model/save.rb @@ -0,0 +1,25 @@ +require 'sequencer/unit/import/common/model/mixin/handle_failure' + +class Sequencer + class Unit + module Import + module Common + module Model + class Save < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure + + uses :instance, :dry_run + + def process + return if dry_run + return if instance.blank? + instance.save! + rescue => e + handle_failure(e) + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/skip/blank/base.rb b/lib/sequencer/unit/import/common/model/skip/blank/base.rb new file mode 100644 index 000000000..d57a10126 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/skip/blank/base.rb @@ -0,0 +1,43 @@ +require 'sequencer/unit/mixin/dynamic_attribute' + +class Sequencer + class Unit + module Import + module Common + module Model + module Skip + module Blank + class Base < Sequencer::Unit::Base + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction + include ::Sequencer::Unit::Mixin::DynamicAttribute + + provides :instance_action + + def process + return if !skip? + logger.debug("Skipping. Blank #{attribute} found: #{attribute_value.inspect}") + state.provide(:instance_action, :skipped) + end + + private + + def ignore + [:id] + end + + def skip? + return true if attribute_value.blank? + relevant_blank? + end + + def relevant_blank? + !attribute_value.except(*ignore).values.any?(&:present?) + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/skip/blank/mapped.rb b/lib/sequencer/unit/import/common/model/skip/blank/mapped.rb new file mode 100644 index 000000000..25acaad86 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/skip/blank/mapped.rb @@ -0,0 +1,17 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Skip + module Blank + class Mapped < Sequencer::Unit::Import::Common::Model::Skip::Blank::Base + uses :mapped + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/skip/blank/resource.rb b/lib/sequencer/unit/import/common/model/skip/blank/resource.rb new file mode 100644 index 000000000..757ca4165 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/skip/blank/resource.rb @@ -0,0 +1,17 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Skip + module Blank + class Resource < Sequencer::Unit::Import::Common::Model::Skip::Blank::Base + uses :resource + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/skip/missing_mandatory/base.rb b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/base.rb new file mode 100644 index 000000000..cb327161e --- /dev/null +++ b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/base.rb @@ -0,0 +1,46 @@ +require 'sequencer/unit/mixin/dynamic_attribute' + +class Sequencer + class Unit + module Import + module Common + module Model + module Skip + module MissingMandatory + class Base < Sequencer::Unit::Base + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction + include ::Sequencer::Unit::Mixin::DynamicAttribute + + provides :instance_action + + def process + return if !skip? + logger.debug("Skipping. Missing mandatory attributes for #{attribute}: #{attribute_value.inspect}") + state.provide(:instance_action, :skipped) + end + + private + + def mandatory + raise "Missing implementation of '#{__method__}' method for '#{self.class.name}'" + end + + def skip? + return true if attribute_value.blank? + mandatory_missing? + end + + def mandatory_missing? + values = attribute_value.fetch_values(*mandatory) + !values.any?(&:present?) + rescue KeyError => e + false + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/skip/missing_mandatory/resource.rb b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/resource.rb new file mode 100644 index 000000000..f33bc280b --- /dev/null +++ b/lib/sequencer/unit/import/common/model/skip/missing_mandatory/resource.rb @@ -0,0 +1,17 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Skip + module MissingMandatory + class Resource < Sequencer::Unit::Import::Common::Model::Skip::MissingMandatory::Base + uses :resource + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/statistics/diff.rb b/lib/sequencer/unit/import/common/model/statistics/diff.rb new file mode 100644 index 000000000..787123b1d --- /dev/null +++ b/lib/sequencer/unit/import/common/model/statistics/diff.rb @@ -0,0 +1,21 @@ +require 'sequencer/unit/import/common/model/statistics/mixin/diff' + +class Sequencer + class Unit + module Import + module Common + module Model + module Statistics + class Diff < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Statistics::Mixin::Diff + + def process + state.provide(:statistics_diff, diff) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/statistics/mixin/diff.rb b/lib/sequencer/unit/import/common/model/statistics/mixin/diff.rb new file mode 100644 index 000000000..1dd1194c7 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/statistics/mixin/diff.rb @@ -0,0 +1,47 @@ +class Sequencer + class Unit + module Import + module Common + module Model + module Statistics + module Mixin + module Diff + + def self.included(base) + base.uses :instance_action + base.provides :statistics_diff + end + + private + + def actions + %i(skipped created updated unchanged failed deactivated) + end + + def diff + raise "Unknown action '#{instance_action}'" if !possible? + defaults.merge( + instance_action => 1, + sum: 1, + ) + end + + def possible? + possible_actions.include?(instance_action) + end + + def defaults + possible_actions.collect { |key| [key, 0] }.to_h + end + + def possible_actions + @possible_actions ||= actions + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/model/update.rb b/lib/sequencer/unit/import/common/model/update.rb new file mode 100644 index 000000000..fa0947317 --- /dev/null +++ b/lib/sequencer/unit/import/common/model/update.rb @@ -0,0 +1,56 @@ +class Sequencer + class Unit + module Import + module Common + module Model + class Update < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnProvidedInstanceAction + + uses :instance, :mapped + provides :instance_action + + def process + # check if no instance is given - so we can't update it + return if !instance + + # lock the current instance for write access + instance.with_lock do + # delete since we have an update and + # the record is already created + mapped.delete(:created_by_id) + + # assign regular attributes + instance.assign_attributes(mapped) + + action = changed? ? :updated : :unchanged + state.provide(:instance_action, action) + end + rescue => e + handle_failure(e) + end + + private + + def changed? + logger.debug("Changed instance attributes: #{changes.inspect}") + changes.present? + end + + def changes + @changes ||= begin + if instance.changed? + # dry run + instance.changes + else + # live run + instance.previous_changes + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/user/attributes/downcase.rb b/lib/sequencer/unit/import/common/user/attributes/downcase.rb new file mode 100644 index 000000000..c5791e062 --- /dev/null +++ b/lib/sequencer/unit/import/common/user/attributes/downcase.rb @@ -0,0 +1,24 @@ +class Sequencer + class Unit + module Import + module Common + module User + module Attributes + class Downcase < Sequencer::Unit::Base + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance + + uses :mapped + + def process + %i(login email).each do |attribute| + next if mapped[attribute].blank? + mapped[attribute].downcase! + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/common/user/email/check_validity.rb b/lib/sequencer/unit/import/common/user/email/check_validity.rb new file mode 100644 index 000000000..28f035d4f --- /dev/null +++ b/lib/sequencer/unit/import/common/user/email/check_validity.rb @@ -0,0 +1,45 @@ +class Sequencer + class Unit + module Import + module Common + module User + module Email + class CheckValidity < Sequencer::Unit::Base + prepend ::Sequencer::Unit::Import::Common::Model::Mixin::SkipOnSkippedInstance + + uses :mapped + + def process + return if mapped[:email].blank? + + # TODO: This should be done totally somewhere central + mapped[:email] = ensure_valid_email(mapped[:email]) + end + + private + + def ensure_valid_email(source) + # TODO: should get unified with User#check_email + email = extract_email(source) + return if !email + email.downcase + end + + def extract_email(source) + # Support format like "Bob Smith (bob@example.com)" + if source =~ /\((.+@.+)\)/ + source = $1 + end + + Mail::Address.new(source).address + rescue + return source if source !~ /<\s*([^>]+)/ + $1.strip + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/attribute_examples.rb b/lib/sequencer/unit/import/exchange/attribute_examples.rb new file mode 100644 index 000000000..f4c59c45a --- /dev/null +++ b/lib/sequencer/unit/import/exchange/attribute_examples.rb @@ -0,0 +1,39 @@ +require 'sequencer/mixin/exchange/folder' + +class Sequencer + class Unit + module Import + module Exchange + class AttributeExamples < Sequencer::Unit::Base + include ::Sequencer::Mixin::Exchange::Folder + + uses :ews_folder_ids + provides :ews_attributes_examples + + def process + state.provide(:ews_attributes_examples) do + ::Import::Helper::AttributesExamples.new do |extractor| + + ews_folder_ids.collect do |folder_id| + + begin + ews_folder.find(folder_id).items.each do |resource| + attributes = ::Import::Exchange::ItemAttributes.extract(resource) + extractor.extract(attributes) + break if extractor.enough + end + rescue NoMethodError => e + raise if e.message !~ /Viewpoint::EWS::/ + + logger.error e + logger.error "Skipping folder_id '#{folder_id}' due to unsupported entries." + end + end + end.examples + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/attribute_mapper/attribute_examples.rb b/lib/sequencer/unit/import/exchange/attribute_mapper/attribute_examples.rb new file mode 100644 index 000000000..482a8cf3c --- /dev/null +++ b/lib/sequencer/unit/import/exchange/attribute_mapper/attribute_examples.rb @@ -0,0 +1,18 @@ +class Sequencer + class Unit + module Import + module Exchange + module AttributeMapper + class AttributeExamples < Sequencer::Unit::Common::AttributeMapper + + def self.map + { + ews_attributes_examples: :attributes, + } + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/attribute_mapper/available_folders.rb b/lib/sequencer/unit/import/exchange/attribute_mapper/available_folders.rb new file mode 100644 index 000000000..c1b91c187 --- /dev/null +++ b/lib/sequencer/unit/import/exchange/attribute_mapper/available_folders.rb @@ -0,0 +1,18 @@ +class Sequencer + class Unit + module Import + module Exchange + module AttributeMapper + class AvailableFolders < Sequencer::Unit::Common::AttributeMapper + + def self.map + { + ews_folder_id_path_map: :folders, + } + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contact/http_log.rb b/lib/sequencer/unit/import/exchange/folder_contact/http_log.rb new file mode 100644 index 000000000..eefb9c553 --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contact/http_log.rb @@ -0,0 +1,18 @@ +class Sequencer + class Unit + module Import + module Exchange + module FolderContact + class HttpLog < Import::Common::Model::HttpLog + + private + + def facility + 'EWS' + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contact/mapping.rb b/lib/sequencer/unit/import/exchange/folder_contact/mapping.rb new file mode 100644 index 000000000..076492699 --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contact/mapping.rb @@ -0,0 +1,27 @@ +class Sequencer + class Unit + module Import + module Exchange + module FolderContact + class Mapping < Sequencer::Unit::Import::Common::Mapping::FlatKeys + + uses :import_job + + private + + def mapping + from_import_job || ::Import::Exchange.config[:attributes] + end + + def from_import_job + return if !state.provided?(:import_job) + payload = import_job.payload + return if payload.blank? + payload[:ews_attributes] + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contact/remote_id.rb b/lib/sequencer/unit/import/exchange/folder_contact/remote_id.rb new file mode 100644 index 000000000..8cbfe713f --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contact/remote_id.rb @@ -0,0 +1,17 @@ +class Sequencer + class Unit + module Import + module Exchange + module FolderContact + class RemoteId < Sequencer::Unit::Import::Common::Model::Attributes::RemoteId + private + + def attribute + :item_id + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contact/static_attributes.rb b/lib/sequencer/unit/import/exchange/folder_contact/static_attributes.rb new file mode 100644 index 000000000..e7e22e026 --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contact/static_attributes.rb @@ -0,0 +1,19 @@ +class Sequencer + class Unit + module Import + module Exchange + module FolderContact + class StaticAttributes < Sequencer::Unit::Base + + provides :model_class, :external_sync_source + + def process + state.provide(:model_class, ::User) + state.provide(:external_sync_source, 'EWS::FolderContact') + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contact/statistics/diff.rb b/lib/sequencer/unit/import/exchange/folder_contact/statistics/diff.rb new file mode 100644 index 000000000..9ed81f27f --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contact/statistics/diff.rb @@ -0,0 +1,39 @@ +class Sequencer + class Unit + module Import + module Exchange + module FolderContact + module Statistics + class Diff < Sequencer::Unit::Base + include ::Sequencer::Unit::Import::Common::Model::Statistics::Mixin::Diff + + uses :ews_folder_name + + def process + state.provide(:statistics_diff) do + # remove :sum since it's already set via + # the exchange item attribte + result = diff.except(:sum) + + # build structure for a general diff + # and a folder specific sub structure + result.merge( + folders: { + ews_folder_name => result + } + ) + end + end + + private + + def actions + %i(created updated unchanged skipped failed) + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/dry_run_payload.rb b/lib/sequencer/unit/import/exchange/folder_contacts/dry_run_payload.rb new file mode 100644 index 000000000..5827d25d1 --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contacts/dry_run_payload.rb @@ -0,0 +1,14 @@ +class Sequencer + class Unit + module Import + module Exchange + module FolderContacts + class DryRunPayload < Sequencer::Unit::Import::Common::ImportJob::Payload::ToState + + provides :ews_config, :ews_folder_ids + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/folder_ids.rb b/lib/sequencer/unit/import/exchange/folder_contacts/folder_ids.rb new file mode 100644 index 000000000..5e29a9cee --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contacts/folder_ids.rb @@ -0,0 +1,27 @@ +require 'sequencer/mixin/exchange/folder' + +class Sequencer + class Unit + module Import + module Exchange + module FolderContacts + class FolderIds < Sequencer::Unit::Base + include ::Sequencer::Mixin::Exchange::Folder + + provides :ews_folder_ids + + def process + # check if ids are already processed + return if state.provided?(:ews_folder_ids) + + state.provide(:ews_folder_ids) do + config = ::Import::Exchange.config + config[:folders] + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/sub_sequence.rb b/lib/sequencer/unit/import/exchange/folder_contacts/sub_sequence.rb new file mode 100644 index 000000000..6ddd1c970 --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contacts/sub_sequence.rb @@ -0,0 +1,37 @@ +require 'sequencer/mixin/exchange/folder' +require 'sequencer/mixin/import_job/resource_loop' + +class Sequencer + class Unit + module Import + module Exchange + module FolderContacts + class SubSequence < Sequencer::Unit::Base + include ::Sequencer::Mixin::Exchange::Folder + include ::Sequencer::Mixin::ImportJob::ResourceLoop + + uses :ews_folder_ids, :import_job + + def process + + ews_folder_ids.each do |folder_id| + folder = ews_folder.find(folder_id) + display_path = ews_folder.display_path(folder) + + resource_sequence('Import::Exchange::FolderContact', folder.items) do |item| + + logger.debug("Extracting attributes from Exchange item: #{item.get_all_properties!.inspect}") + + { + resource: ::Import::Exchange::ItemAttributes.extract(item), + ews_folder_name: display_path, + } + end + end + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/import/exchange/folder_contacts/sum.rb b/lib/sequencer/unit/import/exchange/folder_contacts/sum.rb new file mode 100644 index 000000000..1f73d01d4 --- /dev/null +++ b/lib/sequencer/unit/import/exchange/folder_contacts/sum.rb @@ -0,0 +1,48 @@ +require 'sequencer/mixin/exchange/folder' + +class Sequencer + class Unit + module Import + module Exchange + module FolderContacts + class Sum < Sequencer::Unit::Base + include ::Sequencer::Mixin::Exchange::Folder + + uses :ews_folder_ids + provides :statistics_diff + + def process + state.provide(:statistics_diff, diff) + end + + private + + def diff + result = { + sum: 0, + } + folder_sum_map.each do |display_path, sum| + + result[display_path] = { + sum: sum + } + + result[:sum] += sum + end + result + end + + def folder_sum_map + ews_folder_ids.collect do |folder_id| + folder = ews_folder.find(folder_id) + display_path = ews_folder.display_path(folder) + + [display_path, folder.total_count] + end.to_h + end + end + end + end + end + end +end diff --git a/lib/sequencer/unit/mixin/dynamic_attribute.rb b/lib/sequencer/unit/mixin/dynamic_attribute.rb new file mode 100644 index 000000000..e37012d90 --- /dev/null +++ b/lib/sequencer/unit/mixin/dynamic_attribute.rb @@ -0,0 +1,35 @@ +# rubocop:disable Lint/NestedMethodDefinition +class Sequencer + class Unit + module Mixin + module DynamicAttribute + + def self.included(base) + + class << base + + def inherited(base) + base.extend(Forwardable) + base.instance_delegate [:attribute] => base + end + + def attribute + @attribute ||= begin + if uses.size != 1 + raise "DynamicAttribute classes can use exactly one attribute. Found #{uses.size}." + end + uses.first + end + end + end + end + + private + + def attribute_value + @attribute_value ||= state.use(attribute) + end + end + end + end +end diff --git a/lib/sequencer/units.rb b/lib/sequencer/units.rb new file mode 100644 index 000000000..e926ead87 --- /dev/null +++ b/lib/sequencer/units.rb @@ -0,0 +1,70 @@ +require 'mixin/instance_wrapper' + +class Sequencer + class Units + include ::Enumerable + include ::Mixin::InstanceWrapper + + wrap :@units + + # Initializes a new Sequencer::Units instance with the given Units Array. + # + # @param [*Array] *units a list of Units with or without the Sequencer::Unit prefix. + # + # @example + # Sequencer::Units.new('Example::Unit', 'Sequencer::Unit::Second::Unit') + def initialize(*units) + @units = units + end + + # Required #each implementation for ::Enumerable functionality. Constantizes + # the list of units given when initialized. + # + # @example + # units.each do |unit| + # unit.process(sequencer) + # end + # + # @return [nil] + def each + @units.each do |unit| + yield constantize(unit) + end + end + + # Provides an Array of :uses and :provides declarations for each Unit. + # + # @example + # units.declarations + # #=> [{uses: [:question], provides: [:answer], ...}] + # + # @return [Array Array}>] the declarations of the Units + def declarations + collect do |unit| + { + uses: unit.uses, + provides: unit.provides, + } + end + end + + # Enables the access to an Unit class via index. + # + # @param [Integer] index the index for the requested Unit class. + # + # @example + # units[1] + # #=> Sequencer::Unit::Example + # + # @return [Object] the Unit class for the requested index + def [](index) + constantize(@units[index]) + end + + private + + def constantize(unit) + Sequencer::Unit.constantize(unit) + end + end +end diff --git a/lib/sequencer/units/attribute.rb b/lib/sequencer/units/attribute.rb new file mode 100644 index 000000000..3e716bb2b --- /dev/null +++ b/lib/sequencer/units/attribute.rb @@ -0,0 +1,30 @@ +class Sequencer + class Units + class Attribute + + attr_accessor :from, :to + + # Checks if the attribute will be provided by one or more Units. + # + # @example + # attribute.will_be_provided? + # # => true + # + # @return [Boolean] + def will_be_provided? + !from.nil? + end + + # Checks if the attribute will be used by one or more Units. + # + # @example + # attribute.will_be_used? + # # => true + # + # @return [Boolean] + def will_be_used? + !to.nil? + end + end + end +end diff --git a/lib/sequencer/units/attributes.rb b/lib/sequencer/units/attributes.rb new file mode 100644 index 000000000..f9d47aa29 --- /dev/null +++ b/lib/sequencer/units/attributes.rb @@ -0,0 +1,87 @@ +require 'mixin/instance_wrapper' + +class Sequencer + class Units + class Attributes + include ::Mixin::InstanceWrapper + + wrap :@attributes + + # Initializes the lifespan store for the attributes + # of the given Units declarations. + # + # @param [Array Array<:Symbol>}>] declarations the list of Unit declarations. + # + # @example + # declarations = [{uses: [:attribute1, ...], provides: [:result], ...}] + # attributes = Sequencer::Units::Attributes(declarations) + # + # @return [nil] + def initialize(declarations) + @declarations = declarations + + initialize_attributes + initialize_lifespan + end + + # Lists all `provides` declarations of the Units the instance was initialized with. + # + # @example + # attributes.provided + # # => [:result, ...] + # + # @return [Array] the list of all `provides` declarations + def provided + select do |_attribute, instance| + instance.will_be_provided? + end.keys + end + + # Lists all `uses` declarations of the Units the instance was initialized with. + # + # @example + # attributes.used + # # => [:attribute1, ...] + # + # @return [Array] the list of all `uses` declarations + def used + select do |_attribute, instance| + instance.will_be_used? + end.keys + end + + # Checks if the given attribute is known in the list of Unit declarations. + # + # @example + # attributes.known?(:attribute2) + # # => false + # + # @return [Boolean] + def known?(attribute) + key?(attribute) + end + + private + + def initialize_attributes + @attributes = Hash.new do |hash, key| + hash[key] = Sequencer::Units::Attribute.new + end + end + + def initialize_lifespan + @declarations.each_with_index do |unit, index| + + unit[:uses].try(:each) do |attribute| + self[attribute].to = index + end + + unit[:provides].try(:each) do |attribute| + next if self[attribute].will_be_provided? + self[attribute].from = index + end + end + end + end + end +end diff --git a/lib/sessions.rb b/lib/sessions.rb index 1fd03a2b9..c6e3d4435 100644 --- a/lib/sessions.rb +++ b/lib/sessions.rb @@ -5,7 +5,7 @@ module Sessions # get application root directory @root = Dir.pwd.to_s - if !@root || @root.empty? || @root == '/' + if @root.blank? || @root == '/' @root = Rails.root end @@ -214,9 +214,10 @@ returns return false if !data path = "#{@path}/#{client_id}" data[:meta][:last_ping] = Time.now.utc.to_i - content = data.to_json File.open("#{path}/session", 'wb' ) { |file| - file.write content + file.flock(File::LOCK_EX) + file.write data.to_json + file.flock(File::LOCK_UN) } true end @@ -261,7 +262,7 @@ returns end begin File.open(session_file, 'rb') { |file| - file.flock(File::LOCK_EX) + file.flock(File::LOCK_SH) all = file.read file.flock(File::LOCK_UN) data_json = JSON.parse(all) @@ -378,8 +379,8 @@ broadcase also not to sender next if !session if recipient != 'public' - next if !session[:user] - next if !session[:user]['id'] + next if session[:user].blank? + next if session[:user]['id'].blank? end if sender_user_id @@ -432,14 +433,15 @@ returns end def self.queue_file_read(path, filename) - file_old = "#{path}#{filename}" - file_new = "#{path}a-#{filename}" - FileUtils.mv(file_old, file_new) + location = "#{path}#{filename}" message = '' - File.open(file_new, 'rb') { |file| + File.open(location, 'rb') { |file| + file.flock(File::LOCK_EX) message = file.read + file.flock(File::LOCK_UN) } - File.delete(file_new) + File.delete(location) + return if message.blank? begin return JSON.parse(message) rescue => e @@ -466,13 +468,15 @@ remove all session and spool messages msg = JSON.generate(data) path = "#{@path}/spool/" FileUtils.mkpath path + data = { + msg: msg, + timestamp: Time.now.utc.to_i, + } file_path = "#{path}/#{Time.now.utc.to_f}-#{rand(99_999)}" File.open(file_path, 'wb') { |file| - data = { - msg: msg, - timestamp: Time.now.utc.to_i, - } + file.flock(File::LOCK_EX) file.write data.to_json + file.flock(File::LOCK_UN) } end @@ -491,7 +495,9 @@ remove all session and spool messages filename = "#{path}/#{entry}" next if !File.exist?(filename) File.open(filename, 'rb') { |file| + file.flock(File::LOCK_SH) message = file.read + file.flock(File::LOCK_UN) begin spool = JSON.parse(message) message_parsed = JSON.parse(spool['msg']) 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/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/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/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}'" 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_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/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/lib/tweet_base.rb b/lib/tweet_base.rb index 3c1388bef..8e67c0ac1 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 @@ -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,8 +255,15 @@ class TweetBase ticket = existing_article.ticket else begin - parent_tweet = @client.status(tweet.in_reply_to_status_id) - ticket = to_group(parent_tweet, group_id, channel) + + # in case of streaming mode, get parent tweet via REST client + if @connection_type == 'stream' + client = TweetRest.new(@auth) + parent_tweet = client.status(tweet.in_reply_to_status_id) + else + parent_tweet = @client.status(tweet.in_reply_to_status_id) + end + ticket = to_group(parent_tweet, group_id, channel) rescue Twitter::Error::NotFound, Twitter::Error::Forbidden => e # just ignore if tweet has already gone Rails.logger.info "Can't import tweet (#{tweet.in_reply_to_status_id}), #{e.message}" @@ -343,11 +333,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 +349,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 @@ -377,8 +369,16 @@ 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| + if sub_level.class == NilClass + value[sub_key] = nil + next + end + 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 } @@ -386,4 +386,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/lib/user_agent.rb b/lib/user_agent.rb index 1f835840a..620fb04c4 100644 --- a/lib/user_agent.rb +++ b/lib/user_agent.rb @@ -264,7 +264,10 @@ returns def self.get_http(uri, options) proxy = options['proxy'] || Setting.get('proxy') - if proxy.present? + proxy_no = options['proxy_no'] || Setting.get('proxy_no') || '' + proxy_no = proxy_no.split(',').map(&:strip) || [] + proxy_no.push('localhost', '127.0.0.1', '::1') + if proxy.present? && !proxy_no.include?(uri.host.downcase) if proxy =~ /^(.+?):(.+?)$/ proxy_host = $1 proxy_port = $2 diff --git a/public/assets/chat/chat.coffee b/public/assets/chat/chat.coffee index 94a5b00ef..23b008a31 100644 --- a/public/assets/chat/chat.coffee +++ b/public/assets/chat/chat.coffee @@ -159,7 +159,7 @@ do($ = window.jQuery, window) -> buttonClass: 'open-zammad-chat' inactiveClass: 'is-inactive' title: 'Chat with us!' - scrollHint: 'Scrolle nach unten um neue Nachrichten zu sehen' + scrollHint: 'Scroll down to see new messages' idleTimeout: 6 idleTimeoutIntervallCheck: 0.5 inactiveTimeout: 8 @@ -184,7 +184,6 @@ do($ = window.jQuery, window) -> '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' @@ -197,11 +196,26 @@ 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' + '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' 'Online': 'En-ligne' - 'Online': 'En-ligne' 'Offline': 'Hors-ligne' 'Connecting': 'Connexion en cours' 'Connection re-established': 'Connexion rétablie' @@ -218,7 +232,6 @@ do($ = window.jQuery, window) -> 'Chat with us!': '发起即时对话!' 'Scroll down to see new messages': '向下滚动以查看新消息' 'Online': '在线' - 'Online': '在线' 'Offline': '离线' 'Connecting': '连接中' 'Connection re-established': '正在重新建立连接' @@ -235,7 +248,6 @@ do($ = window.jQuery, window) -> 'Chat with us!': '開始即時對话!' 'Scroll down to see new messages': '向下滑動以查看新訊息' 'Online': '線上' - 'Online': '線上' 'Offline': '离线' 'Connecting': '連線中' 'Connection re-established': '正在重新建立連線中' @@ -251,6 +263,11 @@ do($ = window.jQuery, window) -> sessionId: undefined scrolledToBottom: true scrollSnapTolerance: 10 + richTextFormatKey: + 66: true # b + 73: true # i + 85: true # u + 83: true # s T: (string, items...) => if @options.lang && @options.lang isnt 'en' @@ -367,9 +384,211 @@ do($ = window.jQuery, window) -> @el.find('.zammad-chat-controls').on 'submit', @onSubmit @el.find('.zammad-chat-body').on 'scroll', @detectScrolledtoBottom @el.find('.zammad-scroll-hint').click @onScrollHintClick - @input.on + @input.on( keydown: @checkForEnter input: @onInput + ) + @input.on('keydown', (e) => + richtTextControl = false + if !e.altKey && !e.ctrlKey && e.metaKey + richtTextControl = true + else if !e.altKey && e.ctrlKey && !e.metaKey + richtTextControl = true + + if richtTextControl && @richTextFormatKey[ e.keyCode ] + e.preventDefault() + if e.keyCode is 66 + document.execCommand('bold') + return true + if e.keyCode is 73 + document.execCommand('italic') + return true + if e.keyCode is 85 + document.execCommand('underline') + return true + if e.keyCode is 83 + document.execCommand('strikeThrough') + return true + ) + @input.on('paste', (e) => + 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 = (e) => + result = e.target.result + img = document.createElement('img') + img.src = result + + insert = (dataUrl, width, height, isRetina) => + + # adapt image if we are on retina devices + if @isRetina() + width = width / 2 + height = height / 2 + result = dataUrl + img = "" + document.execCommand('insertHTML', false, img) + + # resize if to big + @resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert) + + reader.readAsDataURL(imageFile) + imageInserted = true + + return if imageInserted + + # check existing + paste text for limit + text = undefined + docType = undefined + try + text = clipboardData.getData('text/html') + docType = 'html' + if !text || text.length is 0 + docType = 'text' + text = clipboardData.getData('text/plain') + if !text || text.length is 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') + + if docType is 'text' || docType is 'text2' || docType is 'text3' + text = '
' + text.replace(/\n/g, '
') + '
' + text = text.replace(/
<\/div>/g, '

') + console.log('p', docType, text) + if docType is '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 = @wordFilter(html) + #html + + html = $(html) + + html.contents().each( -> + if @nodeType == 8 + $(@).remove() + ) + + # remove tags, keep content + html.find('a, font, small, time, form, label').replaceWith( -> + $(@).contents() + ) + + # replace tags with generic div + # New type of the tag + replacementTag = 'div'; + + # Replace all x tags with the type of replacementTag + html.find('textarea').each( -> + outer = @outerHTML + + # Replace opening tag + regex = new RegExp('<' + @tagName, 'i') + newTag = outer.replace(regex, '<' + replacementTag) + + # Replace closing tag + regex = new RegExp(' + e.stopPropagation() + e.preventDefault() + + dataTransfer + if window.dataTransfer # ie + dataTransfer = window.dataTransfer + else if e.originalEvent.dataTransfer # other browsers + dataTransfer = e.originalEvent.dataTransfer + else + throw 'No clipboardData support' + + x = e.clientX + y = e.clientY + file = dataTransfer.files[0] + + # look for images + if file.type.match('image.*') + reader = new FileReader() + reader.onload = (e) => + result = e.target.result + img = document.createElement('img') + img.src = result + + # Insert the image at the carat + insert = (dataUrl, width, height, isRetina) => + + # adapt image if we are on retina devices + if @isRetina() + width = width / 2 + height = height / 2 + + result = dataUrl + img = $("") + img = img.get(0) + + if document.caretPositionFromPoint + pos = document.caretPositionFromPoint(x, y) + range = document.createRange() + range.setStart(pos.offsetNode, pos.offset) + range.collapse() + range.insertNode(img) + else if document.caretRangeFromPoint + range = document.caretRangeFromPoint(x, y) + range.insertNode(img) + else + console.log('could not find carat') + + # resize if to big + @resizeImage(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert) + reader.readAsDataURL(file) + ) + $(window).on('beforeunload', => @onLeaveTemporary() ) @@ -471,7 +690,7 @@ do($ = window.jQuery, window) -> from: if message.created_by_id then 'agent' else 'customer' if unfinishedMessage - @input.val unfinishedMessage + @input.html(unfinishedMessage) # show wait list if data.position @@ -489,7 +708,7 @@ do($ = window.jQuery, window) -> @el.find('.zammad-chat-message--unread') .removeClass 'zammad-chat-message--unread' - sessionStorage.setItem 'unfinished_message', @input.val() + sessionStorage.setItem 'unfinished_message', @input.html() @onTyping() @@ -520,7 +739,7 @@ do($ = window.jQuery, window) -> @sendMessage() sendMessage: -> - message = @input.val() + message = @input.html() return if !message @inactiveTimeout.start() @@ -536,14 +755,14 @@ do($ = window.jQuery, window) -> @maybeAddTimestamp() # add message before message typing loader - if @el.find('.zammad-chat-message--typing').size() + if @el.find('.zammad-chat-message--typing').get(0) @lastAddedType = 'typing-placeholder' @el.find('.zammad-chat-message--typing').before messageElement else @lastAddedType = 'message--customer' @el.find('.zammad-chat-body').append messageElement - @input.val('') + @input.html('') @scrollToBottom() # send message event @@ -585,11 +804,6 @@ do($ = window.jQuery, window) -> @el.addClass('zammad-chat-is-open') - if !@inputInitialized - @inputInitialized = true - @input.autoGrow - extraLine: false - remainerHeight = @el.height() - @el.find('.zammad-chat-header').outerHeight() @el.css 'bottom', -remainerHeight @@ -725,7 +939,7 @@ do($ = window.jQuery, window) -> @stopTypingId = setTimeout(@onAgentTypingEnd, 3000) # never display two typing indicators - return if @el.find('.zammad-chat-message--typing').size() + return if @el.find('.zammad-chat-message--typing').get(0) @maybeAddTimestamp() @@ -1032,4 +1246,204 @@ do($ = window.jQuery, window) -> else if direction is 'horizontal' return !!clientSize && ((compareRight <= viewRight) && (compareLeft >= viewLeft)) + isRetina: -> + 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)) + false + + resizeImage: (dataURL, x = 'auto', y = 'auto', sizeFactor = 1, type, quallity, callback, force = true) -> + + # load image from data url + imageObject = new Image() + imageObject.onload = -> + imageWidth = imageObject.width + imageHeight = imageObject.height + console.log('ImageService', 'current size', imageWidth, imageHeight) + if y is 'auto' && x is 'auto' + x = imageWidth + y = imageHeight + + # get auto dimensions + if y is 'auto' + factor = imageWidth / x + y = imageHeight / factor + + if x is 'auto' + factor = imageWidth / y + x = imageHeight / factor + + # check if resize is needed + resize = false + if x < imageWidth || y < imageHeight + resize = true + x = x * sizeFactor + y = y * sizeFactor + else + x = imageWidth + y = imageHeight + + # create canvas and set dimensions + canvas = document.createElement('canvas') + canvas.width = x + canvas.height = y + + # draw image on canvas and set image dimensions + context = canvas.getContext('2d') + context.drawImage(imageObject, 0, 0, x, y) + + # set quallity based on image size + 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 + + # execute callback with resized image + 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') + callback(newDataUrl, x, y, false) + + # load image from data url + imageObject.src = dataURL + + # taken from https://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div/6691294#6691294 + pasteHtmlAtCaret: (html) -> + sel = undefined + range = undefined + 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() + sel.addRange(range) + else if document.selection && document.selection.type != 'Control' + document.selection.createRange().pasteHTML(html) + + # (C) sbrin - https://github.com/sbrin + # https://gist.github.com/sbrin/6801034 + wordFilter: (editor) -> + content = editor.html() + + # Word comments like conditional comments etc + content = content.replace(//gi, '') + + # Remove comments, scripts (e.g., msoShowComment), XML tag, VML content, + # MS Office namespaced tags, and a few other tags + content = content.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi, '') + + # Convert into for line-though + content = content.replace(/<(\/?)s>/gi, '<$1strike>') + + # Replace nbsp entites to char since it's easier to handle + # content = content.replace(/ /gi, "\u00a0") + content = content.replace(/ /gi, ' ') + + # Convert ___ to string of alternating + # breaking/non-breaking spaces of same length + #content = content.replace(/([\s\u00a0]*)<\/span>/gi, (str, spaces) -> + # return (spaces.length > 0) ? spaces.replace(/./, " ").slice(Math.floor(spaces.length/2)).split("").join("\u00a0") : '' + #) + + editor.html(content) + + # Parse out list indent level for lists + $('p', editor).each( -> + str = $(@).attr('style') + matches = /mso-list:\w+ \w+([0-9]+)/.exec(str) + if matches + $(@).data('_listLevel', parseInt(matches[1], 10)) + ) + + # Parse Lists + last_level = 0 + pnt = null + $('p', editor).each(-> + cur_level = $(@).data('_listLevel') + if cur_level != undefined + txt = $(@).text() + list_tag = '
    ' + if (/^\s*\w+\./.test(txt)) + matches = /([0-9])\./.exec(txt) + if matches + start = parseInt(matches[1], 10) + list_tag = start>1 ? '
      ' : '
        ' + else + list_tag = '
          ' + + if cur_level > last_level + if last_level == 0 + $(@).before(list_tag) + pnt = $(@).prev() + else + pnt = $(list_tag).appendTo(pnt) + + if cur_level < last_level + for i in [i..last_level-cur_level] + pnt = pnt.parent() + + $('span:first', @).remove() + pnt.append('
        1. ' + $(@).html() + '
        2. ') + $(@).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.css b/public/assets/chat/chat.css index 451ca8b09..255fa5b7a 100644 --- a/public/assets/chat/chat.css +++ b/public/assets/chat/chat.css @@ -320,9 +320,9 @@ .zammad-chat-controls { overflow: hidden; display: none; - -webkit-align-items: flex-start; - -ms-flex-align: start; - align-items: flex-start; + -webkit-align-items: flex-end; + -ms-flex-align: end; + align-items: flex-end; border-top: 1px solid #ededed; padding: 0; margin: 0; @@ -340,25 +340,23 @@ margin: 0; padding: 1em 2em; float: left; - width: auto; - height: auto; max-height: 6em; - min-height: 1.4em !important; + min-height: 1.4em; font-family: inherit; line-height: 1.4em; font-size: inherit; -webkit-appearance: none; -moz-appearance: none; appearance: none; - border: none !important; + border: none; background: none; - box-shadow: none !important; + box-shadow: none; box-sizing: content-box; outline: none; - resize: none; -webkit-flex: 1; -ms-flex: 1; - flex: 1; } + flex: 1; + overflow: auto; } .zammad-chat-input::-webkit-input-placeholder { color: #d9d9d9; } @@ -373,7 +371,7 @@ background: #379ad7; color: white; padding: 0.5em 1.2em; - margin: 0.5em 1em 0.5em; + margin: 0.63em 1em; cursor: pointer; border: none; border-radius: 1.5em; diff --git a/public/assets/chat/chat.js b/public/assets/chat/chat.js index 480f5a78d..6183a6f63 100644 --- a/public/assets/chat/chat.js +++ b/public/assets/chat/chat.js @@ -121,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); @@ -130,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); @@ -234,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(); } @@ -333,7 +333,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); buttonClass: 'open-zammad-chat', inactiveClass: 'is-inactive', title: 'Chat with us!', - scrollHint: 'Scrolle nach unten um neue Nachrichten zu sehen', + scrollHint: 'Scroll down to see new messages', idleTimeout: 6, idleTimeoutIntervallCheck: 0.5, inactiveTimeout: 8, @@ -371,7 +371,6 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); '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', @@ -385,11 +384,27 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); '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', + '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', 'Online': 'En-ligne', - 'Online': 'En-ligne', 'Offline': 'Hors-ligne', 'Connecting': 'Connexion en cours', 'Connection re-established': 'Connexion rétablie', @@ -407,7 +422,6 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); 'Chat with us!': '发起即时对话!', 'Scroll down to see new messages': '向下滚动以查看新消息', 'Online': '在线', - 'Online': '在线', 'Offline': '离线', 'Connecting': '连接中', 'Connection re-established': '正在重新建立连接', @@ -425,7 +439,6 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); 'Chat with us!': '開始即時對话!', 'Scroll down to see new messages': '向下滑動以查看新訊息', 'Online': '線上', - 'Online': '線上', 'Offline': '离线', 'Connecting': '連線中', 'Connection re-established': '正在重新建立連線中', @@ -447,8 +460,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]) { @@ -462,8 +482,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); } } @@ -486,6 +506,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); @@ -613,6 +634,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(); @@ -656,9 +874,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': @@ -744,15 +962,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, @@ -760,7 +978,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }); } if (unfinishedMessage) { - this.input.val(unfinishedMessage); + this.input.html(unfinishedMessage); } } if (data.position) { @@ -776,7 +994,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); ZammadChat.prototype.onInput = function() { this.el.find('.zammad-chat-message--unread').removeClass('zammad-chat-message--unread'); - sessionStorage.setItem('unfinished_message', this.input.val()); + sessionStorage.setItem('unfinished_message', this.input.html()); return this.onTyping(); }; @@ -810,7 +1028,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); ZammadChat.prototype.sendMessage = function() { var message, messageElement; - message = this.input.val(); + message = this.input.html(); if (!message) { return; } @@ -823,14 +1041,14 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); unreadClass: '' }); this.maybeAddTimestamp(); - if (this.el.find('.zammad-chat-message--typing').size()) { + if (this.el.find('.zammad-chat-message--typing').get(0)) { this.lastAddedType = 'typing-placeholder'; this.el.find('.zammad-chat-message--typing').before(messageElement); } else { this.lastAddedType = 'message--customer'; this.el.find('.zammad-chat-body').append(messageElement); } - this.input.val(''); + this.input.html(''); this.scrollToBottom(); return this.send('chat_session_message', { content: message, @@ -871,12 +1089,6 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); this.showLoader(); } this.el.addClass('zammad-chat-is-open'); - if (!this.inputInitialized) { - this.inputInitialized = true; - this.input.autoGrow({ - extraLine: false - }); - } remainerHeight = this.el.height() - this.el.find('.zammad-chat-header').outerHeight(); this.el.css('bottom', -remainerHeight); if (!this.sessionId) { @@ -1022,7 +1234,7 @@ var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); clearTimeout(this.stopTypingId); } this.stopTypingId = setTimeout(this.onAgentTypingEnd, 3000); - if (this.el.find('.zammad-chat-message--typing').size()) { + if (this.el.find('.zammad-chat-message--typing').get(0)) { return; } this.maybeAddTimestamp(); @@ -1389,104 +1601,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); -/*! - * ---------------------------------------------------------------------------- - * "THE BEER-WARE LICENSE" (Revision 42): - * wrote this file. As long as you retain this notice you - * can do whatever you want with this stuff. If we meet some day, and you think - * this stuff is worth it, you can buy me a beer in return. Jevin O. Sewaruth - * ---------------------------------------------------------------------------- - * - * Autogrow Textarea Plugin Version v3.0 - * http://www.technoreply.com/autogrow-textarea-plugin-3-0 - * - * THIS PLUGIN IS DELIVERD ON A PAY WHAT YOU WHANT BASIS. IF THE PLUGIN WAS USEFUL TO YOU, PLEASE CONSIDER BUYING THE PLUGIN HERE : - * https://sites.fastspring.com/technoreply/instant/autogrowtextareaplugin - * - * Date: October 15, 2012 - * - * Zammad modification: - * - remove overflow:hidden when maximum height is reached - * - mirror box-sizing - * - */ - -jQuery.fn.autoGrow = function(options) { - return this.each(function() { - var settings = jQuery.extend({ - extraLine: true, - }, options); - - var createMirror = function(textarea) { - jQuery(textarea).after('
                  '); - return jQuery(textarea).next('.autogrow-textarea-mirror')[0]; - } - - var sendContentToMirror = function (textarea) { - mirror.innerHTML = String(textarea.value) - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(//g, '>') - .replace(/ /g, ' ') - .replace(/\n/g, '
                  ') + - (settings.extraLine? '.
                  .' : '') - ; - - if (jQuery(textarea).height() != jQuery(mirror).height()) { - jQuery(textarea).height(jQuery(mirror).height()); - - var overflow = jQuery(mirror).height() > maxHeight ? '' : 'hidden'; - jQuery(textarea).css('overflow', overflow); - } - } - - var growTextarea = function () { - sendContentToMirror(this); - } - - // Create a mirror - var mirror = createMirror(this); - - // Store max-height - var maxHeight = parseInt(jQuery(this).css('max-height'), 10); - - // Style the mirror - mirror.style.display = 'none'; - mirror.style.wordWrap = 'break-word'; - mirror.style.whiteSpace = 'normal'; - mirror.style.padding = jQuery(this).css('paddingTop') + ' ' + - jQuery(this).css('paddingRight') + ' ' + - jQuery(this).css('paddingBottom') + ' ' + - jQuery(this).css('paddingLeft'); - - mirror.style.width = jQuery(this).css('width'); - mirror.style.fontFamily = jQuery(this).css('font-family'); - mirror.style.fontSize = jQuery(this).css('font-size'); - mirror.style.lineHeight = jQuery(this).css('line-height'); - mirror.style.letterSpacing = jQuery(this).css('letter-spacing'); - mirror.style.boxSizing = jQuery(this).css('boxSizing'); - - // Style the textarea - this.style.overflow = "hidden"; - this.style.minHeight = this.rows+"em"; - - // Bind the textarea's event - this.onkeyup = growTextarea; - this.onfocus = growTextarea; - - // Fire the event for text already present - sendContentToMirror(this); - - }); -}; if (!window.zammadChatTemplates) { window.zammadChatTemplates = {}; } @@ -1555,11 +1886,11 @@ window.zammadChatTemplates["chat"] = function (__obj) { __out.push(this.T(this.scrollHint)); - __out.push('\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=[],s=function(t){return t&&t.ecoSafe?t:void 0!==t&&null!=t?i(t):""},n=t.safe,i=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==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(){this.agent.avatar&&(e.push('\n\n')),e.push('\n\n '),e.push(s(this.agent.name)),e.push("\n")}).call(this)}.call(t),t.safe=n,t.escape=i,e.join("")};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,c;c=document.getElementsByTagName("script"),r=c[c.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;if(t=1<=arguments.length?slice.call(arguments,0):[],this.options.debug)return this.log("debug",t)},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="",i=0,o=s.length;i"+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,s),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;if(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.)"),!(s<0))return 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(){if(this.intervallId)return this.log.debug("Stop timeout of "+this.options.timeout+" minutes"),clearInterval(this.intervallId)},e}(),n=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,s),n.prototype.logPrefix="io",n.prototype.set=function(t){var e,s,n;s=[];for(e in t)n=t[e],s.push(this.options[e]=n);return s},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 s,n,i;for(i=JSON.parse(e.data),t.log.debug("onMessage",e.data),s=0,n=i.length;sChat with us!",scrollHint:"Scroll down to see new messages",idleTimeout:6,idleTimeoutIntervallCheck:.5,inactiveTimeout:8,inactiveTimeoutIntervallCheck:.5,waitingListTimeout:4,waitingListTimeoutIntervallCheck:.5},a.prototype.logPrefix="chat",a.prototype._messageCount=0,a.prototype.isOpen=!1,a.prototype.blinkOnlineInterval=null,a.prototype.stopBlinOnlineStateTimeout=null,a.prototype.showTimeEveryXMinutes=2,a.prototype.lastTimestamp=null,a.prototype.lastAddedType=null,a.prototype.inputTimeout=null,a.prototype.isTyping=!1,a.prototype.state="offline",a.prototype.initialQueueDelay=1e4,a.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",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!"},es:{"Chat with us!":"Chatee con nosotros!","Scroll down to see new messages":"Haga scroll hacia abajo para ver nuevos mensajes",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",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:"在线",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:"線上",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!":"非常抱歉, 當前需要等候更長的時間方可排入對話程序, 請稍後重試或向我們寄送電子郵件. 謝謝!"}},a.prototype.sessionId=void 0,a.prototype.scrolledToBottom=!0,a.prototype.scrollSnapTolerance=10,a.prototype.richTextFormatKey={66:!0,73:!0,85:!0,83:!0},a.prototype.T=function(){var t,e,s,n,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])[i]||this.log.notice("Translation needed for '"+i+"'"),i=o[i]||i):this.log.notice("Translation '"+this.options.lang+"' needed!")),e)for(s=0,n=e.length;ss?e:document.body)},a.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})},a.prototype.renderBase=function(){if(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 s;if(s=!1,e.altKey||e.ctrlKey||!e.metaKey?e.altKey||!e.ctrlKey||e.metaKey||(s=!0):s=!0,s&&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(s){return function(n){var i,o,a,r,l,h,c,d,u,p,m,g;if(n.stopPropagation(),n.preventDefault(),n.clipboardData)i=n.clipboardData;else if(e.clipboardData)i=e.clipboardData;else{if(!n.originalEvent.clipboardData)throw"No clipboardData support";i=n.originalEvent.clipboardData}if(h=!1,i&&i.items&&i.items[0]&&("file"!==(c=i.items[0]).kind||"image/png"!==c.type&&"image/jpeg"!==c.type||(l=c.getAsFile(),(u=new FileReader).onload=function(t){var e,n,i;return i=t.target.result,e=document.createElement("img"),e.src=i,n=function(t,n,o,a){return s.isRetina()&&(n/=2,2),i=t,e='',document.execCommand("insertHTML",!1,e)},s.resizeImage(e.src,460,"auto",2,"image/jpeg","auto",n)},u.readAsDataURL(l),h=!0)),!h){g=void 0,o=void 0;try{g=i.getData("text/html"),o="html",g&&0!==g.length||(o="text",g=i.getData("text/plain")),g&&0!==g.length||(o="text2",g=i.getData("text"))}catch(t){n=t,console.log("Sorry, can't insert markup because browser is not supporting it."),o="text3",g=i.getData("text")}return"text"!==o&&"text2"!==o&&"text3"!==o||(g="
                  "+g.replace(/\n/g,"
                  ")+"
                  ",g=g.replace(/
                  <\/div>/g,"

                  ")),console.log("p",o,g),"html"===o&&(a=t("
                  "+g+"
                  "),d=!1,r=g,p=new RegExp("<(/w|w):[A-Za-z]"),r.match(p)&&(d=!0,r=r.replace(p,"")),p=new RegExp("<(/o|o):[A-Za-z]"),r.match(p)&&(d=!0,r=r.replace(p,"")),d&&(a=s.wordFilter(a)),(a=t(a)).contents().each(function(){if(8===this.nodeType)return t(this).remove()}),a.find("a, font, small, time, form, label").replaceWith(function(){return t(this).contents()}),m="div",a.find("textarea").each(function(){var e,s;return s=this.outerHTML,p=new RegExp("<"+this.tagName,"i"),e=s.replace(p,"<"+m),p=new RegExp("'),n=n.get(0),document.caretPositionFromPoint?(c=document.caretPositionFromPoint(r,l),(d=document.createRange()).setStart(c.offsetNode,c.offset),d.collapse(),d.insertNode(n)):document.caretRangeFromPoint?(d=document.caretRangeFromPoint(r,l)).insertNode(n):console.log("could not find carat")},s.resizeImage(n.src,460,"auto",2,"image/jpeg","auto",i)},a.readAsDataURL(o)}}(this)),t(e).on("beforeunload",function(t){return function(){return t.onLeaveTemporary()}}(this)),t(e).bind("hashchange",function(t){return function(){if(!t.isOpen)return t.idleTimeout.start();t.sessionId&&t.send("chat_session_notice",{session_id:t.sessionId,message:e.location.href})}}(this)),this.isFullscreen)return this.input.on({focus:this.onFocus,focusout:this.onFocusOut})},a.prototype.checkForEnter=function(t){if(!t.shiftKey&&13===t.keyCode)return t.preventDefault(),this.sendMessage()},a.prototype.send=function(t,e){return null==e&&(e={}),e.chat_id=this.options.chatId,this.io.send(t,e)},a.prototype.onWebSocketMessage=function(t){var e,s,n;for(e=0,s=t.length;e0,t(e).scrollTop(0),s)return this.log.notice("virtual keyboard shown")},a.prototype.onFocusOut=function(){},a.prototype.onTyping=function(){if(!(this.isTyping&&this.isTyping>new Date((new Date).getTime()-1500)))return this.isTyping=new Date,this.send("chat_session_typing",{session_id:this.sessionId}),this.inactiveTimeout.start()},a.prototype.onSubmit=function(t){return t.preventDefault(),this.sendMessage()},a.prototype.sendMessage=function(){var t,e;if(t=this.input.html())return 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})},a.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})},a.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))},a.prototype.open=function(){var t;{if(!this.isOpen)return 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}));this.log.debug("widget already open, block")}},a.prototype.onOpenAnimationEnd=function(){if(this.idleTimeout.stop(),this.isFullscreen)return this.disableScrollOnRoot()},a.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)},a.prototype.toggle=function(t){return this.isOpen?this.close(t):this.open(t)},a.prototype.close=function(t){var e;if(this.isOpen){if(this.initDelayId&&clearTimeout(this.initDelayId),this.sessionId)return 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);this.log.debug("can't close widget without sessionId")}else this.log.debug("can't close widget, it's not open")},a.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()},a.prototype.onWebSocketClose=function(){if(!this.isOpen)return this.el?(this.el.removeClass("zammad-chat-is-shown"),this.el.removeClass("zammad-chat-is-loaded")):void 0},a.prototype.show=function(){if("offline"!==this.state)return this.el.addClass("zammad-chat-is-loaded"),this.el.addClass("zammad-chat-is-shown")},a.prototype.disableInput=function(){return this.input.prop("disabled",!0),this.el.find(".zammad-chat-send").prop("disabled",!0)},a.prototype.enableInput=function(){return this.input.prop("disabled",!1),this.el.find(".zammad-chat-send").prop("disabled",!1)},a.prototype.hideModal=function(){return this.el.find(".zammad-chat-modal").html("")},a.prototype.onQueueScreen=function(t){var e;if(this.setSessionId(t.session_id),e=function(e){return function(){return e.onQueue(t),e.waitingListTimeout.start()}}(this),!this.initialQueueDelay||this.onInitialQueueDelayId)return this.onInitialQueueDelayId&&clearTimeout(this.onInitialQueueDelayId),e();this.onInitialQueueDelayId=setTimeout(e,this.initialQueueDelay)},a.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}))},a.prototype.onAgentTypingStart=function(){if(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)))return this.scrollToBottom()},a.prototype.onAgentTypingEnd=function(){return this.el.find(".zammad-chat-message--typing").remove()},a.prototype.onLeaveTemporary=function(){if(this.sessionId)return this.send("chat_session_leave_temporary",{session_id:this.sessionId})},a.prototype.maybeAddTimestamp=function(){var t,e,s;if(s=Date.now(),!this.lastTimestamp||s-this.lastTimestamp>6e4*this.showTimeEveryXMinutes)return 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())},a.prototype.updateLastTimestamp=function(t,e){if(this.el)return this.el.find(".zammad-chat-body").find(".zammad-chat-timestamp").last().replaceWith(this.view("timestamp")({label:t,time:e}))},a.prototype.addStatus=function(t){if(this.el)return this.maybeAddTimestamp(),this.el.find(".zammad-chat-body").append(this.view("status")({status:t})),this.scrollToBottom()},a.prototype.detectScrolledtoBottom=function(){var t;if(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)return this.el.find(".zammad-scroll-hint").addClass("is-hidden")},a.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())},a.prototype.onScrollHintClick=function(){return this.el.find(".zammad-chat-body").animate({scrollTop:this.el.find(".zammad-chat-body").prop("scrollHeight")},300)},a.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},a.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()},a.prototype.reconnect=function(){return this.log.notice("reconnecting"),this.disableInput(),this.lastAddedType="status",this.setAgentOnlineState("connecting"),this.addStatus(this.T("Connection lost"))},a.prototype.onConnectionReestablished=function(){return this.lastAddedType="status",this.setAgentOnlineState("online"),this.addStatus(this.T("Connection re-established"))},a.prototype.onSessionClosed=function(t){return this.addStatus(this.T("Chat closed by %s",t.realname)),this.disableInput(),this.setAgentOnlineState("offline"),this.inactiveTimeout.stop()},a.prototype.setSessionId=function(t){return this.sessionId=t,void 0===t?sessionStorage.removeItem("sessionId"):sessionStorage.setItem("sessionId",t)},a.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()},a.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()},a.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()},a.prototype.showLoader=function(){return this.el.find(".zammad-chat-modal").html(this.view("loader")())},a.prototype.setAgentOnlineState=function(t){var e;if(this.state=t,this.el)return e=t.charAt(0).toUpperCase()+t.slice(1),this.el.find(".zammad-chat-agent-status").attr("data-status",t).text(this.T(e))},a.prototype.detectHost=function(){var t;return t="ws://","https"===h&&(t="wss://"),this.options.host=""+t+l+"/ws"},a.prototype.loadCss=function(){var t,e,s;if(this.options.cssAutoload)return(s=this.options.cssUrl)||(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)},a.prototype.onCssLoaded=function(){return this.socketReady?this.onReady():this.cssLoaded=!0},a.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)})},a.prototype.disableScrollOnRoot=function(){return this.rootScrollOffset=this.scrollRoot.scrollTop(),this.scrollRoot.css({overflow:"hidden",position:"fixed"})},a.prototype.enableScrollOnRoot=function(){return this.scrollRoot.scrollTop(this.rootScrollOffset),this.scrollRoot.css({overflow:"",position:""})},a.prototype.isVisible=function(s,n,i,o){var a,r,l,h,c,d,u,p,m,g,f,v,y,b,w,T,C,z,S,k,I,A,x,_,E,O;if(!(s.length<1))if(r=t(e),a=s.length>1?s.eq(0):s,z=a.get(0),O=r.width(),E=r.height(),o=o||"both",p=!0!==i||z.offsetWidth*z.offsetHeight,"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=n?S||u:S&&u,y=n?b||T:b&&T,"both"===o)return p&&k&&y;if("vertical"===o)return p&&k;if("horizontal"===o)return p&&y}else{if(_=r.scrollTop(),I=_+E,A=r.scrollLeft(),x=A+O,w=a.offset(),d=w.top,l=d+a.height(),h=w.left,c=h+a.width(),v=!0===n?l:d,m=!0===n?d:l,g=!0===n?c:h,f=!0===n?h:c,"both"===o)return!!p&&m<=I&&v>=_&&f<=x&&g>=A;if("vertical"===o)return!!p&&m<=I&&v>=_;if("horizontal"===o)return!!p&&f<=x&&g>=A}},a.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.matches||e.devicePixelRatio>1)},a.prototype.resizeImage=function(t,e,s,n,i,o,a,r){var l;return null==e&&(e="auto"),null==s&&(s="auto"),null==n&&(n=1),null==r&&(r=!0),l=new Image,l.onload=function(){var t,r,h,c,d;return h=l.width,r=l.height,console.log("ImageService","current size",h,r),"auto"===s&&"auto"===e&&(e=h,s=r),"auto"===s&&(s=r/(h/e)),"auto"===e&&(e=r/(h/s)),d=!1,e/gi,""),s=s.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi,""),s=s.replace(/<(\/?)s>/gi,"<$1strike>"),s=s.replace(/ /gi," "),e.html(s),t("p",e).each(function(){var e,s;if(s=t(this).attr("style"),e=/mso-list:\w+ \w+([0-9]+)/.exec(s))return t(this).data("_listLevel",parseInt(e[1],10))}),n=0,i=null,t("p",e).each(function(){var e,s,o,a,r,l,h,c,d,u;if(void 0!==(e=t(this).data("_listLevel"))){if(u=t(this).text(),a="
                    ",/^\s*\w+\./.test(u)&&(a=(r=/([0-9])\./.exec(u))?null!=(l=(d=parseInt(r[1],10))>1)?l:'
                      ':"
                        "}:"
                          "),e>n&&(0===n?(t(this).before(a),i=t(this).prev()):i=t(a).appendTo(i)),e=c;s=h<=c?++o:--o)i=i.parent();return t("span:first",this).remove(),i.append("
                        1. "+t(this).html()+"
                        2. "),t(this).remove(),n=e}return n=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},a.prototype.removeAttribute=function(e){var s,n,i,o,a;if(e){for(s=t(e),i=0,o=(a=e.attributes).length;i/g,">").replace(/"/g,""")}),function(){(function(){e.push('
                          \n
                          \n
                          \n \n \n \n \n \n
                          \n
                          \n
                          \n
                          \n \n '),e.push(this.T(this.title)),e.push('\n
                          \n
                          \n
                          \n \n
                          \n
                          \n
                          \n \n
                          \n
                          ")}).call(this)}.call(t),t.safe=n,t.escape=i,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.customer_timeout=function(t){t||(t={});var e=[],s=function(t){return t&&t.ecoSafe?t:void 0!==t&&null!=t?i(t):""},n=t.safe,i=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==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(){e.push('
                          \n '),this.agent?(e.push("\n "),e.push(this.T("Since you didn't respond in the last %s minutes your conversation with %s got closed.",this.delay,this.agent)),e.push("\n ")):(e.push("\n "),e.push(this.T("Since you didn't respond in the last %s minutes your conversation got closed.",this.delay)),e.push("\n ")),e.push('\n
                          \n
                          "),e.push(this.T("Start new conversation")),e.push("
                          \n
                          ")}).call(this)}.call(t),t.safe=n,t.escape=i,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.loader=function(t){t||(t={});var e=[],s=t.safe,n=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==t&&null!=t||(t="");var e=new String(t);return e.ecoSafe=!0,e},n||(n=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){e.push('\n \n \n \n\n'),e.push(this.T("Connecting")),e.push("")}).call(this)}.call(t),t.safe=s,t.escape=n,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.message=function(t){t||(t={});var e=[],s=function(t){return t&&t.ecoSafe?t:void 0!==t&&null!=t?i(t):""},n=t.safe,i=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==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(){e.push('
                          \n "),e.push(this.message),e.push("\n
                          ")}).call(this)}.call(t),t.safe=n,t.escape=i,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.status=function(t){t||(t={});var e=[],s=t.safe,n=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==t&&null!=t||(t="");var e=new String(t);return e.ecoSafe=!0,e},n||(n=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){e.push('
                          \n
                          \n '),e.push(this.status),e.push("\n
                          \n
                          ")}).call(this)}.call(t),t.safe=s,t.escape=n,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.timestamp=function(t){t||(t={});var e=[],s=function(t){return t&&t.ecoSafe?t:void 0!==t&&null!=t?i(t):""},n=t.safe,i=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==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(){e.push('
                          '),e.push(s(this.label)),e.push(" "),e.push(s(this.time)),e.push("
                          ")}).call(this)}.call(t),t.safe=n,t.escape=i,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.typingIndicator=function(t){t||(t={});var e=[],s=t.safe,n=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==t&&null!=t||(t="");var e=new String(t);return e.ecoSafe=!0,e},n||(n=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){e.push('
                          \n \n \n \n \n \n \n \n
                          ')}).call(this)}.call(t),t.safe=s,t.escape=n,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting=function(t){t||(t={});var e=[],s=t.safe,n=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==t&&null!=t||(t="");var e=new String(t);return e.ecoSafe=!0,e},n||(n=t.escape=function(t){return(""+t).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}),function(){(function(){e.push('
                          \n \n \n \n \n \n '),e.push(this.T("All colleagues are busy.")),e.push("
                          \n "),e.push(this.T("You are on waiting list position %s.",this.position)),e.push("\n
                          ")}).call(this)}.call(t),t.safe=s,t.escape=n,e.join("")},window.zammadChatTemplates||(window.zammadChatTemplates={}),window.zammadChatTemplates.waiting_list_timeout=function(t){t||(t={});var e=[],s=function(t){return t&&t.ecoSafe?t:void 0!==t&&null!=t?i(t):""},n=t.safe,i=t.escape;return t.safe=function(t){if(t&&t.ecoSafe)return t;void 0!==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(){e.push('
                          \n '),e.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!")),e.push('\n
                          \n
                          "),e.push(this.T("Start new conversation")),e.push("
                          \n
                          ")}).call(this)}.call(t),t.safe=n,t.escape=i,e.join("")}; \ No newline at end of file diff --git a/public/assets/chat/chat.scss b/public/assets/chat/chat.scss index 1cec7cef1..64f0c4cca 100644 --- a/public/assets/chat/chat.scss +++ b/public/assets/chat/chat.scss @@ -29,7 +29,7 @@ } .zammad-chat.zammad-chat-is-open { height: 30em; - + @media only screen and (max-width: 768px) { height: 100%; } @@ -208,12 +208,12 @@ padding: 7px 10px 6px; color: hsl(0,0%,60%); cursor: pointer; - + &.is-hidden { display: none; } } - + .zammad-scroll-hint-icon { fill: hsl(210,5%,78%); margin-right: 8px; @@ -329,7 +329,7 @@ .zammad-chat-controls { overflow: hidden; display: none; - align-items: flex-start; + align-items: flex-end; border-top: 1px solid hsl(0,0%,93%); padding: 0; margin: 0; @@ -349,21 +349,19 @@ margin: 0; padding: 1em 2em; float: left; - width: auto; - height: auto; max-height: 6em; - min-height: 1.4em !important; + min-height: 1.4em; font-family: inherit; line-height: 1.4em; font-size: inherit; appearance: none; - border: none !important; + border: none; background: none; - box-shadow: none !important; + box-shadow: none ; box-sizing: content-box; outline: none; - resize: none; flex: 1; + overflow: auto; } .zammad-chat-input::-webkit-input-placeholder { @@ -378,7 +376,7 @@ background: hsl(203,67%,53%); color: white; padding: 0.5em 1.2em; - margin: 0.5em 1em 0.5em; + margin: 0.63em 1em; cursor: pointer; border: none; border-radius: 1.5em; diff --git a/public/assets/chat/gulpfile.js b/public/assets/chat/gulpfile.js index 60372ab40..c2be20ca3 100644 --- a/public/assets/chat/gulpfile.js +++ b/public/assets/chat/gulpfile.js @@ -9,7 +9,7 @@ var rename = require('gulp-rename'); var uglify = require('gulp-uglify'); var merge = require('merge-stream'); var plumber = require('gulp-plumber'); - + gulp.task('css', function(){ return gulp.src('chat.scss') .pipe(sass.sync().on('error', gutil.log)) @@ -29,9 +29,7 @@ gulp.task('js', function(){ .pipe(plumber()) .pipe(coffee({bare: true}).on('error', gutil.log)); - var autoGrow = gulp.src('jquery.autoGrow.js'); - - return merge(templates, js, autoGrow) + return merge(templates, js) .pipe(concat('chat.js')) .pipe(gulp.dest('./')) .pipe(uglify()) @@ -40,7 +38,7 @@ gulp.task('js', function(){ }); gulp.task('default', function(){ - var cssWatcher = gulp.watch('chat.scss', ['css']); + var cssWatcher = gulp.watch(['chat.scss'], ['css']); cssWatcher.on('change', function(event) { console.log('File ' + event.path + ' was ' + event.type + ', running tasks...'); }); diff --git a/public/assets/chat/jquery.autoGrow.js b/public/assets/chat/jquery.autoGrow.js deleted file mode 100644 index cda824c42..000000000 --- a/public/assets/chat/jquery.autoGrow.js +++ /dev/null @@ -1,92 +0,0 @@ -/*! - * ---------------------------------------------------------------------------- - * "THE BEER-WARE LICENSE" (Revision 42): - * wrote this file. As long as you retain this notice you - * can do whatever you want with this stuff. If we meet some day, and you think - * this stuff is worth it, you can buy me a beer in return. Jevin O. Sewaruth - * ---------------------------------------------------------------------------- - * - * Autogrow Textarea Plugin Version v3.0 - * http://www.technoreply.com/autogrow-textarea-plugin-3-0 - * - * THIS PLUGIN IS DELIVERD ON A PAY WHAT YOU WHANT BASIS. IF THE PLUGIN WAS USEFUL TO YOU, PLEASE CONSIDER BUYING THE PLUGIN HERE : - * https://sites.fastspring.com/technoreply/instant/autogrowtextareaplugin - * - * Date: October 15, 2012 - * - * Zammad modification: - * - remove overflow:hidden when maximum height is reached - * - mirror box-sizing - * - */ - -jQuery.fn.autoGrow = function(options) { - return this.each(function() { - var settings = jQuery.extend({ - extraLine: true, - }, options); - - var createMirror = function(textarea) { - jQuery(textarea).after('
                          '); - return jQuery(textarea).next('.autogrow-textarea-mirror')[0]; - } - - var sendContentToMirror = function (textarea) { - mirror.innerHTML = String(textarea.value) - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(//g, '>') - .replace(/ /g, ' ') - .replace(/\n/g, '
                          ') + - (settings.extraLine? '.
                          .' : '') - ; - - if (jQuery(textarea).height() != jQuery(mirror).height()) { - jQuery(textarea).height(jQuery(mirror).height()); - - var overflow = jQuery(mirror).height() > maxHeight ? '' : 'hidden'; - jQuery(textarea).css('overflow', overflow); - } - } - - var growTextarea = function () { - sendContentToMirror(this); - } - - // Create a mirror - var mirror = createMirror(this); - - // Store max-height - var maxHeight = parseInt(jQuery(this).css('max-height'), 10); - - // Style the mirror - mirror.style.display = 'none'; - mirror.style.wordWrap = 'break-word'; - mirror.style.whiteSpace = 'normal'; - mirror.style.padding = jQuery(this).css('paddingTop') + ' ' + - jQuery(this).css('paddingRight') + ' ' + - jQuery(this).css('paddingBottom') + ' ' + - jQuery(this).css('paddingLeft'); - - mirror.style.width = jQuery(this).css('width'); - mirror.style.fontFamily = jQuery(this).css('font-family'); - mirror.style.fontSize = jQuery(this).css('font-size'); - mirror.style.lineHeight = jQuery(this).css('line-height'); - mirror.style.letterSpacing = jQuery(this).css('letter-spacing'); - mirror.style.boxSizing = jQuery(this).css('boxSizing'); - - // Style the textarea - this.style.overflow = "hidden"; - this.style.minHeight = this.rows+"em"; - - // Bind the textarea's event - this.onkeyup = growTextarea; - this.onfocus = growTextarea; - - // Fire the event for text already present - sendContentToMirror(this); - - }); -}; \ No newline at end of file diff --git a/public/assets/chat/package.json b/public/assets/chat/package.json index 087ef68ba..dec0faec0 100644 --- a/public/assets/chat/package.json +++ b/public/assets/chat/package.json @@ -3,17 +3,17 @@ "version": "1.0.0", "description": "Zammad Customer Chat Javascript Widget", "devDependencies": { - "gulp": "^3.9.0", - "gulp-autoprefixer": "^3.0.2", - "gulp-coffee": "^2.3.1", - "gulp-concat": "^2.6.0", + "gulp": "^3.9.1", + "gulp-autoprefixer": "^3.1.1", + "gulp-coffee": "^2.3.4", + "gulp-concat": "^2.6.1", "gulp-eco": "0.0.2", - "gulp-plumber": "^1.0.1", + "gulp-plumber": "^1.1.0", "gulp-rename": "^1.2.2", - "gulp-sass": "^2.0.4", - "gulp-uglify": "^1.4.2", - "gulp-util": "^3.0.6", - "merge-stream": "^1.0.0", - "sass.js": "^0.9.2" + "gulp-sass": "^3.1.0", + "gulp-uglify": "^3.0.0", + "gulp-util": "^3.0.8", + "merge-stream": "^1.0.1", + "sass.js": "^0.10.5" } } diff --git a/public/assets/chat/views/chat.eco b/public/assets/chat/views/chat.eco index 6d73cc626..2860d193e 100644 --- a/public/assets/chat/views/chat.eco +++ b/public/assets/chat/views/chat.eco @@ -21,7 +21,7 @@
                          - +
                          \ No newline at end of file diff --git a/public/assets/form/form.html b/public/assets/form/form.html index 3e8577459..9053ae955 100644 --- a/public/assets/form/form.html +++ b/public/assets/form/form.html @@ -14,7 +14,7 @@
                          -
                          +

                          Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.

                          diff --git a/public/assets/form/form.js b/public/assets/form/form.js index ed4d2d7ed..fa21bed91 100644 --- a/public/assets/form/form.js +++ b/public/assets/form/form.js @@ -204,8 +204,14 @@ $(function() { if (this.options.test) { params.test = true } + + params.fingerprint = this.fingerprint() + $.ajax({ + method: 'post', url: _this.endpoint_config, + cache: false, + processData: true, data: params }).done(function(data) { _this.log('debug', 'config:', data) @@ -256,7 +262,7 @@ $(function() { _this.log('debug', 'currentTime', currentTime) _this.log('debug', 'modalOpenTime', _this.modalOpenTime.getTime()) _this.log('debug', 'diffTime', diff) - if (diff < 1000*8) { + if (diff < 1000*10) { alert('Sorry, you look like an robot!') return } @@ -317,7 +323,10 @@ $(function() { formData.append('test', true) } formData.append('token', this._config.token) + + formData.append('fingerprint', this.fingerprint()) _this.log('debug', 'formData', formData) + return formData } @@ -463,6 +472,22 @@ $(function() { return string } + Plugin.prototype.fingerprint = function () { + var canvas = document.createElement('canvas') + var ctx = canvas.getContext('2d') + var txt = 'https://zammad.com' + ctx.textBaseline = 'top' + ctx.font = '12px \'Arial\'' + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = '#f60' + ctx.fillRect(125,1,62,20) + ctx.fillStyle = '#069' + ctx.fillText(txt, 2, 15) + ctx.fillStyle = 'rgba(100, 200, 0, 0.7)' + ctx.fillText(txt, 4, 17) + return canvas.toDataURL() + } + $.fn[pluginName] = function (options) { return this.each(function () { var instance = $.data(this, 'plugin_' + pluginName) diff --git a/public/assets/images/icons.svg b/public/assets/images/icons.svg index 5a377c4a6..b4b8b1d0e 100644 --- a/public/assets/images/icons.svg +++ b/public/assets/images/icons.svg @@ -1 +1 @@ -arrow-downarrow-leftarrow-rightarrow-upchatcheckbox-checkedcheckbox-indeterminatecheckboxcheckmarkclipboardclockcloudcogcrowndashboarddiagonal-crossdownloaddraggabledropdown-listemail-buttonemaileyedropperfacebook-buttonfacebookformgithub-buttongitlab-buttongoogle-buttongrouphelpimportantin-processinfoline-left-arrowline-right-arrowlinkedin-buttonlistloadinglock-openlocklogotypelong-arrow-rightmagnifiermarkermessageminus-smallminusmood-badmood-goodmood-okmood-superbadmood-supergoodmutenoteoauth2-buttonone-ticketorganizationoutbound-callsoverviewspackagepaperclippenpersonphoneplus-smallplusprinterradio-checkedradioreceived-callsreloadreopeningreply-allreplyreportsearchdetailsignoutsmall-dotsplitstatus-modified-outer-circlestatusstopwatchswitchViewtask-stateteamtelegramtemplatestoolstotal-ticketstrashtwitter-buttontwitterunmuteuserwebzoom-inzoom-out \ No newline at end of file +arrow-downarrow-leftarrow-rightarrow-upchatcheckbox-checkedcheckbox-indeterminatecheckboxcheckmarkclipboardclockcloudcogcrowndashboarddiagonal-crossdownloaddraggabledropdown-listemail-buttonemaileyedropperfacebook-buttonfacebookformgithub-buttongitlab-buttongoogle-buttongrouphelpimportantin-processinfoline-left-arrowline-right-arrowlinkedin-buttonlistloadinglock-openlocklogotypelong-arrow-rightmagnifiermarkermessageminus-smallminusmood-badmood-goodmood-okmood-superbadmood-supergoodmutenoteoauth2-buttonoffice365-buttonone-ticketorganizationoutbound-callsoverviewspackagepaperclippenpersonphoneplus-smallplusprinterradio-checkedradioreceived-callsreloadreopeningreply-allreplyreportsearchdetailsignoutsmall-dotsplitstatus-modified-outer-circlestatusstopwatchswitchViewtask-stateteamtelegramtemplatestoolstotal-ticketstrashtwitter-buttontwitterunmuteuserwebzoom-inzoom-out \ No newline at end of file diff --git a/public/assets/images/icons/office365-button.svg b/public/assets/images/icons/office365-button.svg new file mode 100644 index 000000000..8fe7b1fda --- /dev/null +++ b/public/assets/images/icons/office365-button.svg @@ -0,0 +1,12 @@ + + + + office365-button + Created with Sketch. + + + + + + + \ No newline at end of file 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'], } diff --git a/public/assets/tests/form_searchable_select.js b/public/assets/tests/form_searchable_select.js index 61712d703..72c1c23d4 100644 --- a/public/assets/tests/form_searchable_select.js +++ b/public/assets/tests/form_searchable_select.js @@ -45,13 +45,13 @@ test( "searchable_select check", function() { autofocus: true }) - var params = App.ControllerForm.params( el ) + var params = App.ControllerForm.params(el) var test_params = { searchable_select1: '', searchable_select2: 'bbb', searchable_select3: '', } - deepEqual( params, test_params, 'form param check' ) + deepEqual(params, test_params, 'form param check') // change selection $('[name="searchable_select1"].js-shadow + .js-input').focus().val('').trigger('input') @@ -62,13 +62,13 @@ test( "searchable_select check", function() { var entries = $element.find('li:not(.is-hidden)').length equal(entries, 1, 'dropdown count') $element.find('li:not(.is-hidden)').first().click() - params = App.ControllerForm.params( el ) + params = App.ControllerForm.params(el) test_params = { searchable_select1: 'ccc', searchable_select2: 'bbb', searchable_select3: '', } - deepEqual( params, test_params, 'form param check' ) + deepEqual(params, test_params, 'form param check') $('[name="searchable_select2"].js-shadow + .js-input').focus().val('').trigger('input') var $element = $('[name="searchable_select2"]').closest('.searchableSelect').find('.js-optionsList') @@ -79,13 +79,13 @@ test( "searchable_select check", function() { equal(entries, 1, 'dropdown count') $element.find('li:not(.is-hidden)').first().click() - params = App.ControllerForm.params( el ) + params = App.ControllerForm.params(el) test_params = { searchable_select1: 'ccc', searchable_select2: 'ccc', searchable_select3: '', } - deepEqual( params, test_params, 'form param check' ) + deepEqual(params, test_params, 'form param check') $('[name="searchable_select3"].js-shadow + .js-input').focus().val('').trigger('input') var $element = $('[name="searchable_select3"]').closest('.searchableSelect').find('.js-optionsList') @@ -105,12 +105,12 @@ test( "searchable_select check", function() { e.keyCode = 13 $('[name="searchable_select3"].js-shadow + .js-input').trigger(e) - params = App.ControllerForm.params( el ) + params = App.ControllerForm.params(el) test_params = { searchable_select1: 'ccc', searchable_select2: 'ccc', searchable_select3: 'unknown value', } - deepEqual( params, test_params, 'form param check' ) + deepEqual(params, test_params, 'form param check') }); diff --git a/public/assets/tests/form_tree_select.js b/public/assets/tests/form_tree_select.js new file mode 100644 index 000000000..bd431482e --- /dev/null +++ b/public/assets/tests/form_tree_select.js @@ -0,0 +1,194 @@ +test("form elements check", function() { + $('#forms').append('

                          form elements check

                          ') + var el = $('#form1') + new App.ControllerForm({ + el: el, + model: { + "configure_attributes": [ + { + "name": "tree_select", + "display": "tree_select", + "tag": "tree_select", + "null": true, + "translate": true, + "options": [ + { + "value": "aa", + "name": "yes", + "children": [ + { + "value": "aa::aaa", + "name": "yes1", + }, + { + "value": "aa::aab", + "name": "yes2", + }, + { + "value": "aa::aac", + "name": "yes3", + }, + ] + }, + { + "value": "bb", + "name": "bb (comment)", + "children": [ + { + "value": "bb::bba", + "name": "yes11", + }, + { + "value": "bb::bbb", + "name": "yes22", + }, + { + "value": "bb::bbc", + "name": "yes33", + }, + ] + }, + ], + } + ] + }, + autofocus: true + }); + equal(el.find('[name="tree_select"]').val(), '', 'check tree_select value'); + equal(el.find('[name="tree_select"]').closest('.searchableSelect').find('.js-input').val(), '', 'check tree_select .js-input value'); + var params = App.ControllerForm.params(el) + var test_params = { + tree_select: '' + } + deepEqual(params, test_params, 'form param check') + + $('#forms').append('

                          form elements check

                          ') + var el = $('#form2') + new App.ControllerForm({ + el: el, + model: { + "configure_attributes": [ + { + "name": "tree_select", + "display": "tree_select", + "tag": "tree_select", + "null": true, + "translate": true, + "value": "aa", + "options": [ + { + "value": "aa", + "name": "yes", + "children": [ + { + "value": "aa::aaa", + "name": "yes1", + }, + { + "value": "aa::aab", + "name": "yes2", + }, + { + "value": "aa::aac", + "name": "yes3", + }, + ] + }, + { + "value": "bb", + "name": "bb (comment)", + "children": [ + { + "value": "bb::bba", + "name": "yes11", + }, + { + "value": "bb::bbb", + "name": "yes22", + }, + { + "value": "bb::bbc", + "name": "yes33", + }, + ] + }, + ], + } + ] + }, + autofocus: true + }); + + equal(el.find('[name="tree_select"]').val(), 'aa', 'check tree_select value'); + equal(el.find('[name="tree_select"]').closest('.searchableSelect').find('.js-input').val(), 'yes', 'check tree_select .js-input value'); + var params = App.ControllerForm.params(el) + var test_params = { + tree_select: 'aa' + } + deepEqual(params, test_params, 'form param check') + + $('#forms').append('

                          form elements check

                          ') + var el = $('#form3') + new App.ControllerForm({ + el: el, + model: { + "configure_attributes": [ + { + "name": "tree_select", + "display": "tree_select", + "tag": "tree_select", + "null": true, + "translate": true, + "value": "aa::aab", + "options": [ + { + "value": "aa", + "name": "yes", + "children": [ + { + "value": "aa::aaa", + "name": "yes1", + }, + { + "value": "aa::aab", + "name": "yes2", + }, + { + "value": "aa::aac", + "name": "yes3", + }, + ] + }, + { + "value": "bb", + "name": "bb (comment)", + "children": [ + { + "value": "bb::bba", + "name": "yes11", + }, + { + "value": "bb::bbb", + "name": "yes22", + }, + { + "value": "bb::bbc", + "name": "yes33", + }, + ] + }, + ], + } + ] + }, + autofocus: true + }); + equal(el.find('[name="tree_select"]').val(), 'aa::aab', 'check tree_select value'); + equal(el.find('[name="tree_select"]').closest('.searchableSelect').find('.js-input').val(), 'yes2', 'check tree_select .js-input value'); + var params = App.ControllerForm.params(el) + var test_params = { + tree_select: 'aa::aab' + } + deepEqual(params, test_params, 'form param check') + +}); diff --git a/public/assets/tests/html_utils.js b/public/assets/tests/html_utils.js index 9569c8a8c..9b3263c48 100644 --- a/public/assets/tests/html_utils.js +++ b/public/assets/tests/html_utils.js @@ -201,12 +201,42 @@ test("html2text", function() { test("phoneify", function() { var source = "+1 123 123 123-123" - var should = 'tel://%2B1123123123-123' + var should = 'tel:+1123123123123' var result = App.Utils.phoneify(source) equal(result, should, source) source = "+1 123 123 A 123-123<>" - should = 'tel://%2B1123123A123-123%3C%3E' + should = 'tel:+1123123123123' + result = App.Utils.phoneify(source) + equal(result, should, source) + + source = "+1 (123) 123 123-123" + should = 'tel:+1123123123123' + result = App.Utils.phoneify(source) + equal(result, should, source) + + source = "+1 (123) 123 1#23-123" + should = 'tel:+11231231#23123' + result = App.Utils.phoneify(source) + equal(result, should, source) + + source = "+1 (123) 12*3 1#23-123" + should = 'tel:+112312*31#23123' + result = App.Utils.phoneify(source) + equal(result, should, source) + + source = "+1 (123) 12+3" + should = 'tel:+1123123' + result = App.Utils.phoneify(source) + equal(result, should, source) + + source = "+1 (123) 123 " + should = 'tel:+1123123' + result = App.Utils.phoneify(source) + equal(result, should, source) + + source = " +1 (123) 123 " + should = 'tel:+1123123' result = App.Utils.phoneify(source) equal(result, should, source) }) @@ -341,6 +371,11 @@ test("htmlRemoveTags", function() { result = App.Utils.htmlRemoveTags($(source)) equal(result.html(), should, source) +}); + +// htmlRemoveRichtext +test("htmlRemoveRichtext", function() { + source = "
                          test 123
                          " //should = "
                          test 123
                          " should = "test 123" @@ -367,11 +402,6 @@ test("htmlRemoveTags", function() { result = App.Utils.htmlRemoveRichtext(source) equal(result.html(), should, source) -}); - -// htmlRemoveRichtext -test("htmlRemoveRichtext", function() { - var source = "" //var should = "
                          test
                          " var should = "test" @@ -466,6 +496,25 @@ test("htmlRemoveRichtext", function() { result = App.Utils.htmlRemoveRichtext(source) equal(result.html(), should, source) + var source = "" + var should = "
                          test
                          " + var result = App.Utils.htmlRemoveRichtext($(source)) + equal(result.get(0).outerHTML, should, source) + + source = "
                          some link to somewhere" + should = "
                          some link to somewhere
                          " + result = App.Utils.htmlRemoveRichtext($(source)) + equal(result.get(0).outerHTML, should, source) + + source = "

                          " + should = "

                          " + result = App.Utils.htmlRemoveRichtext($(source)) + equal(result.get(0).outerHTML, should, source) + + source = "
                          111aaa
                          keyvalue
                          " + should = "
                          111aaakeyvalue
                          " + result = App.Utils.htmlRemoveRichtext(source, true) + equal(result.get(0).outerHTML, should, source) }); // htmlCleanup @@ -573,14 +622,44 @@ test("htmlCleanup", function() { equal(result.html(), should, source) source = "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n

                          ·            \nTest 1

                          \n\n

                          ·            \nTest 2

                          \n\n

                          ·            \nTest 3

                          \n\n

                          ·            \nTest 4

                          \n\n

                          ·            \nTest5

                          \n\n\n\n\n" - should = "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n

                          · \nTest 1

                          \n\n

                          · \nTest 2

                          \n\n

                          · \nTest 3

                          \n\n

                          · \nTest 4

                          \n\n

                          · \nTest5

                          \n\n\n\n\n" + should = "
                          • Test 1
                          • Test 2
                          • Test 3
                          • Test 4
                          • Test5
                          " + result = App.Utils.htmlCleanup(source) + equal(result.html().trim(), should, source) + + source = "\n\n\n \n \n \n \n\n\n

                          1.\nGehe auf https://www.pferdiathek.ge

                          \n


                          \n\n

                          \n

                          2.\nMelde Dich mit folgende Zugangsdaten an:

                          \n

                          Benutzer:\nme@xxx.net

                          \n

                          Passwort:\nxxx.

                          \n\n" + should = "\n\n\n \n \n \n \n\n\n

                          1.\nGehe auf https://www.pferdiathek.ge

                          \n


                          \n\n

                          \n

                          2.\nMelde Dich mit folgende Zugangsdaten an:

                          \n

                          Benutzer:\nme@xxx.net

                          \n

                          Passwort:\nxxx.

                          \n\n" result = App.Utils.htmlCleanup(source) equal(result.html(), should, source) - source = "\n\n\n \n \n \n \n\n\n

                          1.\nGehe auf https://www.pferdiathek.ge

                          \n


                          \n\n

                          \n

                          2.\nMelde Dich mit folgende Zugangsdaten an:

                          \n

                          Benutzer:\nme@xxx.net

                          \n

                          Passwort:\nxxx.

                          \n\n" - should = "\n\n\n \n \n \n \n\n\n

                          1.\nGehe auf https://www.pferdiathek.ge

                          \n


                          \n\n

                          \n

                          2.\nMelde Dich mit folgende Zugangsdaten an:

                          \n

                          Benutzer:\nme@xxx.net

                          \n

                          Passwort:\nxxx.

                          \n\n" + source = "
                          aaa
                          value
                          " + should = "
                          aaa
                          value
                          " result = App.Utils.htmlCleanup(source) - equal(result.html(), should, source) + equal(result.get(0).outerHTML, should, source) + + source = "
                          aaa
                          value
                          " + should = "
                          aaa
                          value
                          " + result = App.Utils.htmlCleanup(source) + result.get(0).outerHTML + //equal(result.get(0).outerHTML, should, source) / string order is different on browsers + equal(result.first().attr('bgcolor'), 'green') + equal(result.first().attr('style'), 'color:red;') + equal(result.first().attr('aaa'), undefined) + equal(result.find('tr').first().attr('style'), 'margin-top:10px;') + equal(result.find('th').first().attr('colspan'), '2') + equal(result.find('th').first().attr('abc'), undefined) + equal(result.find('th').first().attr('style'), 'margin-top:12px;') + + source = "
                          aaa
                          value
                          " + should = "
                          aaa
                          value
                          " + result = App.Utils.htmlCleanup(source) + //equal(result.get(0).outerHTML, should, source) / string order is different on browsers + equal(result.first().attr('bgcolor'), 'green') + equal(result.first().attr('style'), 'color:red;') + equal(result.first().attr('aaa'), undefined) + equal(result.find('tr').first().attr('style'), undefined) + equal(result.find('th').first().attr('colspan'), '2') + equal(result.find('th').first().attr('abc'), undefined) + equal(result.find('th').first().attr('style'), undefined) }); @@ -1051,6 +1130,16 @@ test("check replace tags", function() { verify = App.Utils.replaceTags(message, data) equal(verify, result) + message = "
                          #{user.firstname} #{user.lastname}
                          " + result = '
                          Bob Smith
                          ' + data = { + user: { + firstname: 'Bob', + lastname: 'Smith', + }, + } + verify = App.Utils.replaceTags(message, data) + equal(verify, result) }); // check attibute validation @@ -1616,4 +1705,49 @@ test("check diffPosition format", function() { }); -} \ No newline at end of file +// check textLengthWithUrl format +test("check textLengthWithUrl format", function() { + + var string = '123' + var result = 3 + var verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + + string = '123 http is not here' + result = 20 + verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + + string = '123 http://host is not here' + result = 39 + verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + + string = '123 http://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX is not here' + result = 39 + verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + + string = 'http://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + result = 23 + verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + + string = 'http://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX, some other text' + result = 23 + 17 + verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + + string = 'some other text,http://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + result = 23 + 16 + verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + + string = 'some other text, http://XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX?abc=123;aaa=ab+c usw' + result = 23 + 21 + verify = App.Utils.textLengthWithUrl(string) + equal(verify, result) + +}); + +} diff --git a/script/bootstrap.sh b/script/bootstrap.sh index ac34cdc3d..f8bcb41ae 100755 --- a/script/bootstrap.sh +++ b/script/bootstrap.sh @@ -1,6 +1,6 @@ #!/bin/bash -bundle install +bundle install --jobs 8 rm -rf tmp/cache* diff --git a/script/install.sh b/script/install.sh index 67da70b27..b5a44c868 100644 --- a/script/install.sh +++ b/script/install.sh @@ -79,7 +79,7 @@ sudo -u "${USER}" -H bash -l -c 'rvm alias create default 2.1.2' sudo -u "${USER}" -H bash -l -c 'cd ~/zammad && gem install rails --no-ri --no-rdoc' -sudo -u "${USER}" -H bash -l -c 'cd ~/zammad && bundle install' +sudo -u "${USER}" -H bash -l -c 'cd ~/zammad && bundle install --jobs 8' DBPASS=$(apg -x8|head -1) echo Password $DBPASS diff --git a/script/local_browser_tests.sh b/script/local_browser_tests.sh index 13a6bede2..6c3ada269 100755 --- a/script/local_browser_tests.sh +++ b/script/local_browser_tests.sh @@ -15,7 +15,7 @@ export RAILS_SERVE_STATIC_FILES=true export ZAMMAD_SETTING_TTL=15 export Z_LOCALES=en-us:de-de -bundle install +bundle install --jobs 8 rm -rf tmp/screenshot* rm -rf tmp/cache* diff --git a/script/scheduler.rb b/script/scheduler.rb index 076a91d82..5be7db8c3 100755 --- a/script/scheduler.rb +++ b/script/scheduler.rb @@ -47,6 +47,7 @@ daemon_options = { name = 'scheduler' Daemons.run_proc(name, daemon_options) do + if ARGV.include?('--') ARGV.slice! 0..ARGV.index('--') else @@ -55,6 +56,12 @@ Daemons.run_proc(name, daemon_options) do after_fork(dir) + Rails.logger.info 'Scheduler started.' + + at_exit do + Rails.logger.info 'Scheduler stopped.' + end + require 'scheduler' Scheduler.threads end diff --git a/script/websocket-server.rb b/script/websocket-server.rb index 740d3af39..d7aee77ea 100755 --- a/script/websocket-server.rb +++ b/script/websocket-server.rb @@ -237,11 +237,10 @@ EventMachine.run { next if client[:disconnect] log 'debug', 'checking for data...', client_id begin - queue = Sessions.queue( client_id ) - if queue && queue[0] - log 'notice', 'send data to client', client_id - websocket_send(client_id, queue) - end + queue = Sessions.queue(client_id) + next if queue.blank? + log 'notice', 'send data to client', client_id + websocket_send(client_id, queue) rescue => e log 'error', 'problem:' + e.inspect, client_id 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/lib/import/base_resource_spec.rb b/spec/lib/import/base_resource_spec.rb index f0fb77016..8b5321321 100644 --- a/spec/lib/import/base_resource_spec.rb +++ b/spec/lib/import/base_resource_spec.rb @@ -1,7 +1,5 @@ require 'rails_helper' -RSpec::Matchers.define_negated_matcher :not_change, :change - RSpec.describe Import::BaseResource do it "needs an implementation of the 'import_class' method" do diff --git a/spec/lib/import/ldap/user_factory_spec.rb b/spec/lib/import/ldap/user_factory_spec.rb index 80f1a4c31..d0ea4b2f3 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) @@ -49,22 +49,25 @@ RSpec.describe Import::Ldap::UserFactory do }.by(1) end - it 'supports dry run' do + it 'deactivates lost users' do config = { user_filter: '(objectClass=user)', group_filter: '(objectClass=group)', user_uid: 'uid', user_attributes: { - 'uid' => 'login', + 'uid' => 'login', 'email' => 'email', } } - mocked_entry = build(:ldap_entry) + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] - mocked_entry['uid'] = ['exampleuid'] - mocked_entry['email'] = ['example@example.com'] + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid_lost'] + lost_entry['email'] = ['lost@example.com'] mocked_ldap = double( host: 'ldap.example.com', @@ -73,22 +76,266 @@ RSpec.describe Import::Ldap::UserFactory do base_dn: 'dc=example,dc=com' ) + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + # group user role mapping expect(mocked_ldap).to receive(:search) # user counting expect(mocked_ldap).to receive(:count).and_return(1) # user search - expect(mocked_ldap).to receive(:search).and_yield(mocked_entry) + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) expect do + described_class.import( + config: config, + ldap: mocked_ldap, + ) + end.to change { + User.find_by(email: 'lost@example.com').active + } + end + + it 're-activates previously lost users' do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid_lost'] + lost_entry['email'] = ['lost@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + ) + end.to change { + User.find_by(email: 'lost@example.com').active + } + end + + it 'deactivates skipped users' do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + }, + } + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid'] + lost_entry['email'] = ['example@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # activate skipping + config[:unassigned_users] = 'skip_sync' + config[:group_role_map] = { + 'dummy' => %w(1 2), + } + + # group user role mapping + mocked_entry = build(:ldap_entry) + mocked_entry['dn'] = 'dummy' + mocked_entry['member'] = ['dummy'] + expect(mocked_ldap).to receive(:search).and_yield(mocked_entry) + + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(lost_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + ) + end.to change { + User.find_by(email: 'example@example.com').active + } + end + + context 'dry run' do + + it "doesn't sync users" do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + mocked_entry = build(:ldap_entry) + + mocked_entry['uid'] = ['exampleuid'] + mocked_entry['email'] = ['example@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(mocked_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + dry_run: true + ) + end.not_to change { + User.count + } + end + + it "doesn't deactivates lost users" do + + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid'] + lost_entry['email'] = ['example@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + described_class.import( config: config, ldap: mocked_ldap, dry_run: true ) - end.not_to change { - User.count - } + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + expect(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) + + expect do + described_class.import( + config: config, + ldap: mocked_ldap, + dry_run: true + ) + end.not_to change { + User.count + } + end end end @@ -115,23 +362,91 @@ RSpec.describe Import::Ldap::UserFactory do expected = { role_ids: { 1 => { - created: 1, - updated: 0, - unchanged: 0, - failed: 0 + created: 1, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, }, 2 => { - created: 1, - updated: 0, - unchanged: 0, - failed: 0 + created: 1, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, }, }, - skipped: 0, - created: 1, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 0, + created: 1, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, + } + + expect(described_class.statistics).to include(expected) + end + + it 'adds deactivated users' do + config = { + user_filter: '(objectClass=user)', + group_filter: '(objectClass=group)', + user_uid: 'uid', + user_attributes: { + 'uid' => 'login', + 'email' => 'email', + } + } + + persistent_entry = build(:ldap_entry) + persistent_entry['uid'] = ['exampleuid'] + persistent_entry['email'] = ['example@example.com'] + + lost_entry = build(:ldap_entry) + lost_entry['uid'] = ['exampleuid_lost'] + lost_entry['email'] = ['lost@example.com'] + + mocked_ldap = double( + host: 'ldap.example.com', + port: 636, + ssl: true, + base_dn: 'dc=example,dc=com' + ) + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + allow(mocked_ldap).to receive(:count).and_return(2) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry).and_yield(lost_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + # simulate new import + described_class.reset_statistics + + # group user role mapping + expect(mocked_ldap).to receive(:search) + # user counting + allow(mocked_ldap).to receive(:count).and_return(1) + # user search + expect(mocked_ldap).to receive(:search).and_yield(persistent_entry) + + described_class.import( + config: config, + ldap: mocked_ldap, + ) + + expected = { + skipped: 0, + created: 0, + updated: 0, + unchanged: 1, + failed: 0, + deactivated: 1, } expect(described_class.statistics).to include(expected) @@ -150,11 +465,12 @@ RSpec.describe Import::Ldap::UserFactory do described_class.add_to_statistics(mocked_backend_instance) expected = { - skipped: 1, - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 1, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } expect(described_class.statistics).to include(expected) @@ -175,11 +491,12 @@ RSpec.describe Import::Ldap::UserFactory do described_class.add_to_statistics(mocked_backend_instance) expected = { - skipped: 1, - created: 0, - updated: 0, - unchanged: 0, - failed: 0, + skipped: 1, + created: 0, + updated: 0, + unchanged: 0, + failed: 0, + deactivated: 0, } expect(described_class.statistics).to include(expected) @@ -201,7 +518,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 +536,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..14e76c5bd 100644 --- a/spec/lib/import/ldap/user_spec.rb +++ b/spec/lib/import/ldap/user_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' require 'import/ldap/user' -RSpec::Matchers.define_negated_matcher :not_change, :change - RSpec.describe Import::Ldap::User do let(:uid) { 'exampleuid' } @@ -29,7 +27,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 +89,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 diff --git a/spec/lib/import/otrs/dynamic_field/checkbox_spec.rb b/spec/lib/import/otrs/dynamic_field/checkbox_spec.rb index 658467d92..000ecc5a1 100644 --- a/spec/lib/import/otrs/dynamic_field/checkbox_spec.rb +++ b/spec/lib/import/otrs/dynamic_field/checkbox_spec.rb @@ -29,7 +29,7 @@ RSpec.describe Import::OTRS::DynamicField::Checkbox do true => 'Yes', false => 'No' }, - null: false, + null: true, translate: true } } diff --git a/spec/lib/import/otrs/dynamic_field/date_spec.rb b/spec/lib/import/otrs/dynamic_field/date_spec.rb index 2fa688a37..0de5e4cb4 100644 --- a/spec/lib/import/otrs/dynamic_field/date_spec.rb +++ b/spec/lib/import/otrs/dynamic_field/date_spec.rb @@ -33,7 +33,7 @@ RSpec.describe Import::OTRS::DynamicField::Date do future: false, past: false, diff: 0, - null: false + null: true } } diff --git a/spec/lib/import/otrs/dynamic_field/date_time_spec.rb b/spec/lib/import/otrs/dynamic_field/date_time_spec.rb index 1fc1c7601..b2ac81093 100644 --- a/spec/lib/import/otrs/dynamic_field/date_time_spec.rb +++ b/spec/lib/import/otrs/dynamic_field/date_time_spec.rb @@ -33,7 +33,7 @@ RSpec.describe Import::OTRS::DynamicField::DateTime do future: true, past: true, diff: 72, - null: false + null: true } } diff --git a/spec/lib/import/otrs/dynamic_field/dropdown_spec.rb b/spec/lib/import/otrs/dynamic_field/dropdown_spec.rb index 75b7bce81..5cc0f1365 100644 --- a/spec/lib/import/otrs/dynamic_field/dropdown_spec.rb +++ b/spec/lib/import/otrs/dynamic_field/dropdown_spec.rb @@ -32,8 +32,9 @@ RSpec.describe Import::OTRS::DynamicField::Dropdown do 'Köln' => 'Köln', 'Berlin' => 'Berlin' }, - null: true, - translate: false + nulloption: true, + null: true, + translate: false } } diff --git a/spec/lib/import/otrs/dynamic_field/multiselect_spec.rb b/spec/lib/import/otrs/dynamic_field/multiselect_spec.rb index 4b7e0e1c8..be8ca129b 100644 --- a/spec/lib/import/otrs/dynamic_field/multiselect_spec.rb +++ b/spec/lib/import/otrs/dynamic_field/multiselect_spec.rb @@ -32,8 +32,9 @@ RSpec.describe Import::OTRS::DynamicField::Multiselect do 'Köln' => 'Köln', 'Berlin' => 'Berlin' }, - null: false, - translate: false + nulloption: false, + null: true, + translate: false } } diff --git a/spec/lib/import/otrs/dynamic_field/text_area_spec.rb b/spec/lib/import/otrs/dynamic_field/text_area_spec.rb index fa8e430af..18199a7ca 100644 --- a/spec/lib/import/otrs/dynamic_field/text_area_spec.rb +++ b/spec/lib/import/otrs/dynamic_field/text_area_spec.rb @@ -26,7 +26,7 @@ RSpec.describe Import::OTRS::DynamicField::TextArea do data_option: { default: '', rows: '20', - null: false + null: true } } diff --git a/spec/lib/import/otrs/dynamic_field/text_spec.rb b/spec/lib/import/otrs/dynamic_field/text_spec.rb index 34201654b..1b0919958 100644 --- a/spec/lib/import/otrs/dynamic_field/text_spec.rb +++ b/spec/lib/import/otrs/dynamic_field/text_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Import::OTRS::DynamicField::Text do default: '', type: 'text', maxlength: 255, - null: false + null: true } } diff --git a/spec/lib/import/otrs/state_factory_spec.rb b/spec/lib/import/otrs/state_factory_spec.rb index 205cfb892..734025fec 100644 --- a/spec/lib/import/otrs/state_factory_spec.rb +++ b/spec/lib/import/otrs/state_factory_spec.rb @@ -1,8 +1,6 @@ require 'rails_helper' require 'lib/import/transaction_factory_examples' -RSpec::Matchers.define_negated_matcher :not_change, :change - RSpec.describe Import::OTRS::StateFactory do it_behaves_like 'Import::TransactionFactory' diff --git a/spec/lib/import/statistical_factory_spec.rb b/spec/lib/import/statistical_factory_spec.rb index 02bc905a9..728c28527 100644 --- a/spec/lib/import/statistical_factory_spec.rb +++ b/spec/lib/import/statistical_factory_spec.rb @@ -45,11 +45,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 1, - updated: 0, - unchanged: 0, - skipped: 0, - failed: 0, + created: 1, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -67,11 +68,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -90,11 +92,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -113,11 +116,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -134,11 +138,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes]) statistics = { - created: 0, - updated: 0, - unchanged: 1, - skipped: 0, - failed: 0, + created: 0, + updated: 0, + unchanged: 1, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -151,11 +156,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([attributes], dry_run: true) statistics = { - created: 1, - updated: 0, - unchanged: 0, - skipped: 0, - failed: 0, + created: 1, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -181,11 +187,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([update_attributes], dry_run: true) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -199,11 +206,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([update_attributes], dry_run: true) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -217,11 +225,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([update_attributes], dry_run: true) statistics = { - created: 0, - updated: 1, - unchanged: 0, - skipped: 0, - failed: 0, + created: 0, + updated: 1, + unchanged: 0, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end @@ -241,11 +250,12 @@ RSpec.describe Import::StatisticalFactory do Import::Test::GroupFactory.import([local_group.attributes], dry_run: true) statistics = { - created: 0, - updated: 0, - unchanged: 1, - skipped: 0, - failed: 0, + created: 0, + updated: 0, + unchanged: 1, + skipped: 0, + failed: 0, + deactivated: 0, } expect(Import::Test::GroupFactory.statistics).to eq(statistics) end 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 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/spec/lib/sequencer/unit/common/attribute_mapper_spec.rb b/spec/lib/sequencer/unit/common/attribute_mapper_spec.rb new file mode 100644 index 000000000..991180335 --- /dev/null +++ b/spec/lib/sequencer/unit/common/attribute_mapper_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe Sequencer::Unit::Common::AttributeMapper, sequencer: :unit do + + let(:map) { + { + old_key: :new_key, + second: :new_second, + } + } + + it 'expects an implementation of the .map method' do + expect do + described_class.map + end.to raise_error(RuntimeError) + end + + it 'declares uses from map keys' do + expect(described_class).to receive(:map).and_return(map) + expect(described_class.uses).to eq(map.keys) + end + + it 'declares provides from map values' do + expect(described_class).to receive(:map).and_return(map) + expect(described_class.provides).to eq(map.values) + end + + it 'maps as configured' do + + old = { + old_key: :value, + second: :second_value, + } + + allow(described_class).to receive(:map).and_return(map) + result = process(old) + + expect(result.keys.size).to eq 2 + expect(result[:new_key]).to eq old[:old_key] + expect(result[:new_second]).to eq old[:second] + end +end diff --git a/spec/lib/sequencer/unit/import/common/mapping/flat_keys_spec.rb b/spec/lib/sequencer/unit/import/common/mapping/flat_keys_spec.rb new file mode 100644 index 000000000..0ce2aacd2 --- /dev/null +++ b/spec/lib/sequencer/unit/import/common/mapping/flat_keys_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe Sequencer::Unit::Import::Common::Mapping::FlatKeys, sequencer: :unit do + + it 'raises an error if mapping method is not implemented' do + expect do + process( + resource: { + remote_attribute: 'value', + } + ) + end.to raise_error(RuntimeError, /mapping/) + end + + it 'maps flat key structures' do + + parameters = { + resource: { + remote_attribute: 'value', + } + } + + mapping = { + remote_attribute: :local_attribute + } + + provided = process(parameters) do |instance| + expect(instance).to receive(:mapping).and_return(mapping) + end + + expect(provided).to eq( + mapped: { + 'local_attribute' => 'value', + } + ) + expect(provided[:mapped]).to be_a(ActiveSupport::HashWithIndifferentAccess) + 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..4a91dec86 --- /dev/null +++ b/spec/models/concerns/has_groups_examples.rb @@ -0,0 +1,557 @@ +# Requires: let(:group_access_instance) { ... } +# Requires: let(:new_group_access_instance) { ... } +RSpec.shared_examples 'HasGroups' do + + context 'group' do + let(:group_access_instance_inactive) { + group_access_instance.update_attribute(:active, false) + group_access_instance + } + 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(group_access_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(group_access_instance).to respond_to(:groups) + end + + context '#groups.access' do + + it 'responds to groups.access' do + expect(group_access_instance.groups).to respond_to(:access) + end + + context 'result' do + + before(:each) do + group_access_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(group_access_instance.groups.access.size).to eq(3) + end + + it 'adds join table attribute(s like) access' do + expect(group_access_instance.groups.access.first).to respond_to(:access) + end + + it 'filters for given access parameter' do + expect(group_access_instance.groups.access('read')).to include(group_read) + end + + it 'filters for given access list parameter' do + expect(group_access_instance.groups.access('read', 'write')).to include(group_read, group_inactive) + end + + it 'always includes full access groups' do + expect(group_access_instance.groups.access('read')).to include(group_full) + end + end + end + end + + context '#group_access?' do + + it 'responds to group_access?' do + expect(group_access_instance).to respond_to(:group_access?) + end + + before(:each) do + group_access_instance.group_names_access_map = { + group_read.name => 'read', + } + 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 + group_access_instance.group_names_access_map = { + group_inactive.name => 'read', + } + + expect(group_access_instance.group_access?(group_inactive.id, 'read')).to be false + end + + it 'prevents inactive instances' do + group_access_instance_inactive.group_names_access_map = { + group_read.name => 'read', + } + + expect(group_access_instance_inactive.group_access?(group_read.id, 'read')).to be false + end + end + + context '#group_ids_access' do + + it 'responds to group_ids_access' do + expect(group_access_instance).to respond_to(:group_ids_access) + end + + before(:each) do + group_access_instance.group_names_access_map = { + group_read.name => 'read', + } + end + + it 'lists only active Group IDs' do + group_access_instance.group_names_access_map = { + group_read.name => 'read', + group_inactive.name => 'read', + } + + result = group_access_instance.group_ids_access('read') + expect(result).not_to include(group_inactive.id) + end + + it "doesn't list for inactive instances" do + group_access_instance_inactive.group_names_access_map = { + group_read.name => 'read', + } + + expect(group_access_instance_inactive.group_ids_access('read')).to be_empty + end + + context 'single access' do + + it 'lists access Group IDs' do + result = group_access_instance.group_ids_access('read') + expect(result).to include(group_read.id) + end + + it "doesn't list for no access" do + result = group_access_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 = group_access_instance.group_ids_access(%w(read write)) + expect(result).to include(group_read.id) + end + + it "doesn't list for no access" do + result = group_access_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(group_access_instance).to respond_to(:groups_access) + end + + it 'wraps #group_ids_access' do + expect(group_access_instance).to receive(:group_ids_access) + group_access_instance.groups_access('read') + end + + it 'returns Groups' do + group_access_instance.group_names_access_map = { + group_read.name => 'read', + } + result = group_access_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(group_access_instance).to respond_to(:group_names_access_map=) + end + + context 'existing instance' do + + it 'stores Hash with String values' do + expect do + group_access_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 Array values' do + expect do + group_access_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 + end + + context 'new instance' do + + it "doesn't store directly" do + expect do + new_group_access_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_group_access_instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => 'read', + } + + new_group_access_instance.save + end.to change { + described_class.group_through.klass.count + }.by(2) + end + end + end + + context '#group_names_access_map' do + + it 'responds to group_names_access_map' do + expect(group_access_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'], + } + + group_access_instance.group_names_access_map = expected + + expect(group_access_instance.group_names_access_map).to eq(expected) + end + + it "doesn't map for inactive instances" do + group_access_instance_inactive.group_names_access_map = { + group_full.name => ['full'], + group_read.name => ['read'], + } + + expect(group_access_instance_inactive.group_names_access_map).to be_empty + end + end + + context '#group_ids_access_map=' do + + it 'responds to group_ids_access_map=' do + expect(group_access_instance).to respond_to(:group_ids_access_map=) + end + + context 'existing instance' do + + it 'stores Hash with String values' do + expect do + group_access_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 + group_access_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 + end + + context 'new instance' do + + it "doesn't store directly" do + expect do + new_group_access_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_group_access_instance.group_ids_access_map = { + group_full.id => 'full', + group_read.id => 'read', + } + + new_group_access_instance.save + end.to change { + described_class.group_through.klass.count + }.by(2) + end + end + end + + context '#group_ids_access_map' do + + it 'responds to group_ids_access_map' do + expect(group_access_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'], + } + + group_access_instance.group_ids_access_map = expected + + expect(group_access_instance.group_ids_access_map).to eq(expected) + end + + it "doesn't map for inactive instances" do + group_access_instance_inactive.group_ids_access_map = { + group_full.id => ['full'], + group_read.id => ['read'], + } + + expect(group_access_instance_inactive.group_ids_access_map).to be_empty + 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'], + } + + group_access_instance.associations_from_param(group_ids: expected) + expect(group_access_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'], + } + + group_access_instance.associations_from_param(groups: expected) + expect(group_access_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'], + } + + group_access_instance.group_ids_access_map = expected + + result = group_access_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'], + } + + group_access_instance.group_ids_access_map = expected + + result = group_access_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'], + } + + group_access_instance.group_names_access_map = expected + + result = group_access_instance.attributes_with_association_names + expect(result['groups']).to eq(expected) + end + end + + context '.group_access' do + + it 'responds to group_access' do + expect(described_class).to respond_to(:group_access) + end + + before(:each) do + group_access_instance.group_names_access_map = { + group_read.name => 'read', + } + end + + it 'lists only active instances' do + group_access_instance_inactive.group_names_access_map = { + group_read.name => 'read', + } + + result = described_class.group_access(group_read.id, 'read') + expect(result).not_to include(group_access_instance_inactive) + 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 + end + + context '.group_access_ids' do + + it 'responds to group_access_ids' do + expect(described_class).to respond_to(:group_access_ids) + end + + it 'wraps .group_access' do + expect(described_class).to receive(:group_access).and_call_original + described_class.group_access_ids(group_read, 'read') + end + + it 'returns class instances' do + group_access_instance.group_names_access_map = { + group_read.name => 'read', + } + + result = described_class.group_access_ids(group_read, 'read') + expect(result).to include(group_access_instance.id) + end + end + + it 'destroys relations before instance gets destroyed' do + + group_access_instance.group_names_access_map = { + group_full.name => 'full', + group_read.name => 'read', + group_inactive.name => 'write', + } + expect do + group_access_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(group_access_instance.group_access?(group_parameter, 'read')).to be true + end + + it 'checks negative' do + expect(group_access_instance.group_access?(group_parameter, 'write')).to be false + end + end + + context 'access list' do + + it 'checks positive' do + expect(group_access_instance.group_access?(group_parameter, %w(read write))).to be true + end + + it 'checks negative' do + expect(group_access_instance.group_access?(group_parameter, %w(write create))).to be false + end + end +end + +RSpec.shared_examples '.group_access call' do + context 'single access' do + + it 'lists access IDs' do + expect(described_class.group_access(group_parameter, 'read')).to include(group_access_instance) + end + + it 'excludes non access IDs' do + expect(described_class.group_access(group_parameter, 'write')).not_to include(group_access_instance) + end + end + + context 'access list' do + + it 'lists access IDs' do + expect(described_class.group_access(group_parameter, %w(read write))).to include(group_access_instance) + end + + it 'excludes non access IDs' do + expect(described_class.group_access(group_parameter, %w(write create))).not_to include(group_access_instance) + end + end +end diff --git a/spec/models/concerns/has_groups_permissions_examples.rb b/spec/models/concerns/has_groups_permissions_examples.rb new file mode 100644 index 000000000..0e66786be --- /dev/null +++ b/spec/models/concerns/has_groups_permissions_examples.rb @@ -0,0 +1,79 @@ +# Requires: let(:group_access_no_permission_instance) { ... } +RSpec.shared_examples 'HasGroups and Permissions' do + + context 'group' do + + let(:group_read) { create(:group) } + + before(:each) do + group_access_no_permission_instance.group_names_access_map = { + group_read.name => 'read', + } + end + + context '#group_access?' do + + it 'prevents instances without permissions' do + expect(group_access_no_permission_instance.group_access?(group_read, 'read')).to be false + end + end + + context '#group_ids_access' do + + it 'prevents instances without permissions' do + expect(group_access_no_permission_instance.group_ids_access('read')).to be_empty + end + end + + context '#groups_access' do + + it 'prevents instances without permissions' do + expect(group_access_no_permission_instance.groups_access('read')).to be_empty + end + end + + context '#group_names_access_map' do + + it 'prevents instances without permissions' do + expect(group_access_no_permission_instance.group_names_access_map).to be_empty + end + end + + context '#group_ids_access_map' do + + it 'prevents instances without permissions' do + expect(group_access_no_permission_instance.group_ids_access_map).to be_empty + end + end + + context '#attributes_with_association_ids' do + + it 'prevents instances without permissions' do + expect(group_access_no_permission_instance.attributes_with_association_ids['group_ids']).to be_empty + end + end + + context '#attributes_with_association_names' do + + it 'prevents instances without permissions' do + expect(group_access_no_permission_instance.attributes_with_association_names['group_ids']).to be_empty + end + end + + context '.group_access' do + + it 'prevents instances without permissions' do + result = described_class.group_access(group_read.id, 'read') + expect(result).not_to include(group_access_no_permission_instance) + end + end + + context '.group_access_ids' do + + it 'prevents instances without permissions' do + result = described_class.group_access(group_read.id, 'read') + expect(result).not_to include(group_access_no_permission_instance.id) + end + 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..c591c317b --- /dev/null +++ b/spec/models/concerns/has_roles_examples.rb @@ -0,0 +1,271 @@ +# Requires: let(:group_access_instance) { ... } +# Requires: let(:new_group_access_instance) { ... } +RSpec.shared_examples 'HasRoles' do + + context 'role' do + + let(:group_access_instance_inactive) { + group_access_instance.update_attribute(:active, false) + group_access_instance + } + 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 + + it 'responds to role_access?' do + expect(group_access_instance).to respond_to(:role_access?) + end + + context 'active Role' do + before(:each) do + role.group_names_access_map = { + group_role.name => 'read', + } + + group_access_instance.roles.push(role) + group_access_instance.save + 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(group_access_instance.group_access?(group_inactive.id, 'read')).to be false + end + end + + it 'prevents inactive Role' do + role_inactive = create(:role, active: false) + role_inactive.group_names_access_map = { + group_role.name => 'read', + } + + group_access_instance.roles.push(role_inactive) + group_access_instance.save + + expect(group_access_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', + } + + group_access_instance.roles.push(role) + group_access_instance.save + 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', + } + + group_access_instance_inactive.roles.push(role) + group_access_instance_inactive.save + group_access_instance_inactive.save + + result = described_class.role_access_ids(group_role.id, 'read') + expect(result).not_to include(group_access_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 } + end + end + end + + context 'group' do + + before(:each) do + role.group_names_access_map = { + group_role.name => 'read', + } + + group_access_instance.roles.push(role) + group_access_instance.save + + group_access_instance.group_names_access_map = { + group_instance.name => 'read', + } + end + + context '#group_access?' do + + it 'falls back to #role_access?' do + expect(group_access_instance).to receive(:role_access?) + group_access_instance.group_access?(group_role, 'read') + end + + it "doesn't fall back to #role_access? if not needed" do + expect(group_access_instance).not_to receive(:role_access?) + group_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', + } + + group_access_instance.roles.push(role) + group_access_instance.save + + group_access_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 = group_access_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 = group_access_instance.group_ids_access('read') + expect(result).to include(group_role.id) + end + + it "doesn't list for no access" do + result = group_access_instance.group_ids_access('write') + expect(result).not_to include(group_role.id) + end + + it "doesn't contain duplicate IDs" do + group_access_instance.group_names_access_map = { + group_role.name => 'read', + } + + result = group_access_instance.group_ids_access('read') + expect(result.uniq).to eq(result) + end + end + + context 'access list' do + + it 'lists access Group IDs' do + result = group_access_instance.group_ids_access(%w(read write)) + expect(result).to include(group_role.id) + end + + it "doesn't list for no access" do + result = group_access_instance.group_ids_access(%w(write create)) + expect(result).not_to include(group_role.id) + end + + it "doesn't contain duplicate IDs" do + group_access_instance.group_names_access_map = { + group_role.name => 'read', + } + + result = group_access_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(group_access_instance.id) + end + + it "doesn't contain duplicate IDs" do + group_access_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(group_access_instance.role_access?(group_parameter, 'read')).to be true + end + + it 'checks negative' do + expect(group_access_instance.role_access?(group_parameter, 'write')).to be false + end + end + + context 'access list' do + + it 'checks positive' do + expect(group_access_instance.role_access?(group_parameter, %w(read write))).to be true + end + + it 'checks negative' do + expect(group_access_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(group_access_instance.id) + end + + it 'excludes non access IDs' do + expect(described_class.role_access_ids(group_parameter, 'write')).not_to include(group_access_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(group_access_instance.id) + end + + it 'excludes non access IDs' do + expect(described_class.role_access_ids(group_parameter, %w(write create))).not_to include(group_access_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..7f02ceb5c --- /dev/null +++ b/spec/models/role_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' +require 'models/concerns/has_groups_examples' + +RSpec.describe Role do + let(:group_access_instance) { create(:role) } + let(:new_group_access_instance) { build(:role) } + + include_examples 'HasGroups' +end diff --git a/spec/models/scheduler_spec.rb b/spec/models/scheduler_spec.rb index 6f9302e65..7432186d0 100644 --- a/spec/models/scheduler_spec.rb +++ b/spec/models/scheduler_spec.rb @@ -1,7 +1,5 @@ require 'rails_helper' -RSpec::Matchers.define_negated_matcher :not_change, :change - RSpec.describe Scheduler do before do diff --git a/spec/models/ticket_spec.rb b/spec/models/ticket_spec.rb index 3ef97f67e..f90bf2029 100644 --- a/spec/models/ticket_spec.rb +++ b/spec/models/ticket_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe Ticket do - describe '.merge_to' do + describe '#merge_to' do it 'reassigns all links to the target ticket after merge' do source_ticket = create(:ticket) @@ -32,9 +32,38 @@ 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('ticket already merged, no merge into merged ticket possible') + end + + it 'prevents merging ticket in it self' do + source_ticket = create(:ticket) + + expect { + result = source_ticket.merge_to( + ticket_id: source_ticket.id, + user_id: 1, + ) + }.to raise_error('Can\'t merge ticket with it self!') + end + end - describe '.destroy' do + describe '#destroy' do it 'deletes all related objects before destroy' do ApplicationHandleInfo.current = 'application_server' @@ -144,7 +173,7 @@ RSpec.describe Ticket do end - describe '.perform_changes' do + describe '#perform_changes' do it 'performes a ticket state change on a ticket' do source_ticket = create(:ticket) @@ -174,4 +203,27 @@ RSpec.describe Ticket do end + context 'callbacks' do + + describe '#reset_pending_time' do + + it 'resets the pending time on state change' do + ticket = create(:ticket, + state: Ticket::State.lookup(name: 'pending reminder'), + pending_time: Time.zone.now + 2.days) + expect(ticket.pending_time).not_to be nil + + ticket.update_attribute(:state, Ticket::State.lookup(name: 'open')) + expect(ticket.pending_time).to be nil + end + + it 'lets handle ActiveRecord nil as new value' do + ticket = create(:ticket) + expect do + ticket.update_attribute(:state, nil) + end.to raise_error(ActiveRecord::StatementInvalid) + end + + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7c932eebb..e2a8bf18f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,7 +1,18 @@ require 'rails_helper' +require 'models/concerns/has_groups_examples' +require 'models/concerns/has_roles_examples' +require 'models/concerns/has_groups_permissions_examples' RSpec.describe User do + let(:group_access_instance) { create(:user, roles: [Role.find_by(name: 'Agent')]) } + let(:new_group_access_instance) { build(:user, roles: [Role.find_by(name: 'Agent')]) } + let(:group_access_no_permission_instance) { build(:user) } + + include_examples 'HasGroups' + include_examples 'HasRoles' + include_examples 'HasGroups and Permissions' + let(:new_password) { 'N3W54V3PW!' } context 'password' do @@ -15,6 +26,34 @@ RSpec.describe User do end end + context '#out_of_office_agent' do + + it 'responds to out_of_office_agent' do + user = create(:user) + expect(user).to respond_to(:out_of_office_agent) + end + + context 'replacement' do + + it 'finds assigned' do + user_replacement = create(:user) + + user_ooo = create(:user, + out_of_office: true, + out_of_office_start_at: Time.zone.yesterday, + out_of_office_end_at: Time.zone.tomorrow, + out_of_office_replacement_id: user_replacement.id,) + + expect(user_ooo.out_of_office_agent).to eq user_replacement + end + + it 'finds none for available users' do + user = create(:user) + expect(user.out_of_office_agent).to be nil + end + end + end + context '#max_login_failed?' do it 'responds to max_login_failed?' do diff --git a/spec/support/cache.rb b/spec/support/cache.rb new file mode 100644 index 000000000..ff2569724 --- /dev/null +++ b/spec/support/cache.rb @@ -0,0 +1,8 @@ +RSpec.configure do |config| + config.before(:each) do + # clear the cache otherwise it won't + # be able to recognize the rollbacks + # done by RSpec + Cache.clear + end +end diff --git a/spec/support/negated_matchers.rb b/spec/support/negated_matchers.rb new file mode 100644 index 000000000..93f5b66e5 --- /dev/null +++ b/spec/support/negated_matchers.rb @@ -0,0 +1 @@ +RSpec::Matchers.define_negated_matcher :not_change, :change diff --git a/spec/support/sequencer.rb b/spec/support/sequencer.rb new file mode 100644 index 000000000..bbbfa9f8b --- /dev/null +++ b/spec/support/sequencer.rb @@ -0,0 +1,19 @@ +module SequencerUnit + + def process(parameters, &block) + Sequencer::Unit.process(described_class.name, parameters, &block) + end +end + +module SequencerSequence + + def process(parameters = {}) + Sequencer.process(described_class.name, + parameters: parameters) + end +end + +RSpec.configure do |config| + config.include SequencerUnit, sequencer: :unit + config.include SequencerSequence, sequencer: :sequence +end diff --git a/spec/support/system_init.rb b/spec/support/system_init.rb index 2ff4deb8c..3b4bcec18 100644 --- a/spec/support/system_init.rb +++ b/spec/support/system_init.rb @@ -1,11 +1,15 @@ RSpec.configure do |config| config.before(:suite) do - FactoryGirl.create(:user, - login: 'admin', - firstname: 'Admin', - lastname: 'Admin', - email: 'admin@example.com', - password: 'admin', - roles: [Role.lookup(name: 'Admin')],) + + email = 'admin@example.com' + if !::User.exists?(email: email) + FactoryGirl.create(:user, + login: 'admin', + firstname: 'Admin', + lastname: 'Admin', + email: email, + password: 'admin', + roles: [Role.lookup(name: 'Admin')],) + end end end diff --git a/test/browser/aab_unit_test.rb b/test/browser/aab_unit_test.rb index c0bd30301..6191dfa85 100644 --- a/test/browser/aab_unit_test.rb +++ b/test/browser/aab_unit_test.rb @@ -87,6 +87,13 @@ class AAbUnitTest < TestCase value: '0', ) + location(url: browser_url + '/tests_form_tree_select') + sleep 2 + match( + css: '.result .failed', + value: '0', + ) + location(url: browser_url + '/tests_form_column_select') sleep 2 match( 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/agent_ticket_update_and_reload_test.rb b/test/browser/agent_ticket_update_and_reload_test.rb index 823477cf1..27a5583d1 100644 --- a/test/browser/agent_ticket_update_and_reload_test.rb +++ b/test/browser/agent_ticket_update_and_reload_test.rb @@ -25,6 +25,7 @@ class AgentTicketUpdateAndReloadTest < TestCase sleep 6 # check if customer is shown in sidebar + click(css: '.active .tabsSidebar-tab[data-tab="customer"]') match( css: '.active .sidebar[data-tab="customer"]', value: 'nicole', @@ -46,6 +47,7 @@ class AgentTicketUpdateAndReloadTest < TestCase reload() # check if customer is still shown in sidebar + click(css: '.active .tabsSidebar-tab[data-tab="customer"]') watch_for( css: '.active .sidebar[data-tab="customer"]', value: 'nicole', 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/form_test.rb b/test/browser/form_test.rb index 5bb89f5d0..8b6f35a91 100644 --- a/test/browser/form_test.rb +++ b/test/browser/form_test.rb @@ -82,23 +82,6 @@ class FormTest < TestCase browser: agent, css: 'body div.zammad-form-modal button[type="submit"][disabled]', ) - set( - browser: agent, - css: 'body div.zammad-form-modal [name="email"]', - value: 'notexistinginanydomainspacealsonothere@znuny.com', - ) - click( - browser: agent, - css: 'body div.zammad-form-modal button[type="submit"]', - ) - watch_for( - browser: agent, - css: 'body div.zammad-form-modal .has-error [name="email"]', - ) - watch_for_disappear( - browser: agent, - css: 'body div.zammad-form-modal button[type="submit"][disabled]', - ) set( browser: agent, css: 'body div.zammad-form-modal [name="email"]', @@ -315,23 +298,6 @@ class FormTest < TestCase browser: customer, css: 'body div.zammad-form-modal button[type="submit"][disabled]', ) - set( - browser: customer, - css: 'body div.zammad-form-modal [name="email"]', - value: 'notexistinginanydomainspacealsonothere@znuny.com', - ) - click( - browser: customer, - css: 'body div.zammad-form-modal button[type="submit"]', - ) - watch_for( - browser: customer, - css: 'body div.zammad-form-modal .has-error [name="email"]', - ) - watch_for_disappear( - browser: customer, - css: 'body div.zammad-form-modal button[type="submit"][disabled]', - ) set( browser: customer, css: 'body div.zammad-form-modal [name="email"]', diff --git a/test/browser_test_helper.rb b/test/browser_test_helper.rb index 55e527f3b..f0c42a8d1 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( @@ -2242,7 +2261,7 @@ wait untill text in selector disabppears 9.times { begin text = instance.find_elements(css: '.content.active .js-reset')[0].text - if !text || text.empty? + if text.blank? screenshot(browser: instance, comment: 'ticket_update_ok') sleep 1 return true @@ -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/form_controller_test.rb b/test/controllers/form_controller_test.rb new file mode 100644 index 000000000..7637802d0 --- /dev/null +++ b/test/controllers/form_controller_test.rb @@ -0,0 +1,247 @@ +# encoding: utf-8 +require 'test_helper' +require 'rake' + +class FormControllerTest < ActionDispatch::IntegrationTest + setup do + @headers = { 'ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json', 'REMOTE_ADDR' => '1.2.3.4' } + + 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']) + + # 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') + + 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']) + end + + Ticket.destroy_all + + # drop/create indexes + Setting.reload + Rake::Task.clear + Zammad::Application.load_tasks + Rake::Task['searchindex:rebuild'].execute + end + + teardown do + if ENV['ES_URL'].present? + Rake::Task['searchindex:drop'].execute + end + end + + test '01 - get config call' do + post '/api/v1/form_config', {}.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + end + + test '02 - get config call' do + Setting.set('form_ticket_create', true) + post '/api/v1/form_config', {}.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + + end + + test '03 - get config call & do submit' do + Setting.set('form_ticket_create', true) + fingerprint = SecureRandom.hex(40) + post '/api/v1/form_config', { fingerprint: fingerprint }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['enabled'], true) + assert_equal(result['endpoint'], 'http://zammad.example.com/api/v1/form_submit') + assert(result['token']) + token = result['token'] + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: 'invalid' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'required') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, email: 'some' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'invalid') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test', body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + + travel 5.hours + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test', body: 'hello' }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + + travel 20.hours + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test', body: 'hello' }.to_json, @headers + assert_response(401) + + end + + test '04 - get config call & do submit' do + Setting.set('form_ticket_create', true) + fingerprint = SecureRandom.hex(40) + post '/api/v1/form_config', { fingerprint: fingerprint }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['enabled'], true) + assert_equal(result['endpoint'], 'http://zammad.example.com/api/v1/form_submit') + assert(result['token']) + token = result['token'] + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: 'invalid' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['error'], 'Not authorized') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'required') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, email: 'some' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['name'], 'required') + assert_equal(result['errors']['email'], 'invalid') + assert_equal(result['errors']['title'], 'required') + assert_equal(result['errors']['body'], 'required') + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'somebody@example.com', title: 'test', body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert(result['errors']) + assert_equal(result['errors']['email'], 'invalid') + + end + + test '05 - limits' do + return if !SearchIndexBackend.enabled? + + Setting.set('form_ticket_create', true) + fingerprint = SecureRandom.hex(40) + post '/api/v1/form_config', { fingerprint: fingerprint }.to_json, @headers + + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['enabled'], true) + assert_equal(result['endpoint'], 'http://zammad.example.com/api/v1/form_submit') + assert(result['token']) + token = result['token'] + + (1..20).each { |count| + travel 10.seconds + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: "test#{count}", body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + Scheduler.worker(true) + sleep 1 # wait until elasticsearch is index + } + + sleep 10 # wait until elasticsearch is index + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test-last', body: 'hello' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert(result['error']) + + @headers = { 'ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json', 'REMOTE_ADDR' => '1.2.3.5' } + + (1..20).each { |count| + travel 10.seconds + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: "test-2-#{count}", body: 'hello' }.to_json, @headers + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + + assert_not(result['errors']) + assert(result['ticket']) + assert(result['ticket']['id']) + assert(result['ticket']['number']) + Scheduler.worker(true) + sleep 1 # wait until elasticsearch is index + } + + sleep 10 # wait until elasticsearch is index + + post '/api/v1/form_submit', { fingerprint: fingerprint, token: token, name: 'Bob Smith', email: 'discard@znuny.com', title: 'test-2-last', body: 'hello' }.to_json, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert(result['error']) + end + +end diff --git a/test/controllers/integration_check_mk_controller_test.rb b/test/controllers/integration_check_mk_controller_test.rb new file mode 100644 index 000000000..6bef50bea --- /dev/null +++ b/test/controllers/integration_check_mk_controller_test.rb @@ -0,0 +1,270 @@ +# encoding: utf-8 +require 'test_helper' + +class IntegationCheckMkControllerTest < ActionDispatch::IntegrationTest + setup do + token = SecureRandom.urlsafe_base64(16) + Setting.set('check_mk_token', token) + Setting.set('check_mk_integration', true) + end + + test '01 without token' do + post '/api/v1/integration/check_mk/', {} + assert_response(404) + end + + test '01 invalid token & enabled feature' do + post '/api/v1/integration/check_mk/invalid_token', {} + assert_response(422) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_equal('Invalid token!', result['error']) + end + + test '01 invalid token & disabled feature' do + Setting.set('check_mk_integration', false) + + post '/api/v1/integration/check_mk/invalid_token', {} + assert_response(422) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_equal('Feature is disable, please contact your admin to enable it!', result['error']) + end + + test '02 ticket create & close' do + params = { + event_id: '123', + state: 'down', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert(result['result']) + assert(result['ticket_id']) + assert(result['ticket_number']) + + ticket = Ticket.find(result['ticket_id']) + assert_equal('new', ticket.state.name) + assert_equal(1, ticket.articles.count) + + params = { + event_id: '123', + state: 'up', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert(result['result']) + assert(result['ticket_ids'].include?(ticket.id)) + + ticket.reload + assert_equal('closed', ticket.state.name) + assert_equal(2, ticket.articles.count) + end + + test '02 ticket create & create & auto close' do + params = { + event_id: '123', + state: 'down', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert(result['result']) + assert(result['ticket_id']) + assert(result['ticket_number']) + + ticket = Ticket.find(result['ticket_id']) + assert_equal('new', ticket.state.name) + assert_equal(1, ticket.articles.count) + + params = { + event_id: '123', + state: 'down', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert_equal('ticket already open, added note', result['result']) + assert(result['ticket_ids'].include?(ticket.id)) + + ticket.reload + assert_equal('new', ticket.state.name) + assert_equal(2, ticket.articles.count) + + params = { + event_id: '123', + state: 'up', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert(result['result']) + assert(result['ticket_ids'].include?(ticket.id)) + + ticket.reload + assert_equal('closed', ticket.state.name) + assert_equal(3, ticket.articles.count) + end + + test '02 ticket close' do + params = { + event_id: '123', + state: 'up', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert_equal('no open tickets found, ignore action', result['result']) + end + + test '02 ticket create & create & no auto close' do + Setting.set('check_mk_auto_close', false) + params = { + event_id: '123', + state: 'down', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert(result['result']) + assert(result['ticket_id']) + assert(result['ticket_number']) + + ticket = Ticket.find(result['ticket_id']) + assert_equal('new', ticket.state.name) + assert_equal(1, ticket.articles.count) + + params = { + event_id: '123', + state: 'down', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert_equal('ticket already open, added note', result['result']) + assert(result['ticket_ids'].include?(ticket.id)) + + ticket.reload + assert_equal('new', ticket.state.name) + assert_equal(2, ticket.articles.count) + + params = { + event_id: '123', + state: 'up', + host: 'some host', + service: 'some service', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert_equal('ticket already open, added note', result['result']) + assert(result['ticket_ids'].include?(ticket.id)) + + ticket.reload + assert_equal('new', ticket.state.name) + assert_equal(3, ticket.articles.count) + end + + test '02 ticket create & create & auto close - host only' do + params = { + event_id: '123', + state: 'down', + host: 'some host', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert(result['result']) + assert(result['ticket_id']) + assert(result['ticket_number']) + + ticket = Ticket.find(result['ticket_id']) + assert_equal('new', ticket.state.name) + assert_equal(1, ticket.articles.count) + + params = { + event_id: '123', + state: 'down', + host: 'some host', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert_equal('ticket already open, added note', result['result']) + assert(result['ticket_ids'].include?(ticket.id)) + + ticket.reload + assert_equal('new', ticket.state.name) + assert_equal(2, ticket.articles.count) + + params = { + event_id: '123', + state: 'up', + host: 'some host', + } + post "/api/v1/integration/check_mk/#{Setting.get('check_mk_token')}", params + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + + assert(result['result']) + assert(result['ticket_ids'].include?(ticket.id)) + + ticket.reload + assert_equal('closed', ticket.state.name) + assert_equal(3, ticket.articles.count) + end +end diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb index 5ba3aa1de..dea48e6be 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 @@ -90,18 +85,14 @@ class SearchControllerTest < ActionDispatch::IntegrationTest organization_id: @organization.id, ) - 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 +102,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( + travel 1.second + @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 +121,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( + travel 1.second + @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 +140,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']) @@ -171,18 +152,25 @@ 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'] - #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 + 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) @@ -191,9 +179,13 @@ class SearchControllerTest < ActionDispatch::IntegrationTest end end - test 'settings index with nobody' do - base_data + teardown do + if ENV['ES_URL'].present? + Rake::Task['searchindex:drop'].execute + end + end + test 'settings index with nobody' do params = { query: 'test 1234', limit: 2, @@ -219,19 +211,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 +281,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 +352,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 +395,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 +442,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/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/controllers/user_organization_controller_test.rb b/test/controllers/user_organization_controller_test.rb index 71159adc6..4754a75de 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', @@ -114,7 +126,23 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest assert_response(422) result = JSON.parse(@response.body) assert(result['error']) - assert_equal('User already exists!', result['error']) + assert_equal('Email address is already used for other user.', result['error']) + + # email missing with enabled feature + params = { firstname: 'some firstname', signup: true } + post '/api/v1/users', params.to_json, headers + assert_response(422) + result = JSON.parse(@response.body) + assert(result['error']) + assert_equal('Attribute \'email\' required!', result['error']) + + # email missing with enabled feature + params = { firstname: 'some firstname', signup: true } + post '/api/v1/users', params.to_json, headers + assert_response(422) + result = JSON.parse(@response.body) + assert(result['error']) + assert_equal('Attribute \'email\' required!', result['error']) # create user with enabled feature (take customer role) params = { firstname: 'Me First', lastname: 'Me Last', email: 'new_here@example.com', signup: true } @@ -310,7 +338,7 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest assert_response(422) result = JSON.parse(@response.body) assert(result) - assert_equal('User already exists!', result['error']) + assert_equal('Email address is already used for other user.', result['error']) # missing required attributes params = { note: 'some note' } @@ -318,15 +346,9 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest assert_response(422) result = JSON.parse(@response.body) assert(result) - assert_equal('Attribute \'login\' required!', result['error']) - - params = { firstname: 'newfirstname123', note: 'some note' } - post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials) - assert_response(422) - result = JSON.parse(@response.body) - assert(result) - assert_equal('Attribute \'login\' required!', result['error']) + assert_equal('Minimum one identifier (login, firstname, lastname, phone or email) for user is required.', result['error']) + # invalid email params = { firstname: 'newfirstname123', email: 'some_what', note: 'some note' } post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials) assert_response(422) @@ -334,6 +356,20 @@ class UserOrganizationControllerTest < ActionDispatch::IntegrationTest assert(result) assert_equal('Invalid email', result['error']) + # with valid attributes + params = { firstname: 'newfirstname123', note: 'some note' } + post '/api/v1/users', params.to_json, @headers.merge('Authorization' => credentials) + assert_response(201) + result = JSON.parse(@response.body) + assert(result) + user = User.find(result['id']) + assert_not(user.role?('Admin')) + assert_not(user.role?('Agent')) + assert(user.role?('Customer')) + assert(result['login'].start_with?('auto-')) + assert_equal('', result['email']) + assert_equal('newfirstname123', result['firstname']) + assert_equal('', result['lastname']) end test 'user index and create with agent' do @@ -384,17 +420,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/fixtures/calendar1.ics b/test/fixtures/calendar1.ics new file mode 100644 index 000000000..ad2c285ca --- /dev/null +++ b/test/fixtures/calendar1.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +VERSION:2.0 +X-WR-CALNAME:test2 +PRODID:-//Apple Inc.//Mac OS X 10.12.6//EN +X-APPLE-CALENDAR-COLOR:#CC73E1 +X-WR-TIMEZONE:Europe/Berlin +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010E +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas1 +DTSTART;VALUE=DATE:20121224 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/calendar2.ics b/test/fixtures/calendar2.ics new file mode 100644 index 000000000..5bed19d71 --- /dev/null +++ b/test/fixtures/calendar2.ics @@ -0,0 +1,51 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +VERSION:2.0 +X-WR-CALNAME:test2 +PRODID:-//Apple Inc.//Mac OS X 10.12.6//EN +X-APPLE-CALENDAR-COLOR:#CC73E1 +X-WR-TIMEZONE:Europe/Berlin +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010E +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas1 +DTSTART;VALUE=DATE:20121224 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010G +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas2 +DTSTART;VALUE=DATE:20121225 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/calendar3.ics b/test/fixtures/calendar3.ics new file mode 100644 index 000000000..98649a964 --- /dev/null +++ b/test/fixtures/calendar3.ics @@ -0,0 +1,111 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +VERSION:2.0 +X-WR-CALNAME:test2 +PRODID:-//Apple Inc.//Mac OS X 10.12.6//EN +X-APPLE-CALENDAR-COLOR:#CC73E1 +X-WR-TIMEZONE:Europe/Berlin +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +DTSTART:19810329T020000 +TZNAME:GMT+2 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +DTSTART:19961027T030000 +TZNAME:GMT+1 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010E +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas1 +DTSTART;VALUE=DATE:20121224 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T123409Z +UID:B461611E-DF33-42BA-B40E-DF32FAD3BA92 +RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=10 +DTEND;TZID=Europe/Berlin:20121228T100000 +TRANSP:OPAQUE +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day4 +DTSTART;TZID=Europe/Berlin:20121228T090000 +DTSTAMP:20170824T134117Z +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T123419Z +UID:4F1F0444-9F71-4FDD-9D4A-EBA6BF1EF72E +RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=5 +DTEND;VALUE=DATE:20121227 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day3 +DTSTART;VALUE=DATE:20161226 +DTSTAMP:20170824T134106Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:118EB330-738F-4956-9FAD-D548462CC26A +UID:118EB330-738F-4956-9FAD-D548462CC26A +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T134043Z +UID:3606DA64-D734-434A-A430-E2AC7ADECACF +DTEND;TZID=Europe/Berlin:20121226T100000 +TRANSP:OPAQUE +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day2 +DTSTART;TZID=Europe/Berlin:20121226T090000 +DTSTAMP:20170824T134043Z +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T134127Z +UID:7B5AB905-39EA-42B6-AD31-1E63D4C5C2C7 +DTEND;VALUE=DATE:20121229 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day5 +DTSTART;VALUE=DATE:20161228 +DTSTAMP:20170824T134134Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:45BF1D7B-3D2C-48DC-AB5C-BB391AFA7837 +UID:45BF1D7B-3D2C-48DC-AB5C-BB391AFA7837 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +END:VCALENDAR 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/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/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/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/fixtures/mail60.box b/test/fixtures/mail60.box new file mode 100644 index 000000000..8dde96cac --- /dev/null +++ b/test/fixtures/mail60.box @@ -0,0 +1,16 @@ +From: Martin Edenhofer +Content-Type: multipart/alternative; boundary="Apple-Mail=_EB2F27C4-F4CD-40C9-82F1-D115D4FFA394" +Subject: abc +Date: Fri, 4 May 2012 14:01:03 +0200 +Message-Id: +To: metest@znuny.com +Mime-Version: 1.0 (Apple Message framework v1257) + +--Apple-Mail=_EB2F27C4-F4CD-40C9-82F1-D115D4FFA394 +Content-Transfer-Encoding: base64 +Content-Type: text/plain; + charset=iso-gb2312 + +SGVyZSBpdCBnb2VzIC0gw6TDtsO8IC0g5beu5Ye65Lq6SGVyZSBpdCBnb2VzIC0g5Pb8IC0gaGkgrQ== + +--Apple-Mail=_EB2F27C4-F4CD-40C9-82F1-D115D4FFA394-- 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/integration/elasticsearch_test.rb b/test/integration/elasticsearch_test.rb index d58771423..6f7646b7d 100644 --- a/test/integration/elasticsearch_test.rb +++ b/test/integration/elasticsearch_test.rb @@ -1,111 +1,123 @@ # 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'" + + teardown do + if ENV['ES_URL'].present? + Rake::Task['searchindex:drop'].execute + end 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 +125,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 +158,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 +183,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 +218,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 +263,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 +289,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 +314,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 +332,7 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for html content result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'strong', limit: 15, ) @@ -326,7 +344,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 +352,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 +361,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 +376,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 +397,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 +411,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 +425,7 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for tags result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'tag:someTagA', limit: 15, ) @@ -416,7 +434,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 +457,7 @@ class ElasticsearchTest < ActiveSupport::TestCase # search for tags result = Ticket.search( - current_user: agent, + current_user: @agent, query: 'tag:someTagA', limit: 15, ) @@ -447,7 +465,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 +474,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 +483,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 +492,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 +501,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 +509,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 +531,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/email_deliver_test.rb b/test/integration/email_deliver_test.rb index 6d13296b6..7fa409897 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', @@ -202,9 +202,13 @@ class EmailDeliverTest < ActiveSupport::TestCase created_by_id: 1, ) + ticket1.state = Ticket::State.find_by(name: 'closed') + ticket1.save + assert_raises(RuntimeError) { Scheduler.worker(true) } + ticket1.reload article2_lookup = Ticket::Article.find(article2.id) assert_equal(2, ticket1.articles.count) @@ -214,6 +218,7 @@ class EmailDeliverTest < ActiveSupport::TestCase assert(article2_lookup.preferences['delivery_status_message']) Scheduler.worker(true) + ticket1.reload article2_lookup = Ticket::Article.find(article2.id) assert_equal(2, ticket1.articles.count) @@ -221,11 +226,13 @@ class EmailDeliverTest < ActiveSupport::TestCase assert_equal('fail', article2_lookup.preferences['delivery_status']) assert(article2_lookup.preferences['delivery_status_date']) assert(article2_lookup.preferences['delivery_status_message']) + assert_equal('closed', ticket1.state.name) sleep 6 assert_raises(RuntimeError) { Scheduler.worker(true) } + ticket1.reload article2_lookup = Ticket::Article.find(article2.id) assert_equal(2, ticket1.articles.count) @@ -233,30 +240,39 @@ class EmailDeliverTest < ActiveSupport::TestCase assert_equal('fail', article2_lookup.preferences['delivery_status']) assert(article2_lookup.preferences['delivery_status_date']) assert(article2_lookup.preferences['delivery_status_message']) + assert_equal('closed', ticket1.state.name) Scheduler.worker(true) + ticket1.reload + article2_lookup = Ticket::Article.find(article2.id) assert_equal(2, ticket1.articles.count) assert_equal(2, article2_lookup.preferences['delivery_retry']) assert_equal('fail', article2_lookup.preferences['delivery_status']) assert(article2_lookup.preferences['delivery_status_date']) assert(article2_lookup.preferences['delivery_status_message']) + assert_equal('closed', ticket1.state.name) sleep 11 assert_raises(RuntimeError) { Scheduler.worker(true) } + ticket1.reload + article2_lookup = Ticket::Article.find(article2.id) assert_equal(2, ticket1.articles.count) assert_equal(3, article2_lookup.preferences['delivery_retry']) assert_equal('fail', article2_lookup.preferences['delivery_status']) assert(article2_lookup.preferences['delivery_status_date']) assert(article2_lookup.preferences['delivery_status_message']) + assert_equal('closed', ticket1.state.name) sleep 16 assert_raises(RuntimeError) { Scheduler.worker(true) } + ticket1.reload + article2_lookup = Ticket::Article.find(article2.id) article_delivery_system = ticket1.articles.last assert_equal(3, ticket1.articles.count) @@ -267,7 +283,8 @@ class EmailDeliverTest < ActiveSupport::TestCase assert_equal('System', article_delivery_system.sender.name) assert_equal(true, article_delivery_system.preferences['delivery_message']) assert_equal(article2.id, article_delivery_system.preferences['delivery_article_id_related']) - + assert_equal(true, article_delivery_system.preferences['notification']) + assert_equal(Ticket::State.find_by(default_follow_up: true).name, ticket1.state.name) end end 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 diff --git a/test/integration/geo_location_test.rb b/test/integration/geo_location_test.rb index 388378255..8bd148a9a 100644 --- a/test/integration/geo_location_test.rb +++ b/test/integration/geo_location_test.rb @@ -51,7 +51,7 @@ class GeoLocationTest < ActiveSupport::TestCase login: 'some_geo_login2', firstname: 'First', lastname: 'Last', - email: 'some_geo_login1@example.com', + email: 'some_geo_login2@example.com', password: 'test', street: 'Marienstrasse 13', city: 'Berlin', diff --git a/test/integration/otrs_import_browser_test.rb b/test/integration/otrs_import_browser_test.rb index 52b5ab59a..9adea8be2 100644 --- a/test/integration/otrs_import_browser_test.rb +++ b/test/integration/otrs_import_browser_test.rb @@ -54,7 +54,7 @@ class OtrsImportBrowserTest < TestCase watch_for( css: 'body', value: 'login', - timeout: 300, + timeout: 480, ) end 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/report_test.rb b/test/integration/report_test.rb index adc5db302..90b52a714 100644 --- a/test/integration/report_test.rb +++ b/test/integration/report_test.rb @@ -1,262 +1,264 @@ # 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'].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_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'" + + teardown do + if ENV['ES_URL'].present? + Rake::Task['searchindex:drop'].execute + end 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 +278,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 +286,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 +316,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 +329,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 +357,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 +370,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 +389,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 +398,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 +442,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 +451,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 +487,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 +496,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 +519,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 +527,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 +555,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 +568,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 +596,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 +609,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 +639,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 +655,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 +686,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 +702,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 +726,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 +736,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/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) diff --git a/test/integration/twitter_browser_test.rb b/test/integration/twitter_browser_test.rb index abd763ae8..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', @@ -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 diff --git a/test/integration/zendesk_import_test.rb b/test/integration/zendesk_import_test.rb index 709ff1f2f..563ed7bff 100644 --- a/test/integration/zendesk_import_test.rb +++ b/test/integration/zendesk_import_test.rb @@ -136,10 +136,17 @@ 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") + assert_equal(check[:groups], user.groups_access('full').sort.to_a, "#{user.login} groups") } end @@ -173,6 +180,10 @@ class ZendeskImportTest < ActiveSupport::TestCase last_login source login_failed + out_of_office + out_of_office_start_at + out_of_office_end_at + out_of_office_replacement_id preferences updated_by_id created_by_id @@ -247,7 +258,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 +300,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 +311,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 +333,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 +394,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 @@ -454,6 +479,7 @@ If you\'re reading this message in your email, click the ticket number link that last_contact_at last_contact_agent_at last_contact_customer_at + last_owner_update_at create_article_type_id create_article_sender_id article_count @@ -471,7 +497,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') 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..7be5849fc 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 @@ -59,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/activity_stream_test.rb b/test/unit/activity_stream_test.rb index 47cef8b35..8da295f33 100644 --- a/test/unit/activity_stream_test.rb +++ b/test/unit/activity_stream_test.rb @@ -2,43 +2,43 @@ 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', - email: 'bob@example.com', + email: 'bob+active_stream@example.com', 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( + 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( + 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,41 +61,40 @@ 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 - ticket.destroy + ticket.destroy! travel_back end test 'organization' do - - organization = Organization.create( + 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,36 +106,36 @@ 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 - organization.destroy + organization.destroy! travel_back end test 'user with update check false' do - user = User.create( + 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, ) assert_equal(user.class, User) user.update_attributes( @@ -145,31 +144,30 @@ 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 - user.destroy + user.destroy! travel_back end test 'user with update check true' do - - user = User.create( + 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,26 +185,26 @@ 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 - user.destroy + user.destroy! travel_back end diff --git a/test/unit/assets_test.rb b/test/unit/assets_test.rb index 6f042ab9e..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,15 +126,15 @@ 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' ) travel_back - user1.destroy - user2.destroy user3.destroy + user2.destroy + user1.destroy org1.destroy org2.destroy @@ -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,15 +264,15 @@ 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' ) travel_back - user1.destroy - user2.destroy user3.destroy + user2.destroy + user1.destroy org.destroy org_new.destroy 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/calendar_test.rb b/test/unit/calendar_test.rb index 197d5e1f3..6561e3569 100644 --- a/test/unit/calendar_test.rb +++ b/test/unit/calendar_test.rb @@ -3,7 +3,7 @@ require 'test_helper' class CalendarTest < ActiveSupport::TestCase test 'default test' do - Calendar.delete_all + Calendar.destroy_all calendar1 = Calendar.create_or_update( name: 'US 1', timezone: 'America/Los_Angeles', @@ -91,4 +91,154 @@ class CalendarTest < ActiveSupport::TestCase travel_back end + test 'sync test' do + Calendar.destroy_all + + travel_to Time.zone.parse('2017-08-24T01:04:44Z0') + + calendar1 = Calendar.create_or_update( + name: 'Sync 1', + timezone: 'America/Los_Angeles', + business_hours: { + mon: { '09:00' => '17:00' }, + tue: { '09:00' => '17:00' }, + wed: { '09:00' => '17:00' }, + thu: { '09:00' => '17:00' }, + fri: { '09:00' => '17:00' } + }, + default: true, + ical_url: 'test/fixtures/calendar1.ics', + updated_by_id: 1, + created_by_id: 1, + ) + + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_nil(calendar1.public_holidays['2016-12-25']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_nil(calendar1.public_holidays['2017-12-25']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_nil(calendar1.public_holidays['2018-12-25']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2019-12-25']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + Calendar.sync + + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_nil(calendar1.public_holidays['2016-12-25']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_nil(calendar1.public_holidays['2017-12-25']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_nil(calendar1.public_holidays['2018-12-25']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2019-12-25']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + cache_key = "CalendarIcal::#{calendar1.id}" + cache = Cache.get(cache_key) + + calendar1.update_columns(ical_url: 'test/fixtures/calendar2.ics') + cache_key = "CalendarIcal::#{calendar1.id}" + cache = Cache.get(cache_key) + cache[:ical_url] = 'test/fixtures/calendar2.ics' + Cache.write( + cache_key, + cache, + { expires_in: 1.day }, + ) + + Calendar.sync + + calendar1.reload + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_nil(calendar1.public_holidays['2016-12-25']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_nil(calendar1.public_holidays['2017-12-25']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_nil(calendar1.public_holidays['2018-12-25']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2019-12-25']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + travel 2.days + + Calendar.sync + + calendar1.reload + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2016-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2016-12-25']['summary']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2017-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2017-12-25']['summary']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2018-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2018-12-25']['summary']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2019-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2019-12-25']['summary']) + assert_nil(calendar1.public_holidays['2020-12-24']) + assert_nil(calendar1.public_holidays['2020-12-25']) + + Calendar.destroy_all + + calendar1 = Calendar.create_or_update( + name: 'Sync 2', + timezone: 'America/Los_Angeles', + business_hours: { + mon: { '09:00' => '17:00' }, + tue: { '09:00' => '17:00' }, + wed: { '09:00' => '17:00' }, + thu: { '09:00' => '17:00' }, + fri: { '09:00' => '17:00' } + }, + default: true, + ical_url: 'test/fixtures/calendar3.ics', + updated_by_id: 1, + created_by_id: 1, + ) + + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2016-12-26']['active']) + assert_equal('day3', calendar1.public_holidays['2016-12-26']['summary']) + assert_equal(true, calendar1.public_holidays['2016-12-28']['active']) + assert_equal('day5', calendar1.public_holidays['2016-12-28']['summary']) + assert_equal(true, calendar1.public_holidays['2017-01-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-01-26']['summary']) + assert_equal(true, calendar1.public_holidays['2017-02-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-02-26']['summary']) + assert_equal(true, calendar1.public_holidays['2017-03-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-03-26']['summary']) + assert_equal(true, calendar1.public_holidays['2017-04-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-04-26']['summary']) + assert_nil(calendar1.public_holidays['2017-05-26']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + travel_back + + end + 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/cti_caller_id_test.rb b/test/unit/cti_caller_id_test.rb index a5dea0a81..94c08c078 100644 --- a/test/unit/cti_caller_id_test.rb +++ b/test/unit/cti_caller_id_test.rb @@ -3,12 +3,12 @@ require 'test_helper' class CtiCallerIdTest < ActiveSupport::TestCase - test '2 lookups' do + setup do Ticket.destroy_all Cti::CallerId.destroy_all - agent1 = User.create_or_update( + @agent1 = User.create_or_update( login: 'ticket-caller_id-agent1@example.com', firstname: 'CallerId', lastname: 'Agent1', @@ -22,7 +22,7 @@ class CtiCallerIdTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - agent2 = User.create_or_update( + @agent2 = User.create_or_update( login: 'ticket-caller_id-agent2@example.com', firstname: 'CallerId', lastname: 'Agent2', @@ -34,7 +34,7 @@ class CtiCallerIdTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - agent3 = User.create_or_update( + @agent3 = User.create_or_update( login: 'ticket-caller_id-agent3@example.com', firstname: 'CallerId', lastname: 'Agent3', @@ -46,7 +46,7 @@ class CtiCallerIdTest < ActiveSupport::TestCase created_by_id: 1, ) - customer1 = User.create_or_update( + @customer1 = User.create_or_update( login: 'ticket-caller_id-customer1@example.com', firstname: 'CallerId', lastname: 'Customer1', @@ -57,6 +57,10 @@ class CtiCallerIdTest < ActiveSupport::TestCase created_by_id: 1, ) + end + + test '1 lookups' do + Cti::CallerId.rebuild caller_ids = Cti::CallerId.lookup('491111222277') @@ -64,27 +68,27 @@ class CtiCallerIdTest < ActiveSupport::TestCase caller_ids = Cti::CallerId.lookup('491111222223') assert_equal(1, caller_ids.length) - assert_equal(agent1.id, caller_ids[0].user_id) + assert_equal(@agent1.id, caller_ids[0].user_id) assert_equal('known', caller_ids[0].level) caller_ids = Cti::CallerId.lookup('492222222222') assert_equal(2, caller_ids.length) - assert_equal(agent3.id, caller_ids[0].user_id) + assert_equal(@agent3.id, caller_ids[0].user_id) assert_equal('known', caller_ids[0].level) - assert_equal(agent2.id, caller_ids[1].user_id) + assert_equal(@agent2.id, caller_ids[1].user_id) assert_equal('known', caller_ids[1].level) # create ticket in group - ticket1 = Ticket.create( + ticket1 = Ticket.create!( title: 'some caller id test 1', group: Group.lookup(name: 'Users'), - customer: customer1, + customer: @customer1, 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, ) - article1 = Ticket::Article.create( + article1 = Ticket::Article.create!( ticket_id: ticket1.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -96,22 +100,22 @@ Mob: +49 333 8362222", internal: false, sender: Ticket::Article::Sender.where(name: 'Customer').first, type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: customer1.id, - created_by_id: customer1.id, + updated_by_id: @customer1.id, + created_by_id: @customer1.id, ) assert(ticket1) # create ticket in group - ticket2 = Ticket.create( + ticket2 = Ticket.create!( title: 'some caller id test 2', group: Group.lookup(name: 'Users'), - customer: customer1, + customer: @customer1, 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, ) - article2 = Ticket::Article.create( + article2 = Ticket::Article.create!( ticket_id: ticket2.id, from: 'some_sender@example.com', to: 'some_recipient@example.com', @@ -123,8 +127,8 @@ Mob: +49 333 1112222", internal: false, sender: Ticket::Article::Sender.where(name: 'Agent').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(ticket2) @@ -135,19 +139,19 @@ Mob: +49 333 1112222", caller_ids = Cti::CallerId.lookup('491111222223') assert_equal(1, caller_ids.length) - assert_equal(agent1.id, caller_ids[0].user_id) + assert_equal(@agent1.id, caller_ids[0].user_id) assert_equal('known', caller_ids[0].level) caller_ids = Cti::CallerId.lookup('492222222222') assert_equal(2, caller_ids.length) - assert_equal(agent3.id, caller_ids[0].user_id) + assert_equal(@agent3.id, caller_ids[0].user_id) assert_equal('known', caller_ids[0].level) - assert_equal(agent2.id, caller_ids[1].user_id) + assert_equal(@agent2.id, caller_ids[1].user_id) assert_equal('known', caller_ids[1].level) caller_ids = Cti::CallerId.lookup('492226112222') assert_equal(1, caller_ids.length) - assert_equal(customer1.id, caller_ids[0].user_id) + assert_equal(@customer1.id, caller_ids[0].user_id) assert_equal('maybe', caller_ids[0].level) caller_ids = Cti::CallerId.lookup('492221112222') @@ -155,7 +159,7 @@ Mob: +49 333 1112222", end - test '3 lookups' do + test '2 lookups' do Cti::CallerId.destroy_all @@ -210,10 +214,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 +227,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 +236,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,8 +244,87 @@ 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 + + test '3 process - log' do + + ticket1 = Ticket.create!( + title: 'some caller id test 1', + group: Group.lookup(name: 'Users'), + customer: @customer1, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: @agent1.id, + created_by_id: @agent1.id, + ) + 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\nFon (GEL): +49 111 366-1111 Mi-Fr +Fon (LIN): +49 222 6112222 Mo-Di +Mob: +49 333 8362222", + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: @customer1.id, + created_by_id: @customer1.id, + ) + assert(ticket1) + ticket2 = Ticket.create!( + title: 'some caller id test 2', + group: Group.lookup(name: 'Users'), + customer: @customer1, + state: Ticket::State.lookup(name: 'new'), + priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: @agent1.id, + created_by_id: @agent1.id, + ) + 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\nFon (GEL): +49 111 366-1111 Mi-Fr +Fon (LIN): +49 222 6112222 Mo-Di +Mob: +49 333 8362222", + internal: false, + sender: Ticket::Article::Sender.where(name: 'Customer').first, + type: Ticket::Article::Type.where(name: 'email').first, + updated_by_id: @customer1.id, + created_by_id: @customer1.id, + ) + assert(ticket2) + + Cti::CallerId.rebuild + + Cti::Log.process( + 'cause' => '', + 'event' => 'newCall', + 'user' => 'user 1', + 'from' => '491113661111', + 'to' => '4930600000000', + 'callId' => '4991155921769858278-1', + 'direction' => 'in', + ) + + log = Cti::Log.log + assert(log[:list]) + assert(log[:assets]) + assert(log[:list][0]) + assert_not(log[:list][1]) + assert(log[:list][0].preferences) + assert(log[:list][0].preferences[:from]) + assert_equal(1, log[:list][0].preferences[:from].count) + assert_equal(@customer1.id, log[:list][0].preferences[:from][0][:user_id]) + assert_equal('maybe', log[:list][0].preferences[:from][0][:level]) + + end + 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/email_build_test.rb b/test/unit/email_build_test.rb index 7e2d3410e..8d161ea5b 100644 --- a/test/unit/email_build_test.rb +++ b/test/unit/email_build_test.rb @@ -22,6 +22,12 @@ class EmailBuildTest < ActiveSupport::TestCase assert(result !~ /font-family/, 'test 2') assert(result =~ %r{test}, 'test 2') + # Issue #1230, missing backslashes + # 'Test URL: \\storage\project\100242-Inc' + html = 'Test URL: \\\\storage\\project\\100242-Inc' + result = Channel::EmailBuild.html_complete_check(html) + assert(result.include?(html), 'backslashes must be kept') + end test 'html email + attachment check' do diff --git a/test/unit/email_parser_test.rb b/test/unit/email_parser_test.rb index c2d0e5be6..08b472ba4 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: { @@ -499,7 +499,7 @@ Managing Director: Martin Edenhofer body_md5: '6021dd92d8e7844e6bb9b5bb7a4adfb8', params: { from: '"我" <>', - from_email: '"我" <>', + from_email: 'vipyiming@126.com', from_display_name: '', subject: '《欧美简讯》', to: '377861373 <377861373@qq.com>', @@ -1099,6 +1099,98 @@ 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!', + }, + }, + { + 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 +', + }, + }, + { + 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 @@ -1139,7 +1231,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 diff --git a/test/unit/email_postmaster_test.rb b/test/unit/email_postmaster_test.rb index e17fa6b44..3893ddab3 100644 --- a/test/unit/email_postmaster_test.rb +++ b/test/unit/email_postmaster_test.rb @@ -3,20 +3,8 @@ require 'test_helper' class EmailPostmasterTest < ActiveSupport::TestCase - test 'process with postmaster filter' do - group_default = Group.lookup(name: 'Users') - group1 = Group.create_if_not_exists( - name: 'Test Group1', - created_by_id: 1, - updated_by_id: 1, - ) - group2 = Group.create_if_not_exists( - name: 'Test Group2', - created_by_id: 1, - updated_by_id: 1, - ) - PostmasterFilter.destroy_all - PostmasterFilter.create( + test 'valid/invalid postmaster filter' do + PostmasterFilter.create!( name: 'not used', match: { from: { @@ -34,7 +22,390 @@ class EmailPostmasterTest < ActiveSupport::TestCase created_by_id: 1, updated_by_id: 1, ) - PostmasterFilter.create( + assert_raises(Exceptions::UnprocessableEntity) { + PostmasterFilter.create!( + name: 'empty filter should not work', + match: {}, + perform: { + 'X-Zammad-Ticket-priority' => { + value: '3 high', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + } + assert_raises(Exceptions::UnprocessableEntity) { + PostmasterFilter.create!( + name: 'empty filter should not work', + match: { + from: { + operator: 'contains', + value: '', + }, + }, + perform: { + 'X-Zammad-Ticket-priority' => { + value: '3 high', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + } + assert_raises(Exceptions::UnprocessableEntity) { + PostmasterFilter.create!( + name: 'invalid regex', + match: { + from: { + operator: 'contains', + value: 'regex:[]', + }, + }, + perform: { + 'X-Zammad-Ticket-priority' => { + value: '3 high', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + } + assert_raises(Exceptions::UnprocessableEntity) { + PostmasterFilter.create!( + name: 'invalid regex', + match: { + from: { + operator: 'contains', + value: 'regex:??', + }, + }, + perform: { + 'X-Zammad-Ticket-priority' => { + value: '3 high', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + } + assert_raises(Exceptions::UnprocessableEntity) { + PostmasterFilter.create!( + name: 'invalid regex', + match: { + from: { + operator: 'contains', + value: 'regex:*', + }, + }, + perform: { + 'X-Zammad-Ticket-priority' => { + value: '3 high', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + } + PostmasterFilter.create!( + name: 'use .*', + match: { + from: { + operator: 'contains', + value: '*', + }, + }, + perform: { + 'X-Zammad-Ticket-priority' => { + value: '3 high', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + end + + test 'process with postmaster filter with regex' do + group_default = Group.lookup(name: 'Users') + group1 = Group.create_if_not_exists( + name: 'Test Group1', + created_by_id: 1, + updated_by_id: 1, + ) + group2 = Group.create_if_not_exists( + name: 'Test Group2', + created_by_id: 1, + updated_by_id: 1, + ) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'used - empty selector', + match: { + from: { + operator: 'contains', + value: 'regex:.*', + }, + }, + perform: { + 'X-Zammad-Ticket-group_id' => { + value: group2.id, + }, + 'X-Zammad-Ticket-priority_id' => { + value: '1', + }, + 'x-Zammad-Article-Internal' => { + value: true, + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: Some Body +To: Bob +Cc: any@example.com +Subject: some subject - no selector + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + + assert_equal('Test Group2', ticket.group.name) + assert_equal('1 low', ticket.priority.name) + assert_equal('some subject - no selector', ticket.title) + + assert_equal('Customer', article.sender.name) + assert_equal('email', article.type.name) + assert_equal(true, article.internal) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'used - empty selector', + match: { + from: { + operator: 'contains', + value: '*', + }, + }, + perform: { + 'X-Zammad-Ticket-group_id' => { + value: group2.id, + }, + 'X-Zammad-Ticket-priority_id' => { + value: '1', + }, + 'x-Zammad-Article-Internal' => { + value: true, + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: Some Body +To: Bob +Cc: any@example.com +Subject: some subject - no selector + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + + assert_equal('Test Group2', ticket.group.name) + assert_equal('1 low', ticket.priority.name) + assert_equal('some subject - no selector', ticket.title) + + assert_equal('Customer', article.sender.name) + assert_equal('email', article.type.name) + assert_equal(true, article.internal) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'used - empty selector', + match: { + subject: { + operator: 'contains', + value: '*me*', + }, + }, + perform: { + 'X-Zammad-Ticket-group_id' => { + value: group2.id, + }, + 'X-Zammad-Ticket-priority_id' => { + value: '1', + }, + 'x-Zammad-Article-Internal' => { + value: true, + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: Some Body +To: Bob +Cc: any@example.com +Subject: *me* + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + + assert_equal('Test Group2', ticket.group.name) + assert_equal('1 low', ticket.priority.name) + assert_equal('*me*', ticket.title) + + assert_equal('Customer', article.sender.name) + assert_equal('email', article.type.name) + assert_equal(true, article.internal) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'used - empty selector', + match: { + subject: { + operator: 'contains not', + value: '*me*', + }, + }, + perform: { + 'X-Zammad-Ticket-group_id' => { + value: group2.id, + }, + 'X-Zammad-Ticket-priority_id' => { + value: '1', + }, + 'x-Zammad-Article-Internal' => { + value: true, + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: Some Body +To: Bob +Cc: any@example.com +Subject: *mo* + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + + assert_equal('Test Group2', ticket.group.name) + assert_equal('1 low', ticket.priority.name) + assert_equal('*mo*', ticket.title) + + assert_equal('Customer', article.sender.name) + assert_equal('email', article.type.name) + assert_equal(true, article.internal) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'used - empty selector', + match: { + subject: { + operator: 'contains not', + value: '*me*', + }, + }, + perform: { + 'X-Zammad-Ticket-group_id' => { + value: group2.id, + }, + 'X-Zammad-Ticket-priority_id' => { + value: '1', + }, + 'x-Zammad-Article-Internal' => { + value: true, + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: Some Body +To: Bob +Cc: any@example.com +Subject: *me* + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + + assert_equal('Users', ticket.group.name) + assert_equal('2 normal', ticket.priority.name) + assert_equal('*me*', ticket.title) + + assert_equal('Customer', article.sender.name) + assert_equal('email', article.type.name) + assert_equal(false, article.internal) + + PostmasterFilter.destroy_all + end + + test 'process with postmaster filter' do + group_default = Group.lookup(name: 'Users') + group1 = Group.create_if_not_exists( + name: 'Test Group1', + created_by_id: 1, + updated_by_id: 1, + ) + group2 = Group.create_if_not_exists( + name: 'Test Group2', + created_by_id: 1, + updated_by_id: 1, + ) + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'not used', + match: { + from: { + operator: 'contains', + value: 'nobody@example.com', + }, + }, + perform: { + 'X-Zammad-Ticket-priority' => { + value: '3 high', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + PostmasterFilter.create!( name: 'used', match: { from: { @@ -55,7 +426,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 +449,7 @@ class EmailPostmasterTest < ActiveSupport::TestCase updated_by_id: 1, ) + data = 'From: me@example.com To: customer@example.com Subject: some subject @@ -111,9 +484,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,53 +529,8 @@ Some Text' PostmasterFilter.destroy_all - PostmasterFilter.create( - name: 'used - empty selector', - match: { - from: { - operator: 'contains', - value: '', - }, - }, - perform: { - 'X-Zammad-Ticket-group_id' => { - value: group2.id, - }, - 'X-Zammad-Ticket-priority_id' => { - value: '1', - }, - 'x-Zammad-Article-Internal' => { - value: true, - }, - }, - channel: 'email', - active: true, - created_by_id: 1, - updated_by_id: 1, - ) - - data = 'From: Some Body -To: Bob -Cc: any@example.com -Subject: some subject - no selector - -Some Text' - - parser = Channel::EmailParser.new - ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) - - assert_equal('Users', ticket.group.name) - assert_equal('2 normal', ticket.priority.name) - assert_equal('some subject - no selector', ticket.title) - - assert_equal('Customer', article.sender.name) - assert_equal('email', article.type.name) - assert_equal(false, article.internal) - - PostmasterFilter.destroy_all - # follow up with create post master filter test - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used - empty selector', match: { from: { @@ -270,7 +597,7 @@ Some Text" PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used', match: { from: { @@ -310,7 +637,7 @@ Some Text' assert_equal('me@example.com', ticket.customer.email) PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used', match: { from: { @@ -350,7 +677,7 @@ Some Text' assert_equal('me@example.com', ticket.customer.email) PostmasterFilter.destroy_all - PostmasterFilter.create( + PostmasterFilter.create!( name: 'used', match: { from: { @@ -394,6 +721,205 @@ Some Text' assert_equal('2 normal', ticket.priority.name) PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'Autoresponder', + match: { + 'auto-submitted' => { + 'operator' => 'contains not', + 'value' => 'auto-generated', + }, + 'from' => { + 'operator' => 'contains', + 'value' => '@example.com', + } + }, + perform: { + 'x-zammad-article-internal' => { + 'value' => 'true', + }, + 'x-zammad-article-type_id' => { + 'value' => Ticket::Article::Type.find_by(name: 'note').id.to_s, + }, + 'x-zammad-ignore' => { + 'value' => 'false', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: ME Bob +To: customer@example.com +Subject: some subject + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + assert_equal('Users', ticket.group.name) + assert_equal('2 normal', ticket.priority.name) + assert_equal('some subject', ticket.title) + assert_equal('me@example.com', ticket.customer.email) + assert_equal('2 normal', ticket.priority.name) + + assert_equal('Customer', article.sender.name) + assert_equal('note', article.type.name) + assert_equal(true, article.internal) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'Autoresponder', + match: { + 'auto-submitted' => { + 'operator' => 'contains', + 'value' => 'auto-generated', + }, + 'from' => { + 'operator' => 'contains', + 'value' => '@example.com', + } + }, + perform: { + 'x-zammad-article-internal' => { + 'value' => 'true', + }, + 'x-zammad-article-type_id' => { + 'value' => Ticket::Article::Type.find_by(name: 'note').id.to_s, + }, + 'x-zammad-ignore' => { + 'value' => 'false', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: ME Bob +To: customer@example.com +Subject: some subject + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + assert_equal('Users', ticket.group.name) + assert_equal('2 normal', ticket.priority.name) + assert_equal('some subject', ticket.title) + assert_equal('me@example.com', ticket.customer.email) + assert_equal('2 normal', ticket.priority.name) + + assert_equal('Customer', article.sender.name) + assert_equal('email', article.type.name) + assert_equal(false, article.internal) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'Autoresponder', + match: { + 'auto-submitted' => { + 'operator' => 'contains not', + 'value' => 'auto-generated', + }, + 'to' => { + 'operator' => 'contains', + 'value' => 'customer@example.com', + }, + 'from' => { + 'operator' => 'contains', + 'value' => '@example.com', + } + }, + perform: { + 'x-zammad-article-internal' => { + 'value' => 'true', + }, + 'x-zammad-article-type_id' => { + 'value' => Ticket::Article::Type.find_by(name: 'note').id.to_s, + }, + 'x-zammad-ignore' => { + 'value' => 'false', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: ME Bob +To: customer@example.com +Subject: some subject + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + assert_equal('Users', ticket.group.name) + assert_equal('2 normal', ticket.priority.name) + assert_equal('some subject', ticket.title) + assert_equal('me@example.com', ticket.customer.email) + assert_equal('2 normal', ticket.priority.name) + + assert_equal('Customer', article.sender.name) + assert_equal('note', article.type.name) + assert_equal(true, article.internal) + + PostmasterFilter.destroy_all + PostmasterFilter.create!( + name: 'Autoresponder', + match: { + 'auto-submitted' => { + 'operator' => 'contains', + 'value' => 'auto-generated', + }, + 'to' => { + 'operator' => 'contains', + 'value' => 'customer1@example.com', + }, + 'from' => { + 'operator' => 'contains', + 'value' => '@example.com', + } + }, + perform: { + 'x-zammad-article-internal' => { + 'value' => 'true', + }, + 'x-zammad-article-type_id' => { + 'value' => Ticket::Article::Type.find_by(name: 'note').id.to_s, + }, + 'x-zammad-ignore' => { + 'value' => 'false', + }, + }, + channel: 'email', + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + data = 'From: ME Bob +To: customer@example.com +Subject: some subject + +Some Text' + + parser = Channel::EmailParser.new + ticket, article, user = parser.process({ group_id: group_default.id, trusted: false }, data) + assert_equal('Users', ticket.group.name) + assert_equal('2 normal', ticket.priority.name) + assert_equal('some subject', ticket.title) + assert_equal('me@example.com', ticket.customer.email) + assert_equal('2 normal', ticket.priority.name) + + assert_equal('Customer', article.sender.name) + assert_equal('email', article.type.name) + assert_equal(false, article.internal) + end end 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 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 diff --git a/test/unit/email_process_sender_is_system_address_or_agent_test.rb b/test/unit/email_process_sender_is_system_address_or_agent_test.rb index f4a62daba..47c0f62e4 100644 --- a/test/unit/email_process_sender_is_system_address_or_agent_test.rb +++ b/test/unit/email_process_sender_is_system_address_or_agent_test.rb @@ -7,7 +7,7 @@ class EmailProcessSenderIsSystemAddressOrAgent < ActiveSupport::TestCase EmailAddress.create_or_update( channel_id: 1, realname: 'My System', - email: 'myzammad@system.test', + email: 'Myzammad@system.TEST', active: true, updated_by_id: 1, created_by_id: 1, @@ -176,5 +176,56 @@ Some Text" assert_equal(agent1.id, ticket.created_by_id) assert_equal(agent1.id, article.created_by_id) + email_raw_string = "From: ticket-system-sender-AGENT1@example.com +To: MYZAMMAD@system.test, ticket-system-sender-CUSTOMER1@example.com +Subject: some subject #4 + +Some Text" + + ticket_p, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) + ticket = Ticket.find(ticket_p.id) + article = Ticket::Article.find(article_p.id) + assert_equal('some subject #4', ticket.title) + assert_equal('open', ticket.state.name) + assert_equal('Agent', ticket.create_article_sender.name) + assert_equal('Agent', article.sender.name) + assert_equal('ticket-system-sender-customer1@example.com', ticket.customer.email) + assert_equal(agent1.id, ticket.created_by_id) + assert_equal(agent1.id, article.created_by_id) + + email_raw_string = "From: ticket-system-sender-agent1@example.com +To: myzammad@system.test +Subject: some subject #5 + +Some Text" + + ticket_p, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) + ticket = Ticket.find(ticket_p.id) + article = Ticket::Article.find(article_p.id) + assert_equal('some subject #5', ticket.title) + assert_equal('open', ticket.state.name) + assert_equal('Agent', ticket.create_article_sender.name) + assert_equal('Agent', article.sender.name) + assert_equal('ticket-system-sender-agent1@example.com', ticket.customer.email) + assert_equal(agent1.id, ticket.created_by_id) + assert_equal(agent1.id, article.created_by_id) + + email_raw_string = "From: ticket-system-sender-agent1@example.com +To: myZammad@system.Test +Subject: some subject #6 + +Some Text" + + ticket_p, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) + ticket = Ticket.find(ticket_p.id) + article = Ticket::Article.find(article_p.id) + assert_equal('some subject #6', ticket.title) + assert_equal('open', ticket.state.name) + assert_equal('Agent', ticket.create_article_sender.name) + assert_equal('Agent', article.sender.name) + assert_equal('ticket-system-sender-agent1@example.com', ticket.customer.email) + assert_equal(agent1.id, ticket.created_by_id) + assert_equal(agent1.id, article.created_by_id) + end end diff --git a/test/unit/email_process_test.rb b/test/unit/email_process_test.rb index 7a067cd42..e2ecb83d7 100644 --- a/test/unit/email_process_test.rb +++ b/test/unit/email_process_test.rb @@ -134,6 +134,94 @@ Some Textäöü".encode('ISO-8859-1'), }, }, }, + { + data: "From: Realname +To: customer@example.com +Subject: abc some subject +Reply-To: \"no-reply-without-from-email@example.com\" + +Some Text", + success: true, + result: { + 0 => { + priority: '2 normal', + title: 'abc some subject', + }, + 1 => { + body: 'Some Text', + sender: 'Customer', + type: 'email', + internal: false, + }, + }, + verify: { + users: [ + { + firstname: 'no-reply-without-from-email@example.com', + lastname: '', + fullname: 'no-reply-without-from-email@example.com', + email: 'no-reply-without-from-email@example.com', + }, + ], + }, + }, + { + data: "From: me@example.com +To: Alexander Ha , + Alexander Re , Hauke Ko + , Jens Ro , + =?UTF-8?Q?B=c3=bc_Yi?= , + Ja Bl , + \"lars.73@example.de\" , + Luk Hl , + =?UTF-8?Q?Ma_Gr=c3=b6ner_, + Malte Bi , =?UTF-8?Q?Ma_Bfu=c3=9f?= + , Marco Fe , + heidt@example.de, matt.ga@example.com, + Nick Ku , Sergej I , + Thomas Ga , + Peter Wo , + =?UTF-8?B?SsO8cmdlbiB2b24gUsO2bm4=?= , + Frank-Ingo Br +Subject: test 1 + +test 1", + success: true, + result: { + 0 => { + priority: '2 normal', + title: 'test 1', + }, + 1 => { + body: 'test 1', + sender: 'Customer', + type: 'email', + internal: false, + }, + }, + verify: { + users: [ + { + firstname: 'Alexander', + lastname: 'Ha', + fullname: 'Alexander Ha', + email: 'service-d1@example.com', + }, + { + firstname: 'Alexander', + lastname: 'Re', + fullname: 'Alexander Re', + email: 're-mail@example.de', + }, + { + firstname: 'Ma', + lastname: 'Gröner', + fullname: 'Ma Gröner', + email: 'ma.g@example.com', + }, + ], + } + }, { data: "From: me@example.com To: customer@example.com @@ -2449,6 +2537,32 @@ Some Text', ], }, }, + { + data: IO.binread('test/fixtures/mail60.box'), + success: true, + result: { + 0 => { + priority: '2 normal', + title: 'abc', + }, + 1 => { + from: 'Martin Edenhofer ', + sender: 'Customer', + type: 'email', + body: 'Here it goes - ?????? - ?????????Here it goes - ??? - hi ?', + }, + }, + verify: { + users: [ + { + firstname: 'Martin', + lastname: 'Edenhofer', + fullname: 'Martin Edenhofer', + email: 'martin@example.com', + }, + ], + }, + }, ] assert_process(files) end diff --git a/test/unit/history_test.rb b/test/unit/history_test.rb index f8061c68d..989b92152 100644 --- a/test/unit/history_test.rb +++ b/test/unit/history_test.rb @@ -155,12 +155,12 @@ class HistoryTest < ActiveSupport::TestCase # use transaction ActiveRecord::Base.transaction do - ticket = Ticket.create(test[:ticket_create][:ticket]) + ticket = Ticket.create!(test[:ticket_create][:ticket]) test[:ticket_create][:article][:ticket_id] = ticket.id - article = Ticket::Article.create(test[:ticket_create][:article]) + article = Ticket::Article.create!(test[:ticket_create][:article]) - assert_equal(ticket.class.to_s, 'Ticket') - assert_equal(article.class.to_s, 'Ticket::Article') + assert_equal(ticket.class, Ticket) + assert_equal(article.class, Ticket::Article) # update ticket if test[:ticket_update][:ticket] @@ -185,25 +185,21 @@ class HistoryTest < ActiveSupport::TestCase } # delete tickets - tickets.each { |ticket| - ticket_id = ticket.id - ticket.destroy - found = Ticket.where(id: ticket_id).first - assert_not(found, 'Ticket destroyed') - } + tickets.each(&:destroy!) end test 'user' do + name = rand(999_999) tests = [ # test 1 { user_create: { user: { - login: 'some_login_test', + login: "some_login_test-#{name}", firstname: 'Bob', lastname: 'Smith', - email: 'somebody@example.com', + email: "somebody-#{name}@example.com", active: true, updated_by_id: current_user.id, created_by_id: current_user.id, @@ -213,7 +209,7 @@ class HistoryTest < ActiveSupport::TestCase user: { firstname: 'Bob', lastname: 'Master', - email: 'master@example.com', + email: "master-#{name}@example.com", active: false, }, }, @@ -236,8 +232,8 @@ class HistoryTest < ActiveSupport::TestCase history_object: 'User', history_type: 'updated', history_attribute: 'email', - value_from: 'somebody@example.com', - value_to: 'master@example.com', + value_from: "somebody-#{name}@example.com", + value_to: "master-#{name}@example.com", }, { result: true, @@ -258,9 +254,8 @@ class HistoryTest < ActiveSupport::TestCase # user transaction ActiveRecord::Base.transaction do - user = User.create(test[:user_create][:user]) - - assert_equal(user.class.to_s, 'User') + user = User.create!(test[:user_create][:user]) + assert_equal(user.class, User) # update user if test[:user_update][:user] @@ -277,12 +272,7 @@ class HistoryTest < ActiveSupport::TestCase } # delete user - users.each { |user| - user_id = user.id - user.destroy - found = User.where(id: user_id).first - assert_not(found, 'User destroyed') - } + users.each(&:destroy!) end test 'organization' do @@ -328,9 +318,8 @@ class HistoryTest < ActiveSupport::TestCase # user transaction ActiveRecord::Base.transaction do - organization = Organization.create(test[:organization_create][:organization]) - - assert_equal(organization.class.to_s, 'Organization') + organization = Organization.create!(test[:organization_create][:organization]) + assert_equal(organization.class, Organization) # update organization if test[:organization_update][:organization] @@ -346,12 +335,7 @@ class HistoryTest < ActiveSupport::TestCase } # delete user - organizations.each { |organization| - organization_id = organization.id - organization.destroy - found = Organization.where(id: organization_id).first - assert_not(found, 'Organization destroyed') - } + organizations.each(&:destroy!) end def history_check(history_list, history_check) diff --git a/test/unit/integration_icinga_test.rb b/test/unit/integration_icinga_test.rb index 55a5f7273..b10e656ff 100644 --- a/test/unit/integration_icinga_test.rb +++ b/test/unit/integration_icinga_test.rb @@ -19,7 +19,7 @@ User-Agent: Heirloom mailx 12.5 7/5/10 MIME-Version: 1.0 Content-Type: text/plain; charset=us-ascii Content-Transfer-Encoding: quoted-printable -Message-Id: <20160131094621.29ECD400F29C-icinga-1@monitoring.znuny.com> +Message-Id: <20160131094621.29ECD400F29C-icinga-1-0@monitoring.znuny.com> From: icinga_not_matching@monitoring.example.com (icinga) ***** Icinga ***** @@ -41,9 +41,102 @@ Comment: [] = ticket_p, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_p.state.name) assert(ticket_p.preferences) - assert_not(ticket_p.preferences['integration']) assert_not(ticket_p.preferences['icinga']) + # RBL check + email_raw_string = "To: support@example.com +Subject: [PROBLEM] RBL check on apn4711.dc.example.com is CRITICAL! +User-Agent: Heirloom mailx 12.5 7/5/10 +MIME-Version: 1.0 +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: quoted-printable +Message-Id: <20160131094621.29ECD400F29C-icinga-1-1@monitoring.znuny.com> +From: icinga@monitoring.example.com (icinga) + +***** Icinga 2 Service Monitoring on apn4711.dc.example.com ***** + +=3D=3D> RBL check on apn4711.dc.example.com is CRITICAL! <=3D=3D + +Info: CHECK_RBL CRITICAL - apn4711.dc.example.com BLACKLISTED on 1 server of= + 38 (ix.dnsbl.example.com)=20 + +When: 2017-08-06 22:18:43 +0200 +Service: RBL check (Display Name: \"RBL check\") +Host: apn4711.dc.example.com (Display Name: \"apn4711.dc.example.com\") +IPv4: 127.0.0.1=" + + ticket_0, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal('new', ticket_0.state.name) + assert(ticket_0.preferences) + assert(ticket_0.preferences['icinga']) + assert_equal('apn4711.dc.example.com (Display Name: "apn4711.dc.example.com")', ticket_0.preferences['icinga']['host']) + assert_equal('CHECK_RBL CRITICAL - apn4711.dc.example.com BLACKLISTED on 1 server of 38 (ix.dnsbl.example.com)', ticket_0.preferences['icinga']['info']) + assert_equal('RBL check (Display Name: "RBL check")', ticket_0.preferences['icinga']['service']) + assert_equal('CRITICAL', ticket_0.preferences['icinga']['state']) + + # RBL check II + email_raw_string = "To: support@example.com +Subject: [PROBLEM] RBL check on apn4711.dc.example.com is CRITICAL! +User-Agent: Heirloom mailx 12.5 7/5/10 +MIME-Version: 1.0 +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: quoted-printable +Message-Id: <20160131094621.29ECD400F29C-icinga-1-2@monitoring.znuny.com> +From: icinga@monitoring.example.com (icinga) + +***** Icinga 2 Service Monitoring on apn4711.dc.example.com ***** + +=3D=3D> RBL check on apn4711.dc.example.com is CRITICAL! <=3D=3D + +Info: CHECK_RBL CRITICAL - apn4711.dc.example.com BLACKLISTED on 1 server of= + 38 (ix.dnsbl.example.com)=20 + +When: 2017-08-06 22:18:43 +0200 +Service: RBL check (Display Name: \"RBL check\") +Host: apn4711.dc.example.com (Display Name: \"apn4711.dc.example.com\") +IPv4: 127.0.0.1=" + + ticket_0_1, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal('new', ticket_0_1.state.name) + assert(ticket_0_1.preferences) + assert(ticket_0_1.preferences['icinga']) + assert_equal('apn4711.dc.example.com (Display Name: "apn4711.dc.example.com")', ticket_0_1.preferences['icinga']['host']) + assert_equal('CHECK_RBL CRITICAL - apn4711.dc.example.com BLACKLISTED on 1 server of 38 (ix.dnsbl.example.com)', ticket_0_1.preferences['icinga']['info']) + assert_equal('RBL check (Display Name: "RBL check")', ticket_0_1.preferences['icinga']['service']) + assert_equal('CRITICAL', ticket_0_1.preferences['icinga']['state']) + assert_equal(ticket_0_1.id, ticket_0.id) + + email_raw_string = "To: support@example.com +Subject: [PROBLEM] RBL check on apn4711.dc.example.com is OK! +User-Agent: Heirloom mailx 12.5 7/5/10 +MIME-Version: 1.0 +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: quoted-printable +Message-Id: <20160131094621.29ECD400F29C-icinga-1-2@monitoring.znuny.com> +From: icinga@monitoring.example.com (icinga) + +***** Icinga 2 Service Monitoring on apn4711.dc.example.com ***** + +=3D=3D> RBL check on apn4711.dc.example.com is OK! <=3D=3D + +Info: CHECK_RBL OK - apn4711.dc.example.com BLACKLISTED on 1 server of= + 38 (ix.dnsbl.example.com)=20 + +When: 2017-08-06 22:18:43 +0200 +Service: RBL check (Display Name: \"RBL check\") +Host: apn4711.dc.example.com (Display Name: \"apn4711.dc.example.com\") +IPv4: 127.0.0.1=" + + ticket_0_2, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) + assert_equal('closed', ticket_0_2.state.name) + assert(ticket_0_2.preferences) + assert(ticket_0_2.preferences['icinga']) + assert_equal('apn4711.dc.example.com (Display Name: "apn4711.dc.example.com")', ticket_0_2.preferences['icinga']['host']) + assert_equal('CHECK_RBL CRITICAL - apn4711.dc.example.com BLACKLISTED on 1 server of 38 (ix.dnsbl.example.com)', ticket_0_2.preferences['icinga']['info']) + assert_equal('RBL check (Display Name: "RBL check")', ticket_0_2.preferences['icinga']['service']) + assert_equal('CRITICAL', ticket_0_2.preferences['icinga']['state']) + assert_equal(ticket_0_2.id, ticket_0.id) + # matching sender - CPU Load/host.internal.loc email_raw_string = "To: support@example.com Subject: PROBLEM - host.internal.loc - CPU Load is WARNING @@ -73,8 +166,6 @@ Comment: [] = ticket_1, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_1.state.name) assert(ticket_1.preferences) - assert(ticket_1.preferences['integration']) - assert_equal('icinga', ticket_1.preferences['integration']) assert(ticket_1.preferences['icinga']) assert_equal('host.internal.loc', ticket_1.preferences['icinga']['host']) assert_equal('CPU Load', ticket_1.preferences['icinga']['service']) @@ -109,8 +200,6 @@ Comment: [] = ticket_2, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_2.state.name) assert(ticket_2.preferences) - assert(ticket_2.preferences['integration']) - assert_equal('icinga', ticket_2.preferences['integration']) assert(ticket_2.preferences['icinga']) assert_equal('host.internal.loc', ticket_2.preferences['icinga']['host']) assert_equal('Disk Usage 123', ticket_2.preferences['icinga']['service']) @@ -146,8 +235,6 @@ Comment: [] = ticket_1_1, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_1_1.state.name) assert(ticket_1_1.preferences) - assert(ticket_1_1.preferences['integration']) - assert_equal('icinga', ticket_1_1.preferences['integration']) assert(ticket_1_1.preferences['icinga']) assert_equal('host.internal.loc', ticket_1_1.preferences['icinga']['host']) assert_equal('CPU Load', ticket_1_1.preferences['icinga']['service']) @@ -184,8 +271,6 @@ Comment: [] = assert_equal(ticket_1.id, ticket_1_2.id) assert_equal('closed', ticket_1_2.state.name) assert(ticket_1_2.preferences) - assert(ticket_1_2.preferences['integration']) - assert_equal('icinga', ticket_1_2.preferences['integration']) assert(ticket_1_2.preferences['icinga']) assert_equal('host.internal.loc', ticket_1_2.preferences['icinga']['host']) assert_equal('CPU Load', ticket_1_2.preferences['icinga']['service']) @@ -218,8 +303,6 @@ Comment: [] = ticket_3, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_3.state.name) assert(ticket_3.preferences) - assert(ticket_3.preferences['integration']) - assert_equal('icinga', ticket_3.preferences['integration']) assert(ticket_3.preferences['icinga']) assert_equal('apn4711.dc.example.com', ticket_3.preferences['icinga']['host']) assert_nil(ticket_3.preferences['icinga']['service']) @@ -254,8 +337,6 @@ Comment: [] = assert_equal(ticket_3.id, ticket_3_1.id) assert_equal('closed', ticket_3_1.state.name) assert(ticket_3_1.preferences) - assert(ticket_3_1.preferences['integration']) - assert_equal('icinga', ticket_3_1.preferences['integration']) assert(ticket_3_1.preferences['icinga']) assert_equal('apn4711.dc.example.com', ticket_3.preferences['icinga']['host']) assert_nil(ticket_3_1.preferences['icinga']['service']) diff --git a/test/unit/integration_nagios_test.rb b/test/unit/integration_nagios_test.rb index 5b4136352..e4ef19189 100644 --- a/test/unit/integration_nagios_test.rb +++ b/test/unit/integration_nagios_test.rb @@ -37,7 +37,6 @@ WARNING - load average: 3.44, 0.99, 0.35 ticket_p, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_p.state.name) assert(ticket_p.preferences) - assert_not(ticket_p.preferences['integration']) assert_not(ticket_p.preferences['nagios']) # matching sender - CPU Load/host.internal.loc @@ -67,8 +66,6 @@ WARNING - load average: 3.44, 0.99, 0.35 ticket_1, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_1.state.name) assert(ticket_1.preferences) - assert(ticket_1.preferences['integration']) - assert_equal('nagios', ticket_1.preferences['integration']) assert(ticket_1.preferences['nagios']) assert_equal('host.internal.loc', ticket_1.preferences['nagios']['host']) assert_equal('CPU Load', ticket_1.preferences['nagios']['service']) @@ -101,8 +98,6 @@ WARNING - load average: 3.44, 0.99, 0.35 ticket_2, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_2.state.name) assert(ticket_2.preferences) - assert(ticket_2.preferences['integration']) - assert_equal('nagios', ticket_2.preferences['integration']) assert(ticket_2.preferences['nagios']) assert_equal('host.internal.loc', ticket_2.preferences['nagios']['host']) assert_equal('Disk Usage 123', ticket_2.preferences['nagios']['service']) @@ -136,8 +131,6 @@ WARNING - load average: 3.44, 0.99, 0.35 ticket_1_1, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_1_1.state.name) assert(ticket_1_1.preferences) - assert(ticket_1_1.preferences['integration']) - assert_equal('nagios', ticket_1_1.preferences['integration']) assert(ticket_1_1.preferences['nagios']) assert_equal('host.internal.loc', ticket_1_1.preferences['nagios']['host']) assert_equal('CPU Load', ticket_1_1.preferences['nagios']['service']) @@ -170,8 +163,6 @@ Additional Info: assert_equal(ticket_1.id, ticket_1_2.id) assert_equal('closed', ticket_1_2.state.name) assert(ticket_1_2.preferences) - assert(ticket_1_2.preferences['integration']) - assert_equal('nagios', ticket_1_2.preferences['integration']) assert(ticket_1_2.preferences['nagios']) assert_equal('host.internal.loc', ticket_1_2.preferences['nagios']['host']) assert_equal('CPU Load', ticket_1_2.preferences['nagios']['service']) @@ -204,8 +195,6 @@ Comment: [] = ticket_3, article_p, user_p, mail = Channel::EmailParser.new.process({}, email_raw_string) assert_equal('new', ticket_3.state.name) assert(ticket_3.preferences) - assert(ticket_3.preferences['integration']) - assert_equal('nagios', ticket_3.preferences['integration']) assert(ticket_3.preferences['nagios']) assert_equal('apn4711.dc.example.com', ticket_3.preferences['nagios']['host']) assert_nil(ticket_3.preferences['nagios']['service']) @@ -240,8 +229,6 @@ Comment: [] = assert_equal(ticket_3.id, ticket_3_1.id) assert_equal('closed', ticket_3_1.state.name) assert(ticket_3_1.preferences) - assert(ticket_3_1.preferences['integration']) - assert_equal('nagios', ticket_3_1.preferences['integration']) assert(ticket_3_1.preferences['nagios']) assert_equal('apn4711.dc.example.com', ticket_3.preferences['nagios']['host']) assert_nil(ticket_3_1.preferences['nagios']['service']) 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/notification_factory_renderer_test.rb b/test/unit/notification_factory_renderer_test.rb index a09c73015..d3bcffed1 100644 --- a/test/unit/notification_factory_renderer_test.rb +++ b/test/unit/notification_factory_renderer_test.rb @@ -148,6 +148,16 @@ class NotificationFactoryRendererTest < ActiveSupport::TestCase ).render assert_equal(CGI.escapeHTML(ticket.title), result) + template = "\#{ticket.\" title}" + result = described_class.new( + { + ticket: ticket, + }, + 'en-us', + template, + ).render + assert_equal(CGI.escapeHTML(ticket.title), result) + template = "some test
                          \#{article.body}" result = described_class.new( { 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/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/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/overview_test.rb b/test/unit/overview_test.rb new file mode 100644 index 000000000..145b64951 --- /dev/null +++ b/test/unit/overview_test.rb @@ -0,0 +1,215 @@ +# encoding: utf-8 +require 'test_helper' + +class OverviewTest < ActiveSupport::TestCase + + test 'overview link' do + UserInfo.current_user_id = 1 + overview = Overview.create!( + name: 'Not Shown Admin 2', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_equal(overview.link, 'not_shown_admin_2') + overview.destroy! + + overview = Overview.create!( + name: 'My assigned Tickets', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_equal(overview.link, 'my_assigned_tickets') + overview.destroy! + + overview = Overview.create!( + name: 'Übersicht', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_equal(overview.link, 'ubersicht') + overview.destroy! + + overview = Overview.create!( + name: " Übersicht \n", + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_equal(overview.link, 'ubersicht') + overview.destroy! + + overview1 = Overview.create!( + name: 'Meine Übersicht', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_equal(overview1.link, 'meine_ubersicht') + overview2 = Overview.create!( + name: 'Meine Übersicht', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert(overview2.link.start_with?('meine_ubersicht')) + assert_not_equal(overview1.link, overview2.link) + overview1.destroy! + overview2.destroy! + + overview = Overview.create!( + name: 'Д дФ ф', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_match(/^\d{1,3}$/, overview.link) + overview.destroy! + + overview = Overview.create!( + name: ' Д дФ ф abc ', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_equal(overview.link, 'abc') + overview.destroy! + + overview = Overview.create!( + name: 'Übersicht', + link: 'my_overview', + condition: { + 'ticket.state_id' => { + operator: 'is', + value: [1, 2, 3], + }, + }, + order: { + by: 'created_at', + direction: 'DESC', + }, + view: { + d: %w(title customer state created_at), + s: %w(number title customer state created_at), + m: %w(number title customer state created_at), + view_mode_default: 's', + }, + ) + assert_equal(overview.link, 'my_overview') + + overview.name = 'Übersicht2' + overview.link = 'my_overview2' + overview.save! + + assert_equal(overview.link, 'my_overview2') + + overview.destroy! + + end +end 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 58d314716..b7756008c 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 - test 'aaa - 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 }) @@ -56,10 +50,9 @@ 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 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,7 +158,12 @@ 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 # get whole collections @@ -191,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', @@ -201,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)}" @@ -230,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 @@ -242,25 +264,48 @@ 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 Admin)) + groups = Group.all + + agent1 = User.create_or_update( + login: 'ticket_create-agent-1', + firstname: 'Session', + 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, + ) + + 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.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 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 d6883d715..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) @@ -99,7 +95,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 @@ -107,14 +103,16 @@ class SessionCollectionsTest < ActiveSupport::TestCase # change collection group = Group.first + travel 6.seconds group.touch - travel 3.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') - 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 +121,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 @@ -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 f191b5f40..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,13 +41,12 @@ class SessionEnhancedTest < ActiveSupport::TestCase roles: roles, groups: groups, ) - agent3.roles = roles - agent3.save + 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 +142,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 +150,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 +168,8 @@ class SessionEnhancedTest < ActiveSupport::TestCase # exit jobs jobs.exit - + jobs.join + travel_back end test 'b check client and backends' do @@ -196,8 +194,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', @@ -209,16 +206,29 @@ 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', + 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 +240,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 +260,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 +269,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 +281,15 @@ 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 + travel 4.seconds group.touch - sleep 10 + travel 12.seconds + sleep 12 # check collections collections = { @@ -278,16 +299,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/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 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_last_owner_update_test.rb b/test/unit/ticket_last_owner_update_test.rb new file mode 100644 index 000000000..33492783c --- /dev/null +++ b/test/unit/ticket_last_owner_update_test.rb @@ -0,0 +1,258 @@ +# encoding: utf-8 +require 'test_helper' + +class TicketLastOwnerUpdateTest < ActiveSupport::TestCase + + setup do + group = Group.create_or_update( + name: 'LastOwnerUpdate', + assignment_timeout: 60, + updated_by_id: 1, + created_by_id: 1, + ) + roles = Role.where(name: 'Agent') + @agent1 = User.create_or_update( + login: 'ticket-assignment_timeout-agent1@example.com', + firstname: 'Overview', + lastname: 'Agent1', + email: 'ticket-assignment_timeout-agent1@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: Group.all, + updated_by_id: 1, + created_by_id: 1, + ) + end + + test 'last_owner_update_at check' do + + ticket = Ticket.create!( + title: 'assignment_timeout test 1', + group: Group.lookup(name: 'LastOwnerUpdate'), + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_nil(ticket.last_owner_update_at) + + travel 1.hour + ticket.owner = @agent1 + ticket.save! + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket = Ticket.create!( + title: 'assignment_timeout test 1', + group: Group.lookup(name: 'LastOwnerUpdate'), + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_nil(ticket.last_owner_update_at) + + travel 1.hour + ticket.owner = @agent1 + ticket.save! + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket = Ticket.create!( + title: 'assignment_timeout test 1', + group: Group.lookup(name: 'LastOwnerUpdate'), + owner: @agent1, + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket.owner_id = 1 + ticket.save! + assert_nil(ticket.last_owner_update_at) + + ticket = Ticket.create!( + title: 'assignment_timeout test 1', + group: Group.lookup(name: 'LastOwnerUpdate'), + owner: @agent1, + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket.owner_id = 1 + ticket.save! + assert_nil(ticket.last_owner_update_at) + + ticket = Ticket.create!( + title: 'assignment_timeout test 2', + group: Group.lookup(name: 'Users'), + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_nil(ticket.last_owner_update_at) + + travel 1.hour + ticket.owner = @agent1 + ticket.save! + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket = Ticket.create!( + title: 'assignment_timeout test 2', + group: Group.lookup(name: 'Users'), + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_nil(ticket.last_owner_update_at) + + travel 1.hour + ticket.owner = @agent1 + ticket.save! + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket = Ticket.create!( + title: 'assignment_timeout test 2', + group: Group.lookup(name: 'Users'), + owner: @agent1, + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket.owner_id = 1 + ticket.save! + assert_nil(ticket.last_owner_update_at) + + ticket = Ticket.create!( + title: 'assignment_timeout test 2', + group: Group.lookup(name: 'Users'), + owner: @agent1, + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(ticket.last_owner_update_at.to_s, ticket.updated_at.to_s) + + ticket.owner_id = 1 + ticket.save! + assert_nil(ticket.last_owner_update_at) + + end + + test 'last_owner_update_at assignment_timeout check' do + + ticket1 = Ticket.create!( + title: 'assignment_timeout test 1', + group: Group.lookup(name: 'LastOwnerUpdate'), + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_nil(ticket1.last_owner_update_at) + + ticket2 = Ticket.create!( + title: 'assignment_timeout test 2', + group: Group.lookup(name: 'LastOwnerUpdate'), + owner: @agent1, + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(ticket2.last_owner_update_at.to_s, ticket2.updated_at.to_s) + + ticket3 = Ticket.create!( + title: 'assignment_timeout test 3', + group: Group.lookup(name: 'LastOwnerUpdate'), + owner: @agent1, + customer_id: 2, + state: Ticket::State.lookup(name: 'closed'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(ticket3.last_owner_update_at.to_s, ticket3.updated_at.to_s) + + ticket4 = Ticket.create!( + title: 'assignment_timeout test 4', + group: Group.lookup(name: 'Users'), + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_nil(ticket4.last_owner_update_at) + + ticket5 = Ticket.create!( + title: 'assignment_timeout test 5', + group: Group.lookup(name: 'Users'), + owner: @agent1, + customer_id: 2, + state: Ticket::State.lookup(name: 'new'), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(ticket5.last_owner_update_at.to_s, ticket5.updated_at.to_s) + + travel 55.minutes + Ticket.process_auto_unassign + + ticket1after = Ticket.find(ticket1.id) + assert_nil(ticket1.last_owner_update_at) + assert_equal(ticket1.updated_at.to_s, ticket1after.updated_at.to_s) + + ticket2after = Ticket.find(ticket2.id) + assert_equal(ticket2.last_owner_update_at.to_s, ticket2after.last_owner_update_at.to_s) + assert_equal(ticket2.updated_at.to_s, ticket2after.updated_at.to_s) + + ticket3after = Ticket.find(ticket3.id) + assert_equal(ticket3.last_owner_update_at.to_s, ticket3after.last_owner_update_at.to_s) + assert_equal(ticket3.updated_at.to_s, ticket3after.updated_at.to_s) + + ticket4after = Ticket.find(ticket4.id) + assert_nil(ticket4.last_owner_update_at) + assert_equal(ticket4.updated_at.to_s, ticket4after.updated_at.to_s) + + ticket5after = Ticket.find(ticket5.id) + assert_equal(ticket5after.owner_id, @agent1.id) + assert_equal(ticket5.updated_at.to_s, ticket5after.updated_at.to_s) + + travel 15.minutes + Ticket.process_auto_unassign + ticket2_updated_at = Time.current + + ticket1after = Ticket.find(ticket1.id) + assert_nil(ticket1.last_owner_update_at) + assert_equal(ticket1.updated_at.to_s, ticket1after.updated_at.to_s) + + ticket2after = Ticket.find(ticket2.id) + assert_nil(ticket2after.last_owner_update_at) + assert_equal(ticket2after.owner_id, 1) + assert_equal(ticket2_updated_at.to_s, ticket2after.updated_at.to_s) + + ticket3after = Ticket.find(ticket3.id) + assert_equal(ticket3after.owner_id, @agent1.id) + assert_equal(ticket3.last_owner_update_at.to_s, ticket3after.last_owner_update_at.to_s) + assert_equal(ticket3.updated_at.to_s, ticket3after.updated_at.to_s) + + ticket4after = Ticket.find(ticket4.id) + assert_nil(ticket4.last_owner_update_at) + assert_equal(ticket4.updated_at.to_s, ticket4after.updated_at.to_s) + + ticket5after = Ticket.find(ticket5.id) + assert_equal(ticket5after.owner_id, @agent1.id) + assert_equal(ticket5.updated_at.to_s, ticket5after.updated_at.to_s) + + end + +end diff --git a/test/unit/ticket_notification_test.rb b/test/unit/ticket_notification_test.rb index 108e45de7..0288f41dd 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,12 +50,13 @@ 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', email: 'ticket-notification-agent1@example.com', password: 'agentpw', + out_of_office: false, active: true, roles: roles, groups: groups, @@ -68,12 +66,13 @@ 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', email: 'ticket-notification-agent2@example.com', password: 'agentpw', + out_of_office: false, active: true, roles: roles, groups: groups, @@ -83,6 +82,38 @@ class TicketNotificationTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) + @agent3 = User.create_or_update( + login: 'ticket-notification-agent3@example.com', + firstname: 'Notification', + lastname: 'Agent3', + email: 'ticket-notification-agent3@example.com', + password: 'agentpw', + out_of_office: false, + active: true, + roles: roles, + groups: groups, + preferences: { + locale: 'de-de', + }, + updated_by_id: 1, + created_by_id: 1, + ) + @agent4 = User.create_or_update( + login: 'ticket-notification-agent4@example.com', + firstname: 'Notification', + lastname: 'Agent4', + email: 'ticket-notification-agent4@example.com', + password: 'agentpw', + out_of_office: false, + active: true, + roles: roles, + groups: groups, + preferences: { + locale: 'de-de', + }, + updated_by_id: 1, + created_by_id: 1, + ) Group.create_if_not_exists( name: 'WithoutAccess', note: 'Test for notification check.', @@ -90,9 +121,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 +141,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 +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) @@ -138,22 +169,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 +194,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 +203,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 +231,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 +240,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 +266,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 +290,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 +323,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 +333,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 +385,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 +394,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 +452,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 +471,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 +480,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 +525,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 +571,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 +617,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 +681,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 +745,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 +810,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 +887,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 +940,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 +951,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 +965,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) @@ -947,19 +978,139 @@ class TicketNotificationTest < ActiveSupport::TestCase end + test 'ticket notification - out of office' do + + # create ticket in group + ticket1 = Ticket.create!( + title: 'some notification test out of office', + group: Group.lookup(name: 'TicketNotificationTest'), + customer: @customer, + owner_id: @agent2.id, + #state: Ticket::State.lookup(name: 'new'), + #priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: @customer.id, + created_by_id: @customer.id, + ) + 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', + 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, + ) + assert(ticket1, 'ticket created - ticket notification simple') + + # 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) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent3, 'email'), ticket1.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket1, @agent4, 'email'), ticket1.id) + + @agent2.out_of_office = true + @agent2.preferences[:out_of_office_text] = 'at the doctor' + @agent2.out_of_office_replacement_id = @agent3.id + @agent2.out_of_office_start_at = Time.zone.today - 2.days + @agent2.out_of_office_end_at = Time.zone.today + 2.days + @agent2.save! + + # create ticket in group + ticket2 = Ticket.create!( + title: 'some notification test out of office', + group: Group.lookup(name: 'TicketNotificationTest'), + customer: @customer, + owner_id: @agent2.id, + #state: Ticket::State.lookup(name: 'new'), + #priority: Ticket::Priority.lookup(name: '2 normal'), + updated_by_id: @customer.id, + created_by_id: @customer.id, + ) + 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', + 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, + ) + assert(ticket2, 'ticket created - ticket notification simple') + + # execute object transaction + Observer::Transaction.commit + Scheduler.worker(true) + + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent3, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent4, 'email'), ticket2.id) + + # update ticket attributes + ticket2.title = "#{ticket2.title} - #2" + ticket2.priority = Ticket::Priority.lookup(name: '3 high') + ticket2.save! + + # execute object transaction + Observer::Transaction.commit + Scheduler.worker(true) + + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) + assert_equal(2, NotificationFactory::Mailer.already_sent?(ticket2, @agent3, 'email'), ticket2.id) + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent4, 'email'), ticket2.id) + + @agent3.out_of_office = true + @agent3.preferences[:out_of_office_text] = 'at the doctor' + @agent3.out_of_office_replacement_id = @agent4.id + @agent3.out_of_office_start_at = Time.zone.today - 2.days + @agent3.out_of_office_end_at = Time.zone.today + 2.days + @agent3.save! + + # update ticket attributes + ticket2.title = "#{ticket2.title} - #3" + ticket2.priority = Ticket::Priority.lookup(name: '3 high') + ticket2.save! + + # execute object transaction + Observer::Transaction.commit + Scheduler.worker(true) + + # verify notifications to @agent1 + @agent2 + assert_equal(0, NotificationFactory::Mailer.already_sent?(ticket2, @agent1, 'email'), ticket2.id) + assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket2, @agent2, 'email'), ticket2.id) + assert_equal(3, NotificationFactory::Mailer.already_sent?(ticket2, @agent3, 'email'), ticket2.id) + assert_equal(1, NotificationFactory::Mailer.already_sent?(ticket2, @agent4, 'email'), ticket2.id) + + end + 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 +1120,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 +1137,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 +1150,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 +1169,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 +1182,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 +1210,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 +1227,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 +1248,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 +1277,4 @@ class TicketNotificationTest < ActiveSupport::TestCase end - test 'zzz - cleanup' do - Trigger.destroy_all - end - end 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 diff --git a/test/unit/ticket_overview_test.rb b/test/unit/ticket_overview_test.rb index 555ebf023..029c18e6d 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: { @@ -317,10 +302,10 @@ class TicketOverviewTest < ActiveSupport::TestCase ) end - test 'bbb overiview index' do + test 'bbb overview 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,18 +336,18 @@ 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) end - test 'ccc overiview content' do + test 'ccc overview content' do 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 diff --git a/test/unit/ticket_test.rb b/test/unit/ticket_test.rb index ec07825d3..dcb2d9f9b 100644 --- a/test/unit/ticket_test.rb +++ b/test/unit/ticket_test.rb @@ -289,7 +289,7 @@ class TicketTest < ActiveSupport::TestCase test 'ticket subject' do - ticket1 = Ticket.create( + ticket = Ticket.create( title: 'subject test 1', group: Group.lookup(name: 'Users'), customer_id: 2, @@ -298,12 +298,48 @@ class TicketTest < ActiveSupport::TestCase updated_by_id: 1, created_by_id: 1, ) - assert_equal('subject test 1', ticket1.title) - assert_equal("ABC subject test 1 [Ticket##{ticket1.number}]", ticket1.subject_build('ABC subject test 1')) - assert_equal("RE: ABC subject test 1 [Ticket##{ticket1.number}]", ticket1.subject_build('ABC subject test 1', true)) - assert_equal("RE: ABC subject test 1 [Ticket##{ticket1.number}]", ticket1.subject_build(' ABC subject test 1', true)) - assert_equal("RE: ABC subject test 1 [Ticket##{ticket1.number}]", ticket1.subject_build('ABC subject test 1 ', true)) - ticket1.destroy + assert_equal('subject test 1', ticket.title) + assert_equal("ABC subject test 1 [Ticket##{ticket.number}]", ticket.subject_build('ABC subject test 1')) + assert_equal("RE: ABC subject test 1 [Ticket##{ticket.number}]", ticket.subject_build('ABC subject test 1', true)) + assert_equal("RE: ABC subject test 1 [Ticket##{ticket.number}]", ticket.subject_build(' ABC subject test 1', true)) + assert_equal("RE: ABC subject test 1 [Ticket##{ticket.number}]", ticket.subject_build('ABC subject test 1 ', true)) + ticket.destroy + + Setting.set('ticket_hook_position', 'left') + + ticket = Ticket.create( + title: 'subject test 1', + 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, + ) + assert_equal('subject test 1', ticket.title) + assert_equal("[Ticket##{ticket.number}] ABC subject test 1", ticket.subject_build('ABC subject test 1')) + assert_equal("RE: [Ticket##{ticket.number}] ABC subject test 1", ticket.subject_build('ABC subject test 1', true)) + assert_equal("RE: [Ticket##{ticket.number}] ABC subject test 1", ticket.subject_build(' ABC subject test 1', true)) + assert_equal("RE: [Ticket##{ticket.number}] ABC subject test 1", ticket.subject_build('ABC subject test 1 ', true)) + ticket.destroy + + Setting.set('ticket_hook_position', 'none') + + ticket = Ticket.create( + title: 'subject test 1', + 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, + ) + assert_equal('subject test 1', ticket.title) + assert_equal('ABC subject test 1', ticket.subject_build('ABC subject test 1')) + assert_equal('RE: ABC subject test 1', ticket.subject_build('ABC subject test 1', true)) + assert_equal('RE: ABC subject test 1', ticket.subject_build(' ABC subject test 1', true)) + assert_equal('RE: ABC subject test 1', ticket.subject_build('ABC subject test 1 ', true)) + ticket.destroy end diff --git a/test/unit/ticket_trigger_test.rb b/test/unit/ticket_trigger_test.rb index 05343af10..724650e60 100644 --- a/test/unit/ticket_trigger_test.rb +++ b/test/unit/ticket_trigger_test.rb @@ -1414,6 +1414,228 @@ class TicketTriggerTest < ActiveSupport::TestCase end + test '6.1 owner auto assignment based on organization' do + trigger1 = Trigger.create_or_update( + name: 'aaa auto assignment', + condition: { + 'ticket.organization_id' => { + 'operator' => 'is not', + 'pre_condition' => 'not_set', + 'value' => '', + 'value_completion' => '', + }, + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'ticket.owner_id' => { + 'pre_condition' => 'current_user.id', + 'value' => '', + 'value_completion' => '', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + roles = Role.where(name: 'Agent') + agent = User.create_or_update( + login: 'agent@example.com', + firstname: 'Trigger', + lastname: 'Agent1', + email: 'agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + roles = Role.where(name: 'Customer') + customer = User.create_or_update( + login: 'customer@example.com', + firstname: 'Trigger', + lastname: 'Customer1', + email: 'customer@example.com', + password: 'customerpw', + vip: true, + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + + ticket1 = Ticket.create( + title: 'test 123', + group: Group.lookup(name: 'Users'), + customer: customer, + 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 note\nnew line", + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(1, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(1, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + + ticket1.update_attribute(:customer, User.lookup(email: 'nicole.braun@zammad.org') ) + + UserInfo.current_user_id = agent.id + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'update', + message_id: 'some@id', + content_type: 'text/html', + body: 'update', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + ) + Observer::Transaction.commit + UserInfo.current_user_id = nil + + ticket1.reload + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(agent.id, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(2, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + end + + test '6.2 owner auto assignment based on organization' do + trigger1 = Trigger.create_or_update( + name: 'aaa auto assignment', + condition: { + 'ticket.organization_id' => { + 'operator' => 'is', + 'pre_condition' => 'not_set', + 'value' => '', + 'value_completion' => '', + }, + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'update', + }, + }, + perform: { + 'ticket.owner_id' => { + 'pre_condition' => 'current_user.id', + 'value' => '', + 'value_completion' => '', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + roles = Role.where(name: 'Agent') + agent = User.create_or_update( + login: 'agent@example.com', + firstname: 'Trigger', + lastname: 'Agent1', + email: 'agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + roles = Role.where(name: 'Customer') + customer = User.create_or_update( + login: 'customer@example.com', + firstname: 'Trigger', + lastname: 'Customer1', + email: 'customer@example.com', + password: 'customerpw', + vip: true, + active: true, + roles: roles, + updated_by_id: 1, + created_by_id: 1, + ) + + ticket1 = Ticket.create( + title: 'test 123', + group: Group.lookup(name: 'Users'), + customer: User.lookup(email: 'nicole.braun@zammad.org'), + 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 note\nnew line", + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + updated_by_id: 1, + created_by_id: 1, + ) + Observer::Transaction.commit + + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(1, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(1, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + + ticket1.update_attribute(:customer, customer ) + + UserInfo.current_user_id = agent.id + Ticket::Article.create( + ticket_id: ticket1.id, + from: 'some_sender@example.com', + to: 'some_recipient@example.com', + subject: 'update', + message_id: 'some@id', + content_type: 'text/html', + body: 'update', + internal: false, + sender: Ticket::Article::Sender.find_by(name: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + ) + Observer::Transaction.commit + UserInfo.current_user_id = nil + + ticket1.reload + assert_equal('test 123', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal(agent.id, ticket1.owner_id, 'ticket1.owner_id verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(2, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + end + test '7 owner auto assignment' do trigger1 = Trigger.create_or_update( name: 'aaa auto assignment', @@ -2845,4 +3067,417 @@ 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(21, ticket1.articles.count) + assert_equal('some_loop_sender@example.com', ticket1.articles[20].from) + + 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[21].from) + + end + + test '3 invalid condition' do + trigger1 = Trigger.create_or_update( + name: 'aaa loop check', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + }, + perform: { + 'ticket.tags' => { + 'operator' => 'add', + 'value' => 'xxx', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + trigger1.update_column(:condition, { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + 'ticket.first_response_at' => { + 'operator' => 'before (absolute)', + 'value' => 'invalid invalid 4', + }, + }) + assert_equal('invalid invalid 4', trigger1.condition['ticket.first_response_at']['value']) + + trigger2 = Trigger.create_or_update( + name: 'auto reply', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + 'ticket.state_id' => { + 'operator' => 'is', + 'value' => Ticket::State.lookup(name: 'new').id.to_s, + } + }, + perform: { + 'notification.email' => { + 'body' => 'some text
                          #{ticket.customer.lastname}
                          #{ticket.title}
                          #{article.body}', + 'recipient' => 'ticket_customer', + 'subject' => 'Thanks for your inquiry (#{ticket.title})!', + }, + 'ticket.priority_id' => { + 'value' => Ticket::Priority.lookup(name: '3 high').id.to_s, + }, + 'ticket.tags' => { + 'operator' => 'add', + 'value' => 'aa, kk', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket1 = Ticket.create( + title: "some title\n äöüß", + 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_sender@example.com', + to: 'some_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: 'Agent'), + type: Ticket::Article::Type.find_by(name: 'note'), + updated_by_id: 1, + created_by_id: 1, + ) + + ticket1.reload + assert_equal('some title äöüß', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('2 normal', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(1, ticket1.articles.count, 'ticket1.articles verify') + assert_equal([], ticket1.tag_list) + + Observer::Transaction.commit + + ticket1.reload + assert_equal('some title äöüß', ticket1.title, 'ticket1.title verify') + assert_equal('Users', ticket1.group.name, 'ticket1.group verify') + assert_equal('new', ticket1.state.name, 'ticket1.state verify') + assert_equal('3 high', ticket1.priority.name, 'ticket1.priority verify') + assert_equal(2, ticket1.articles.count, 'ticket1.articles verify') + assert_equal(%w(aa kk), ticket1.tag_list) + article1 = ticket1.articles.last + assert_match('Zammad ', article1.from) + assert_match('nicole.braun@zammad.org', article1.to) + assert_match('Thanks for your inquiry (some title äöüß)!', article1.subject) + assert_match('Braun
                          some <b>title</b>', article1.body) + assert_match('> some message <b>note</b>
                          > new line', article1.body) + assert_equal('text/html', article1.content_type) + + end + end diff --git a/test/unit/user_device_test.rb b/test/unit/user_device_test.rb index cd734e971..4d1ff1e52 100644 --- a/test/unit/user_device_test.rb +++ b/test/unit/user_device_test.rb @@ -204,6 +204,42 @@ class UserDeviceTest < ActiveSupport::TestCase ) assert_equal(user_device2.id, user_device6.id) + # signin without ua from country A via basic auth -> new device #3 + user_device7 = UserDevice.add( + '', + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + assert_not_equal(user_device6.id, user_device7.id) + + user_device8 = UserDevice.add( + '', + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + assert_equal(user_device7.id, user_device8.id) + + user_device9 = UserDevice.add( + nil, + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + assert_equal(user_device8.id, user_device9.id) + + user_device10 = UserDevice.add( + nil, + '176.198.137.254', + @agent.id, + nil, + 'basic_auth', + ) + assert_not_equal(user_device9.id, user_device10.id) end test 'ddd - api test' do diff --git a/test/unit/user_out_of_office_test.rb b/test/unit/user_out_of_office_test.rb new file mode 100644 index 000000000..6d8f68968 --- /dev/null +++ b/test/unit/user_out_of_office_test.rb @@ -0,0 +1,131 @@ +require 'test_helper' + +class UserOutOfOfficeTest < ActiveSupport::TestCase + setup do + + UserInfo.current_user_id = 1 + + groups = Group.all + roles = Role.where(name: 'Agent') + @agent1 = User.create_or_update( + login: 'user-out_of_office-agent1@example.com', + firstname: 'UserOutOfOffice', + lastname: 'Agent1', + email: 'user-out_of_office-agent1@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + @agent2 = User.create_or_update( + login: 'user-out_of_office-agent2@example.com', + firstname: 'UserOutOfOffice', + lastname: 'Agent2', + email: 'user-out_of_office-agent2@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + @agent3 = User.create_or_update( + login: 'user-out_of_office-agent3@example.com', + firstname: 'UserOutOfOffice', + lastname: 'Agent3', + email: 'user-out_of_office-agent3@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + end + + test 'check out_of_office?' do + + # check + assert_not(@agent1.out_of_office?) + assert_not(@agent2.out_of_office?) + assert_not(@agent3.out_of_office?) + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + 2.days + @agent1.out_of_office_end_at = Time.zone.now + @agent1.save! + } + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + @agent1.out_of_office_end_at = Time.zone.now - 2.days + @agent1.save! + } + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = nil + @agent1.out_of_office_end_at = Time.zone.now + @agent1.save! + } + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + @agent1.out_of_office_end_at = nil + @agent1.save! + } + + @agent1.out_of_office = false + @agent1.out_of_office_start_at = Time.zone.now + 2.days + @agent1.out_of_office_end_at = Time.zone.now + @agent1.save! + + assert_not(@agent1.out_of_office?) + + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office = true + @agent1.out_of_office_start_at = Time.zone.now + 2.days + @agent1.out_of_office_end_at = Time.zone.now + 4.days + @agent1.save! + } + assert_raises(Exceptions::UnprocessableEntity) { + @agent1.out_of_office_replacement_id = 999_999_999_999 # not existing + @agent1.save! + } + @agent1.out_of_office_replacement_id = @agent2.id + @agent1.save! + + assert_not(@agent1.out_of_office?) + + travel 2.days + + assert(@agent1.out_of_office?) + + travel 1.day + + assert(@agent1.out_of_office?) + + travel 1.day + + assert(@agent1.out_of_office?) + + travel 1.day + + assert_not(@agent1.out_of_office?) + + assert_not(@agent1.out_of_office_agent) + + assert_not(@agent2.out_of_office_agent) + + @agent2.out_of_office = true + @agent2.out_of_office_start_at = Time.zone.now + @agent2.out_of_office_end_at = Time.zone.now + 4.days + @agent2.out_of_office_replacement_id = @agent3.id + @agent2.save! + + assert(@agent2.out_of_office?) + + assert_equal(@agent2.out_of_office_agent.id, @agent3.id) + + 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 diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index 31919b5d0..89447737c 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -245,9 +245,9 @@ class UserTest < ActiveSupport::TestCase tests.each { |test| # check if user exists - user = User.where( login: test[:create][:login] ).first + user = User.where(login: test[:create][:login]).first if user - user.destroy + user.destroy! end user = User.create( test[:create] ) @@ -266,8 +266,8 @@ class UserTest < ActiveSupport::TestCase end } if test[:create_verify][:image_md5] - file = Avatar.get_by_hash( user.image ) - file_md5 = Digest::MD5.hexdigest( file.content ) + file = Avatar.get_by_hash(user.image) + file_md5 = Digest::MD5.hexdigest(file.content) assert_equal(test[:create_verify][:image_md5], file_md5, "create avatar md5 check in (#{test[:name]})") end if test[:update] @@ -275,7 +275,7 @@ class UserTest < ActiveSupport::TestCase test[:update_verify].each { |key, value| next if key == :image_md5 - if user.respond_to?( key ) + if user.respond_to?(key) assert_equal(value, user.send(key), "update check #{key} in (#{test[:name]})") else assert_equal(value, user[key], "update check #{key} in (#{test[:name]})") @@ -289,10 +289,252 @@ class UserTest < ActiveSupport::TestCase end end - user.destroy + user.destroy! } end + test 'without email - but login eq email' do + name = rand(999_999_999) + + login = "admin-role_without_email#{name}@example.com" + email = "admin-role_without_email#{name}@example.com" + admin = User.create_or_update( + login: login, + firstname: 'Role', + lastname: "Admin#{name}", + #email: "", + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + + assert(admin.id) + assert_equal(admin.login, login) + assert_equal(admin.email, '') + + admin.email = email + admin.save! + + assert_equal(admin.login, login) + assert_equal(admin.email, email) + + admin.email = '' + admin.save! + + assert(admin.id) + assert(admin.login) + assert_not_equal(admin.login, login) + assert_equal(admin.email, '') + + admin.destroy! + end + + test 'without email - but login ne email' do + name = rand(999_999_999) + + login = "admin-role_without_email#{name}" + email = "admin-role_without_email#{name}@example.com" + admin = User.create_or_update( + login: login, + firstname: 'Role', + lastname: "Admin#{name}", + #email: "", + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + + assert(admin.id) + assert_equal(admin.login, login) + assert_equal(admin.email, '') + + admin.email = email + admin.save! + + assert_equal(admin.login, login) + assert_equal(admin.email, email) + + admin.email = '' + admin.save! + + assert(admin.id) + assert_equal(admin.login, login) + assert_equal(admin.email, '') + + admin.destroy! + end + + test 'uniq email' do + name = rand(999_999_999) + + email1 = "admin1-role_without_email#{name}@example.com" + admin1 = User.create!( + login: email1, + firstname: 'Role', + lastname: "Admin1#{name}", + email: email1, + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + + assert(admin1.id) + assert_equal(admin1.email, email1) + + assert_raises(Exceptions::UnprocessableEntity) { + User.create!( + login: "#{email1}-1", + firstname: 'Role', + lastname: "Admin1#{name}", + email: email1, + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + } + + email2 = "admin2-role_without_email#{name}@example.com" + admin2 = User.create!( + firstname: 'Role', + lastname: "Admin2#{name}", + email: email2, + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + + assert_raises(Exceptions::UnprocessableEntity) { + admin2.email = email1 + admin2.save! + } + + admin1.email = admin1.email + admin1.save! + + admin2.destroy! + admin1.destroy! + end + + test 'uniq email - multiple use' do + Setting.set('user_email_multiple_use', true) + name = rand(999_999_999) + + email1 = "admin1-role_without_email#{name}@example.com" + admin1 = User.create!( + login: email1, + firstname: 'Role', + lastname: "Admin1#{name}", + email: email1, + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + + assert(admin1.id) + assert_equal(admin1.email, email1) + + admin2 = User.create!( + login: "#{email1}-1", + firstname: 'Role', + lastname: "Admin1#{name}", + email: email1, + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(admin2.email, email1) + admin2.destroy! + admin1.destroy! + Setting.set('user_email_multiple_use', false) + end + + test 'ensure roles' do + name = rand(999_999_999) + + admin = User.create_or_update( + login: "admin-role#{name}@example.com", + firstname: 'Role', + lastname: "Admin#{name}", + email: "admin-role#{name}@example.com", + password: 'adminpw', + active: true, + roles: Role.where(name: %w(Admin Agent)), + updated_by_id: 1, + created_by_id: 1, + ) + + customer1 = User.create_or_update( + login: "user-ensure-role1-#{name}@example.com", + firstname: 'Role', + lastname: "Customer#{name}", + email: "user-ensure-role1-#{name}@example.com", + password: 'customerpw', + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(customer1.role_ids.sort, Role.signup_role_ids) + + roles = Role.where(name: 'Agent') + customer1.roles = roles + customer1.save! + + assert_equal(customer1.role_ids.count, 1) + assert_equal(customer1.role_ids.first, roles.first.id) + assert_equal(customer1.roles.first.id, roles.first.id) + + customer1.roles = [] + customer1.save! + + assert_equal(customer1.role_ids.sort, Role.signup_role_ids) + customer1.destroy! + + customer2 = User.create_or_update( + login: "user-ensure-role2-#{name}@example.com", + firstname: 'Role', + lastname: "Customer#{name}", + email: "user-ensure-role2-#{name}@example.com", + password: 'customerpw', + roles: roles, + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + assert_equal(customer2.role_ids.count, 1) + assert_equal(customer2.role_ids.first, roles.first.id) + assert_equal(customer2.roles.first.id, roles.first.id) + + roles = Role.where(name: 'Admin') + customer2.role_ids = [roles.first.id] + customer2.save! + + assert_equal(customer2.role_ids.count, 1) + assert_equal(customer2.role_ids.first, roles.first.id) + assert_equal(customer2.roles.first.id, roles.first.id) + + customer2.roles = [] + customer2.save! + + assert_equal(customer2.role_ids.sort, Role.signup_role_ids) + customer2.destroy! + + admin.destroy! + end + test 'user default preferences' do name = rand(999_999_999) groups = Group.where(name: 'Users') @@ -352,7 +594,6 @@ class UserTest < ActiveSupport::TestCase assert(customer1.preferences['notification_config']) assert(customer1.preferences['notification_config']['matrix']['create']) assert(customer1.preferences['notification_config']['matrix']['update']) - end test 'permission' do @@ -550,7 +791,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 diff --git a/vendor/lib/microsoft_office365_database.rb b/vendor/lib/microsoft_office365_database.rb new file mode 100644 index 000000000..5d51c084a --- /dev/null +++ b/vendor/lib/microsoft_office365_database.rb @@ -0,0 +1,13 @@ +class MicrosoftOffice365Database < OmniAuth::Strategies::MicrosoftOffice365 + option :name, 'microsoft_office365' + + def initialize(app, *args, &block) + + # database lookup + config = Setting.get('auth_microsoft_office365_credentials') || {} + args[0] = config['app_id'] + args[1] = config['app_secret'] + super + end + +end