Compare commits

...

60 Commits

Author SHA1 Message Date
Martin Edenhofer
d159b40979 Addec changes for 2.6.1. 2018-10-25 03:29:31 +02:00
Martin Edenhofer
d89d86d8d1 Fixed issue #2297 - rake searchindex:rebuild is not working after Elasticsearch 2.4 upgrade to Elasticsearch 5.6 "Limit of total fields [1000] in index" 2018-10-19 00:43:11 +02:00
Billy Zhou
07ed45cb3a Fixed issue #2279 - performance issue in bulk action selection in overview (fixes #2279)
(cherry picked from commit fb24de11cc)
2018-10-17 11:37:13 +02:00
Billy Zhou
b745fb212f Fixed issue #2277 - note is not shown for customer/organisations if it's empty (fixes #2277)
(cherry picked from commit ea12b0f676)
2018-10-17 09:04:42 +02:00
Billy Zhou
967ab56fc4 Fixed issue #2273 - Bulk Form javascript error when checking item (fixes #2273)
(cherry picked from commit b1caed65f5)
2018-10-12 14:17:10 +02:00
Martin Edenhofer
3a4ca7a125 Fixed issue #2285 - Permission issue: response templates only usable when admin is also agent. 2018-10-12 13:07:22 +02:00
Martin Edenhofer
3eecf52c50 Fixed issue #2281 - O365/Office365 authentication missing given- and surname. 2018-10-11 14:42:01 +02:00
Billy Zhou
5222705750 Fixed issue #2246 - Records in Reporting not updated when single ActiveRecord can not be found (fixes #2246)
(cherry picked from commit 5ff371f51b)
2018-10-10 14:02:05 +02:00
Ryan Lue
df402111cb Prevent LDAP sync from needlessly deactivating inactive users (fixes #2256) 2018-10-10 08:28:19 +02:00
Billy Zhou
8e6a63d8da Fixed issue #2195 - Search with id:123 does not work anymore (fixes #2195)
(cherry picked from commit bfccecc012)
2018-10-09 14:17:01 +02:00
Martin Edenhofer
0364225839 Implemented issue #2270 - Italian and Spanish translations for notifications. 2018-10-09 14:05:47 +02:00
Martin Edenhofer
cdaff7906b Updated nokogiri gem (CVE-2018-14404). 2018-10-05 07:14:34 +02:00
Billy Zhou
d57116dada Fixed issue #2173 - Invalid date causes errors (fixes #2173)
(cherry picked from commit 381e00b74f)
2018-10-02 06:46:30 +02:00
Ryan Lue
e19be91338 Test and fix time accounting API endpoint bug (replaces #2243) 2018-09-28 14:53:33 +08:00
Billy Zhou
bed3e54db6 Fixed issue #2233 - Boolean object set to false is not visible (fixes #2233)
(Cherry-picked from 8bf3ab24d and 280ab7471f)
2018-09-26 16:21:35 +08:00
Martin Edenhofer
2b80635f28 Fixed issue #2254 - Unable to process spam email "ERROR: #<Exceptions::UnprocessableEntity: Invalid email>". 2018-09-24 23:07:16 +02:00
Martin Edenhofer
a42a370b59 Fixed issue #2252 - Unable to process email "ERROR: #<NoMethodError: undefined method match' for nil:NilClass>"`. 2018-09-24 14:41:16 +02:00
Billy Zhou
32b0228e11 Fixed #2236 by adding instance_method_already_implemented? check for invalid attribute names (fixes #2236) 2018-09-23 21:45:23 +08:00
Thorsten Eckel
5c07513271 Don't sync cherry-pick branches over to Github. 2018-09-21 16:42:33 +02:00
Billy Zhou
cc1f959c35 Fixed issue #2026 by checking for change permission before showing bulk action form (fixes #2026)
(cherry picked from commit 45fee1b752)
2018-09-21 06:12:47 +02:00
Martin Edenhofer
faa84251fc Fixed issue #2245 - Unable to process spam mail with invalid date field. 2018-09-20 19:17:26 +02:00
Billy Zhou
2e141632b8 Fixed issue #2235 - Overview setting isn't applied on submit (fixes #2235)
(cherry picked from commit 1efcb9d89f)
2018-09-19 17:06:52 +02:00
Martin Edenhofer
43784b0e8b Updated rubyzip to v1.2.2. 2018-09-12 07:20:33 +02:00
Ryan Lue
9b670dfae0 Refactor RecentView 2018-09-12 04:11:40 +02:00
Ryan Lue
040e7bbead Order results of RecentView.list (fixes #2194) 2018-09-12 04:11:40 +02:00
Ryan Lue
e8dcd7f850 Migrate RecentView test from Minitest to RSpec 2018-09-12 04:11:40 +02:00
Ryan Lue
aa1743f17d Update RSpec to 3.8.0 2018-09-11 19:05:02 +08:00
Ryan Lue
355bc82a0b Automate clearing of broken LDAP configurations (fixes #2140) 2018-09-11 19:05:02 +08:00
Martin Edenhofer
70084b538b Fixed issue #2227 - Unprocessable email ERROR: #<ArgumentError: invalid byte sequence in UTF-8> and improved email parser tests to also verify attachments. 2018-09-07 14:51:29 +02:00
Felix Niklas
8732f5aeca Ticket print: remove empty scrollbar 2018-09-05 20:57:01 +02:00
Felix Niklas
ced0d8d05c Ticket print: Firefox multi-page fix, remove colors, show meta
Incorporates the Firefox multi-page fixes (display: flex print bug) from @MrGeneration

see #2162
2018-09-05 20:56:46 +02:00
Ryan Lue
aeaa3a04d5 Support processing of emails with Windows-1258 encoding (fixes #2224) 2018-09-05 15:38:09 +08:00
Martin Edenhofer
4bb9400a69 Only allow 1800 loops in a thread, to prevent email IMAP fetch stops working if mailserver is once unreachable. Fixes issue#1861. 2018-09-05 08:01:46 +02:00
Martin Edenhofer
d8ce602906 Disabled twitter streaming. Use twitter webhooks in next version. 2018-09-05 07:12:05 +02:00
Ryan Lue
49bcb62036 Really fix regression introduced in 23879bc (fixes #2220) 2018-09-04 11:49:41 +02:00
Ryan Lue
5a2f2afe8d Fix nil handling regression from #2152 fix 2018-09-03 09:36:24 +08:00
Felix Niklas
c1c9a79eb7 Print layout: fix cut off when printing a ticket
+ show full articles (disable show more) when printing.

fixes #2162
2018-09-02 16:11:10 +02:00
Ryan Lue
37734495cc Stop converting binary user attributes to text during LDAP setup (fixes #2140) 2018-09-01 10:27:23 +02:00
Martin Edenhofer
16f7edc7b9 Fixed issue #2218 - CTI Caller Log blocks user deletion/destroy and fails 2018-08-31 13:43:11 +02:00
Ryan Lue
9486025111 Improve 'URL' parsing in Avatar model (fixes #2199) 2018-08-28 21:01:24 +08:00
Ryan Lue
04ad7f1dcd Ensure CTI Log caller ID entries include user organization names (fixes #2075) 2018-08-28 15:34:40 +08:00
Thorsten Eckel
0f9073acbd Added dependency vulerability check exclusion for CVE-2018-1000544 (https://github.com/rubyzip/rubyzip/issues/369) which only affects test env (selenium-webdriver dependency) until it's resolved (https://github.com/rubyzip/rubyzip/pull/376). 2018-08-27 14:55:41 +02:00
Billy Zhou
7ea396d043 Fixed issue #2206 and #2207 by limiting historical_options to only select attributes
(cherry picked from commit 9be7269646)
2018-08-24 12:10:44 +02:00
Ryan Lue
23879bca1b Force UTF-8 encoding for Exchange folders (fixes #2152) 2018-08-24 08:23:45 +08:00
Martin Edenhofer
c0d5584a1c Fixed issue #2209 - Order of relation select fields (Group, State etc.) values is broken. 2018-08-23 23:01:51 +02:00
Martin Edenhofer
1ea788c7a9 Fixed issue #2204 - Max size of tabs is not recognised 2018-08-23 02:21:18 +02:00
Martin Edenhofer
6d23dae9c3 Fixed issue #2200 - Unable to process email EncodingError: could not find a valid input encoding. Needed to downgrade mail gem because of parsing issues of valid mime issues. 2018-08-22 14:07:16 +02:00
Billy Zhou
b5dd2caa72 Fixed issue #1864 - Bulk-Action: Not possible to change owner (fixes #1864)
See merge request zammad/zammad!8

(cherry picked from commit 10007e6409)

9c600bbf Fixed issue #1864 - Bulk-Action: Not possible to change owner (fixes #1864)
54055bcc Switch to overview_open
d42d0ff4 Addressed QA comments on merge request 8
16a4c2dc Renamed alternative_user_options to possible_groups_owners
2018-08-22 03:07:39 +02:00
Billy Zhou
ea425d5062 Fixed issue #2020 by checking against past select attribute options (fixes #2020) 2018-08-21 10:35:36 +08:00
Martin Edenhofer
54deb6b47d Follow up for issue #2193 - re-enabled shown anonymous if no caller is exists. Fixed triggering event of new/changed caller log items. 2018-08-20 11:39:23 +02:00
Billy Zhou
22add101d9 Fixed issue #2020 by checking against past select attribute options (fixes #2020)
(cherry picked from commit 1f7841340c)
2018-08-20 15:06:25 +08:00
Martin Edenhofer
5a7232900c Fixed issue #2193 - Puma performance is going worst if users with phone attribute are updated or new tickets are created. 2018-08-17 10:01:02 +02:00
Martin Edenhofer
e6f2290336 Fixed issue #2191 - Sometimes automatisation is not executed. 2018-08-16 15:14:11 +02:00
Thorsten Eckel
4df1330cd3 Follow up: Fixed issue #2183 - Mysql2::Error: Invalid default value for 'start_at'. 2018-08-16 09:33:11 +02:00
Ryan Lue
63bedc53f9 Avoid saving users during LDAP import if unchanged (fixes #2187) 2018-08-16 09:53:26 +08:00
Thorsten Eckel
39e96dd338 Fixed issue #2183 - Mysql2::Error: Invalid default value for 'start_at'. 2018-08-15 21:43:53 +02:00
Martin Edenhofer
c3a3ed7bec Follow up for issue #2171 - Overview not showing unassigned tickets "if not defined" (e. g. for owner but also for select or input fields). 2018-08-15 17:17:04 +02:00
Ryan Lue
cf1a8455dd Adjust Channel::EmailParser#parse to try charset supplied in header (fixes #2167) 2018-08-13 16:05:02 +08:00
Martin Edenhofer
bb08ba0353 Improved migration. 2018-08-10 03:42:41 +02:00
Martin Edenhofer
5930e2addd Updated CHANGES. 2018-08-10 02:34:08 +02:00
145 changed files with 32858 additions and 929 deletions

View File

@@ -1,16 +1,50 @@
# Change Log
## [2.6.0](https://github.com/zammad/zammad/tree/2.6.0) (2018-xx-xx)
[Full Changelog](https://github.com/zammad/zammad/compare/2.5.0...2.6.0)
## [2.6.1](https://github.com/zammad/zammad/tree/2.6.1) (2018-10-25)
[Full Changelog](https://github.com/zammad/zammad/compare/2.6.0...2.6.1)
**Implemented enhancements:**
- O365/Office365 authentication missing given- and surname [2281](https://github.com/zammad/zammad/issues/2281) [[enhancement](https://github.com/zammad/zammad/labels/enhancement)]
- Italian and Spanish translations for notifications. [2270](https://github.com/zammad/zammad/issues/2270) [[enhancement](https://github.com/zammad/zammad/labels/enhancement)]
**Fixed bugs:**
\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*
- `rake searchindex:rebuild` is not working after Elasticsearch 2.4 upgrade to Elasticsearch 5.6 "Limit of total fields [1000] in index" [2297](https://github.com/zammad/zammad/issues/2297) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Performance issue in bulk action selection in overview [2279](https://github.com/zammad/zammad/issues/2279) [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)] [[overviews](https://github.com/zammad/zammad/labels/overviews)] [[regression](https://github.com/zammad/zammad/labels/regression)]
- Note is not shown for customer / organisations if it's empty. [2277](https://github.com/zammad/zammad/issues/2277) [[bug](https://github.com/zammad/zammad/labels/bug)] [[regression](https://github.com/zammad/zammad/labels/regression)]
- Bulk Form javascript error when checking item [2273](https://github.com/zammad/zammad/issues/2273) [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)] [[needs verification](https://github.com/zammad/zammad/labels/needs verification)] [[overviews](https://github.com/zammad/zammad/labels/overviews)] [[regression](https://github.com/zammad/zammad/labels/regression)]
- Permission issue: response templates only usable when admin is also agent [2285](https://github.com/zammad/zammad/issues/2285) [[admin area](https://github.com/zammad/zammad/labels/admin area)] [[bug](https://github.com/zammad/zammad/labels/bug)]
- Records in Reporting not updated when single ActiveRecord can not be found [2246](https://github.com/zammad/zammad/issues/2246) [[bug](https://github.com/zammad/zammad/labels/bug)] [[reporting](https://github.com/zammad/zammad/labels/reporting)]
- Users deactivated by the LDAP sync get updated on every run [2256](https://github.com/zammad/zammad/issues/2256) [[LDAP](https://github.com/zammad/zammad/labels/LDAP)] [[bug](https://github.com/zammad/zammad/labels/bug)] [[integration](https://github.com/zammad/zammad/labels/integration)]
- Search with id:123 does not work anymore [2195](https://github.com/zammad/zammad/issues/2195) [[bug](https://github.com/zammad/zammad/labels/bug)] [[search](https://github.com/zammad/zammad/labels/search)]
- Invalid date causes errors [2173](https://github.com/zammad/zammad/issues/2173) [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)]
- Check the correct hash array if the agent is present [2243](https://github.com/zammad/zammad/pull/2243) [[bug](https://github.com/zammad/zammad/labels/bug)] [[needs verification](https://github.com/zammad/zammad/labels/needs verification)]
- Boolean object set to false is not visible [2233](https://github.com/zammad/zammad/issues/2233) [[bug](https://github.com/zammad/zammad/labels/bug)] [[object manager attribute](https://github.com/zammad/zammad/labels/object manager attribute)]
- Unable to process spam email `"ERROR: #<Exceptions::UnprocessableEntity: Invalid email>"` [2254](https://github.com/zammad/zammad/issues/2254) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Unable to process email `"ERROR: #<NoMethodError: undefined method `match' for nil:NilClass>"` [2252](https://github.com/zammad/zammad/issues/2252) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Naming an attribute "attribute" causes ActiveRecord failure [2236](https://github.com/zammad/zammad/issues/2236) [[bug](https://github.com/zammad/zammad/labels/bug)] [[object manager attribute](https://github.com/zammad/zammad/labels/object manager attribute)]
- Bulk action should not be shown if user has no change permissions [2026](https://github.com/zammad/zammad/issues/2026) [[bug](https://github.com/zammad/zammad/labels/bug)] [[bulk](https://github.com/zammad/zammad/labels/bulk)] [[frontend](https://github.com/zammad/zammad/labels/frontend)] [[overviews](https://github.com/zammad/zammad/labels/overviews)]
- Unable to process spam mail with invalid date field [2245](https://github.com/zammad/zammad/issues/2245) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Overview setting isn't applied on submit [2235](https://github.com/zammad/zammad/issues/2235) [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)]
- Wrong Recent viewed tickets list [2194](https://github.com/zammad/zammad/issues/2194) [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)]
- LDAP/Exchange UTF-8 Status Code 500 [2140](https://github.com/zammad/zammad/issues/2140) [[LDAP](https://github.com/zammad/zammad/labels/LDAP)] [[bug](https://github.com/zammad/zammad/labels/bug)] [[import](https://github.com/zammad/zammad/labels/import)]
- Unprocessable email ERROR: #<ArgumentError: invalid byte sequence in UTF-8> [2227](https://github.com/zammad/zammad/issues/2227) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Printed documents do not contain the whole ticket content, if it gets too much [2162](https://github.com/zammad/zammad/issues/2162) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Unable to process email (Encoding::ConverterNotFoundError: code converter not found (Windows-1258 to UTF-8)) [2224](https://github.com/zammad/zammad/issues/2224) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Email IMAP fetch stops working if mailserver is once unreachable [1861](https://github.com/zammad/zammad/issues/1861) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Unable to use exchange sync (stack level too deep (SystemStackError)) [2220](https://github.com/zammad/zammad/issues/2220) [[bug](https://github.com/zammad/zammad/labels/bug)] [[integration](https://github.com/zammad/zammad/labels/integration)]
- Exchange import fails if folder names can't be converted to Unicode [2152](https://github.com/zammad/zammad/issues/2152) [[bug](https://github.com/zammad/zammad/labels/bug)] [[import](https://github.com/zammad/zammad/labels/import)] [[waiting for feedback](https://github.com/zammad/zammad/labels/waiting for feedback)]
- CTI Caller Log blocks user deletion/destroy and fails [2218](https://github.com/zammad/zammad/issues/2218) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Unprocessable Mail if sender contains http.net@ [2199](https://github.com/zammad/zammad/issues/2199) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Call log shows only orga infos on event, not on site load [2075](https://github.com/zammad/zammad/issues/2075) [[CTI](https://github.com/zammad/zammad/labels/CTI)] [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)] [[needs verification](https://github.com/zammad/zammad/labels/needs verification)]
- Unable to modify tree_select attributes with fresh 2.6 [2206](https://github.com/zammad/zammad/issues/2206) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Order of relation select fields (Group, State etc.) values is broken [2209](https://github.com/zammad/zammad/issues/2209) [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)]
- Max size of tabs is not recognised [2204](https://github.com/zammad/zammad/issues/2204) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Unable to process email `EncodingError: could not find a valid input encoding` [2200](https://github.com/zammad/zammad/issues/2200) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Bulk-Action: Not possible to change owner [1864](https://github.com/zammad/zammad/issues/1864) [[UX/UI](https://github.com/zammad/zammad/labels/UX/UI)] [[bug](https://github.com/zammad/zammad/labels/bug)] [[frontend](https://github.com/zammad/zammad/labels/frontend)]
- Incorrect reset after deleting single object-selection [2020](https://github.com/zammad/zammad/issues/2020) [[bug](https://github.com/zammad/zammad/labels/bug)] [[object manager attribute](https://github.com/zammad/zammad/labels/object manager attribute)]
- Puma performance is going worst if users with phone attribute are updated or new tickets are created. [2193](https://github.com/zammad/zammad/issues/2193) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Sometimes automatisation is not executed [2191](https://github.com/zammad/zammad/issues/2191) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Problem while updating to 2.6: Mysql2::Error: Invalid default value for 'start_at' [2183](https://github.com/zammad/zammad/issues/2183) [[bug](https://github.com/zammad/zammad/labels/bug)] [[update](https://github.com/zammad/zammad/labels/update)]
- LDAP / Exchange update (save!) records unnecessarily [2187](https://github.com/zammad/zammad/issues/2187) [[bug](https://github.com/zammad/zammad/labels/bug)] [[import](https://github.com/zammad/zammad/labels/import)]
- Overview not showing unassigned tickets "if not defined" (e. g. for owner but also for select or input fields) [2171](https://github.com/zammad/zammad/issues/2171) [[bug](https://github.com/zammad/zammad/labels/bug)]
- Incorrect encoding of email messages in HTML format (Windows-1250) [2167](https://github.com/zammad/zammad/issues/2167) [[bug](https://github.com/zammad/zammad/labels/bug)]

View File

@@ -80,7 +80,7 @@ gem 'twitter'
# channels - email additions
gem 'htmlentities'
gem 'mail', '>= 2.7.1.rc1'
gem 'mail', '2.6.6'
gem 'mime-types'
gem 'rchardet', '>= 1.8.0'
gem 'valid_email2'
@@ -163,6 +163,10 @@ group :development, :test do
# mock http calls
gem 'webmock'
# record and replay TCP/HTTP transactions
gem 'tcr'
gem 'vcr'
end
# Want to extend Zammad with additional gems?

View File

@@ -220,15 +220,14 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
lumberjack (1.0.12)
mail (2.7.1.rc1)
mini_mime (>= 0.1.1)
mail (2.6.6)
mime-types (>= 1.16, < 4)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
method_source (0.9.0)
mime-types (3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521)
mini_mime (1.0.0)
mini_portile2 (2.3.0)
minitest (5.11.3)
multi_json (1.12.2)
@@ -241,7 +240,7 @@ GEM
net-ldap (0.16.1)
netrc (0.11.0)
nio4r (2.3.0)
nokogiri (1.8.4)
nokogiri (1.8.5)
mini_portile2 (~> 2.3.0)
nori (2.6.0)
notiffany (0.1.1)
@@ -318,7 +317,7 @@ GEM
rack (2.0.5)
rack-livereload (0.3.16)
rack
rack-test (1.0.0)
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.1.5)
actioncable (= 5.1.5)
@@ -358,23 +357,23 @@ GEM
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
retriable (2.1.0)
rspec-core (3.7.0)
rspec-support (~> 3.7.0)
rspec-expectations (3.7.0)
rspec-core (3.8.0)
rspec-support (~> 3.8.0)
rspec-expectations (3.8.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.7.0)
rspec-mocks (3.7.0)
rspec-support (~> 3.8.0)
rspec-mocks (3.8.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.7.0)
rspec-rails (3.7.2)
rspec-support (~> 3.8.0)
rspec-rails (3.8.0)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.7.0)
rspec-support (~> 3.7.0)
rspec-support (3.7.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.0)
rubocop (0.54.0)
parallel (~> 1.10)
parser (>= 2.5)
@@ -384,7 +383,7 @@ GEM
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-progressbar (1.9.0)
ruby_dep (1.5.0)
rubyzip (1.2.1)
rubyzip (1.2.2)
safe_yaml (1.0.4)
sass (3.5.3)
sass-listen (~> 4.0.0)
@@ -422,6 +421,7 @@ GEM
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sqlite3 (1.3.13)
tcr (0.2.0)
telegramAPI (1.4.2)
rest-client (~> 2.0, >= 2.0.2)
telephone_number (1.3.0)
@@ -461,6 +461,7 @@ GEM
valid_email2 (2.1.0)
activemodel (>= 3.2)
mail (~> 2.5)
vcr (4.0.0)
viewpoint (1.1.0)
httpclient
logging
@@ -519,7 +520,7 @@ DEPENDENCIES
json
koala
libv8
mail (>= 2.7.1.rc1)
mail (= 2.6.6)
mime-types
mysql2
net-ldap
@@ -556,6 +557,7 @@ DEPENDENCIES
slack-notifier
sprockets
sqlite3
tcr
telegramAPI
telephone_number
test-unit
@@ -564,6 +566,7 @@ DEPENDENCIES
uglifier
unicorn
valid_email2
vcr
viewpoint
webmock
writeexcel

View File

@@ -412,6 +412,12 @@ class App.ControllerTable extends App.Controller
return if !@bindCheckbox
return if !@bindCheckbox.events
return if _.isEmpty(clicks)
# If a select_all callback exists, then trigger it once insteading of triggering the callback once for each checkbox
if @bindCheckbox.select_all
@bindCheckbox.select_all(clicks[0]..., e)
return
for event, callback of @bindCheckbox.events
if event == 'click' || event == 'change'
for click in clicks
@@ -1007,4 +1013,4 @@ class App.ControllerTable extends App.Controller
for position in [0..array1.length-1]
if array1[position] isnt array2[position]
return position
true
true

View File

@@ -73,7 +73,7 @@ class Index extends App.ControllerSubContent
user_group_config = false
groups = []
group_ids = App.User.find(@Session.get('id')).all_group_ids()
group_ids = App.User.find(@Session.get('id')).allGroupIds()
if group_ids
for group_id in group_ids
group = App.Group.find(group_id)

View File

@@ -37,6 +37,7 @@ class App.UiElement.datetime extends App.UiElement.basedate
@buildTimestamp: (currentInput) ->
timestamp = "#{currentInput[0]}T#{currentInput[1]}:00.000Z"
time = new Date( Date.parse(timestamp) )
return '' if isNaN time
time.setMinutes( time.getMinutes() + time.getTimezoneOffset() )
@log 'setNewTime', time.toString()
time.toISOString().replace(/\d\d\.\d\d\dZ$/, '00.000Z')

View File

@@ -8,6 +8,9 @@ class App.UiElement.select extends App.UiElement.ApplicationUiElement
else
attribute.multiple = ''
# add deleted historical options if required
@addDeletedOptions(attribute, params)
# build options list based on config
@getConfigOptionList(attribute, params)
@@ -31,3 +34,20 @@ class App.UiElement.select extends App.UiElement.ApplicationUiElement
# return item
$( App.view('generic/select')(attribute: attribute) )
# 1. If attribute.value is not among the current options, then search within historical options
# 2. If attribute.value is not among current and historical options, then add the value itself as an option
@addDeletedOptions: (attribute) ->
return if !_.isEmpty(attribute.relation) # do not apply for attributes with relation, relations will fill options automatically
value = attribute.value
return if !value
return if _.isArray(value)
return if !attribute.options
return if !_.isObject(attribute.options)
return if value of attribute.options
return if value in (temp for own prop, temp of attribute.options)
if attribute.historical_options && value of attribute.historical_options
attribute.options[value] = attribute.historical_options[value]
else
attribute.options[value] = value

View File

@@ -25,6 +25,13 @@ class App.CTI extends App.Controller
@delay(delay, 500, 'cti_list_push_render')
'cti_list_push'
)
@bind('cti_event', (data) =>
return if data.state isnt 'newCall'
return if data.direction isnt 'in'
return if @switch() isnt true
@notify(data)
'cti_event'
)
@bind('auth', (data) =>
@meta.counter = 0
)
@@ -71,6 +78,7 @@ class App.CTI extends App.Controller
# render new caller list
if data.list
@list = data.list
@updateNavMenu()
if @renderDone
@renderCallerLog()
return
@@ -145,8 +153,8 @@ class App.CTI extends App.Controller
if item.comment
item.state_human += ", #{item.comment}"
if item.start && item.end
item.duration = format((Date.parse(item.end) - Date.parse(item.start))/1000)
if item.start_at && item.end_at
item.duration = format((Date.parse(item.end_at) - Date.parse(item.start_at))/1000)
diff_in_min = ((Date.now() - Date.parse(item.created_at)) / 1000) / 60
if diff_in_min > 1
@@ -198,9 +206,9 @@ class App.CTI extends App.Controller
counter: =>
count = 0
for item in @list
if item.state is 'hangup' && !item.done
if !item.done
count++
@meta.counter + count
@meta.counter = count
switch: (state = undefined) =>

View File

@@ -1,9 +1,10 @@
class App.TestController1 extends App.Controller
constructor: ->
super
@showState = false
@hideState = false
@activeState = false
@showState = false
@hideState = false
@activeState = false
@changedState ||= false
@render()
meta: ->
@@ -21,6 +22,9 @@ class App.TestController1 extends App.Controller
@activeState = state
@render()
changed: =>
@changedState
render: ->
@html "<div>some test controller message:'#{@message}',show:'#{@showState}',hide:'#{@hideState}',active:'#{@activeState}'</div>"

View File

@@ -1,3 +1,47 @@
ValidUsersForTicketSelectionMethods =
validUsersForTicketSelection: ->
items = $('.content.active .table-overview .table').find('[name="bulk"]:checked')
# we want to display all users for which we can assign the tickets directly
# for this we need to get the groups of all selected tickets
# after we got those we need to check which users are available in all groups
# users that are not in all groups can't get the tickets assigned
ticket_ids = _.map(items, (el) -> $(el).val() )
ticket_group_ids = _.map(App.Ticket.findAll(ticket_ids), (ticket) -> ticket.group_id)
users = @usersInGroups(ticket_group_ids)
# get the list of possible groups for the current user
# from the TicketCreateCollection
# (filled for e.g. the TicketCreation or TicketZoom assignment)
# and order them by name
group_ids = _.keys(@formMeta?.dependencies?.group_id)
groups = App.Group.findAll(group_ids)
groups_sorted = _.sortBy(groups, (group) -> group.name)
# get the number of visible users per group
# from the TicketCreateCollection
# (filled for e.g. the TicketCreation or TicketZoom assignment)
for group in groups
group.valid_users_count = @formMeta?.dependencies?.group_id?[group.id]?.owner_id.length || 0
{
users: users
groups: groups_sorted
}
usersInGroups: (group_ids) ->
ids_by_group = _.chain(@formMeta?.dependencies?.group_id)
.pick(group_ids)
.values()
.map( (e) -> e.owner_id)
.value()
# Underscore's intersection doesn't work when chained
ids_in_all_groups = _.intersection(ids_by_group...)
users = App.User.findAll(ids_in_all_groups)
_.sortBy(users, (user) -> user.firstname)
class App.TicketOverview extends App.Controller
className: 'overviews'
activeFocus: 'nav'
@@ -25,6 +69,8 @@ class App.TicketOverview extends App.Controller
'mouseenter .js-batch-hover-target': 'highlightBatchEntry'
'mouseleave .js-batch-hover-target': 'unhighlightBatchEntry'
@include ValidUsersForTicketSelectionMethods
constructor: ->
super
@batchSupport = @permissionCheck('ticket.agent')
@@ -540,19 +586,6 @@ class App.TicketOverview extends App.Controller
.velocity({ opacity: [0.5, 1] }, { duration: 120 })
.velocity({ opacity: [1, 0.5] }, { duration: 60, delay: 40 })
usersInGroups: (group_ids) ->
ids_by_group = _.chain(@formMeta?.dependencies?.group_id)
.pick(group_ids)
.values()
.map( (e) -> e.owner_id)
.value()
# Underscore's intersection doesn't work when chained
ids_in_all_groups = _.intersection(ids_by_group...)
users = App.User.findAll(ids_in_all_groups)
_.sortBy(users, (user) -> user.firstname)
render: ->
elLocal = $(App.view('ticket_overview/index')())
@@ -604,33 +637,8 @@ class App.TicketOverview extends App.Controller
@renderOptionsMacros()
renderOptionsGroups: =>
items = @el.find('[name="bulk"]:checked')
# we want to display all users for which we can assign the tickets directly
# for this we need to get the groups of all selected tickets
# after we got those we need to check which users are available in all groups
# users that are not in all groups can't get the tickets assigned
ticket_ids = _.map(items, (el) -> $(el).val() )
ticket_group_ids = _.map(App.Ticket.findAll(ticket_ids), (ticket) -> ticket.group_id)
users = @usersInGroups(ticket_group_ids)
# get the list of possible groups for the current user
# from the TicketCreateCollection
# (filled for e.g. the TicketCreation or TicketZoom assignment)
# and order them by name
group_ids = _.keys(@formMeta?.dependencies?.group_id)
groups = App.Group.findAll(group_ids)
groups_sorted = _.sortBy(groups, (group) -> group.name)
# get the number of visible users per group
# from the TicketCreateCollection
# (filled for e.g. the TicketCreation or TicketZoom assignment)
for group in groups
group.valid_users_count = @formMeta?.dependencies?.group_id?[group.id]?.owner_id.length || 0
@batchAssignInner.html $(App.view('ticket_overview/batch_overlay_user_group')(
users: users
groups: groups_sorted
@validUsersForTicketSelection()
))
renderOptionsMacros: =>
@@ -1086,10 +1094,11 @@ class Table extends App.Controller
id: object.organization_id
value
callbackCheckbox = (id, checked, e) =>
if @$('table').find('input[name="bulk"]:checked').length == 0
@bulkForm.hide()
else
if @shouldShowBulkForm()
@bulkForm.render()
@bulkForm.show()
else
@bulkForm.hide()
if @lastChecked && e.shiftKey
# check items in a row
@@ -1163,6 +1172,7 @@ class Table extends App.Controller
bindCheckbox:
events:
'click': callbackCheckbox
select_all: callbackCheckbox
)
# start user popups
@@ -1183,11 +1193,11 @@ class Table extends App.Controller
# show/hide bulk action
@$('.table-overview').delegate('input[name="bulk"], input[name="bulk_all"]', 'change', (e) =>
if @$('.table-overview').find('input[name="bulk"]:checked').length == 0
if @shouldShowBulkForm()
@bulkForm.show()
else
@bulkForm.hide()
@bulkForm.reset()
else
@bulkForm.show()
)
# deselect bulk_all if one item is uncheck observ
@@ -1207,6 +1217,17 @@ class Table extends App.Controller
bulkAll.prop('indeterminate', true)
)
shouldShowBulkForm: =>
items = @$('table').find('input[name="bulk"]:checked')
return false if items.length == 0
ticket_ids = _.map(items, (el) -> $(el).val() )
ticket_group_ids = _.map(App.Ticket.findAll(ticket_ids), (ticket) -> ticket.group_id)
ticket_group_ids = _.uniq(ticket_group_ids)
allowed_group_ids = App.User.find(@Session.get('id')).allGroupIds('change')
allowed_group_ids = _.map(allowed_group_ids, (id_string) -> parseInt(id_string, 10) )
_.every(ticket_group_ids, (id) -> id in allowed_group_ids)
viewmode: (e) =>
e.preventDefault()
@view_mode = $(e.target).data('mode')
@@ -1233,6 +1254,8 @@ class BulkForm extends App.Controller
'click .js-confirm': 'confirm'
'click .js-cancel': 'reset'
@include ValidUsersForTicketSelectionMethods
constructor: ->
super
@@ -1266,6 +1289,12 @@ class BulkForm extends App.Controller
handlers = @Config.get('TicketZoomFormHandler')
for attribute in @configure_attributes_ticket
continue if attribute.name != 'owner_id'
{users, groups} = @validUsersForTicketSelection()
options = _.map(users, (user) -> {value: user.id, name: user.displayName()} )
attribute.possible_groups_owners = options
new App.ControllerForm(
el: @$('#form-ticket-bulk')
model:

View File

@@ -0,0 +1,26 @@
class OwnerFormHandlerDependencies
# central method, is getting called on every ticket form change
@run: (params, attribute, attributes, classname, form, ui) ->
return if 'group_id' not of params
return if 'owner_id' not of params
owner_attribute = _.find(attributes, (o) -> o.name == 'owner_id')
return if !owner_attribute
return if 'possible_groups_owners' not of owner_attribute
# fetch contents using User relation if a Group has been selected, otherwise render possible_groups_owners
if params.group_id
owner_attribute.relation = 'User'
delete owner_attribute['options']
else
owner_attribute.options = owner_attribute.possible_groups_owners
delete owner_attribute['relation']
# replace new option list
owner_attribute.default = params[owner_attribute.name]
owner_attribute.newValue = params[owner_attribute.name]
newElement = ui.formGenItem(owner_attribute, classname, form)
form.find('select[name="owner_id"]').closest('.form-group').replaceWith(newElement)
App.Config.set('150-ticketFormChanges', OwnerFormHandlerDependencies, 'TicketZoomFormHandler')

View File

@@ -25,12 +25,20 @@ class App.WidgetOrganization extends App.Controller
if nameNew of organization
name = nameNew
# add to show if value exists
if ( organization[name] || attributeConfig.tag is 'richtext' ) && attributeConfig.shown
# do not show name since it's already shown via diplayName()
continue if name is 'name'
# do not show firstname and lastname / already show via diplayName()
if name isnt 'name'
organizationData.push attributeConfig
# do not show if configured to be not shown
continue if !attributeConfig.shown
# Fix for issue #2277 - note is not shown for customer/organisations if it's empty
# Always show for these two conditions:
# 1. the attribute exists and is not empty
# 2. it is a richtext note field
continue if ( !organization[name]? || organization[name] is '' ) && attributeConfig.tag isnt 'richtext'
# add to show if all checks passed
organizationData.push attributeConfig
# insert userData
@html App.view('widget/organization')(

View File

@@ -27,12 +27,20 @@ class App.WidgetUser extends App.Controller
if nameNew of user
name = nameNew
# add to show if value exists
if ( user[name] || attributeConfig.tag is 'richtext' ) && attributeConfig.shown
# do not show firstname and lastname since they are already shown via diplayName()
continue if name is 'firstname' || name is 'lastname' || name is 'organization'
# do not show firstname and lastname / already show via displayName()
if name isnt 'firstname' && name isnt 'lastname' && name isnt 'organization'
userData.push attributeConfig
# do not show if configured to be not shown
continue if !attributeConfig.shown
# Fix for issue #2277 - note is not shown for customer/organisations if it's empty
# Always show for these two conditions:
# 1. the attribute exists and is not empty
# 2. it is a richtext note field
continue if ( !user[name]? || user[name] is '' ) && attributeConfig.tag isnt 'richtext'
# add to show if all checks passed
userData.push attributeConfig
if user.preferences
items = []

View File

@@ -83,6 +83,18 @@ class App.TaskManager
return if !_instance
_instance.preferencesTrigger(key)
@tasksAutoCleanupDelayTime: (key) ->
return if !_instance
if !key
return _instance.tasksAutoCleanupDelayTime
_instance.tasksAutoCleanupDelayTime = key
@tasksAutoCleanupTaskMax: (key) ->
return if !_instance
if !key
return _instance.maxTaskCount
_instance.maxTaskCount = key
class _taskManagerSingleton extends App.Controller
@include App.LogInclude
@@ -108,9 +120,11 @@ class _taskManagerSingleton extends App.Controller
@tasksToUpdate = {}
@tasksPreferences = {}
@tasksPreferencesCallbacks = {}
@tasksAutoCleanupDelayTime = 12000
@activeTaskHistory = []
@queue = []
@queueRunning = false
@maxTaskCount = 30
all: ->
@@ -599,7 +613,7 @@ class _taskManagerSingleton extends App.Controller
tasksAutoCleanupDelay: =>
delay = =>
@tasksAutoCleanup()
App.Delay.set(delay, 12000, 'task-autocleanup', undefined, true)
App.Delay.set(delay, @tasksAutoCleanupDelayTime, 'task-autocleanup', undefined, true)
tasksAutoCleanup: =>
@@ -607,13 +621,19 @@ class _taskManagerSingleton extends App.Controller
currentTaskCount = =>
Object.keys(@allTasksByKey).length
maxTaskCount = 30
if currentTaskCount() > maxTaskCount
for task in App.Taskbar.search(sortBy:'updated_at', order:'ASC')
if currentTaskCount() > maxTaskCount
if currentTaskCount() > @maxTaskCount
if @offlineModus
tasks = @all()
else
tasks = App.Taskbar.search(sortBy:'updated_at', order:'ASC')
for task in tasks
if currentTaskCount() > @maxTaskCount
if !task.active
if _.isEmpty(task.state) || (_.isEmpty(task.state.ticket) && _.isEmpty(task.state.article))
@log 'notice', "More then #{maxTaskCount} tasks open, close oldest untouched task #{task.key}"
worker = App.TaskManager.worker(task.key)
if worker
if worker.changed && worker.changed()
continue
@log 'notice', "More then #{@maxTaskCount} tasks open, close oldest untouched task #{task.key}"
@remove(task.key)
tasksInitial: =>

View File

@@ -1,5 +1,5 @@
class App.Overview extends App.Model
@configure 'Overview', 'name', 'prio', 'condition', 'order', 'group_by', 'group_direction', 'view', 'user_ids', 'organization_shared', 'role_ids', 'active'
@configure 'Overview', 'name', 'prio', 'condition', 'order', 'group_by', 'group_direction', 'view', 'user_ids', 'organization_shared', 'out_of_office', 'role_ids', 'active'
@extend Spine.Model.Ajax
@url: @apiPath + '/overviews'
@configure_attributes = [

View File

@@ -249,7 +249,7 @@ class App.Ticket extends App.Model
user_id = App.Session.get('id')
return true if user_id is @customer_id
return false if !App.User.exists(user_id)
group_ids = App.User.find(user_id).all_group_ids(permission)
group_ids = App.User.find(user_id).allGroupIds(permission)
for local_group_id in group_ids
if local_group_id.toString() is @group_id.toString()
return true

View File

@@ -254,7 +254,19 @@ class App.User extends App.Model
return access if access
false
all_group_ids: (permission = 'full') ->
###
Returns a list of all groups for which the user is permitted to perform the given permission key
user = App.User.find(3)
result = user.allGroupIds('change') # access to a given permission key
returns
["1", "2"]
###
allGroupIds: (permission = 'full') ->
group_ids = []
user_group_ids = App.Session.get('group_ids')
if user_group_ids

View File

@@ -25,24 +25,28 @@
<% for caller_id in item.preferences.from: %>
<% if caller_id.user_id && App.User.exists(caller_id.user_id): %>
<% shown = true %>
<% user = App.User.find(caller_id.user_id) %>
<% user = App.User.fullLocal(caller_id.user_id) %>
<% if caller_id.level isnt 'known': %><%- @T('maybe') %> <% end %>
<span class="user-popover" data-id="<%- user.id %>"><%= user.displayNameLong() %></span><br>
<% else if caller_id.comment: %>
<% else if !_.isEmpty(caller_id.comment): %>
<% shown = true %>
<%- @T('maybe') %> <%= caller_id.comment %><br>
<% end %>
<% end %>
<% end %>
<% if !shown && item.from_comment: %>
<% if !shown && !_.isEmpty(item.from_comment): %>
<% shown = true %>
<% if item.from_comment: %><%= item.from_comment %><% end %>
<%= item.from_comment %>
<br>
<% end %>
<% if shown: %>
<small><%= item.from_pretty %></small>
<% else: %>
<span class="js-userNew u-clickable" href="#"><%= item.from_pretty %></span>
<% if !_.isEmpty(item.from_pretty): %>
<span class="js-userNew u-clickable" href="#"><%= item.from_pretty %></span>
<% else: %>
<span><%= item.from %></span>
<% end %>
<% end %>
</td>
<td>
@@ -51,7 +55,7 @@
<% for caller_id in item.preferences.to: %>
<% if caller_id.user_id && App.User.exists(caller_id.user_id): %>
<% shown = true %>
<% user = App.User.find(caller_id.user_id) %>
<% user = App.User.fullLocal(caller_id.user_id) %>
<% if caller_id.level isnt 'known': %><%- @T('maybe') %> <% end %>
<span class="user-popover" data-id="<%- user.id %>"><%= user.displayNameLong() %></span><br>
<% else if caller_id.comment: %>
@@ -60,15 +64,19 @@
<% end %>
<% end %>
<% end %>
<% if !shown && item.to_comment: %>
<% if !shown && !_.isEmpty(item.to_comment): %>
<% shown = true %>
<% if item.to_comment: %><%= item.to_comment %><% end %>
<%= item.to_comment %>
<br>
<% end %>
<% if shown: %>
<small><%= item.to_pretty %></small>
<% else: %>
<%= item.to_pretty %>
<% if !_.isEmpty(item.to_pretty): %>
<%= item.to_pretty %>
<% else: %>
<%= item.to %>
<% end %>
<% end %>
</td>
<td style="vertical-align: middle">

View File

@@ -8,7 +8,7 @@
</div>
<% for row in @organizationData: %>
<% if @organization[row.name] || row.name is 'note': %>
<% if @organization[row.name]? || row.name is 'note': %>
<div class="sidebar-block">
<% if row.tag isnt 'richtext': %>
<label><%- @T(row.display) %></label>

View File

@@ -3,7 +3,7 @@
<h3 title="<%- @Ti('Name') %>"><%= @user.displayName() %></h3>
</div>
<% for row in @userData: %>
<% if @user[row.name] || row.name is 'note': %>
<% if @user[row.name]? || row.name is 'note': %>
<div class="sidebar-block">
<label><%- @T(row.display) %></label>
<% if row.tag isnt 'richtext': %>

View File

@@ -11,6 +11,29 @@
border-bottom: 1px solid hsl(0,0%,90%);
}
html {
height: auto;
}
body,
.content {
background: none;
color: black;
}
// Disable flex to allow multi page prints in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=939897
body,
#app,
#app > .content.active,
.tabsSidebar-holder,
main {
display: block;
}
#app {
min-width: 0;
}
/*
Dashboard
@@ -54,9 +77,85 @@
}
.tabsSidebar-sidebarSpacer {
margin-right: 0;
margin: 0;
transition: none;
}
.article-meta .article-meta-key {
display: none;
.textBubble-content {
height: auto !important;
}
.tabsSidebar-tabsSpacer {
padding: 0 !important;
overflow: visible;
}
.ticket-article,
.article-new {
max-width: none;
padding-left: 0;
padding-right: 0;
}
.ticketZoom {
background: none;
}
.textBubble {
padding: 20px;
border: 1px solid !important;
background: white !important;
}
.bubble-arrow:after {
background: white !important;
border-color: black !important;
}
.article-meta {
color: black;
background: none;
border: 1px dashed hsl(240,2%,60%);
padding: 11px 10px 1px;
&.top {
border-bottom-width: 0;
}
&.bottom {
border-top-width: 0;
padding-top: 7px;
padding-bottom: 3px;
}
&-clip {
height: auto !important;
}
&-icon {
fill: currentColor;
}
}
.article-content {
color: inherit;
a[href]:after {
content: none !important;
}
}
.article-content-meta {
opacity: 1 !important;
display: block !important;
transform: none !important;
position: static;
}
.attachments.attachments--list {
border-color: currentColor !important;
}
.avatar {
border: 1px solid black;
}

View File

@@ -69,7 +69,7 @@ class SessionsController < ApplicationController
user_id = session[:user_id]
end
if !user_id
if !user_id || !User.exists?(user_id)
# get models
models = SessionHelper.models()

View File

@@ -49,7 +49,7 @@ curl http://localhost/api/v1/text_modules.json -v -u #{login}:#{password}
=end
def index
permission_check('ticket.agent')
permission_check(['admin.text_module', 'ticket.agent'])
model_index_render(TextModule, params)
end
@@ -71,7 +71,7 @@ curl http://localhost/api/v1/text_modules/#{id}.json -v -u #{login}:#{password}
=end
def show
permission_check('ticket.agent')
permission_check(['admin.text_module', 'ticket.agent'])
model_show_render(TextModule, params)
end

View File

@@ -46,7 +46,7 @@ class TimeAccountingsController < ApplicationController
end
end
end
if !customers[local_time_unit[:agent_id]]
if !agents[local_time_unit[:agent_id]]
agent_user = User.lookup(id: local_time_unit[:agent_id])
agent = '-'
if agent_user

View File

@@ -20,17 +20,25 @@ class Authorization < ApplicationModel
)
# update username of auth entry if empty
if !auth.username && hash['info']['nickname']
if !auth.username && hash['info']['nickname'].present?
auth.update!(
username: hash['info']['nickname'],
)
end
# update image if needed
if hash['info']['image']
user = User.find(auth.user_id)
# update firstname/lastname if needed
user = User.find(auth.user_id)
if user.firstname.blank? && user.lastname.blank?
if hash['info']['first_name'].present? && hash['info']['last_name'].present?
user.firstname = hash['info']['first_name']
user.lastname = hash['info']['last_name']
elsif hash['info']['display_name'].present?
user.firstname = hash['info']['display_name']
end
end
# save/update avatar
# update image if needed
if hash['info']['image'].present?
avatar = Avatar.add(
object: 'User',
o_id: user.id,
@@ -40,13 +48,14 @@ class Authorization < ApplicationModel
updated_by_id: user.id,
created_by_id: user.id,
)
# update user link
if avatar && user.image != avatar.store_hash
user.image = avatar.store_hash
user.save
end
end
if user.changed?
user.save
end
end
auth
end

View File

@@ -123,7 +123,7 @@ add avatar by url
data[:full][:content] = content
data[:full][:mime_type] = mime_type
elsif data[:url].to_s.match?(/^http/)
elsif data[:url].to_s.match?(%r{^https?://})
url = data[:url].to_s
# check if source ist already updated within last 2 minutes
@@ -168,7 +168,7 @@ add avatar by url
data[:full][:mime_type] = mime_type
# try zammad backend to find image based on email
elsif data[:url].to_s.match?(/@/)
elsif data[:url].to_s.match?(URI::MailTo::EMAIL_REGEXP)
url = data[:url].to_s
# check if source ist already updated within last 3 minutes
@@ -179,9 +179,7 @@ add avatar by url
# fetch image
image = Service::Image.user(url)
return if !image
data[:resize] ||= {}
data[:resize] = image
data[:full] ||= {}
data[:full] = image
end
end

View File

@@ -134,7 +134,7 @@ returns
=end
def self.streamable?
true
false
end
=begin

View File

@@ -70,15 +70,16 @@ class Channel::EmailParser
=end
def parse(msg)
mail = Mail.new(msg.utf8_encode)
mail = Mail.new(msg.force_encoding('binary'))
headers = message_header_hash(mail)
body = message_body_hash(mail)
message_attributes = [
{ mail_instance: mail },
message_header_hash(mail),
message_body_hash(mail),
self.class.sender_attributes(mail),
headers,
body,
self.class.sender_attributes(headers),
]
message_attributes.reduce({}.with_indifferent_access, &:merge)
end
@@ -340,8 +341,8 @@ returns
end
def self.sender_attributes(from)
if from.is_a?(Mail::Message)
from = SENDER_FIELDS.map { |f| from.header[f] }.compact
if from.is_a?(HashWithIndifferentAccess)
from = SENDER_FIELDS.map { |f| from[f] }.compact
.map(&:to_utf8).reject(&:blank?)
.partition { |address| address.match?(EMAIL_REGEX) }
.flatten.first
@@ -476,12 +477,14 @@ process unprocessable_mails (tmp/unprocessable_mail/*.eml) again
def message_header_hash(mail)
imported_fields = mail.header.fields.map do |f|
value = begin
f.to_utf8
rescue NameError # handle bug #1238 in Mail 2.7.1.rc1
'' # swap out for commented line below once upgrade is available
end
begin
value = f.to_utf8
if value.blank?
value = f.raw_value.to_utf8
end
rescue
value = f.raw_value.to_utf8(fallback: :read_as_sanitized_binary)
end
[f.name.downcase, value]
end.to_h
@@ -492,10 +495,15 @@ process unprocessable_mails (tmp/unprocessable_mail/*.eml) again
.transform_values { |v| v.match?(EMAIL_REGEX) ? v : '' }
h.merge!(validated_recipients)
h['date'] = Time.zone.parse(mail.date.to_s) || imported_fields['date']
h['message_id'] = imported_fields['message-id']
h['subject'] = imported_fields['subject']&.sub(/^=\?us-ascii\?Q\?(.+)\?=$/, '\1')
h['x-any-recipient'] = validated_recipients.values.select(&:present?).join(', ')
begin
h['date'] = Time.zone.parse(mail.date.to_s) || imported_fields['date']
rescue
h['date'] = nil
end
end
[imported_fields, raw_fields, custom_fields].reduce({}.with_indifferent_access, &:merge)
@@ -570,7 +578,7 @@ process unprocessable_mails (tmp/unprocessable_mail/*.eml) again
def get_attachments(file, attachments, mail)
return file.parts.map { |p| get_attachments(p, attachments, mail) } if file.parts.any?
return [] if [mail.text_part, mail.html_part].include?(file)
return [] if [mail.text_part&.body&.encoded, mail.html_part&.body&.encoded].include?(file.body.encoded)
# get file preferences
headers_store = {}
@@ -597,15 +605,29 @@ process unprocessable_mails (tmp/unprocessable_mail/*.eml) again
# get filename from content-disposition
# workaround for: NoMethodError: undefined method `filename' for #<Mail::UnstructuredField:0x007ff109e80678>
filename = file.header[:content_disposition].try(:filename)
begin
filename = file.header[:content_disposition].try(:filename)
rescue
begin
if file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})="(.+?)"/i
filename = $3
elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})='(.+?)'/i
filename = $3
elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})=(.+?);/i
filename = $3
end
rescue
Rails.logger.debug { 'Unable to get filename' }
end
end
begin
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
if file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})="(.+?)"/i
filename = $3
elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})='(.+?)'/i
filename = $3
elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})=(.+?);/i
filename = $3
end
rescue
Rails.logger.debug { 'Unable to get filename' }
@@ -613,12 +635,12 @@ process unprocessable_mails (tmp/unprocessable_mail/*.eml) again
# 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
if headers_store['Content-Disposition'].to_s =~ /(filename|name)(\*{0,1})="(.+?)"/i
filename = $3
elsif headers_store['Content-Disposition'].to_s =~ /(filename|name)(\*{0,1})='(.+?)'/i
filename = $3
elsif headers_store['Content-Disposition'].to_s =~ /(filename|name)(\*{0,1})=(.+?);/i
filename = $3
end
end
@@ -652,9 +674,9 @@ process unprocessable_mails (tmp/unprocessable_mail/*.eml) again
# e. g. Content-Type: video/quicktime; name="Video.MOV";
if filename.blank?
['name="(.+?)"(;|$)', "name='(.+?)'(;|$)", 'name=(.+?)(;|$)'].each do |regexp|
['(filename|name)(\*{0,1})="(.+?)"(;|$)', '(filename|name)(\*{0,1})=\'(.+?)\'(;|$)', '(filename|name)(\*{0,1})=(.+?)(;|$)'].each do |regexp|
if headers_store['Content-Type'] =~ /#{regexp}/i
filename = $1
filename = $3
break
end
end
@@ -743,7 +765,11 @@ module Mail
# workaround to get content of no parseable headers - in most cases with non 7 bit ascii signs
class Field
def raw_value
value = @raw_value.try(:utf8_encode)
begin
value = @raw_value.try(:utf8_encode)
rescue
value = @raw_value.utf8_encode(fallback: :read_as_sanitized_binary)
end
return value if value.blank?
value.sub(/^.+?:(\s|)/, '')
end
@@ -784,4 +810,19 @@ module Mail
end.join('')
end
end
# issue#348 - IMAP mail fetching stops because of broken spam email (e. g. broken Content-Transfer-Encoding value see test/fixtures/mail43.box)
# https://github.com/zammad/zammad/issues/348
class Body
def decoded
if !Encodings.defined?(encoding)
#raise UnknownEncodingType, "Don't know how to decode #{encoding}, please call #encoded and decode it yourself."
Rails.logger.info "UnknownEncodingType: Don't know how to decode #{encoding}!"
raw_source
else
Encodings.get_encoding(encoding).decode(raw_source)
end
end
end
end

View File

@@ -143,63 +143,57 @@ module Channel::Filter::IdentifySender
end
end
def self.user_create(data, role_ids = nil)
data[:email] += '@local' if !data[:email].match?(/@/)
data[:email] = cleanup_email(data[:email])
user = User.find_by(email: data[:email]) ||
User.find_by(login: data[:email])
def self.user_create(attrs, role_ids = nil)
populate_attributes!(attrs, role_ids: role_ids)
# check if firstname or lastname need to be updated
if user
if user.firstname.blank? && user.lastname.blank?
if data[:firstname].present?
data[:firstname] = cleanup_name(data[:firstname])
user.update!(
firstname: data[:firstname]
)
end
end
return user
if (user = User.find_by('email = :email OR login = :email', attrs))
user.update!(attrs.slice(:firstname)) if user.firstname.blank? && user.lastname.blank? && attrs[:firstname].present?
elsif (user = User.create!(attrs))
user.update!(updated_by_id: user.id, created_by_id: user.id)
end
# create new user
role_ids ||= Role.signup_role_ids
# fillup
%w[firstname lastname].each do |item|
if data[item.to_sym].nil?
data[item.to_sym] = ''
end
data[item.to_sym] = cleanup_name(data[item.to_sym])
end
data[:password] = ''
data[:active] = true
data[:role_ids] = role_ids
data[:updated_by_id] = 1
data[:created_by_id] = 1
user = User.create!(data)
user.update!(
updated_by_id: user.id,
created_by_id: user.id,
)
user
end
def self.cleanup_name(string)
string.strip!
string.delete!('"')
string.gsub!(/^'/, '')
string.gsub!(/'$/, '')
string.gsub!(/.+?\s\(.+?\)$/, '')
string
def self.populate_attributes!(attrs, **extras)
if attrs[:email].match?(/\S\s+\S/) || attrs[:email].match?(/^<|>$/)
attrs[:preferences] = { mail_delivery_failed: true,
mail_delivery_failed_reason: 'invalid email',
mail_delivery_failed_data: Time.zone.now }
end
attrs.merge!(
email: sanitize_email(attrs[:email]),
firstname: sanitize_name(attrs[:firstname]),
lastname: sanitize_name(attrs[:lastname]),
password: '',
active: true,
role_ids: extras[:role_ids] || Role.signup_role_ids,
updated_by_id: 1,
created_by_id: 1
)
end
def self.cleanup_email(string)
string = string.downcase
string.strip!
string.delete!('"')
string
def self.sanitize_name(string)
return '' if string.nil?
string.strip
.delete('"')
.gsub(/^'/, '')
.gsub(/'$/, '')
.gsub(/.+?\s\(.+?\)$/, '')
end
def self.sanitize_email(string)
string += '@local' if !string.include?('@')
string.downcase
.strip
.delete('"')
.delete(' ') # see https://github.com/zammad/zammad/issues/2254
.sub(/^<|>$/, '') # see https://github.com/zammad/zammad/issues/2254
.sub(/\A'(.*)'\z/, '\1') # see https://github.com/zammad/zammad/issues/2154
.gsub(/\s/, '') # see https://github.com/zammad/zammad/issues/2198
end
end

View File

@@ -106,6 +106,10 @@ returns
# set user id
user_id = record[:created_by_id]
if model == User
if record.destroyed?
Cti::CallerId.where(user_id: user_id).destroy_all
return
end
user_id = record.id
end
return if !user_id
@@ -121,9 +125,31 @@ returns
caller_ids = caller_ids.concat(local_caller_ids)
end
# store caller ids
Cti::CallerId.where(object: model.to_s, o_id: record.id).destroy_all
caller_ids.each do |caller_id|
# search for caller ids to keep
caller_ids_to_add = []
existing_record_ids = Cti::CallerId.where(object: model.to_s, o_id: record.id).pluck(:id)
caller_ids.uniq.each do |caller_id|
existing_record_id = Cti::CallerId.where(
object: model.to_s,
o_id: record.id,
caller_id: caller_id,
level: level,
user_id: user_id,
).pluck(:id)
if existing_record_id[0]
existing_record_ids.delete(existing_record_id[0])
next
end
caller_ids_to_add.push caller_id
end
# delete not longer existing caller ids
existing_record_ids.each do |record_id|
Cti::CallerId.destroy(record_id)
end
# create new caller ids
caller_ids_to_add.each do |caller_id|
Cti::CallerId.maybe_add(
caller_id: caller_id,
level: level,
@@ -259,10 +285,13 @@ returns
end
def update_cti_logs
return if object != 'User'
UpdateCtiLogsByCallerJob.perform_later(caller_id)
end
def update_cti_logs_with_fg_optimization
return if object != 'User'
return if level != 'known'
UpdateCtiLogsByCallerJob.perform_now(caller_id, limit: 20)
UpdateCtiLogsByCallerJob.perform_later(caller_id, limit: 40, offset: 20)
end

View File

@@ -4,13 +4,11 @@ module Cti
store :preferences
after_create :push_event, :push_caller_list
after_update :push_event, :push_caller_list
after_destroy :push_event, :push_caller_list
after_commit :push_incoming_call, :push_caller_list_update
=begin
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '007',
from_comment: 'AAA',
@@ -21,7 +19,7 @@ module Cti
state: 'newCall',
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '007',
from_comment: '',
@@ -32,7 +30,7 @@ module Cti
state: 'answer',
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '009',
from_comment: '',
@@ -45,13 +43,13 @@ module Cti
example data, can be used for demo
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '4930609854180',
from_comment: 'Franz Bauer',
to: '4930609811111',
to_comment: 'Bob Smith',
call_id: '00001',
call_id: '435452113',
comment: '',
state: 'newCall',
done: false,
@@ -69,13 +67,13 @@ example data, can be used for demo
}
)
Cti::Log.create(
Cti::Log.create!(
direction: 'out',
from: '4930609854180',
from_comment: 'Franz Bauer',
to: '4930609811111',
to_comment: 'Bob Smith',
call_id: '00002',
call_id: rand(999_999_999),
comment: '',
state: 'newCall',
preferences: {
@@ -92,13 +90,13 @@ example data, can be used for demo
}
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '4930609854180',
from_comment: 'Franz Bauer',
to: '4930609811111',
to_comment: 'Bob Smith',
call_id: '00003',
call_id: rand(999_999_999),
comment: '',
state: 'answer',
preferences: {
@@ -115,13 +113,13 @@ example data, can be used for demo
}
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '4930609854180',
from_comment: 'Franz Bauer',
to: '4930609811111',
to_comment: 'Bob Smith',
call_id: '00004',
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
comment: 'normalClearing',
@@ -140,13 +138,13 @@ example data, can be used for demo
}
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '4930609854180',
from_comment: 'Franz Bauer',
to: '4930609811111',
to_comment: 'Bob Smith',
call_id: '00005',
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
start_at: Time.zone.now - 15.seconds,
@@ -165,13 +163,13 @@ example data, can be used for demo
}
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '4930609854180',
from_comment: 'Franz Bauer',
to: '4930609811111',
to_comment: '',
call_id: '00006',
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
start_at: Time.zone.now - 15.seconds,
@@ -190,13 +188,13 @@ example data, can be used for demo
}
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '4930609854180',
from_comment: 'Franz Bauer',
to: '4930609811111',
to_comment: 'Bob Smith',
call_id: '00007',
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
start_at: Time.zone.now - 15.seconds,
@@ -215,11 +213,11 @@ example data, can be used for demo
}
)
Cti::Log.create(
Cti::Log.create!(
direction: 'in',
from: '4930609854180',
to: '4930609811112',
call_id: '00008',
call_id: rand(999_999_999),
comment: '',
state: 'hangup',
start_at: Time.zone.now - 20.seconds,
@@ -236,14 +234,14 @@ example data, can be used for demo
returns
{
list: [...]
assets: {...}
list: [log_record1, log_record2, log_record3],
assets: {...},
}
=end
def self.log
list = Cti::Log.order('created_at DESC, id DESC').limit(60)
list = Cti::Log.log_records
# add assets
assets = list.map(&:preferences)
@@ -259,6 +257,20 @@ returns
}
end
=begin
Cti::Log.log_records
returns
[log_record1, log_record2, log_record3]
=end
def self.log_records
Cti::Log.order('created_at DESC, id DESC').limit(60)
end
=begin
processes a incoming event
@@ -269,7 +281,7 @@ Cti::Log.process(
'user' => 'user 1',
'from' => '4912347114711',
'to' => '4930600000000',
'callId' => '4991155921769858278-1', # or call_id
'callId' => '43545211', # or call_id
'direction' => 'in',
)
@@ -287,6 +299,7 @@ Cti::Log.process(
from_comment = nil
to_comment = nil
preferences = nil
done = true
if params['direction'] == 'in'
to_comment = user
from_comment, preferences = CallerId.get_comment_preferences(params['from'], 'from')
@@ -297,6 +310,9 @@ Cti::Log.process(
case event
when 'newCall'
if params['direction'] == 'in'
done = false
end
create(
direction: params['direction'],
from: params['from'],
@@ -308,6 +324,7 @@ Cti::Log.process(
state: event,
initialized_at: Time.zone.now,
preferences: preferences,
done: done,
)
when 'answer'
log = find_by(call_id: call_id)
@@ -318,16 +335,19 @@ Cti::Log.process(
if user
log.to_comment = user
end
log.done = true
log.comment = comment
log.save
when 'hangup'
log = find_by(call_id: call_id)
raise "No such call_id #{call_id}" if !log
if params['direction'] == 'in' && log.state == 'newCall'
log.done = false
end
if params['direction'] == 'in' && log.to_comment == 'voicemail'
log.done = false
log.done = done
if params['direction'] == 'in'
if log.state == 'newCall'
log.done = false
elsif log.to_comment == 'voicemail'
log.done = false
end
end
log.state = 'hangup'
log.end_at = Time.zone.now
@@ -343,11 +363,14 @@ Cti::Log.process(
end
end
def push_event
def push_incoming_call
return true if destroyed?
return true if state != 'newCall'
return true if direction != 'in'
# send notify about event
users = User.with_permissions('cti.agent')
users.each do |user|
# send notify about event
Sessions.send_to(
user.id,
{
@@ -356,23 +379,29 @@ Cti::Log.process(
},
)
end
true
end
def push_caller_list
list = Cti::Log.log
def self.push_caller_list_update?(record)
list_ids = Cti::Log.log_records.pluck(:id)
return true if list_ids.include?(record.id)
false
end
def push_caller_list_update
return false if !Cti::Log.push_caller_list_update?(self)
# send notify on create/update/delete
users = User.with_permissions('cti.agent')
users.each do |user|
# send notify on create/update/delete
Sessions.send_to(
user.id,
{
event: 'cti_list_push',
data: list,
},
)
end
true
end
=begin

View File

@@ -24,8 +24,9 @@ Job.run
def self.run
start_at = Time.zone.now
jobs = Job.where(active: true, running: false)
jobs = Job.where(active: true)
jobs.each do |job|
next if !job.executable?
job.run(false, start_at)
end
true
@@ -75,6 +76,7 @@ job.run(true)
self.processed = ticket_count || 0
self.running = true
self.last_run_at = Time.zone.now
save!
tickets&.each do |ticket|
@@ -94,6 +96,9 @@ job.run(true)
# only execute jobs, older then 1 min, to give admin posibility to change
return false if updated_at > Time.zone.now - 1.minute
# check if job got stuck
return false if running == true && last_run_at && Time.zone.now - 1.day < last_run_at
# check if jobs need to be executed
# ignore if job was running within last 10 min.
return false if last_run_at && last_run_at > start_at - 10.minutes

View File

@@ -621,6 +621,12 @@ to send no browser reload event, pass false
# config changes
if attribute.to_config
execute_config_count += 1
if attribute.data_type == 'select' && attribute.data_option[:options]
historical_options = attribute.data_option[:historical_options] || {}
historical_options.update(attribute.data_option[:options])
historical_options.update(attribute.data_option_new[:options])
attribute.data_option_new[:historical_options] = historical_options
end
attribute.data_option = attribute.data_option_new
attribute.data_option_new = {}
attribute.to_config = false
@@ -628,6 +634,10 @@ to send no browser reload event, pass false
next if !attribute.to_create && !attribute.to_migrate && !attribute.to_delete
end
if attribute.data_type == 'select' && attribute.data_option[:options]
attribute.data_option[:historical_options] = attribute.data_option[:options]
end
data_type = nil
if attribute.data_type.match?(/^input|select|tree_select|richtext|textarea|checkbox$/)
data_type = :string
@@ -868,6 +878,13 @@ is certain attribute used by triggers, overviews or schedulers
reserved_words = %w[destroy true false integer select drop create alter index table varchar blob date datetime timestamp]
raise "#{name} is a reserved word, please choose a different one" if name.match?(/^(#{reserved_words.join('|')})$/)
# fixes issue #2236 - Naming an attribute "attribute" causes ActiveRecord failure
begin
ObjectLookup.by_id(object_lookup_id).constantize.instance_method_already_implemented? name
rescue ActiveRecord::DangerousAttributeError => e
raise "#{name} is a reserved word, please choose a different one"
end
record = object_lookup.name.constantize.new
return true if !record.respond_to?(name.to_sym)
raise "#{name} already exists!" if record.attributes.key?(name) && new_record?

View File

@@ -13,24 +13,16 @@ class RecentView < ApplicationModel
after_destroy :notify_clients
def self.log(object, o_id, user)
# access check
return if !access(object, o_id, user)
# lookups
object_lookup_id = ObjectLookup.by_name(object)
# create entry
record = {
o_id: o_id,
recent_view_object_id: object_lookup_id.to_i,
created_by_id: user.id,
}
RecentView.create(record)
RecentView.create(o_id: o_id,
recent_view_object_id: ObjectLookup.by_name(object),
created_by_id: user.id)
end
def self.log_destroy(requested_object, requested_object_id)
return if requested_object == 'RecentView'
RecentView.where(recent_view_object_id: ObjectLookup.by_name(requested_object))
.where(o_id: requested_object_id)
.destroy_all
@@ -41,43 +33,34 @@ class RecentView < ApplicationModel
end
def self.list(user, limit = 10, object_name = nil)
recent_views = if !object_name
RecentView.select('o_id, recent_view_object_id, MAX(created_at) as created_at, MAX(id) as id, created_by_id')
.group(:o_id, :recent_view_object_id, :created_by_id)
.where(created_by_id: user.id)
.limit(limit)
elsif object_name == 'Ticket'
state_ids = Ticket::State.by_category(:viewable_agent_new).pluck(:id)
local_recent_views = RecentView.select('o_id, recent_view_object_id, MAX(created_at) as created_at, MAX(id) as id, created_by_id')
.group(:o_id, :recent_view_object_id, :created_by_id)
.where(created_by_id: user.id, recent_view_object_id: ObjectLookup.by_name(object_name))
.limit(limit + 10)
clear_list = []
local_recent_views.each do |item|
ticket = Ticket.find_by(id: item.o_id)
next if !ticket
next if !state_ids.include?(ticket.state_id)
clear_list.push item
break if clear_list.count == limit
end
clear_list
else
RecentView.select('o_id, recent_view_object_id, MAX(created_at) as created_at, MAX(id) as id, created_by_id')
.group(:o_id, :recent_view_object_id, :created_by_id)
.where(created_by_id: user.id, recent_view_object_id: ObjectLookup.by_name(object_name))
.limit(limit)
end
recent_views = RecentView.select('o_id, ' \
'recent_view_object_id, ' \
'created_by_id, ' \
'MAX(created_at) as created_at, ' \
'MAX(id) as id')
.group(:o_id, :recent_view_object_id, :created_by_id)
.where(created_by_id: user.id)
.order('MAX(created_at) DESC, MAX(id) DESC')
.limit(limit)
list = []
recent_views.each do |item|
# access check
next if !access(ObjectLookup.by_id(item['recent_view_object_id']), item['o_id'], user)
# add to result list
list.push item
if object_name.present?
recent_views = recent_views.where(recent_view_object_id: ObjectLookup.by_name(object_name))
end
list
# hide merged / removed tickets in Ticket Merge dialog
if object_name == 'Ticket'
recent_views = recent_views.limit(limit * 2)
viewable_ticket_ids = Ticket.where('id IN (?) AND state_id in (?)',
recent_views.map(&:o_id),
Ticket::State.by_category(:viewable_agent_new).pluck(:id))
.pluck(:id)
recent_views = recent_views.select { |rv| viewable_ticket_ids.include?(rv.o_id) }
.first(limit)
end
recent_views.select { |rv| access(ObjectLookup.by_id(rv.recent_view_object_id), rv.o_id, user) }
end
def notify_clients
@@ -91,19 +74,12 @@ class RecentView < ApplicationModel
end
def self.access(object, o_id, user)
# check if object exists
begin
return if !Kernel.const_get(object)
record = Kernel.const_get(object).lookup(id: o_id)
return if !record
rescue
return
end
# check permission
return if !record.respond_to?(:access?)
record.access?(user, 'read')
object.to_s
.constantize
.try(:lookup, { id: o_id })
.try(:access?, user, 'read')
rescue NameError
false
end
=begin

View File

@@ -233,7 +233,9 @@ class Scheduler < ApplicationModel
# start loop for periods equal or under 5 minutes
if job.period && job.period <= 5.minutes
loop_count = 0
loop do
loop_count += 1
_start_job(job)
job = Scheduler.lookup(id: job.id)
@@ -246,6 +248,9 @@ class Scheduler < ApplicationModel
# exit if there is no loop period defined
break if !job.period
# only do a certain amount of loops in this thread
break if loop_count == 1800
# wait until next run
sleep job.period
end

View File

@@ -51,7 +51,7 @@ get config setting
def self.get(name)
load
@@current[name]
@@current[name].deep_dup # prevents accidental modification of settings in console
end
=begin

View File

@@ -465,7 +465,7 @@ condition example
value: '2015-10-17T06:00:00.000Z',
},
'ticket.created_at' => {
operator: 'within next (relative)', # before,within,in,after
operator: 'within next (relative)', # within next, within last, after, before
range: 'day', # minute|hour|day|month|year
value: '25',
},
@@ -544,9 +544,12 @@ condition example
raise "Invalid selector #{selector_raw.inspect}" if !selector_raw.respond_to?(:key?)
selector = selector_raw.stringify_keys
raise "Invalid selector, operator missing #{selector.inspect}" if !selector['operator']
raise "Invalid selector, operator #{selector['operator']} is invalid #{selector.inspect}" if selector['operator'] !~ /^(is|is\snot|contains|contains\s(not|all|one|all\snot|one\snot)|(after|before)\s\(absolute\)|(within\snext|within\slast|after|before)\s\(relative\))$/
# validate value / allow blank but only if pre_condition exists and is not specific
if !selector.key?('value') || ((selector['value'].class == String || selector['value'].class == Array) && (selector['value'].respond_to?(:blank?) && selector['value'].blank?))
if !selector.key?('value') ||
(selector['value'].class == Array && selector['value'].respond_to?(:blank?) && selector['value'].blank?) ||
(selector['operator'] =~ /^contains/ && selector['value'].respond_to?(:blank?) && selector['value'].blank?)
return nil if selector['pre_condition'].nil?
return nil if selector['pre_condition'].respond_to?(:blank?) && selector['pre_condition'].blank?
return nil if selector['pre_condition'] == 'specific'
@@ -598,10 +601,18 @@ condition example
if selector['value'].nil?
query += "#{attribute} IS NULL"
else
query += "#{attribute} IN (?)"
if attributes[1] == 'out_of_office_replacement_id'
query += "#{attribute} IN (?)"
bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id)
else
if selector['value'].class != Array
selector['value'] = [selector['value']]
end
query += if selector['value'].include?('')
"(#{attribute} IN (?) OR #{attribute} IS NULL)"
else
"#{attribute} IN (?)"
end
bind_params.push selector['value']
end
end
@@ -631,10 +642,18 @@ condition example
if selector['value'].nil?
query += "#{attribute} IS NOT NULL"
else
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
if attributes[1] == 'out_of_office_replacement_id'
bind_params.push User.find(selector['value']).out_of_office_agent_of.pluck(:id)
query += "(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
else
if selector['value'].class != Array
selector['value'] = [selector['value']]
end
query += if selector['value'].include?('')
"(#{attribute} IS NOT NULL AND #{attribute} NOT IN (?))"
else
"(#{attribute} IS NULL OR #{attribute} NOT IN (?))"
end
bind_params.push selector['value']
end
end

View File

@@ -19,7 +19,8 @@ class User < ApplicationModel
has_many :authorizations, after_add: :cache_update, after_remove: :cache_update
belongs_to :organization, inverse_of: :members
before_validation :check_name, :check_email, :check_login, :check_mail_delivery_failed, :ensure_uniq_email, :ensure_password, :ensure_roles, :ensure_identifier
before_validation :check_name, :check_email, :check_login, :ensure_uniq_email, :ensure_password, :ensure_roles, :ensure_identifier
before_validation :check_mail_delivery_failed, on: :update
before_create :check_preferences_default, :validate_preferences, :validate_ooo, :domain_based_assignment, :set_locale
before_update :check_preferences_default, :validate_preferences, :validate_ooo, :reset_login_failed, :validate_agent_limit_by_attributes, :last_admin_check_by_attribute
after_create :avatar_for_email_check
@@ -355,26 +356,30 @@ returns
def self.create_from_hash!(hash)
role_ids = Role.signup_role_ids
url = ''
hash['info']['urls']&.each_value do |local_url|
next if local_url.blank?
url = local_url
end
begin
create!(
data = {
login: hash['info']['nickname'] || hash['uid'],
firstname: hash['info']['name'],
firstname: hash['info']['name'] || hash['info']['display_name'],
email: hash['info']['email'],
image_source: hash['info']['image'],
web: url,
address: hash['info']['location'],
note: hash['info']['description'],
source: hash['provider'],
role_ids: role_ids,
role_ids: Role.signup_role_ids,
updated_by_id: 1,
created_by_id: 1,
)
}
if hash['info']['first_name'].present? && hash['info']['last_name'].present?
data[:firstname] = hash['info']['first_name']
data[:lastname] = hash['info']['last_name']
end
create!(data)
rescue => e
logger.error e
raise Exceptions::UnprocessableEntity, e.message
@@ -1153,15 +1158,15 @@ raise 'Minimum one user need to have admin permissions'
end
def destroy_longer_required_objects
Authorization.where(user_id: id).destroy_all
Avatar.remove('User', id)
Cti::CallerId.where(user_id: id).destroy_all
Taskbar.where(user_id: id).destroy_all
Karma::ActivityLog.where(user_id: id).destroy_all
Karma::User.where(user_id: id).destroy_all
OnlineNotification.where(user_id: id).destroy_all
RecentView.where(created_by_id: id).destroy_all
UserDevice.remove(id)
::Authorization.where(user_id: id).destroy_all
::Avatar.remove('User', id)
::Cti::CallerId.where(user_id: id).destroy_all
::Taskbar.where(user_id: id).destroy_all
::Karma::ActivityLog.where(user_id: id).destroy_all
::Karma::User.where(user_id: id).destroy_all
::OnlineNotification.where(user_id: id).destroy_all
::RecentView.where(created_by_id: id).destroy_all
::UserDevice.remove(id)
true
end

View File

@@ -0,0 +1,11 @@
La contraseña para #{config.product_name} ha sido cambiada
<div>Buenos días #{user.firstname},</div>
<br>
<div>la contraseña para acceder a #{config.product_name} cuenta #{user.login}</b> ha cambiado recientemente. </div>
<br>
<div>¿Esta actividad es desconocida para vosotros? En este caso, ponerse en contacto con su administrador de sistema.</div>
<br>
<div>Siempre atentamente </div>
<br>
<div>El equipo #{config.product_name} </div>

View File

@@ -0,0 +1,11 @@
La password per #{config.product_name} è stata modificata
<div>Buongiorno #{user.firstname},</div>
<br>
<div>la password per l'accesso a #{config.product_name} account #{user.login}</b> è stata modificata di recente. </div>
<br>
<div>Questa attività vi è sconosciuta? In questo caso, contattare l'amministratore di sistema.</div>
<br>
<div>Sempre cordiali saluti </div>
<br>
<div>Il team #{config.product_name} </div>

View File

@@ -0,0 +1,18 @@
Restablecer contraseña para #{config.product_name}
<div>Buenos días #{user.firstname},</div>
<br>
<div>hemo recibido una solicitud de restablecimiento de contraseña para #{config.product_name} cuenta <b>#{user.login}</b>.</div>
<br>
<div>Si desea restablecer su contraseña, clicar sobre el link abajo (o copiar la URL en el browser
insertar):</div>
<br>
<div><a href="#{config.http_type}://#{config.fqdn}/#password_reset_verify/#{token.name}">#{config.http_type}://#{config.fqdn}/#password_reset_verify/#{token.name}</a></div>
<br>
<div>Este link abre a una página donde puede cambiar su contraseña.</div>
<br>
<div>Si no desea restablecer su contraseña puede ignorar este mensaje. La contraseña no se cambia.</div>
<br>
<div>Siempre atentamente </div>
<br>
<div>El equipo #{config.product_name} </div>

View File

@@ -0,0 +1,18 @@
Reimpostare password per #{config.product_name}
<div>Buongiorno #{user.firstname},</div>
<br>
<div>abbiamo ricevuto una richiesta di reimpostazione password per #{config.product_name} account <b>#{user.login}</b>.</div>
<br>
<div>Se desidera reimpostare la password, fare un clic sul collegamento sotto (o copiare l'URL nel browser
inserire):</div>
<br>
<div><a href="#{config.http_type}://#{config.fqdn}/#password_reset_verify/#{token.name}">#{config.http_type}://#{config.fqdn}/#password_reset_verify/#{token.name}</a></div>
<br>
<div>Questo link porta a una pagina nella quale è possibile modificare la password.</div>
<br>
<div>Se non si desidera reimpostare la password, ignorare questo messaggio. La password rimane invariata.</div>
<br>
<div>Sempre cordiali saluti</div>
<br>
<div>Il team #{config.product_name} </div>

View File

@@ -0,0 +1,11 @@
Confirma #{config.product_name} cuenta, #{user.firstname} #{user.lastname}
<div>Buenos días #{user.firstname},</div>
<br>
<div>confirma la dirección de correo electrónico para completar la registración para #{config.product_name}. Cliccar sobre el link abajo.</div>
<br>
<div><a href="#{config.http_type}://#{config.fqdn}/#email_verify/#{token.name}">#{config.http_type}://#{config.fqdn}/#email_verify/#{token.name}</a></div>
<br>
<div>Siempre atentamente </div>
<br>
<div>El equipo #{config.product_name} </div>

View File

@@ -0,0 +1,11 @@
Conferma #{config.product_name} account, #{user.firstname} #{user.lastname}
<div>Buongiorno #{user.firstname},</div>
<br>
<div>confermare l'indirizzo email per completare la registrazione su #{config.product_name}. Cliccare sul link riportato sotto.</div>
<br>
<div><a href="#{config.http_type}://#{config.fqdn}/#email_verify/#{token.name}">#{config.http_type}://#{config.fqdn}/#email_verify/#{token.name}</a></div>
<br>
<div>Sempre cordiali saluti</div>
<br>
<div>Il team #{config.product_name} </div>

View File

@@ -0,0 +1,9 @@
Boleto de prueba!
<div>Buenos días #{agent.firstname},</div>
<br>
<div>Este es un <b>ticket de prueba </b>. Soy un cliente y necesito ayuda! :)</div>
<br>
<div>#{customer.fullname}</div>
<br>
<div>El Proyecto Zammad </div>

View File

@@ -0,0 +1,9 @@
Biglietto di prova!
<div>Cear #{agent.firstname},</div>,</div>.
<br>
<div>Questo è un <b>test ticket</b>. Sono un cliente e ho bisogno di aiuto! :)</div>
<br>
<div>#{customer.fullname}</div></div>
<br>
<div>Il Progetto Zammad</div>

View File

@@ -0,0 +1,24 @@
Nuevo Ticket (#{ticket.title})
<div>Buenos días #{recipient.firstname},</div>
<br>
<div>Se ha creado un nuevo ticket (#{ticket.title}) con "<b>#{current_user.longname}</b>".</div>
<br>
<div>
#{t('Group')}: #{ticket.group.name}<br>
#{t('Owner')}: #{ticket.owner.fullname}<br>
#{t('State')}: #{t(ticket.state.name)}<br>
</div>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,24 @@
Nuovo Ticket (#{ticket.title})
<div>Buongiorno #{recipient.firstname},</div>
<br>
<div>Un nuovo ticket (#{ticket.title}) è stato creato da "<b>##{current_user.longname}</b>".</div>.
<br>
<div>
#{t('Group')}: #{ticket.group.name}<br>
#{t('Owner')}: #{ticket.owner.fullname}<br>
#{t('State')}: #{t(ticket.state.name)}<br>
</div>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,18 @@
El ticket está escalado (#{ticket.title})
<div>Buenos días #{recipient.firstname},</div>
<br>
<div>Un ticket (#{ticket.title}) de "<b>#{ticket.customer.longname}</b>" se incrementa desde "#{ticket.escalation_at}"!</div>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,18 @@
Il ticket si è intensificato (#{ticket.title})
<div>Buongiorno #{recipient.firstname},</div>
<br>
<div>Un ticket (#{ticket.title}) da "<b>#{ticket.customer.longname}</b>" si è intensificato a "#{ticket.escalation_at}"!</div>.
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,18 @@
El ticket escalará (#{ticket.title})
<div>Buenos días #{recipient.firstname},</div>
<br>
<div>Un ticket (#{ticket.title}) de "<b>#{ticket.customer.longname}</b>" escalará a "#{ticket.escalation_at}"!</div>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,18 @@
Il ticket intensificarsi (#{ticket.title})
<div>Buongiorno #{recipient.firstname},</div>
<br>
<div>Un ticket (#{ticket.title}) da "<b>#{ticket.customer.longname}</b>" intensificarsi a "#{ticket.escalation_at}"!</div>!
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,18 @@
Recordatorio alcanzado (#{ticket.title})
<div>Buenos días #{recipient.firstname},</div>
<br>
<div>Un ticket necesita atención, recordatorio alcanzado para (#{ticket.title}) con el cliente "<b>#{ticket.customer.longname}</b>".</div>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,18 @@
Promemoria raggiunto (#{ticket.title})
<div>Buongiorno #{recipient.firstname},</div>
<br>
<div>Un biglietto ha bisogno di attenzione, promemoria raggiunto per (#{ticket.title}) con il cliente "<b>#{ticket.customer.longname}</b>".</div>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,29 @@
Ticket actualizado (#{ticket.title})
<div>Buenos días #{recipient.firstname},</div>
<br>
<div>
Ticket (#{ticket.title}) ha sido actualizado por "<b>#{current_user.longname}</b>".
</div>
<br>
<% if @objects[:changes].present? %>
<div>
#{t('Changes')}:<br>
<% @objects[:changes].each do |key, value| %>
<%= t key %>: <%= h value[0] %> -&gt; <%= h value[1] %><br>
<% end %>
</div>
<% end %>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,29 @@
Biglietto aggiornato (#{ticket.title})
<div>Buongiorno #{recipient.firstname},</div>
<br>
<div>
Ticket (#{ticket.title}) è stato aggiornato da "<b>#{current_user.longname}</b>".
</div>
<br>
<% if @objects[:changes].present? %>
<div>
#{t('Changes')}:<br>
<% @objects[:changes].each do |key, value| %>
<%= t key %>: <%= h value[0] %> -&gt; <%= h value[1] %><br>
<% end %>
</div>
<% end %>
<br>
<% if @objects[:article] %>
<div>
#{t('Information')}:
<blockquote type="cite">
#{article.body_as_html}
</blockquote>
</div>
<% end %>
<br>
<div>
<a href="#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}" target="zammad_app">#{t('View this in Zammad')}</a>
</div>

View File

@@ -0,0 +1,22 @@
#{config.product_name}-login detectado por un nuevo dispositivo
<div>Buenos días #{user.firstname},</div>
<br>
<div>parece haber accedido a <b>un nuevo dispositivo</b> para "#{user_device.created_at}" a:</div>
<br>
<div>
Su dispositivo: #{user_device.name}<br>
Su posición (relativa): #{user_device.location}<br>
Su dirección IP: #{user_device.ip}<br>
</div>
<br>
<div>El dispositivo se ha agregado a la lista de dispositivos conocidos, esta lista se puede ver aquí:</div>
<br>
<div>#{config.http_type}://#{config.fqdn}/#profile/devices</div>
<br>
<div>En caso contrario, eliminar el dispositivo de la lista, cambiar la contraseña de la cuenta y contactar su administrador. Alguien podría
tener acceso no autorizado a su cuenta.</div>
<br>
<div>Siempre atentamente </div>
<br>
<div>El equipo #{config.product_name} </div>

View File

@@ -0,0 +1,21 @@
#{config.product_name}-login rilevato da un nuovo dispositivo
<div>Buongiorno #{user.firstname},</div>
<br>
<div>sembra che abbia eseguito l'accesso a <b>un nuovo dispositivo</b> per "#{user_device.created_at}" a:</div>
<br>
<div>
Il suo dispositivo: #{user_device.name}<br>
La sua posizione (relativa): #{user_device.location}<br>
Il suo indirizzo IP: #{user_device.ip}<br>
</div>
<br>
<div>Il dispositivo è stato aggiunto all'elenco dei dispositivi conosciuti, questo elenco può essere visualizzato qui:</div>
<br>
<div>#{config.http_type}://#{config.fqdn}/#profile/devices</div>
<br>
<div>In caso contrario, rimuovere il dispositivo dall'elenco, modificare la password dell'account e contattare l'amministratore. Qualcuno potrebbe avere accesso non autorizzato al suo account.</div>
<br>
<div>Sempre cordiali saluti</div>
<br>
<div>Il team #{config.product_name} </div>

View File

@@ -0,0 +1,21 @@
#{config.product_name}-acceso desde otro país
<div>Buenos días #{user.firstname},</div>
<br>
<div>parece haber accedido a las "#{user_device.created_at}" desde un <b>dispositivo conocido desde un otro país</b>:</div>
<br>
<div>
Su dispositivo: #{user_device.name}<br>
Su posición (relativa): #{user_device.location}<br>
Su dirección IP: #{user_device.ip}<br>
</div>
<br>
<div>El nuevo país se ha agregado a la lista de dispositivos conocidos, esta lista se puede ver aquí:</div>
<br>
<div>#{config.http_type}://#{config.fqdn}/#profile/devices</div>
<br>
<div>En caso contrario, eliminar la nueva ubicación de la lista, cambiar la contraseña de la cuenta y contactar su administrador. Alguien puede tener acceso no autorizado a su cuenta.</div>
<br>
<div>Siempre atentamente </div>
<br>
<div>El equipo #{config.product_name} </div>

View File

@@ -0,0 +1,21 @@
#{config.product_name}-accesso da un altro paese
<div>Buongiorno #{user.firstname},</div>
<br>
<div>sembra che abbia eseguito l'accesso alle "#{user_device.created_at}" da un <b>dispositivo conosciuto da un altro paese</b>:</div>
<br>
<div>
Il suo dispositivo: #{user_device.name}<br>
La sua posizione (relativa): #{user_device.location}<br>
Il suo indirizzo IP: #{user_device.ip}<br>
</div>
<br>
<div>Il nuovo paese è stato aggiunto all'elenco dei dispositivi conosciuti, questo elenco può essere visualizzato qui:</div>
<br>
<div>#{config.http_type}://#{config.fqdn}/#profile/devices</div>
<br>
<div>In caso contrario, rimuovere la nuova posizione dall'elenco, modificare la password dell'account e contattare l'amministratore. Qualcuno potrebbe avere accesso non autorizzato al suo account.</div>
<br>
<div>Sempre cordiali saluti</div>
<br>
<div>Il team #{config.product_name} </div>

View File

@@ -0,0 +1,15 @@
Invitación a #{config.product_name} a través de #{config.fqdn}
<div>Buenos días #{user.firstname},</div>
<br>
<div>A mi (#{current_user.firstname} #{current_user.lastname}) me gustaría invitar a #{config.product_name} - nuestra plataforma Usersupport/Ticket.</div>
<br>
<div>Para iniciar sesión <a href="#{config.http_type}://#{config.fqdn}/#password_reset_verify/#{token.name}">configure aquí</a> la contraseña.</div>
<br>
<div>Gracias,</div>
<br>
<div>#{current_user.firstname} #{current_user.lastname}</div>
<br>
<div>Siempre atentamente </div>
<br>
<div>El equipo #{config.product_name} </div>

View File

@@ -0,0 +1,15 @@
Invitato a #{config.product_name} tramite #{config.fqdn}
<div>Buongiorno #{user.firstname},</div>
<br>
<div>Io (#{current_user.firstname} #{current_user.lastname}) vorrei invitarvi a #{config.product_name} - la nostra piattaforma Usersupport/Ticket.</div>
<br>
<div>Per accedere <a href="#{config.http_type}://#{config.fqdn}/#password_reset_verify/#{token.name}">impostare qui</a> la password.</div>
<br>
<div>Grazie,</div>
<br>
<div>#{current_user.firstname} #{current_user.lastname}</div>
<br>
<div>Sempre cordiali saluti</div>
<br>
<div>Il team #{config.product_name} </div>

View File

@@ -1,5 +1,6 @@
class AddConfidentialToDoorkeeperApplication < ActiveRecord::Migration[5.1]
def change
return if ActiveRecord::Base.connection.column_exists?(:oauth_applications, :confidential)
add_column(
:oauth_applications,
:confidential,

View File

@@ -67,10 +67,19 @@ class CtiGenericApi < ActiveRecord::Migration[5.1]
frontend: false
)
add_column :cti_logs, :queue, :string, limit: 250, null: true
add_column :cti_logs, :initialized_at, :string, limit: 250, null: true
add_column :cti_logs, :duration_waiting_time, :integer, null: true
add_column :cti_logs, :duration_talking_time, :integer, null: true
add_column :cti_logs, :queue, :string, limit: 250, null: true if !column_exists?(:cti_logs, :queue)
add_column :cti_logs, :initialized_at, :string, limit: 250, null: true if !column_exists?(:cti_logs, :initialized_at)
add_column :cti_logs, :duration_waiting_time, :integer, null: true if !column_exists?(:cti_logs, :duration_waiting_time)
add_column :cti_logs, :duration_talking_time, :integer, null: true if !column_exists?(:cti_logs, :duration_talking_time)
# fixes issue #2183 - Mysql2::Error: Invalid default value for 'start_at'
if ActiveRecord::Base.connection_config[:adapter] == 'mysql2'
# disable the MySQL strict_mode for the current connection
execute("SET sql_mode = ''")
change_column_default :cti_logs, :start, '0000-00-00 00:00:00'
change_column_default :cti_logs, :end, '0000-00-00 00:00:00'
end
rename_column :cti_logs, :start, :start_at
rename_column :cti_logs, :end, :end_at

View File

@@ -0,0 +1,15 @@
class Issue2140ResetLdapConfig < ActiveRecord::Migration[5.1]
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
ldap_config = Setting.get('ldap_config')
# finish if LDAP config isn't broken
ldap_config.to_json
rescue Encoding::UndefinedConversionError
ldap_config[:wizardData].delete(:backend_user_attributes)
Setting.set('ldap_config', ldap_config)
end
end

View File

@@ -476,11 +476,11 @@ class String
def utf8_encode!(**options)
return force_encoding('utf-8') if dup.force_encoding('utf-8').valid_encoding?
viable_encodings(try_first: options[:from]).each do |e|
viable_encodings(try_first: options[:from]).each do |enc|
begin
return encode!('utf-8', e)
rescue Encoding::UndefinedConversionError
nil
return encode!('utf-8', enc)
rescue EncodingError => e
Rails.logger.error { e.inspect }
end
end

View File

@@ -387,7 +387,7 @@ cleanup html string:
end
uri.to_s
rescue URI::Error
rescue
url
end

View File

@@ -42,21 +42,14 @@ module Import
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
display_name = folder.display_name.utf8_encode(fallback: :read_as_sanitized_binary)
parent_folder = id_folder_map[folder.parent_folder_id]
return display_name if !parent_folder
# recursive
parent_folder_path = display_path(parent_folder)
return display_name if parent_folder.blank?
"#{parent_folder_path} -> #{display_name}"
"#{display_path(parent_folder)} -> #{display_name}"
rescue Viewpoint::EWS::EwsError
folder.display_name
display_name
end
private

View File

@@ -120,38 +120,34 @@ class Ldap
# #=> {:dn=>"dn (e. g. CN=Administrator,CN=Users,DC=domain,DC=tld)", ...}
#
# @return [Hash{Symbol=>String}] The available User attributes as key and the name and an example as value.
def attributes(filter: nil, base_dn: nil)
filter ||= filter()
attributes = {}
known_attributes = BLACKLISTED.dup
lookup_counter = 1
@ldap.search(filter, base: base_dn) do |entry|
new_attributes = entry.attribute_names - known_attributes
if new_attributes.blank?
lookup_counter += 1
# check max 50 entries with
# the same attributes in a row
break if lookup_counter == 50
next
end
new_attributes.each do |attribute|
value = entry[attribute]
next if value.blank?
next if value[0].blank?
example_value = value[0].force_encoding('UTF-8').utf8_encode(fallback: :read_as_sanitized_binary)
attributes[attribute] = "#{attribute} (e. g. #{example_value})"
end
known_attributes.concat(new_attributes)
def attributes(custom_filter: nil, base_dn: nil)
@attributes ||= begin
attributes = {}.with_indifferent_access
lookup_counter = 0
# collect sample attributes
@ldap.search(custom_filter || filter, base: base_dn) do |entry|
pre_merge_count = attributes.count
attributes.reverse_merge!(entry.to_h
.except(*BLACKLISTED)
.transform_values(&:first)
.compact)
# check max 50 entries with the same attributes in a row
lookup_counter = (pre_merge_count < attributes.count ? 0 : lookup_counter.next)
break if lookup_counter >= 50
end
# format sample values for presentation
attributes.each do |name, value|
attributes[name] = if value.encoding == Encoding.find('ascii-8bit')
"#{name} (binary data)"
else
"#{name} (e.g., #{value.utf8_encode})"
end
end
end
attributes
end
# The active filter of the instance. If none give on initialization an automatic lookup is performed.

View File

@@ -165,8 +165,10 @@ returns
return result if params[:sheet].present?
assets = {}
result[:ticket_ids].each do |ticket_id|
ticket_full = Ticket.find(ticket_id)
assets = ticket_full.assets(assets)
suppress(ActiveRecord::RecordNotFound) do
ticket_full = Ticket.find(ticket_id)
assets = ticket_full.assets(assets)
end
end
result[:assets] = assets
result

View File

@@ -342,6 +342,7 @@ return search result
'query_string' => {
'query' => append_wildcard_to_simple_query(query),
'default_operator' => 'AND',
'analyze_wildcard' => true,
}
}
data['query']['bool']['must'].push condition
@@ -717,10 +718,10 @@ return true if backend is configured
result
end
# add * on simple query like "somephrase23" or "attribute: somephrase23"
# add * on simple query like "somephrase23"
def self.append_wildcard_to_simple_query(query)
query.strip!
query += '*' if query.match?(/^([[:alnum:]._]+|[[:alnum:]]+\:\s*[[:alnum:]._]+)$/)
query += '*' if !query.match?(/:/)
query
end
end

View File

@@ -27,13 +27,15 @@ class Sequencer
# always returns true
def log_associations_error
return true if %i[failed deactivated].include?(action)
return true if %i[skipped failed deactivated].include?(action)
logger.error { 'associations cannot be nil' } if associations.nil?
true
end
def register_changes
return if !(action == :unchanged && changes.any?)
logger.debug { "Changed instance associations: #{changes.inspect}" }
state.provide(:action, :updated)
end

View File

@@ -6,11 +6,14 @@ class Sequencer
module Common
module Model
class Save < Sequencer::Unit::Base
prepend ::Sequencer::Unit::Import::Common::Model::Mixin::Skip::Action
include ::Sequencer::Unit::Import::Common::Model::Mixin::HandleFailure
uses :instance, :dry_run
uses :instance, :action, :dry_run
provides :instance
skip_action :skipped, :failed, :unchanged
def process
return if dry_run
return if instance.blank?

View File

@@ -10,7 +10,7 @@ class Sequencer
skip_any_action
uses :resource, :dn_roles, :ldap_config, :mapped
uses :dn_roles, :ldap_config, :mapped, :instance
provides :action
def process
@@ -25,9 +25,7 @@ class Sequencer
# if unassigned users should not get skipped
return if ldap_config[:unassigned_users] != 'skip_sync'
instance = state.optional(:instance)
if instance.present?
if instance&.active
# deactivate instance if role assignment is lost
instance.update!(active: false)
state.provide(:action, :deactivated)

View File

@@ -24,6 +24,9 @@ namespace :searchindex do
number = info['version']['number'].to_s
end
settings = {
'index.mapping.total_fields.limit': 2000,
}
mapping = {}
Models.searchable.each do |local_object|
mapping.merge!(get_mapping_properties_object(local_object))
@@ -33,7 +36,8 @@ namespace :searchindex do
SearchIndexBackend.index(
action: 'create',
data: {
mappings: mapping
mappings: mapping,
settings: settings,
}
)

View File

@@ -93,6 +93,8 @@ test('form checks', function() {
priority1_id: '1',
priority2_id: ['1', '2'],
priority3_id: '2',
priority4_id: '2',
priority5_id: '1',
working_hours: {
mon: {
active: true,
@@ -146,9 +148,11 @@ test('form checks', function() {
el: el,
model: {
configure_attributes: [
{ name: 'priority1_id', display: 'Priroity1', tag: 'select', relation: 'TicketPriority', null: true, options: {} },
{ name: 'priority2_id', display: 'Priroity2', tag: 'select', multiple: true, relation: 'TicketPriority', null: true, options: {} },
{ name: 'priority3_id', display: 'Priroity3', tag: 'select', relation: 'TicketPriority', null: true },
{ name: 'priority1_id', display: 'Priroity1 (with active selection)', tag: 'select', relation: 'TicketPriority', null: true, options: {} },
{ name: 'priority2_id', display: 'Priroity2 (with active and inactive selection)', tag: 'select', multiple: true, relation: 'TicketPriority', null: true, options: {} },
{ name: 'priority3_id', display: 'Priroity3 (with inactive selection)', tag: 'select', relation: 'TicketPriority', null: true, options: {} },
{ name: 'priority4_id', display: 'Priroity4 (with inactive selection)', tag: 'select', multiple: true, relation: 'TicketPriority', null: true, options: {} },
{ name: 'priority5_id', display: 'Priroity5 (with active selection)', tag: 'select', multiple: true, relation: 'TicketPriority', null: true, options: {} },
{ name: 'escalation_times', display: 'Times', tag: 'sla_times', null: true },
{ name: 'working_hours', display: 'Hours', tag: 'business_hours', null: true },
]
@@ -161,6 +165,8 @@ test('form checks', function() {
priority1_id: '1',
priority2_id: ['1', '2'],
priority3_id: '2',
priority4_id: '2',
priority5_id: '1',
first_response_time: '150',
first_response_time_in_text: '02:30',
solution_time: '',
@@ -219,6 +225,36 @@ test('form checks', function() {
equal(el.find('[name="priority1_id"] option').length, 3)
equal(el.find('[name="priority2_id"] option').length, 4)
equal(el.find('[name="priority3_id"] option').length, 4)
equal(el.find('[name="priority4_id"] option').length, 4)
equal(el.find('[name="priority5_id"] option').length, 3)
// check priority1_id selection order
equal(el.find('[name="priority1_id"] option:nth-child(1)').text(), '1 low')
equal(el.find('[name="priority1_id"] option:nth-child(2)').text(), '3 high')
equal(el.find('[name="priority1_id"] option:nth-child(3)').text(), '4 very high')
// check priority2_id selection order
equal(el.find('[name="priority2_id"] option:nth-child(1)').text(), '1 low')
equal(el.find('[name="priority2_id"] option:nth-child(2)').text(), '2 normal')
equal(el.find('[name="priority2_id"] option:nth-child(3)').text(), '3 high')
equal(el.find('[name="priority2_id"] option:nth-child(4)').text(), '4 very high')
// check priority3_id selection order
equal(el.find('[name="priority3_id"] option:nth-child(1)').text(), '1 low')
equal(el.find('[name="priority3_id"] option:nth-child(2)').text(), '2 normal')
equal(el.find('[name="priority3_id"] option:nth-child(3)').text(), '3 high')
equal(el.find('[name="priority3_id"] option:nth-child(4)').text(), '4 very high')
// check priority4_id selection order
equal(el.find('[name="priority4_id"] option:nth-child(1)').text(), '1 low')
equal(el.find('[name="priority4_id"] option:nth-child(2)').text(), '2 normal')
equal(el.find('[name="priority4_id"] option:nth-child(3)').text(), '3 high')
equal(el.find('[name="priority4_id"] option:nth-child(4)').text(), '4 very high')
// check priority5_id selection order
equal(el.find('[name="priority5_id"] option:nth-child(1)').text(), '1 low')
equal(el.find('[name="priority5_id"] option:nth-child(2)').text(), '3 high')
equal(el.find('[name="priority5_id"] option:nth-child(3)').text(), '4 very high')
// change sla times
el.find('[name="first_response_time_in_text"]').val('0:30').trigger('blur')
@@ -229,6 +265,8 @@ test('form checks', function() {
priority1_id: '1',
priority2_id: ['1', '2'],
priority3_id: '2',
priority4_id: '2',
priority5_id: '1',
working_hours: {
mon: {
active: true,

View File

@@ -309,6 +309,10 @@ test( "datetime selector check", function() {
}
deepEqual(params, test_params, 'params check')
// Regression test for issue #2173 - Invalid date causes errors
el.find('.js-datepicker').datepicker('setDate', '01/01/99999')
el.find('.js-datepicker').datepicker('setDate', '01/01/1ABCDEFG')
el.find('.js-datepicker').datepicker('setDate', '01/01/1äöüß')
});
test( "date selector check", function() {

View File

@@ -40,7 +40,6 @@ test( "taskbar basic tests", function() {
deepEqual(task.state, 'abc')
deepEqual(task.params, { "a": 12 })
App.TaskManager.execute({
key: 'TestKey2',
controller: 'TestController1',
@@ -258,12 +257,60 @@ test( "taskbar basic tests", function() {
equal(App.TaskManager.nextTaskUrl(), false)
equal(App.TaskManager.nextTaskUrl(), false)
// destroy task bar
App.TaskManager.reset()
// check max tabs
var times = 5;
App.TaskManager.tasksAutoCleanupDelayTime(200)
App.TaskManager.tasksAutoCleanupTaskMax(3)
// check if any taskar exists
equal($('#taskbars .content').length, 0, "check available active contents")
for(var i=0; i < times; i++){
App.TaskManager.execute({
key: 'TestKeyLoop' + i,
controller: 'TestController1',
params: {
message: "#" + i,
},
show: true,
persistent: false,
})
}
equal(App.TaskManager.all().length, 5)
})
App.Delay.set(function() {
test( "taskbar check max tabs 2", function() {
equal(App.TaskManager.all().length, 3)
var times = 5;
for(var i=0; i < times; i++){
App.TaskManager.execute({
key: 'TestKeyLoop2' + i,
controller: 'TestController1',
params: {
message: "#" + i,
changedState: true
},
show: true,
persistent: false,
})
}
equal(App.TaskManager.all().length, 8)
})
}, 1000);
App.Delay.set(function() {
test( "taskbar check max tabs 5", function() {
equal(App.TaskManager.all().length, 5)
// destroy task bar
App.TaskManager.reset()
// check if any taskar exists
equal($('#taskbars .content').length, 0, "check available active contents")
})
}, 2000);
}

View File

@@ -9,6 +9,11 @@ if echo $CI_BUILD_REF_NAME | grep private; then
exit 0
fi
if echo $CI_BUILD_REF_NAME | grep cherry-pick-; then
echo 'sync no cherry-pick branches'
exit 0
fi
git checkout $CI_BUILD_REF_NAME
if [ "$CI_BUILD_REF_NAME" != "$CI_BUILD_TAG" ]; then
git pull --rebase origin $CI_BUILD_REF_NAME

View File

@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe Issue2140ResetLdapConfig, type: :db_migration do
before { Setting.set('ldap_config', config) }
context 'when LDAP config isn’t broken' do
let(:config) do
{ 'wizardData' =>
{ 'backend_user_attributes' =>
{ 'foo' => 'bar' },
'user_attributes' =>
{ 'baz' => 'qux' } } }.with_indifferent_access
end
it 'makes no changes' do
expect { migrate }.not_to change { Setting.get('ldap_config') }
end
end
context 'when LDAP config is broken' do
let(:config) do
{ 'wizardData' =>
{ 'backend_user_attributes' =>
{ 'foo' => "\u0001\u0001\u0004€" },
'user_attributes' =>
{ 'baz' => 'qux' } } }.with_indifferent_access
end
it 'removes the offending backend_user_attributes sub-hash' do
expect { migrate }
.to change { Setting.get('ldap_config') }
.to(config.tap { |c| c[:wizardData].delete(:backend_user_attributes) })
end
end
end

View File

@@ -1,9 +1,28 @@
FactoryBot.define do
factory :recent_view do
recent_view_object_id { ObjectLookup.by_name('User') }
o_id 1
created_by_id 1
created_at Time.zone.now
updated_at Time.zone.now
transient do
type { :ticket }
user_role { :agent }
end
recent_view_object_id { ObjectLookup.by_name(type.to_s.camelcase) }
# select a random record of the given object class
o_id do
random_function = case ActiveRecord::Base.connection_config[:adapter]
when 'mysql2'
'RAND'
when 'postgresql'
'RANDOM'
end
type.to_s.camelcase.constantize.order("#{random_function}()").first.id
end
# assign to an existing user, if possible
created_by_id do
User.find { |u| u.role?(user_role.capitalize) }&.id ||
create("#{user_role}_user").id
end
end
end

View File

@@ -0,0 +1,7 @@
FactoryBot.define do
factory :ticket_time_accounting, class: Ticket::TimeAccounting do
ticket
time_unit { rand(100) }
created_by_id 1
end
end

View File

@@ -0,0 +1,76 @@
require 'rails_helper'
RSpec.describe Import::Exchange::Folder do
# see https://github.com/zammad/zammad/issues/2152
describe '#display_path (#2152)' do
let(:subject) { described_class.new(ews_connection) }
let(:ews_connection) { Viewpoint::EWSClient.new(endpoint, user, pass) }
let(:endpoint) { 'https://exchange.example.com/EWS/Exchange.asmx' }
let(:user) { 'user@example.com' }
let(:pass) { 'password' }
let(:grandchild_of_root) { ews_connection.get_folder_by_name('Inbox') }
let(:child_of_root) { ews_connection.get_folder(grandchild_of_root.parent_folder_id) }
around do |example|
cassette_name = example.description.gsub(/[^0-9A-Za-z.\-]+/, '_')
VCR.use_cassette("lib/import/exchange/folder/#{cassette_name}") { example.run }
end
context 'when server returns valid UTF-8' do
context 'and target folder is in root directory' do
it 'returns the display name of the folder' do
expect(subject.display_path(child_of_root))
.to eq('Top of Information Store')
end
end
context 'and target folder is in subfolder of root' do
it 'returns the full path from root to target' do
expect(subject.display_path(grandchild_of_root))
.to eq('Top of Information Store -> Inbox')
end
end
context 'and walking up directory tree raises EwsError' do
it 'returns the partial path from error to target folder' do
allow(subject)
.to receive(:id_folder_map).with(any_args).and_raise(Viewpoint::EWS::EwsError)
expect(subject.display_path(grandchild_of_root))
.to eq('Inbox')
end
end
end
context 'when server returns invalid UTF-8' do
context 'and target folder is in root directory' do
it 'returns the display name of the folder in valid UTF-8' do
allow(child_of_root)
.to receive(:display_name).and_return('你好'.b)
expect { subject.display_path(child_of_root).to_json }.not_to raise_error
end
end
context 'and target folder is in subfolder of root' do
it 'returns the full path from root to target in valid UTF-8' do
allow(grandchild_of_root)
.to receive(:display_name).and_return('你好'.b)
expect { subject.display_path(grandchild_of_root).to_json }.not_to raise_error
end
end
context 'and walking up directory tree raises EwsError' do
it 'returns the partial path from error to target folder in valid UTF-8' do
allow(grandchild_of_root)
.to receive(:display_name).and_return('你好'.b)
allow(subject)
.to receive(:id_folder_map).with(any_args).and_raise(Viewpoint::EWS::EwsError)
expect { subject.display_path(grandchild_of_root).to_json }.not_to raise_error
end
end
end
end
end

View File

@@ -2,6 +2,7 @@ require 'rails_helper'
# rails autoloading issue
require 'ldap'
require 'ldap/user'
require 'tcr/net/ldap'
RSpec.describe Ldap::User do
@@ -195,4 +196,51 @@ RSpec.describe Ldap::User do
end
end
end
# Each of these test cases depends on
# sample TCP transmission data recorded with TCR,
# stored in test/data/tcr_cassettes.
context 'on mocked LDAP connections' do
around do |example|
cassette_name = example.description.gsub(/[^0-9A-Za-z.\-]+/, '_')
begin
original_tcr_format = TCR.configuration.format
TCR.configuration.format = 'marshal'
TCR.use_cassette("lib/ldap/user/#{cassette_name}") { example.run }
ensure
TCR.configuration.format = original_tcr_format
end
end
describe '#attributes' do
let(:subject) { described_class.new(config, ldap: ldap) }
let(:ldap) { Ldap.new(config) }
let(:config) do
{ 'host_url' => 'ldap://localhost',
'options' => { 'dc=example,dc=org' => 'dc=example,dc=org' },
'option' => 'dc=example,dc=org',
'base_dn' => 'dc=example,dc=org',
'bind_user' => 'cn=admin,dc=example,dc=org',
'bind_pw' => 'admin' }.with_indifferent_access
end
# see https://github.com/zammad/zammad/issues/2140
#
# This method grabs sample values of user attributes on the LDAP server.
# It used to coerce ALL values to Unicode strings, including binary attributes
# (e.g., usersmimecertificate / msexchmailboxsecuritydescriptor),
# which led to valid Unicode gibberish (e.g., "\u0001\u0001\u0004...")
#
# When saving these values to the database,
# ActiveRecord::Store would convert them to binary (ASCII-8BIT) strings,
# which would then break #to_json with an Encoding::UndefinedConversion error.
it 'skips binary attributes (#2140)' do
Setting.set('ldap_config', subject.attributes)
expect { Setting.get('ldap_config').to_json }
.not_to raise_error
end
end
end
end

View File

@@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe Report::TicketGenericTime do
=begin
result = Report::TicketGenericTime.items(
range_start: '2015-01-01T00:00:00Z',
range_end: '2015-12-31T23:59:59Z',
selector: selector, # ticket selector to get only a collection of tickets
params: { field: 'created_at' },
)
returns
{
count: 123,
ticket_ids: [4,5,1,5,0,51,5,56,7,4],
assets: assets,
}
=end
describe 'items' do
# Regression test for issue #2246 - Records in Reporting not updated when single ActiveRecord can not be found
it 'correctly handles missing tickets' do
class_double('SearchIndexBackend', selectors: { ticket_ids: [-1] } ).as_stubbed_const
expect do
described_class.items(
range_start: '2015-01-01T00:00:00Z',
range_end: '2015-12-31T23:59:59Z',
selector: {}, # ticket selector to get only a collection of tickets
params: { field: 'created_at' },
)
end.to_not raise_error
end
end
end

View File

@@ -56,6 +56,20 @@ RSpec.describe Sequencer::Unit::Import::Common::Model::Associations::Assign, seq
let(:instance) { create(:user) }
let(:associations) { nil }
context 'and `action == :skipped`' do
let(:action) { :skipped }
it 'makes no changes' do
allow(Rails.logger).to receive(:error).and_call_original
provided = process(parameters)
expect(Rails.logger).not_to have_received(:error)
expect(provided).to include(action: action)
expect(instance.changed?).to be(false)
end
end
context 'and `action == :failed`' do
let(:action) { :failed }

View File

@@ -0,0 +1,47 @@
require 'rails_helper'
RSpec.describe Sequencer::Unit::Import::Common::Model::Save, sequencer: :unit do
let(:user) { instance_double('User') }
before { allow(user).to receive(:save!) }
context 'for action: :created' do
it 'calls #save!' do
process(action: :created, instance: user, dry_run: false)
expect(user).to have_received(:save!)
end
end
context 'for action: :updated' do
it 'calls #save!' do
process(action: :updated, instance: user, dry_run: false)
expect(user).to have_received(:save!)
end
end
context 'for action: :unchanged' do
it 'avoids calling #save!' do
process(action: :unchanged, instance: user, dry_run: false)
expect(user).not_to have_received(:save!)
end
end
context 'for action: :skipped' do
it 'avoids calling #save!' do
process(action: :skipped, instance: user, dry_run: false)
expect(user).not_to have_received(:save!)
end
end
context 'for action: :failed' do
it 'avoids calling #save!' do
process(action: :failed, instance: user, dry_run: false)
expect(user).not_to have_received(:save!)
end
end
end

View File

@@ -0,0 +1,98 @@
require 'rails_helper'
RSpec.describe Sequencer::Unit::Import::Ldap::User::Attributes::RoleIds::Unassigned, sequencer: :unit do
let(:subject) { process(parameters) }
let(:parameters) do
{ resource: resource,
dn_roles: dn_roles,
ldap_config: ldap_config,
mapped: mapped,
instance: instance }
end
let(:resource) do
{ dn: 'uid=jane,ou=People,dc=example,dc=org',
uid: 'jane_doe',
sn: 'Doe',
givenname: 'Jane' }.with_indifferent_access
end
# this sequencer unit only runs when this parameter is PRESENT
let(:dn_roles) do
{ 'uid=john,ou=people,dc=example,dc=org' => [1] }.with_indifferent_access
end
# this sequencer unit only runs when this parameter's "unassigned_users" key is "skip_sync"
let(:ldap_config) do
{ unassigned_users: 'skip_sync' }.with_indifferent_access
end
# this sequencer unit only runs when this parameter's "role_ids" key is BLANK
let(:mapped) do
{ firstname: 'Jane',
lastname: 'Doe',
login: 'jane_doe' }.with_indifferent_access
end
let(:instance) { create(:agent_user, mapped) }
context 'when user exists from previous import' do
context 'and is active' do
before { instance.update(active: true) }
it 'deactivates user (with action: :deactivated)' do
expect(subject).to include(action: :deactivated)
expect(instance.reload.active).to be(false)
end
end
context 'and is inactive' do
before { instance.update(active: false) }
it 'skips user (with action: :skipped)' do
expect(subject).to include(action: :skipped)
expect(instance.reload.active).to be(false)
end
end
end
context 'when user has not been imported yet' do
let(:instance) { nil }
it 'skips user (with action: :skipped)' do
expect(subject).to include(action: :skipped)
end
end
describe 'parameters' do
before { allow(instance).to receive(:update).and_call_original }
context 'without :dn_roles (hash map of LDAP DNs ↔ Zammad role IDs for ALL USERS)' do
before { parameters[:dn_roles].clear }
it 'skips user (with NO action)' do
expect(subject).to include(action: nil)
expect(instance).not_to have_received(:update)
end
end
context 'with :mapped[:role_ids] (hash map of LDAP attributes ↔ Zammad attributes for GIVEN USER)' do
before { parameters[:mapped].merge!(role_ids: [2]) }
it 'skips user (with NO action)' do
expect(subject).to include(action: nil)
expect(instance).not_to have_received(:update)
end
end
context 'when :ldap_config[:unassigned_users] != "skip_sync"' do
before { parameters[:ldap_config].merge!(unassigned_users: 'sigup_roles') }
it 'skips user (with NO action)' do
expect(subject).to include(action: nil)
expect(instance).not_to have_received(:update)
end
end
end
end

View File

@@ -48,5 +48,35 @@ RSpec.describe Channel::EmailParser, type: :model do
end
end
end
# see https://github.com/zammad/zammad/issues/2254
context 'when sender address contains > (#2254)' do
let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail076.box') }
let(:sender_email) { 'millionslotteryspaintransfer@example.com' }
it 'removes them before creating a new user' do
expect { described_class.new.process({}, raw_mail) }
.to change { User.where(email: sender_email).count }.to(1)
end
it 'marks new user email as invalid' do
described_class.new.process({}, raw_mail)
expect(User.find_by(email: sender_email).preferences)
.to include('mail_delivery_failed' => true)
.and include('mail_delivery_failed_reason' => 'invalid email')
.and include('mail_delivery_failed_data' => a_kind_of(ActiveSupport::TimeWithZone))
end
end
# see https://github.com/zammad/zammad/issues/2224
context 'when header specifies Windows-1258 charset (#2224)' do
let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail072.box') }
it 'does not raise Encoding::ConverterNotFoundError' do
expect { described_class.new.process({}, raw_mail) }
.not_to raise_error
end
end
end
end

View File

@@ -0,0 +1,31 @@
require 'rails_helper'
RSpec.describe ObjectManager::Attribute, type: :model do
describe 'check name' do
it 'rejects ActiveRecord reserved word "attribute"' do
expect do
ObjectManager::Attribute.add attributes_for :object_manager_attribute_text, name: 'attribute'
end.to raise_error 'attribute is a reserved word, please choose a different one'
end
it 'rejects Zammad reserved word "table"' do
expect do
ObjectManager::Attribute.add attributes_for :object_manager_attribute_text, name: 'table'
end.to raise_error 'table is a reserved word, please choose a different one'
end
it 'accepts duplicate attribute name on the same types (editing an existing attribute)' do
attribute = attributes_for :object_manager_attribute_select
ObjectManager::Attribute.add attribute
expect do
ObjectManager::Attribute.add attribute
end.to_not raise_error
end
it 'accepts valid attribute names' do
expect do
ObjectManager::Attribute.add attributes_for :object_manager_attribute_text
end.to_not raise_error
end
end
end

View File

@@ -0,0 +1,179 @@
require 'rails_helper'
RSpec.describe RecentView, type: :model do
let(:admin) { create(:admin_user) }
let(:agent) { create(:agent_user) }
let(:customer) { create(:customer_user) }
let(:ticket) { create(:ticket, owner: owner, customer: customer) }
let(:tickets) { create_list(:ticket, 15, owner: owner, customer: customer) }
let(:owner) { admin }
describe '::list' do
it 'returns a sample of recently viewed objects (e.g., tickets/users/organizations)' do
tickets.each { |t| described_class.log('Ticket', t.id, admin) }
expect(described_class.list(admin).map(&:o_id)).to include(*tickets.last(10).map(&:id))
end
it 'returns up to 10 results by default' do
tickets.each { |t| described_class.log('Ticket', t.id, admin) }
expect(described_class.list(admin).length).to eq(10)
end
context 'with a `limit` argument (optional)' do
it 'returns up to that number of results' do
tickets.each { |t| described_class.log('Ticket', t.id, admin) }
expect(described_class.list(admin, 12).length).to eq(12)
end
end
context 'with an `object_name` argument (optional)' do
it 'includes only the specified model class' do
described_class.log('Ticket', ticket.id, admin)
described_class.log('Organization', 1, admin)
expect(described_class.list(admin, 10, 'Organization').length).to eq(1)
end
it 'does not include merged tickets in results' do
described_class.log('Ticket', ticket.id, admin)
ticket.update(state: Ticket::State.find_by(name: 'merged'))
expect(described_class.list(admin, 10, 'Ticket').length).to eq(0)
end
it 'does not include removed tickets in results' do
described_class.log('Ticket', ticket.id, admin)
ticket.update(state: Ticket::State.find_by(name: 'removed'))
expect(described_class.list(admin, 10, 'Ticket').length).to eq(0)
end
end
it 'does not include duplicate results' do
5.times { described_class.log('Ticket', ticket.id, admin) }
expect(described_class.list(admin).length).to eq(1)
end
it 'does not include deleted tickets in results' do
described_class.log('Ticket', ticket.id, admin)
ticket.destroy
expect(described_class.list(admin).length).to eq(0)
end
describe 'access privileges' do
context 'when given user is agent' do
let(:owner) { agent }
it 'includes own tickets in results' do
described_class.log('Ticket', ticket.id, agent)
expect(described_class.list(agent).length).to eq(1)
end
it 'does not include other agents’ tickets in results' do
described_class.log('Ticket', ticket.id, agent)
ticket.update(owner: User.first)
expect(described_class.list(agent).length).to eq(0)
end
it 'includes any organizations in results' do
agent.update(organization: nil)
described_class.log('Organization', 1, agent)
expect(described_class.list(agent).length).to eq(1)
end
end
context 'when given user is customer' do
it 'includes own tickets in results' do
described_class.log('Ticket', ticket.id, customer)
expect(described_class.list(customer).length).to eq(1)
end
it 'does not include other customers’ tickets in results' do
described_class.log('Ticket', ticket.id, customer)
ticket.update(customer: User.first)
expect(described_class.list(customer).length).to eq(0)
end
it 'includes own organization in results' do
customer.update(organization: Organization.first)
described_class.log('Organization', 1, customer)
expect(described_class.list(customer).length).to eq(1)
end
it 'does not include other organizations in results' do
customer.update(organization: Organization.first)
described_class.log('Organization', 1, customer)
customer.update(organization: nil)
expect(described_class.list(customer).length).to eq(0)
end
end
end
end
describe '::user_log_destroy' do
it 'deletes all RecentView records for a given user' do
create_list(:recent_view, 10, created_by_id: admin.id)
expect { described_class.user_log_destroy(admin) }
.to change { described_class.exists?(created_by_id: admin.id) }.to(false)
end
end
describe '::log' do
let(:viewed_object) { ticket }
let(:viewed_object_class_id) { ObjectLookup.by_name(viewed_object.class.name) }
it 'wraps RecentView.create' do
expect do
described_class.log(viewed_object.class.name, viewed_object.id, admin)
end.to change { RecentView.count }.by(1)
end
describe 'access privileges' do
let(:owner) { agent }
it 'does not create RecentView for records the given user cannot read' do
ticket.update(owner: User.first, # read access may come from ticket ownership,
customer: User.first, # customer status,
organization: nil) # organization's 'shared' status,
agent.update(groups: []) # and membership in the Ticket's group
expect do
described_class.log(viewed_object.class.name, viewed_object.id, agent)
end.not_to change { RecentView.count }
end
end
context 'when given an invalid object' do
it 'does not create RecentView for non-existent record' do
expect do
described_class.log('User', 99_999_999, admin)
end.not_to change { RecentView.count }
end
it 'does not create RecentView for instance of non-ObjectLookup class' do
expect do
described_class.log('Overview', 1, admin)
end.not_to change { RecentView.count }
end
it 'does not create RecentView for instance of non-existent class' do
expect do
described_class.log('NonExistentClass', 1, admin)
end.not_to change { RecentView.count }
end
end
end
end

View File

@@ -31,7 +31,7 @@ ActiveRecord::Migration.maintain_test_schema!
Package::Migration.linked
# allow requests to Zammad webservices
WebMock.disable_net_connect!(allow: /zammad\.com/)
WebMock.disable_net_connect!(allow: /zammad\.com|exchange.example.com/)
RSpec.configure do |config|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures

View File

@@ -0,0 +1,25 @@
require 'rails_helper'
RSpec.describe 'Time Accounting API endpoints', type: :request do
let(:admin) { create(:admin_user) }
let(:customer) { create(:customer_user) }
let(:year) { Time.current.year }
let(:month) { Time.current.month }
describe '/api/v1/time_accounting/log/by_ticket' do
context 'when requesting a JSON response' do
# see https://github.com/zammad/zammad/pull/2243
context 'and logs exist for work performed by an agent who is also the customer of the ticket (#2243)' do
let(:ticket) { create(:ticket, customer: admin) }
let!(:time_log) { create(:ticket_time_accounting, ticket: ticket, created_by_id: admin.id) }
it 'responds with a non-nil value for each :agent key' do
authenticated_as(admin)
get "/api/v1/time_accounting/log/by_ticket/#{year}/#{month}", as: :json
expect(json_response.first).not_to include('agent' => nil)
end
end
end
end
end

5
spec/support/tcr.rb Normal file
View File

@@ -0,0 +1,5 @@
TCR.configure do |config|
config.cassette_library_dir = 'test/data/tcr_cassettes'
config.hook_tcp_ports = [389] # LDAP
config.format = 'yaml'
end

5
spec/support/vcr.rb Normal file
View File

@@ -0,0 +1,5 @@
VCR.configure do |config|
config.cassette_library_dir = 'test/data/vcr_cassettes'
config.hook_into :webmock
config.allow_http_connections_when_no_cassette = true
end

View File

@@ -5,10 +5,11 @@ class AAbUnitTest < TestCase
def test_core
@browser = browser_instance
location(url: browser_url + '/tests_core')
sleep 5
watch_for(
css: '.result',
value: 'Tests completed',
timeout: 8,
timeout: 4,
)
match(
css: '.result .failed',
@@ -30,10 +31,11 @@ class AAbUnitTest < TestCase
)
location(url: browser_url + '/tests_model')
sleep 5
watch_for(
css: '.result',
value: 'Tests completed',
timeout: 8,
timeout: 3,
)
match(
css: '.result .failed',
@@ -77,10 +79,11 @@ class AAbUnitTest < TestCase
def test_form
@browser = browser_instance
location(url: browser_url + '/tests_form')
sleep 5
watch_for(
css: '.result',
value: 'Tests completed',
timeout: 8,
timeout: 2,
)
match(
css: '.result .failed',
@@ -212,10 +215,11 @@ class AAbUnitTest < TestCase
)
location(url: browser_url + '/tests_taskbar')
sleep 5
watch_for(
css: '.result',
value: 'Tests completed',
timeout: 8,
timeout: 3,
)
match(
css: '.result .failed',

View File

@@ -347,19 +347,9 @@ class AdminObjectManagerTest < TestCase
name: 'browser_test7',
},
)
click(css: '.content.active .tab-pane.active div.js-execute')
watch_for(
css: '.modal',
value: 'restart',
)
watch_for_disappear(
css: '.modal',
timeout: 7.minutes,
)
sleep 5
watch_for(
css: '.content.active',
)
sleep 1
object_manager_attribute_migrate
match_not(
css: '.content.active',
value: 'Database Update required',
@@ -670,5 +660,269 @@ class AdminObjectManagerTest < TestCase
unsorted_options = select_element.find_elements(xpath: './*').map(&:text).reject { |x| x == '-' }
log unsorted_options.inspect
assert_equal options, unsorted_options
object_manager_attribute_delete(
data: {
name: 'select_attributes_sorting_test',
},
)
object_manager_attribute_migrate
end
def test_deleted_select_attributes
@browser = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
options = Hash[ %w[äöü cat delete dog ß].map { |x| [x, "#{x.capitalize} Display"] } ]
options_no_dog = options.except('dog')
options_no_dog_no_delete = options_no_dog.except('delete')
tasks_close_all()
object_manager_attribute_create(
data: {
name: 'select_attributes_delete_test',
display: 'Select Attributes Delete Test',
data_type: 'Select',
data_option: {
options: options,
},
},
)
object_manager_attribute_migrate
ticket = ticket_create(
data: {
customer: 'nico',
group: 'Users',
title: 'select_attributes_delete_test',
body: 'select_attributes_delete_test',
},
custom_data_select: {
select_attributes_delete_test: 'Delete Display',
},
disable_group_check: true,
)
watch_for(
css: '.content.active select[name="select_attributes_delete_test"]',
)
# confirm that all options and their display values are there and are in the correct order
select_element = @browser.find_elements(css: '.content.active select[name="select_attributes_delete_test"]')[0]
unsorted_options = select_element.find_elements(xpath: './*').map { |o| o.attribute('value') }.reject { |x| x == '' }
assert_equal options.keys, unsorted_options
unsorted_display_options = select_element.find_elements(xpath: './*').map(&:text).reject { |x| x == '-' }
assert_equal options.values, unsorted_display_options
# confirm that the "delete" option is selected and that its display text is indeed "Delete Display"
selected_option = select_element.find_elements(css: 'option:checked')[0]
assert_equal 'delete', selected_option.attribute('value')
assert_equal 'Delete Display', selected_option.text
object_manager_attribute_update(
data: {
name: 'select_attributes_delete_test',
data_option: {
options: options_no_dog_no_delete,
},
},
)
object_manager_attribute_migrate
# open the previously created ticket and verify its attribute selection
click(
xpath: '//a/div[contains(text(),"select_attributes_delete_test")]',
)
# confirm that all options and their display values are there and are in the correct order
select_element = @browser.find_elements(css: '.content.active select[name="select_attributes_delete_test"]')[0]
unsorted_options = select_element.find_elements(xpath: './*').map { |o| o.attribute('value') }.reject { |x| x == '' }
assert_equal options_no_dog.keys, unsorted_options
unsorted_display_options = select_element.find_elements(xpath: './*').map(&:text).reject { |x| x == '-' }
assert_equal options_no_dog.values, unsorted_display_options
# confirm that the "delete" option is still selected and that its display text is still indeed "Delete Display"
selected_option = select_element.find_elements(css: 'option:checked')[0]
assert_equal 'delete', selected_option.attribute('value')
assert_equal 'Delete Display', selected_option.text
# create a new ticket and check that the deleted options no longer appear
click(
css: 'a[href="#ticket/create"]',
mute_log: true,
)
watch_for(
css: 'select[name="select_attributes_delete_test"]',
)
select_element = @browser.find_elements(css: 'select[name="select_attributes_delete_test"]')[0]
unsorted_options = select_element.find_elements(xpath: './*').map { |o| o.attribute('value') }.reject { |x| x == '' }
assert_equal options_no_dog_no_delete.keys, unsorted_options
unsorted_display_options = select_element.find_elements(xpath: './*').map(&:text).reject { |x| x == '-' }
assert_equal options_no_dog_no_delete.values, unsorted_display_options
object_manager_attribute_delete(
data: {
name: 'select_attributes_delete_test',
},
)
object_manager_attribute_migrate
end
# verify fix for issue #2233 - Boolean object set to false is not visible
# verify fix for issue #2277 - Note is not shown for customer / organisations if it's empty
def test_false_boolean_attributes_gets_displayed_for_organizations
@browser = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
object_manager_attribute_create(
data: {
object: 'Organization',
name: 'bool_test',
display: 'bool_test',
data_type: 'Boolean',
data_option: {
options: {
# rubocop:disable Lint/BooleanSymbol
true: 'YES',
false: 'NO',
# rubocop:enable Lint/BooleanSymbol
}
},
},
)
object_manager_attribute_create(
data: {
object: 'Organization',
name: 'text_test',
display: 'text_test',
data_type: 'Text',
},
)
object_manager_attribute_migrate
ticket_open_by_title(title: 'select')
click( css: '.content.active .tabsSidebar-tab[data-tab="organization"]' )
click( css: '.content.active .sidebar[data-tab="organization"] .js-actions .dropdown-toggle' )
click( css: '.content.active .sidebar[data-tab="organization"] .js-actions [data-type="organization-edit"]' )
modal_ready
select(css: '.content.active .modal select[name="bool_test"]', value: 'NO')
click( css: '.content.active .modal .js-submit' )
modal_disappear
watch_for(
css: '.content.active .sidebar[data-tab="organization"] .sidebar-content',
value: 'bool_test',
)
match_not(
css: '.content.active .sidebar[data-tab="organization"] .sidebar-content',
value: 'text_test',
)
match(
css: '.content.active .sidebar[data-tab="organization"] .sidebar-content',
value: 'note',
)
object_manager_attribute_delete(
data: {
object: 'Organization',
name: 'bool_test',
},
)
object_manager_attribute_delete(
data: {
object: 'Organization',
name: 'text_test',
},
)
object_manager_attribute_migrate
end
# verify fix for issue #2233 - Boolean object set to false is not visible
# verify fix for issue #2277 - Note is not shown for customer / organisations if it's empty
def test_false_boolean_attributes_gets_displayed_for_users
@browser = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
object_manager_attribute_create(
data: {
object: 'User',
name: 'bool_test',
display: 'bool_test',
data_type: 'Boolean',
data_option: {
options: {
# rubocop:disable Lint/BooleanSymbol
true: 'YES',
false: 'NO',
# rubocop:enable Lint/BooleanSymbol
}
},
},
)
object_manager_attribute_create(
data: {
object: 'User',
name: 'text_test',
display: 'text_test',
data_type: 'Text',
},
)
object_manager_attribute_migrate
ticket_open_by_title(title: 'select')
click( css: '.content.active .tabsSidebar-tab[data-tab="customer"]' )
click( css: '.content.active .sidebar[data-tab="customer"] .js-actions .dropdown-toggle' )
click( css: '.content.active .sidebar[data-tab="customer"] .js-actions [data-type="customer-edit"]' )
modal_ready
select(css: '.content.active .modal select[name="bool_test"]', value: 'NO')
click( css: '.content.active .modal .js-submit' )
modal_disappear
watch_for(
css: '.content.active .sidebar[data-tab="customer"] .sidebar-content',
value: 'bool_test',
)
match_not(
css: '.content.active .sidebar[data-tab="customer"] .sidebar-content',
value: 'text_test',
)
match(
css: '.content.active .sidebar[data-tab="customer"] .sidebar-content',
value: 'note',
)
object_manager_attribute_delete(
data: {
object: 'User',
name: 'bool_test',
},
)
object_manager_attribute_delete(
data: {
object: 'User',
name: 'text_test',
},
)
object_manager_attribute_migrate
end
end

View File

@@ -116,4 +116,94 @@ class AdminObjectManagerTreeSelectTest < TestCase
)
end
# verify the fix for issue #2206 - Unable to modify tree_select attributes with fresh 2.6
def test_modify_tree_select_attributes
@browser = instance = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
object_manager_attribute_create(
data: {
name: 'browser_test_tree_select2',
display: 'Browser Test TreeSelect2',
data_type: 'Tree Select',
data_option: {
options: {
'Incident' => {
'Hardware' => {
'Monitor' => {},
'Mouse' => {},
},
},
'Service request' => {
'New software requirement' => {},
'New hardware' => {},
},
'Change request' => {},
},
},
},
)
object_manager_attribute_migrate
# open the newly created tree_select and add some new options
object_manager_attribute_update(
data: {
name: 'browser_test_tree_select2',
},
do_not_submit: true,
)
# add two new first level entries
2.times do |i|
instance.find_elements(css: '.modal .js-treeTable .js-key').last.click
element = instance.find_elements(css: '.modal .js-treeTable .js-key').last
element.clear
element.send_keys("new tree option #{i}")
end
click(
css: '.modal button.js-submit'
)
modal_disappear
object_manager_attribute_migrate
# open the tree select again and check that the newly added options are there
watch_for(
css: '.content.active table',
value: 'browser_test_tree_select2',
)
object_manager_attribute_update(
data: {
name: 'browser_test_tree_select2',
},
do_not_submit: true,
)
2.times do |i|
exists(
css: '.modal .js-treeTable',
value: "new tree option #{i}",
)
end
modal_close
# clean up and confirm the deletion of newly created attributes
object_manager_attribute_delete(
data: {
name: 'browser_test_tree_select2',
},
)
object_manager_attribute_migrate
match_not(
css: '.content.active table',
value: 'browser_test_tree_select2',
)
end
end

View File

@@ -125,4 +125,59 @@ class AdminOverviewTest < TestCase
get_location( css: "td[title='#{title}']").y]
end.sort_by { |x| x[1] }.map { |x| x[0] }
end
# verify fix for issue #2235 - Overview setting isn't applied on submit
def test_overview_toggle_out_of_office_setting
@browser = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
out_of_office_css = '.content.active .modal select[name="out_of_office"]'
first_overview_css = '.content.active tr[data-id="1"] td'
click( css: 'a[href="#manage"]' )
click( css: '.content.active a[href="#manage/overviews"]' )
# round 1, open the overview and set out_of_office to true
click( css: first_overview_css )
modal_ready
watch_for(
css: out_of_office_css,
value: 'no',
)
select(
css: out_of_office_css,
value: 'yes',
)
click( css: '.content.active .modal .js-submit' )
modal_disappear
# round 2, open the overview and set out_of_office back to false
click( css: first_overview_css )
modal_ready
watch_for(
css: out_of_office_css,
value: 'yes',
)
select(
css: out_of_office_css,
value: 'no',
)
click( css: '.content.active .modal .js-submit' )
modal_disappear
# round 3, open the overview and confirm that it's still false
click( css: first_overview_css )
modal_ready
watch_for(
css: out_of_office_css,
value: 'no',
)
click( css: '.content.active .modal .js-submit' )
modal_disappear
end
end

View File

@@ -365,4 +365,286 @@ class AgentTicketOverviewLevel0Test < TestCase
# cleanup
tasks_close_all()
end
# verify correct behaviour for issue #1864 - Bulk-Action: Not possible to change owner
def test_bulk_owner_change
@browser = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
# test bulk action
# create new ticket
ticket1 = ticket_create(
data: {
customer: 'nico',
group: 'Users',
title: 'overview owner change test #1',
body: 'overview owner change #1',
}
)
ticket2 = ticket_create(
data: {
customer: 'nico',
group: 'Users',
title: 'overview owner change #2',
body: 'overview owner change #2',
}
)
overview_open(
link: '#ticket/view/all_unassigned',
)
watch_for(
css: '.content.active',
value: 'overview owner change #2',
timeout: 8,
)
# remember current overview count
overview_counter_before = overview_counter()
# select both via bulk action
click(
css: '.content.active table tr td input[value="' + ticket1[:id] + '"] + .icon-checkbox.icon-unchecked',
fast: true,
)
# scroll to reply - needed for chrome
scroll_to(
position: 'top',
css: '.content.active table tr td input[value="' + ticket2[:id] + '"] + .icon-checkbox.icon-unchecked',
)
click(
css: '.content.active table tr td input[value="' + ticket2[:id] + '"] + .icon-checkbox.icon-unchecked',
fast: true,
)
exists(
css: '.content.active table tr td input[value="' + ticket1[:id] + '"][type="checkbox"]:checked',
)
exists(
css: '.content.active table tr td input[value="' + ticket2[:id] + '"][type="checkbox"]:checked',
)
select(
css: '.content.active .bulkAction [name="owner_id"]',
value: 'Test Master Agent',
)
select(
css: '.content.active .bulkAction [name="state_id"]',
value: 'closed',
)
click(
css: '.content.active .bulkAction .js-confirm',
)
click(
css: '.content.active .bulkAction .js-submit',
)
watch_for_disappear(
css: '.content.active table tr td input[value="' + ticket2[:id] + '"]',
timeout: 12,
)
exists_not(
css: '.content.active table tr td input[value="' + ticket1[:id] + '"]',
)
exists_not(
css: '.content.active table tr td input[value="' + ticket2[:id] + '"]',
)
# get new overview count
overview_counter_new = overview_counter()
assert_equal(overview_counter_before['#ticket/view/all_unassigned'] - 2, overview_counter_new['#ticket/view/all_unassigned'])
# cleanup
tasks_close_all()
end
# verify fix for issue #2026 - Bulk action should not be shown if user has no change permissions
def test_no_bulk_action_when_missing_change_permission
@browser = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
# create new group
group_create(
data: {
name: 'some group2',
},
)
role_edit(
data: {
name: 'Agent',
group_permissions: { 2 => ['full'],
3 => ['full'], }
},
)
user_edit(
data: {
login: 'master@example.com',
permissions: { 1 => ['full'],
2 => ['full'],
3 => ['full'], }
},
)
user_create(
data: {
firstname: 'Tester',
lastname: 'Agent 2',
email: 'agent2@example.com',
password: 'test',
role: 'Agent',
permissions: { 1 => %w[read create overview] }
},
)
# create new tickets
can_change_ticket = ticket_create(
data: {
customer: 'nico',
group: 'some group2',
title: 'overview test #5',
body: 'overview test #5',
}
)
cannot_change_ticket = ticket_create(
data: {
customer: 'nico',
group: 'Users',
title: 'overview test #6',
body: 'overview test #6',
}
)
logout() # logout as master@example.com then login as agent2@example.com
login(
username: 'agent2@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
# open Overview menu tab
click(
css: '.js-menu .js-overviewsMenuItem',
)
# enable full overviews
execute(
js: '$(".content.active .sidebar").css("display", "block")',
)
# click Unassigned & Open tab
click(
css: '.content.active [href="#ticket/view/all_unassigned"]',
)
watch_for(
css: '.content.active',
value: 'overview test #6',
timeout: 8,
)
# first select the ticket that we have change rights to
check(
css: '.content.active table tr td input[value="' + can_change_ticket[:id] + '"]',
)
# check that the bulk action form appears
exists(
displayed: true,
css: '.content.active .bulkAction',
)
# then select the ticket that we do not have change rights to
scroll_to(
position: 'top',
css: '.content.active table tr td input[value="' + cannot_change_ticket[:id] + '"] + .icon-checkbox.icon-unchecked',
)
check(
css: '.content.active table tr td input[value="' + cannot_change_ticket[:id] + '"]',
)
# check that the bulk action form disappears
exists(
displayed: false,
css: '.content.active .bulkAction',
)
# de-select the ticket that we do not have change rights to
uncheck(
css: '.content.active table tr td input[value="' + cannot_change_ticket[:id] + '"]',
fast: true,
)
# check that the bulk action form appears again
exists(
displayed: true,
css: '.content.active .bulkAction',
)
# de-select the ticket that we have change rights to
uncheck(
css: '.content.active table tr td input[value="' + can_change_ticket[:id] + '"]',
fast: true,
)
# check that the bulk action form disappears again
exists(
displayed: false,
css: '.content.active .bulkAction',
)
# cleanup
tasks_close_all()
logout() # logout as agent2@example.com and then login as master@example.com to clean up tickets
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
# open ticket by search
ticket_open_by_search(
number: cannot_change_ticket[:number],
)
sleep 1
# close ticket
ticket_update(
data: {
state: 'closed',
}
)
# open ticket by search
ticket_open_by_search(
number: can_change_ticket[:number],
)
sleep 1
# close ticket
ticket_update(
data: {
state: 'closed',
}
)
end
end

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