[Rt-commit] rt 01/01: Import changes of RT-Extension-REST2
sunnavy
sunnavy at bestpractical.com
Mon Jul 19 21:34:01 UTC 2021
This is an automated email from the git hooks/post-receive script.
sunnavy pushed a commit to branch 5.0/import-rest2-extension-changes
in repository rt.
commit df5092aa7525fd4a34b0092ffc3e0132429f9ece
Author: sunnavy <sunnavy at bestpractical.com>
AuthorDate: Tue Jul 20 03:12:29 2021 +0800
Import changes of RT-Extension-REST2
The last imported commit is 7a0e515b1d. Besides various fixes, it mainly
includes the following new features:
Support CustomRoles updates on correspond/comment
Support Status updates on correspond/comment
Support _hyperlinks field in collection
Support roles for ticket/asset searches
Support additional fields parameters for Roles and CustomFields in Collection
Support to search tickets from saved searches
Add /searches/ and /search/ endpoits for saved searches
Add /tickets/bulk/correspond and /tickets/bulk/comment endpoints
---
lib/RT/REST2.pm | 123 ++++++++++-
lib/RT/REST2/Resource.pm | 75 ++++++-
lib/RT/REST2/Resource/Assets.pm | 22 ++
lib/RT/REST2/Resource/Collection.pm | 55 ++++-
lib/RT/REST2/Resource/Collection/Search.pm | 209 ++++++++++++++++++
lib/RT/REST2/Resource/Message.pm | 61 +++++-
lib/RT/REST2/Resource/Record/Hypermedia.pm | 3 +-
lib/RT/REST2/Resource/Record/Writable.pm | 122 +----------
lib/RT/REST2/Resource/Search.pm | 158 ++++++++++++++
lib/RT/REST2/Resource/{Tickets.pm => Searches.pm} | 102 +++++----
lib/RT/REST2/Resource/Ticket.pm | 50 +++--
lib/RT/REST2/Resource/Tickets.pm | 38 ++--
lib/RT/REST2/Resource/TicketsBulk.pm | 72 +++++-
lib/RT/REST2/Util.pm | 156 ++++++++++++-
lib/RT/Test/REST2.pm | 1 +
t/rest2/assets.t | 34 +++
t/rest2/searches.t | 215 ++++++++++++++++++
t/rest2/ticket-correspond-customroles.t | 254 ++++++++++++++++++++++
t/rest2/ticket-customroles.t | 42 +++-
t/rest2/ticket-links.t | 15 +-
t/rest2/tickets-bulk.t | 76 +++++++
t/rest2/tickets.t | 146 ++++++++++++-
22 files changed, 1794 insertions(+), 235 deletions(-)
diff --git a/lib/RT/REST2.pm b/lib/RT/REST2.pm
index f8ed84ea5e..abeef7d3b0 100644
--- a/lib/RT/REST2.pm
+++ b/lib/RT/REST2.pm
@@ -290,6 +290,37 @@ below).
The time, in minutes, you've taken to work on your response/comment, optional.
+=item C<Status>
+
+The new status (for example, "open", "rejected", etc.) to set the
+ticket to. The Status value must be a valid status based on the
+lifecycle of the ticket's current queue.
+
+=item C<CustomRoles>
+
+A hash whose keys are custom role names and values are as described below:
+
+For a single-value custom role, the value must be a string representing an
+email address or user name; the custom role is set to the user with
+that email address or user name.
+
+For a multi-value custom role, the value can be a string representing
+an email address or user name, or can be an array of email addresses
+or user names; in either case, the members of the custom role are set
+to the corresponding users.
+
+=item C<CustomFields>
+
+A hash similar to the C<CustomRoles> hash, but whose keys are custom
+field names that apply to the Ticket; those fields are set to the
+supplied values.
+
+=item C<TxnCustomFields>
+
+A hash similar to the C<CustomRoles> hash, but whose keys are custom
+field names that apply to the Transaction; those fields are set
+to the supplied values.
+
=back
=head3 Add Attachments
@@ -454,8 +485,19 @@ curl for SSL like --cacert.
GET /tickets?simple=1;query=<simple search query>
search for tickets using simple search syntax
+ # If there are multiple saved searches using the same description, the
+ # behavior of "which saved search shall be selected" is undefined, use
+ # id instead in this case.
+
+ # If both search and other arguments like "query" are specified, the
+ # latter takes higher precedence than the corresponding fields defined
+ # in the given saved search.
+
+ GET /tickets?search=<saved search id or description>
+ search for tickets using saved search
+
POST /tickets
- search for tickets with the 'query' and optional 'simple' parameters
+ search for tickets with the 'search' or 'query' and optional 'simple' parameters
POST /ticket
create a ticket; provide JSON content
@@ -487,6 +529,10 @@ curl for SSL like --cacert.
PUT /tickets/bulk
update multiple tickets' metadata; provide JSON content(array of hashes)
+ POST /tickets/bulk/correspond
+ POST /tickets/bulk/comment
+ add a reply or comment to multiple tickets; provide JSON content(array of hashes)
+
=head3 Ticket Examples
Below are some examples using the endpoints above.
@@ -542,7 +588,7 @@ Below are some examples using the endpoints above.
'https://myrt.com/REST/2.0/ticket/6/correspond'
# Comment on a ticket
- curl -X POST -H "Content-Type: text/plain" -u 'root:password'
+ curl -X POST -H "Content-Type: application/json" -u 'root:password'
-d 'Testing a comment'
'https://myrt.com/REST/2.0/ticket/6/comment'
@@ -551,6 +597,21 @@ Below are some examples using the endpoints above.
-d '{ "Content": "Testing a comment", "ContentType": "text/plain", "CustomFields": {"Severity": "High"} }'
'https://myrt.com/REST/2.0/ticket/6/comment'
+ # Comment on a ticket with custom role update
+ curl -X POST -H "Content-Type: application/json" -u 'root:password'
+ -d '{ "Content": "Testing a comment", "ContentType": "text/plain", "CustomRoles": {"Manager": "manager at example.com"} }'
+ 'https://myrt.com/REST/2.0/ticket/6/comment'
+
+ # Update many tickets at once with bulk by sending an array with ticket ids
+ # Results are returned for each update in a JSON array with ticket ids and corresponding messages
+ curl -X POST -H "Content-Type: application/json" -u 'root:password'
+ -d '[{ "id": "20", "Content": "Testing a correspondence", "ContentType": "text/plain" },
+ { "id": "18", "Content": "Testing a correspondence", "ContentType": "text/plain", "Status":"resolved", "CustomRoles": {"Manager": "manager at example.com"}, "CustomFields": {"State": "New York"} }]'
+ 'https://myrt.com/REST/2.0/tickets/bulk/correspond'
+
+ [["20","Correspondence added"],["18","Correspondence added","State New York added","Added manager at example.com as Manager for this ticket","Status changedfrom 'open' to 'resolved'"]]
+
+
=head3 Ticket Fields
The following describes some of the values you can send when creating and updating
@@ -698,6 +759,20 @@ Below are some examples using the endpoints above.
curl -X POST -u 'root:password' -d "query=Catalog='General assets' AND 'CF.{Asset Type}' LIKE 'Computer'"
'https://myrt.com/REST/2.0/assets'
+=head3 Assets Examples
+
+Below are some examples using the endpoints above.
+
+ # Create an Asset
+ curl -X POST -H "Content-Type: application/json" -u 'root:password'
+ -d '{"Name" : "Asset From Rest", "Catalog" : "General assets", "Content" : "Some content"}'
+ 'https://myrt.com/REST/2.0/asset'
+
+ # Search Assets
+ curl -X POST -H "Content-Type: application/json" -u 'root:password'
+ -d '[{ "field" : "id", "operator" : ">=", "value" : 0 }]'
+ 'https://myrt.com/REST/2.0/assets'
+
=head3 Catalogs
GET /catalogs/all
@@ -920,6 +995,16 @@ Below are some examples using the endpoints above.
GET /customrole/:id
retrieve a custom role
+=head3 Saved Searches
+
+ GET /searches?query=<JSON>
+ POST /searches
+ search for saved searches using L</JSON searches> syntax
+
+ GET /search/:id
+ GET /search/:description
+ retrieve a saved search
+
=head3 Miscellaneous
GET /
@@ -1219,14 +1304,14 @@ You can use additional fields parameters to expand child blocks, for
example (line wrapping inserted for readability):
XX_RT_URL_XX/REST/2.0/tickets
- ?fields=Owner,Status,Created,Subject,Queue,CustomFields
+ ?fields=Owner,Status,Created,Subject,Queue,CustomFields,Requestor,Cc,AdminCc,RT::CustomRole-1
&fields[Queue]=Name,Description
Says that in the result set for tickets, the extra fields for Owner, Status,
-Created, Subject, Queue and CustomFields should be included. But in
-addition, for the Queue block, also include Name and Description. The
-results would be similar to this (only one ticket is displayed in this
-example):
+Created, Subject, Queue, CustomFields, Requestor, Cc, AdminCc and
+CustomRoles should be included. But in addition, for the Queue block, also
+include Name and Description. The results would be similar to this (only one
+ticket is displayed in this example):
"items" : [
{
@@ -1256,8 +1341,30 @@ example):
"name" : "My Custom Field",
"values" : [
"CustomField value"
- },
+ ]
}
+ ],
+ "Requestor" : [
+ {
+ "id" : "root",
+ "type" : "user",
+ "_url" : "XX_RT_URL_XX/REST/2.0/user/root"
+ }
+ ],
+ "Cc" : [
+ {
+ "id" : "root",
+ "type" : "user",
+ "_url" : "XX_RT_URL_XX/REST/2.0/user/root"
+ }
+ ],
+ "AdminCc" : [],
+ "RT::CustomRole-1" : [
+ {
+ "_url" : "XX_RT_URL_XX/REST/2.0/user/foo at example.com",
+ "type" : "user",
+ "id" : "foo at example.com"
+ }
]
}
{ … },
diff --git a/lib/RT/REST2/Resource.pm b/lib/RT/REST2/Resource.pm
index 1159ba7802..777023404c 100644
--- a/lib/RT/REST2/Resource.pm
+++ b/lib/RT/REST2/Resource.pm
@@ -84,7 +84,7 @@ sub expand_field {
while (my $cf = $cfs->Next) {
if (! defined $values{$cf->Id}) {
$values{$cf->Id} = {
- %{ expand_uid($cf->UID) },
+ %{ $self->_expand_object($cf, $field, $param_prefix) },
name => $cf->Name,
values => [],
};
@@ -112,6 +112,50 @@ sub expand_field {
}
} elsif ($field eq 'ContentLength' && $item->can('ContentLength')) {
$result = $item->ContentLength;
+ } elsif ($field eq 'CustomRoles') {
+ if ( $item->DOES("RT::Record::Role::Roles") ) {
+ my %data;
+ for my $role ( $item->Roles( ACLOnly => 0 ) ) {
+ next unless $role =~ /^RT::CustomRole-/;
+ $data{$role} = [];
+
+ my $group = $item->RoleGroup($role);
+ if ( !$group->Id ) {
+ $data{$role} = $self->_expand_object( RT->Nobody->UserObj, $field, $param_prefix )
+ if $item->_ROLES->{$role}{Single};
+ next;
+ }
+
+ my $gms = $group->MembersObj;
+ while ( my $gm = $gms->Next ) {
+ push @{ $data{$role} }, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+ }
+
+ # Avoid the extra array ref for single member roles
+ $data{$role} = shift @{$data{$role}} if $group->SingleMemberRoleGroup;
+ }
+ return \%data;
+ }
+ } elsif ($field =~ /^RT::CustomRole-\d+$/) {
+ if ( $item->DOES("RT::Record::Role::Roles") ) {
+ my $result = [];
+
+ my $group = $item->RoleGroup($field);
+ if ( !$group->Id ) {
+ $result = $self->_expand_object( RT->Nobody->UserObj, $field, $param_prefix )
+ if $item->_ROLES->{$field}{Single};
+ next;
+ }
+
+ my $gms = $group->MembersObj;
+ while ( my $gm = $gms->Next ) {
+ push @$result, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+ }
+
+ # Avoid the extra array ref for single member roles
+ $result = shift @$result if $group->SingleMemberRoleGroup;
+ return $result;
+ }
} elsif ($item->can('_Accessible') && $item->_Accessible($field => 'read')) {
# RT::Record derived object, so we can check access permissions.
@@ -120,14 +164,8 @@ sub expand_field {
} elsif ($item->can($field . 'Obj')) {
my $method = $field . 'Obj';
my $obj = $item->$method;
- if ( $obj->can('UID') and $result = expand_uid( $obj->UID ) ) {
- my $param_field = $param_prefix . '[' . $field . ']';
- my @subfields = split( /,/, $self->request->param($param_field) || '' );
-
- for my $subfield (@subfields) {
- my $subfield_result = $self->expand_field( $obj, $subfield, $param_field );
- $result->{$subfield} = $subfield_result if defined $subfield_result;
- }
+ if ( $obj->can('UID') ) {
+ $result = $self->_expand_object( $obj, $field, $param_prefix );
}
}
@@ -137,6 +175,25 @@ sub expand_field {
return $result // '';
}
+sub _expand_object {
+ my $self = shift;
+ my $object = shift;
+ my $field = shift;
+ my $param_prefix = shift || 'fields';
+
+ return unless $object->can('UID');
+
+ my $result = expand_uid( $object->UID ) or return;
+ my $param_field = $param_prefix . '[' . $field . ']';
+ my @subfields = split( /,/, $self->request->param($param_field) || '' );
+
+ for my $subfield (@subfields) {
+ my $subfield_result = $self->expand_field( $object, $subfield, $param_field );
+ $result->{$subfield} = $subfield_result if defined $subfield_result;
+ }
+ return $result;
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/lib/RT/REST2/Resource/Assets.pm b/lib/RT/REST2/Resource/Assets.pm
index 0c3069fd38..50360d8926 100644
--- a/lib/RT/REST2/Resource/Assets.pm
+++ b/lib/RT/REST2/Resource/Assets.pm
@@ -64,6 +64,28 @@ sub dispatch_rules {
)
}
+use RT::REST2::Util qw( expand_uid );
+
+sub expand_field {
+ my $self = shift;
+ my $item = shift;
+ my $field = shift;
+ my $param_prefix = shift;
+ if ( $field =~ /^(Owner|HeldBy|Contact)/ ) {
+ my $role = $1;
+ my $members = [];
+ if ( my $group = $item->RoleGroup($role) ) {
+ my $gms = $group->MembersObj;
+ while ( my $gm = $gms->Next ) {
+ push @$members, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+ }
+ $members = shift @$members if $group->SingleMemberRoleGroup;
+ }
+ return $members;
+ }
+ return $self->SUPER::expand_field( $item, $field, $param_prefix );
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/lib/RT/REST2/Resource/Collection.pm b/lib/RT/REST2/Resource/Collection.pm
index e7feaffc19..587efd9829 100644
--- a/lib/RT/REST2/Resource/Collection.pm
+++ b/lib/RT/REST2/Resource/Collection.pm
@@ -58,8 +58,9 @@ extends 'RT::REST2::Resource';
use Scalar::Util qw( blessed );
use Web::Machine::FSM::States qw( is_status_code );
use Module::Runtime qw( require_module );
-use RT::REST2::Util qw( serialize_record expand_uid format_datetime error_as_json );
+use RT::REST2::Util qw( expand_uid format_datetime error_as_json );
use POSIX qw( ceil );
+use Encode;
has 'collection_class' => (
is => 'ro',
@@ -93,6 +94,21 @@ sub setup_paging {
$self->collection->GotoPage($page - 1);
}
+sub setup_ordering {
+ my $self = shift;
+ my @orderby_cols;
+ my @orders = $self->request->param('order');
+ foreach my $orderby ($self->request->param('orderby')) {
+ $orderby = decode_utf8($orderby);
+ my $order = shift @orders || 'ASC';
+ $order = uc(decode_utf8($order));
+ $order = 'ASC' unless $order eq 'DESC';
+ push @orderby_cols, {FIELD => $orderby, ORDER => $order};
+ }
+ $self->collection->OrderByCols(@orderby_cols)
+ if @orderby_cols;
+}
+
sub limit_collection {
my $self = shift;
my $collection = $self->collection;
@@ -124,6 +140,7 @@ sub limit_collection {
sub search {
my $self = shift;
$self->setup_paging;
+ $self->setup_ordering;
return $self->limit_collection;
}
@@ -134,13 +151,37 @@ sub serialize {
my @fields = defined $self->request->param('fields') ? split(/,/, $self->request->param('fields')) : ();
while (my $item = $collection->Next) {
- my $result = expand_uid( $item->UID );
+ my $result = $self->serialize_record( $item->UID );
# Allow selection of desired fields
if ($result) {
for my $field (@fields) {
- my $field_result = $self->expand_field($item, $field);
- $result->{$field} = $field_result if defined $field_result;
+ if ( $field eq '_hyperlinks' ) {
+ my $class = ref $item;
+ $class =~ s!^RT::!RT::REST2::Resource::!;
+ if ( $class->require ) {
+ my $object = $class->new(
+ record_class => ref $item,
+ record_id => $item->id,
+ record => $item,
+ request => $self->request,
+ response => Plack::Response->new,
+ );
+ if ( $object->can('hypermedia_links') ) {
+ $result->{$field} = $object->hypermedia_links;
+ }
+ else {
+ RT->Logger->warning("_hyperlinks is not supported by $class, skipping");
+ }
+ }
+ else {
+ RT->Logger->warning("Couldn't load $class, skipping _hyperlinks");
+ }
+ }
+ else {
+ my $field_result = $self->expand_field($item, $field);
+ $result->{$field} = $field_result if defined $field_result;
+ }
}
}
push @results, $result;
@@ -180,6 +221,12 @@ sub serialize {
return \%results;
}
+sub serialize_record {
+ my $self = shift;
+ my $record = shift;
+ return expand_uid($record);
+}
+
# XXX TODO: Bulk update via DELETE/PUT on a collection resource?
sub charsets_provided { [ 'utf-8' ] }
diff --git a/lib/RT/REST2/Resource/Collection/Search.pm b/lib/RT/REST2/Resource/Collection/Search.pm
new file mode 100644
index 0000000000..07c9d38098
--- /dev/null
+++ b/lib/RT/REST2/Resource/Collection/Search.pm
@@ -0,0 +1,209 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+# <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::REST2::Resource::Collection::Search;
+use strict;
+use warnings;
+
+use Moose::Role;
+use namespace::autoclean;
+
+requires 'collection';
+use Regexp::Common qw/delimited/;
+
+around BUILDARGS => sub {
+ my $orig = shift;
+ my $class = shift;
+
+ my %args = @_;
+
+ if ( my $id = $args{request}->param('search') ) {
+ my $search = RT::REST2::Resource::Search::_load_search( $args{request}, $id );
+
+ if ( $search && $search->Id ) {
+ if ( !defined $args{query} && !defined $args{request}->param('query') ) {
+ if ( my $query = $search->GetParameter('Query') ) {
+ $args{request}->parameters->set( query => $query );
+ }
+ }
+
+ if ( !defined $args{order} && !defined $args{request}->param('order') ) {
+ if ( my $order = $search->GetParameter('Order') ) {
+ $args{request}->parameters->set( order => split /\|/, $order );
+ }
+ }
+
+ if ( !defined $args{orderby} && !defined $args{request}->param('orderby') ) {
+ if ( my $orderby = $search->GetParameter('OrderBy') ) {
+ $args{request}->parameters->set( orderby => split /\|/, $orderby );
+ }
+ }
+
+ if ( !defined $args{per_page} && !defined $args{request}->param('per_page') ) {
+ if ( my $per_page = $search->GetParameter('RowsPerPage') ) {
+ $args{request}->parameters->set( per_page => $per_page );
+ }
+ }
+
+ if ( !defined $args{fields} && !defined $args{request}->param('fields') ) {
+ if ( my $format = $search->GetParameter('Format') ) {
+ my @attrs;
+
+ # Main logic is copied from share/html/Elements/CollectionAsTable/ParseFormat
+ while ( $format =~ /($RE{delimited}{-delim=>qq{\'"}}|[{}\w.]+)/go ) {
+ my $col = $1;
+ my $colref = {};
+
+ if ( $col =~ /^$RE{quoted}$/o ) {
+ substr( $col, 0, 1 ) = "";
+ substr( $col, -1, 1 ) = "";
+ $col =~ s/\\(.)/$1/g;
+ }
+
+ while ( $col =~ s{/(STYLE|CLASS|TITLE|ALIGN|SPAN|ATTRIBUTE):([^/]*)}{}i ) {
+ $colref->{ lc $1 } = $2;
+ }
+
+ unless ( length $col ) {
+ $colref->{'attribute'} = '' unless defined $colref->{'attribute'};
+ }
+ elsif ( $col =~ /^__(NEWLINE|NBSP)__$/ || $col =~ /^(NEWLINE|NBSP)$/ ) {
+ $colref->{'attribute'} = '';
+ }
+ elsif ( $col =~ /__(.*?)__/io ) {
+ while ( $col =~ s/^(.*?)__(.*?)__//o ) {
+ $colref->{'last_attribute'} = $2;
+ }
+ $colref->{'attribute'} = $colref->{'last_attribute'}
+ unless defined $colref->{'attribute'};
+ }
+ else {
+ $colref->{'attribute'} = $col
+ unless defined $colref->{'attribute'};
+ }
+
+ if ( $colref->{'attribute'} ) {
+ push @attrs, $colref->{'attribute'};
+ }
+ }
+
+ my %fields;
+
+ if (@attrs) {
+ my $record_class = $args{collection_class}->RecordClass;
+ while ( my $attr = shift @attrs ) {
+ if ( $attr =~ /^(Requestors?|AdminCc|Cc|CustomRole\.\{.+?\})(?:\.(.+))?/ ) {
+ my $role = $1;
+ my $field = $2;
+
+ if ( $role eq 'Requestors' ) {
+ $role = 'Requestor';
+ }
+ elsif ( $role =~ /^CustomRole\.\{(.+?)\}/ ) {
+ my $name = $1;
+ my $custom_role = RT::CustomRole->new( $args{request}->env->{"rt.current_user"} );
+ $custom_role->Load($name);
+ if ( $custom_role->Id ) {
+ $role = $custom_role->GroupType;
+ }
+ else {
+ next;
+ }
+ }
+
+ $fields{$role} = 1;
+ if ($field) {
+ $field = 'CustomFields' if $field =~ /^CustomField\./;
+ $args{request}->parameters->set(
+ "fields[$role]" => join ',',
+ $field,
+ $args{request}->parameters->get("fields[$role]") || ()
+ );
+ }
+ }
+ elsif ( $attr =~ /^CustomField\./ ) {
+ $fields{CustomFields} = 1;
+ }
+ elsif ( $attr
+ =~ /^(?:RefersTo|ReferredToBy|DependsOn|DependedOnBy|MemberOf|Members|Parents|Children)$/
+ )
+ {
+ $fields{_hyperlinks} = 1;
+ }
+ elsif ( $record_class->can('_Accessible') && $record_class->_Accessible( $attr => 'read' ) )
+ {
+ $fields{$attr} = 1;
+ }
+ elsif ( $attr =~ s/Relative$// ) {
+
+ # Date fields like LastUpdatedRelative
+ push @attrs, $attr;
+ }
+ elsif ( $attr =~ s/Name$// ) {
+
+ # Fields like OwnerName, QueueName
+ push @attrs, $attr;
+ $args{request}->parameters->set(
+ "fields[$attr]" => join ',',
+ 'Name',
+ $args{request}->parameters->get("fields[$attr]") || ()
+ );
+ }
+ }
+ }
+
+ $args{request}->parameters->set( 'fields' => join ',', sort keys %fields );
+ }
+ }
+ }
+ }
+
+ return $class->$orig( %args );
+};
+
+1;
diff --git a/lib/RT/REST2/Resource/Message.pm b/lib/RT/REST2/Resource/Message.pm
index d1d4f00564..5315585e9b 100644
--- a/lib/RT/REST2/Resource/Message.pm
+++ b/lib/RT/REST2/Resource/Message.pm
@@ -55,7 +55,7 @@ use namespace::autoclean;
use MIME::Base64;
extends 'RT::REST2::Resource';
-use RT::REST2::Util qw( error_as_json update_custom_fields process_uploads );
+use RT::REST2::Util qw( error_as_json update_custom_fields process_uploads update_role_members fix_custom_role_ids );
sub dispatch_rules {
Path::Dispatcher::Rule::Regex->new(
@@ -124,10 +124,9 @@ sub from_json {
unless $attachment->{$field};
}
}
-
- $body->{NoContent} = 1 unless $body->{Content};
}
+ $body->{NoContent} = 1 unless $body->{Content};
if (!$body->{NoContent} && !$body->{ContentType}) {
return error_as_json(
$self->response,
@@ -140,8 +139,36 @@ sub from_json {
sub add_message {
my $self = shift;
my %args = @_;
+
+ my ( $return_code, @results ) = $self->_add_message(%args);
+ if ( $return_code != 201 ) {
+ return error_as_json( $self->response, \$return_code, join "\n", @results );
+ }
+
+ $self->response->body( JSON::to_json( \@results, { pretty => 1 } ) );
+ return 1;
+}
+
+sub _add_message {
+ my $self = shift;
+ my %args = @_;
my @results;
+ # update_role_members wants custom role IDs (like RT::CustomRole-ID)
+ # rather than role names.
+ %args = ( %args, %{ fix_custom_role_ids( $self->record, $args{CustomRoles} ) } ) if $args{CustomRoles};
+
+ # Check for any bad input data before making updates
+ my ($ok, $errmsg, $return_code) = $self->validate_input(\%args);
+ if (!$ok) {
+ if ( $return_code ) {
+ return ($return_code, $errmsg);
+ }
+ else {
+ return (400, $errmsg);
+ }
+ }
+
my $MIME = HTML::Mason::Commands::MakeMIMEEntity(
Interface => 'REST',
$args{NoContent} ? () : (Body => $args{Content} || $self->request->content),
@@ -172,23 +199,30 @@ sub add_message {
);
}
else {
- return \400;
+ push @results, $self->current_user->loc('Unknown type');
+ return ( 400, @results );
}
if (!$Trans) {
- return error_as_json(
- $self->response,
- \400, $msg || "Message failed for unknown reason");
+ push @results, $msg || $self->current_user->loc("Message failed for unknown reason");
+ return ( 400, @results );
}
push @results, $msg;
push @results, update_custom_fields($self->record, $args{CustomFields});
+
+ push @results, update_role_members($self->record, \%args);
push @results, $self->_update_txn_custom_fields( $TransObj, $args{TxnCustomFields} || $args{TransactionCustomFields} );
+ # Set ticket status if we were passed a "Status":"foo" argument
+ if ($args{Status}) {
+ my ($ok, $msg) = $self->record->SetStatus($args{Status});
+ push(@results, $msg);
+ }
+
$self->created_transaction($TransObj);
- $self->response->body(JSON::to_json(\@results, { pretty => 1 }));
- return 1;
+ return ( 201, @results );
}
sub _update_txn_custom_fields {
@@ -238,6 +272,15 @@ sub create_path {
return "/transaction/$id";
}
+sub validate_input {
+ my $self = shift;
+ my $args = shift;
+
+ # Add CF and other pre-update validation here
+
+ return (1, 'Validation passed');
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/lib/RT/REST2/Resource/Record/Hypermedia.pm b/lib/RT/REST2/Resource/Record/Hypermedia.pm
index 01d26c39eb..61f8366506 100644
--- a/lib/RT/REST2/Resource/Record/Hypermedia.pm
+++ b/lib/RT/REST2/Resource/Record/Hypermedia.pm
@@ -105,12 +105,13 @@ sub _rtlink_links {
my $mode = $RT::Link::TYPEMAP{$relation}{Mode};
my $type = $RT::Link::TYPEMAP{$relation}{Type};
my $method = $mode . "Obj";
+ my $mode_uri = $mode . 'URI';
my $links = $record->$relation;
while (my $link = $links->Next) {
my $entry;
- if ( $link->LocalTarget and $link->LocalBase ){
+ if ( $link->$mode_uri->IsLocal ) {
# Internal links
$entry = expand_uid($link->$method->UID);
}
diff --git a/lib/RT/REST2/Resource/Record/Writable.pm b/lib/RT/REST2/Resource/Record/Writable.pm
index 5c033c6a05..e31f6d52a7 100644
--- a/lib/RT/REST2/Resource/Record/Writable.pm
+++ b/lib/RT/REST2/Resource/Record/Writable.pm
@@ -53,7 +53,7 @@ use warnings;
use Moose::Role;
use namespace::autoclean;
use JSON ();
-use RT::REST2::Util qw( deserialize_record error_as_json expand_uid update_custom_fields process_uploads );
+use RT::REST2::Util qw( deserialize_record error_as_json expand_uid update_custom_fields process_uploads update_role_members );
use List::MoreUtils 'uniq';
with 'RT::REST2::Resource::Role::RequestBodyIsJSON'
@@ -158,7 +158,7 @@ sub update_record {
);
push @results, update_custom_fields($self->record, $data->{CustomFields});
- push @results, $self->_update_role_members($data);
+ push @results, update_role_members($self->record, $data);
push @results, $self->_update_links($data);
push @results, $self->_update_disabled($data->{Disabled})
unless grep { $_ eq 'Disabled' } $self->record->WritableAttributes;
@@ -170,124 +170,6 @@ sub update_record {
return @results;
}
-sub _update_role_members {
- my $self = shift;
- my $data = shift;
-
- my $record = $self->record;
-
- return unless $record->DOES('RT::Record::Role::Roles');
-
- my @results;
-
- foreach my $role ($record->Roles) {
- next unless exists $data->{$role};
-
- # special case: RT::Ticket->Update already handles Owner for us
- next if $role eq 'Owner' && $record->isa('RT::Ticket');
-
- my $val = $data->{$role};
-
- if ($record->Role($role)->{Single}) {
- if (ref($val) eq 'ARRAY') {
- $val = $val->[0];
- }
- elsif (ref($val)) {
- die "Invalid value type for role $role";
- }
-
- my ($ok, $msg);
- if ($record->can('AddWatcher')) {
- ($ok, $msg) = $record->AddWatcher(
- Type => $role,
- User => $val,
- );
- } else {
- ($ok, $msg) = $record->AddRoleMember(
- Type => $role,
- User => $val,
- );
- }
- push @results, $msg;
- }
- else {
- my %count;
- my @vals;
-
- for (ref($val) eq 'ARRAY' ? @$val : $val) {
- my ($principal_id, $msg);
-
- if (/^\d+$/) {
- $principal_id = $_;
- }
- elsif ($record->can('CanonicalizePrincipal')) {
- ((my $principal), $msg) = $record->CanonicalizePrincipal(User => $_);
- $principal_id = $principal->Id;
- }
- else {
- my $user = RT::User->new($record->CurrentUser);
- if (/@/) {
- ((my $ok), $msg) = $user->LoadOrCreateByEmail( $_ );
- } else {
- ((my $ok), $msg) = $user->Load( $_ );
- }
- $principal_id = $user->PrincipalId;
- }
-
- if (!$principal_id) {
- push @results, $msg;
- next;
- }
-
- push @vals, $principal_id;
- $count{$principal_id}++;
- }
-
- my $group = $record->RoleGroup($role);
- my $members = $group->MembersObj;
- while (my $member = $members->Next) {
- $count{$member->MemberId}--;
- }
-
- # RT::Ticket has specialized methods
- my $add_method = $record->can('AddWatcher') ? 'AddWatcher' : 'AddRoleMember';
- my $del_method = $record->can('DeleteWatcher') ? 'DeleteWatcher' : 'DeleteRoleMember';
-
- # we want to provide a stable order, so first go by the order
- # provided in the argument list, and then for any role members
- # that are being removed, remove in sorted order
- for my $id (uniq(@vals, sort keys %count)) {
- my $count = $count{$id};
- if ($count == 0) {
- # new == old, no change needed
- }
- elsif ($count > 0) {
- # new > old, need to add new
- while ($count-- > 0) {
- my ($ok, $msg) = $record->$add_method(
- Type => $role,
- PrincipalId => $id,
- );
- push @results, $msg;
- }
- }
- elsif ($count < 0) {
- # old > new, need to remove old
- while ($count++ < 0) {
- my ($ok, $msg) = $record->$del_method(
- Type => $role,
- PrincipalId => $id,
- );
- push @results, $msg;
- }
- }
- }
- }
- }
-
- return @results;
-}
-
sub _update_links {
my $self = shift;
my $data = shift;
diff --git a/lib/RT/REST2/Resource/Search.pm b/lib/RT/REST2/Resource/Search.pm
new file mode 100644
index 0000000000..86affb9ece
--- /dev/null
+++ b/lib/RT/REST2/Resource/Search.pm
@@ -0,0 +1,158 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2021 Best Practical Solutions, LLC
+# <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::REST2::Resource::Search;
+use strict;
+use warnings;
+
+use Moose;
+use namespace::autoclean;
+
+extends 'RT::REST2::Resource::Record';
+with 'RT::REST2::Resource::Record::Readable',
+ 'RT::REST2::Resource::Record::Hypermedia' =>
+ { -alias => { _self_link => '_default_self_link', hypermedia_links => '_default_hypermedia_links' } };
+
+sub dispatch_rules {
+ Path::Dispatcher::Rule::Regex->new(
+ regex => qr{^/search/?$},
+ block => sub { { record_class => 'RT::Attribute' } },
+ ),
+ Path::Dispatcher::Rule::Regex->new(
+ regex => qr{^/search/(.+)/?$},
+ block => sub {
+ my ($match, $req) = @_;
+ my $desc = $match->pos(1);
+ my $record = _load_search($req, $desc);
+
+ return { record_class => 'RT::Attribute', record_id => $record ? $record->Id : 0 };
+ },
+ );
+}
+
+sub _self_link {
+ my $self = shift;
+ my $result = $self->_default_self_link(@_);
+
+ $result->{type} = 'search';
+ $result->{_url} =~ s!/attribute/!/search/!;
+ return $result;
+}
+
+sub hypermedia_links {
+ my $self = shift;
+ my $links = $self->_default_hypermedia_links;
+ my $record = $self->record;
+ if ( my $content = $record->Content ) {
+ if ( ( $content->{SearchType} || 'Ticket' ) eq 'Ticket' ) {
+ my $id = $record->Id;
+ push @$links,
+ { _url => RT::REST2->base_uri . "/tickets?search=$id",
+ type => 'results',
+ ref => 'tickets',
+ };
+ }
+ }
+ return $links;
+}
+
+sub base_uri { join '/', RT::REST2->base_uri, 'search' }
+
+sub resource_exists {
+ my $self = shift;
+ my $record = $self->record;
+ return $record->Id && $record->Name =~ /^(?:SavedSearch$|Search -)/;
+}
+
+sub forbidden {
+ my $self = shift;
+ return 0 unless $self->resource_exists;
+ my $search = RT::SavedSearch->new( $self->current_user );
+ return $search->LoadById( $self->record->Id ) ? 0 : 1;
+}
+
+sub _load_search {
+ my $req = shift;
+ my $id = shift;
+
+ if ( $id =~ /\D/ ) {
+
+ my $attrs = RT::Attributes->new( $req->env->{"rt.current_user"} );
+
+ $attrs->Limit( FIELD => 'Name', VALUE => 'SavedSearch' );
+ $attrs->Limit( FIELD => 'Name', VALUE => 'Search -', OPERATOR => 'STARTSWITH' );
+ $attrs->Limit( FIELD => 'Description', VALUE => $id );
+
+ my @searches;
+ while ( my $attr = $attrs->Next ) {
+ my $search = RT::SavedSearch->new( $req->env->{"rt.current_user"} );
+ if ( $search->LoadById( $attr->Id ) ) {
+ push @searches, $search;
+ }
+ }
+
+ my $record_id;
+ if (@searches) {
+ if ( @searches > 1 ) {
+ RT->Logger->warning("Found multiple searches with description $id");
+ }
+ return $searches[0];
+ }
+ }
+ else {
+ my $search = RT::SavedSearch->new( $req->env->{"rt.current_user"} );
+ if ( $search->LoadById($id) ) {
+ return $search;
+ }
+ }
+ return;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/lib/RT/REST2/Resource/Tickets.pm b/lib/RT/REST2/Resource/Searches.pm
similarity index 52%
copy from lib/RT/REST2/Resource/Tickets.pm
copy to lib/RT/REST2/Resource/Searches.pm
index 3bdccaf8cd..8fe5f488d9 100644
--- a/lib/RT/REST2/Resource/Tickets.pm
+++ b/lib/RT/REST2/Resource/Searches.pm
@@ -46,7 +46,7 @@
#
# END BPS TAGGED BLOCK }}}
-package RT::REST2::Resource::Tickets;
+package RT::REST2::Resource::Searches;
use strict;
use warnings;
@@ -54,12 +54,13 @@ use Moose;
use namespace::autoclean;
extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::ProcessPOSTasGET';
+with 'RT::REST2::Resource::Collection::ProcessPOSTasGET',
+ 'RT::REST2::Resource::Collection::QueryByJSON';
sub dispatch_rules {
Path::Dispatcher::Rule::Regex->new(
- regex => qr{^/tickets/?$},
- block => sub { { collection_class => 'RT::Tickets' } },
+ regex => qr{^/searches/?$},
+ block => sub { { collection_class => 'RT::Attributes' } },
)
}
@@ -67,53 +68,74 @@ use Encode qw( decode_utf8 );
use RT::REST2::Util qw( error_as_json );
use RT::Search::Simple;
-has 'query' => (
- is => 'ro',
- isa => 'Str',
- required => 1,
- lazy_build => 1,
-);
-
-sub _build_query {
- my $self = shift;
- my $query = decode_utf8($self->request->param('query') || "");
-
- if ($self->request->param('simple') and $query) {
- # XXX TODO: Note that "normal" ModifyQuery callback isn't invoked
- # XXX TODO: Special-casing of "#NNN" isn't used
- my $search = RT::Search::Simple->new(
- Argument => $query,
- TicketsObj => $self->collection,
- );
- $query = $search->QueryToSQL;
- }
- return $query;
-}
-
sub allowed_methods {
[ 'GET', 'HEAD', 'POST' ]
}
sub limit_collection {
my $self = shift;
- my ($ok, $msg) = $self->collection->FromSQL( $self->query );
- return error_as_json( $self->response, 0, $msg ) unless $ok;
-
- my @orderby_cols;
- my @orders = $self->request->param('order');
- foreach my $orderby ($self->request->param('orderby')) {
- $orderby = decode_utf8($orderby);
- my $order = shift @orders || 'ASC';
- $order = uc(decode_utf8($order));
- $order = 'ASC' unless $order eq 'DESC';
- push @orderby_cols, {FIELD => $orderby, ORDER => $order};
+ my @objects = RT::SavedSearch->new($self->current_user)->ObjectsForLoading;
+ if ( $self->current_user->HasRight( Object => $RT::System, Right => 'ShowSavedSearches' ) ) {
+ push @objects, RT::System->new( $self->current_user );
+ }
+
+ my $query = $self->query_json;
+ my @fields = $self->searchable_fields;
+ my %searchable = map {; $_ => 1 } @fields;
+
+ my @ids;
+ my @attrs;
+ for my $object (@objects) {
+ my $attrs = $object->Attributes;
+ $attrs->Limit( FIELD => 'Name', VALUE => 'SavedSearch' );
+ push @attrs, $attrs;
+ }
+
+ # Default system searches
+ my $attrs = RT::System->new( $self->current_user )->Attributes;
+ $attrs->Limit( FIELD => 'Name', VALUE => 'Search -', OPERATOR => 'STARTSWITH' );
+ push @attrs, $attrs;
+
+ for my $attrs (@attrs) {
+ for my $limit (@$query) {
+ next
+ unless $limit->{field}
+ and $searchable{ $limit->{field} }
+ and defined $limit->{value};
+
+ $attrs->Limit(
+ FIELD => $limit->{field},
+ VALUE => $limit->{value},
+ ( $limit->{operator} ? ( OPERATOR => $limit->{operator} )
+ : ()
+ ),
+ CASESENSITIVE => ( $limit->{case_sensitive} || 0 ),
+ ( $limit->{entry_aggregator} ? ( ENTRYAGGREGATOR => $limit->{entry_aggregator} )
+ : ()
+ ),
+ );
+ }
+ push @ids, map { $_->Id } @{ $attrs->ItemsArrayRef };
}
- $self->collection->OrderByCols(@orderby_cols)
- if @orderby_cols;
+
+ while ( @ids > 1000 ) {
+ my @batch = splice( @ids, 0, 1000 );
+ $self->Limit( FIELD => 'id', VALUE => \@ids, OPERATOR => 'IN' );
+ }
+ $self->collection->Limit( FIELD => 'id', VALUE => \@ids, OPERATOR => 'IN' );
return 1;
}
+sub serialize_record {
+ my $self = shift;
+ my $record = shift;
+ my $result = $self->SUPER::serialize_record($record);
+ $result->{type} = 'search';
+ $result->{_url} =~ s!/attribute/!/search/!;
+ return $result;
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/lib/RT/REST2/Resource/Ticket.pm b/lib/RT/REST2/Resource/Ticket.pm
index 816dd98992..5caff4709f 100644
--- a/lib/RT/REST2/Resource/Ticket.pm
+++ b/lib/RT/REST2/Resource/Ticket.pm
@@ -87,18 +87,11 @@ sub create_record {
my $self = shift;
my $data = shift;
- return (\400, "Could not create ticket. Queue not set") if !$data->{Queue};
-
- my $queue = RT::Queue->new(RT->SystemUser);
- $queue->Load($data->{Queue});
-
- return (\400, "Unable to find queue") if !$queue->Id;
-
- return (\403, $self->record->loc("No permission to create tickets in the queue '[_1]'", $queue->Name))
- unless $self->record->CurrentUser->HasRight(
- Right => 'CreateTicket',
- Object => $queue,
- ) and $queue->Disabled != 1;
+ # Check for any bad input data before creating a ticket
+ my ($ok, $msg, $return_code) = $self->validate_input(Data => $data, Action => 'create');
+ if (!$ok) {
+ return (\$return_code, $msg);
+ }
if ( defined $data->{Content} || defined $data->{Attachments} ) {
$data->{MIMEObj} = HTML::Mason::Commands::MakeMIMEEntity(
@@ -123,7 +116,8 @@ sub create_record {
}
}
- my ($ok, $txn, $msg) = $self->_create_record($data);
+ my ($txn);
+ ($ok, $txn, $msg) = $self->_create_record($data);
return ($ok, $msg);
}
@@ -214,6 +208,36 @@ sub hypermedia_links {
return $links;
}
+sub validate_input {
+ my $self = shift;
+ my %args = ( Data => '',
+ Action => '',
+ @_ );
+ my $data = $args{'Data'};
+
+ if ( $args{'Action'} eq 'create' ) {
+ return (0, "Could not create ticket. Queue not set", 400) if !$data->{Queue};
+
+ my $queue = RT::Queue->new(RT->SystemUser);
+ $queue->Load($data->{Queue});
+
+ return (0, "Unable to find queue", 400) if !$queue->Id;
+
+ return (0, $self->record->loc("No permission to create tickets in the queue '[_1]'", $queue->Name), 403)
+ unless $self->record->CurrentUser->HasRight(
+ Right => 'CreateTicket',
+ Object => $queue,
+ ) and $queue->Disabled != 1;
+ }
+
+ if ( $args{'Action'} eq 'update' ) {
+ # Add pre-update input validation
+ }
+
+ return (1, "Validation passed");
+}
+
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/lib/RT/REST2/Resource/Tickets.pm b/lib/RT/REST2/Resource/Tickets.pm
index 3bdccaf8cd..82299df9a7 100644
--- a/lib/RT/REST2/Resource/Tickets.pm
+++ b/lib/RT/REST2/Resource/Tickets.pm
@@ -54,7 +54,8 @@ use Moose;
use namespace::autoclean;
extends 'RT::REST2::Resource::Collection';
-with 'RT::REST2::Resource::Collection::ProcessPOSTasGET';
+with 'RT::REST2::Resource::Collection::ProcessPOSTasGET',
+ 'RT::REST2::Resource::Collection::Search';
sub dispatch_rules {
Path::Dispatcher::Rule::Regex->new(
@@ -64,7 +65,7 @@ sub dispatch_rules {
}
use Encode qw( decode_utf8 );
-use RT::REST2::Util qw( error_as_json );
+use RT::REST2::Util qw( error_as_json expand_uid );
use RT::Search::Simple;
has 'query' => (
@@ -94,24 +95,31 @@ sub allowed_methods {
[ 'GET', 'HEAD', 'POST' ]
}
-sub limit_collection {
+override 'limit_collection' => sub {
my $self = shift;
my ($ok, $msg) = $self->collection->FromSQL( $self->query );
return error_as_json( $self->response, 0, $msg ) unless $ok;
+ super();
+ return 1;
+};
- my @orderby_cols;
- my @orders = $self->request->param('order');
- foreach my $orderby ($self->request->param('orderby')) {
- $orderby = decode_utf8($orderby);
- my $order = shift @orders || 'ASC';
- $order = uc(decode_utf8($order));
- $order = 'ASC' unless $order eq 'DESC';
- push @orderby_cols, {FIELD => $orderby, ORDER => $order};
+sub expand_field {
+ my $self = shift;
+ my $item = shift;
+ my $field = shift;
+ my $param_prefix = shift;
+ if ( $field =~ /^(Requestor|AdminCc|Cc)/ ) {
+ my $role = $1;
+ my $members = [];
+ if ( my $group = $item->RoleGroup($role) ) {
+ my $gms = $group->MembersObj;
+ while ( my $gm = $gms->Next ) {
+ push @$members, $self->_expand_object( $gm->MemberObj->Object, $field, $param_prefix );
+ }
+ }
+ return $members;
}
- $self->collection->OrderByCols(@orderby_cols)
- if @orderby_cols;
-
- return 1;
+ return $self->SUPER::expand_field( $item, $field, $param_prefix );
}
__PACKAGE__->meta->make_immutable;
diff --git a/lib/RT/REST2/Resource/TicketsBulk.pm b/lib/RT/REST2/Resource/TicketsBulk.pm
index c3f6e654b5..61f07e03e2 100644
--- a/lib/RT/REST2/Resource/TicketsBulk.pm
+++ b/lib/RT/REST2/Resource/TicketsBulk.pm
@@ -62,9 +62,18 @@ use RT::REST2::Resource::Ticket;
use JSON ();
sub dispatch_rules {
- Path::Dispatcher::Rule::Regex->new( regex => qr{^/tickets/bulk/?$} );
+ Path::Dispatcher::Rule::Regex->new( regex => qr{^/tickets/bulk/?$} ),
+ Path::Dispatcher::Rule::Regex->new(
+ regex => qr{^/tickets/bulk/(correspond|comment)$},
+ block => sub { { type => shift->pos(1) } },
+ )
}
+has type => (
+ is => 'ro',
+ isa => 'Str',
+);
+
sub post_is_create { 1 }
sub create_path { '/tickets/bulk' }
sub charsets_provided { [ 'utf-8' ] }
@@ -100,17 +109,60 @@ sub from_json {
}
else {
for my $param ( @$params ) {
- my $resource = RT::REST2::Resource::Ticket->new(
- request => $self->request,
- response => $self->response,
- record_class => 'RT::Ticket',
- );
- my ( $ok, $msg ) = $resource->create_record( $param );
- if ( ref( $ok ) || !$ok ) {
- push @results, { message => $msg || "Create failed for unknown reason" };
+ if ( $self->type ) {
+ my $id = delete $param->{id};
+ if ( $id && $id =~ /^\d+$/ ) {
+ my $ticket = RT::Ticket->new($self->current_user);
+ $ticket->Load($id);
+ my $resource = RT::REST2::Resource::Message->new(
+ request => $self->request,
+ response => $self->response,
+ type => $self->type,
+ record => $ticket,
+ );
+
+ my @errors;
+
+ # Ported from RT::REST2::Resource::Message::from_json
+ if ( $param->{Attachments} ) {
+ foreach my $attachment ( @{ $param->{Attachments} } ) {
+ foreach my $field ( 'FileName', 'FileType', 'FileContent' ) {
+ push @errors, "$field is a required field for each attachment in Attachments"
+ unless $attachment->{$field};
+ }
+ }
+ }
+
+ $param->{NoContent} = 1 unless $param->{Content};
+ if ( !$param->{NoContent} && !$param->{ContentType} ) {
+ push @errors, "ContentType is a required field for application/json";
+ }
+
+ if (@errors) {
+ push @results, [ $id, @errors ];
+ next;
+ }
+
+ my ( $return_code, @messages ) = $resource->_add_message(%$param);
+ push @results, [ $id, @messages ];
+ }
+ else {
+ push @results, [ $id, 'Resource does not exist' ];
+ }
}
else {
- push @results, expand_uid( $resource->record->UID );
+ my $resource = RT::REST2::Resource::Ticket->new(
+ request => $self->request,
+ response => $self->response,
+ record_class => 'RT::Ticket',
+ );
+ my ( $ok, $msg ) = $resource->create_record($param);
+ if ( ref($ok) || !$ok ) {
+ push @results, { message => $msg || "Create failed for unknown reason" };
+ }
+ else {
+ push @results, expand_uid( $resource->record->UID );
+ }
}
}
}
diff --git a/lib/RT/REST2/Util.pm b/lib/RT/REST2/Util.pm
index 6311cf54a2..e750715e32 100644
--- a/lib/RT/REST2/Util.pm
+++ b/lib/RT/REST2/Util.pm
@@ -70,6 +70,8 @@ use Sub::Exporter -setup => {
format_datetime
update_custom_fields
process_uploads
+ update_role_members
+ fix_custom_role_ids
]]
};
@@ -333,7 +335,7 @@ sub update_custom_fields {
my $val = $data->{$cfid};
my $cf = $record->LoadCustomFieldByIdentifier($cfid);
- next unless $cf->ObjectTypeFromLookupType($cf->__Value('LookupType'))->isa(ref $record);
+ next unless $cf->Id && $cf->ObjectTypeFromLookupType($cf->__Value('LookupType'))->isa(ref $record);
if ($cf->SingleValue) {
my %args;
@@ -373,7 +375,7 @@ sub update_custom_fields {
Value => $val,
%args,
);
- push @results, $msg;
+ push @results, $msg // ();
}
else {
my %count;
@@ -475,4 +477,154 @@ sub process_uploads {
return @ret;
}
+sub update_role_members {
+ my $record = shift;
+ my $data = shift;
+
+ return unless $record->DOES('RT::Record::Role::Roles');
+
+ my @results;
+
+ foreach my $role ($record->Roles) {
+ next unless exists $data->{$role};
+
+ # special case: RT::Ticket->Update already handles Owner for us
+ next if $role eq 'Owner' && $record->isa('RT::Ticket');
+
+ my $val = $data->{$role};
+
+ if ($record->Role($role)->{Single}) {
+ if (ref($val) eq 'ARRAY') {
+ $val = $val->[0];
+ }
+ elsif (ref($val)) {
+ die "Invalid value type for role $role";
+ }
+
+ my ($ok, $msg);
+ if ($record->can('AddWatcher')) {
+ ($ok, $msg) = $record->AddWatcher(
+ Type => $role,
+ User => $val,
+ );
+ } else {
+ ($ok, $msg) = $record->AddRoleMember(
+ Type => $role,
+ User => $val,
+ );
+ }
+ push @results, $msg;
+ }
+ else {
+ my %count;
+ my @vals;
+
+ for (ref($val) eq 'ARRAY' ? @$val : $val) {
+ my ($principal_id, $msg);
+
+ if (/^\d+$/) {
+ $principal_id = $_;
+ }
+ elsif ($record->can('CanonicalizePrincipal')) {
+ ((my $principal), $msg) = $record->CanonicalizePrincipal(User => $_);
+ $principal_id = $principal->Id;
+ }
+ else {
+ my $user = RT::User->new($record->CurrentUser);
+ if (/@/) {
+ ((my $ok), $msg) = $user->LoadOrCreateByEmail( $_ );
+ } else {
+ ((my $ok), $msg) = $user->Load( $_ );
+ }
+ $principal_id = $user->PrincipalId;
+ }
+
+ if (!$principal_id) {
+ push @results, $msg;
+ next;
+ }
+
+ push @vals, $principal_id;
+ $count{$principal_id}++;
+ }
+
+ my $group = $record->RoleGroup($role);
+ my $members = $group->MembersObj;
+ while (my $member = $members->Next) {
+ $count{$member->MemberId}--;
+ }
+
+ # RT::Ticket has specialized methods
+ my $add_method = $record->can('AddWatcher') ? 'AddWatcher' : 'AddRoleMember';
+ my $del_method = $record->can('DeleteWatcher') ? 'DeleteWatcher' : 'DeleteRoleMember';
+
+ # we want to provide a stable order, so first go by the order
+ # provided in the argument list, and then for any role members
+ # that are being removed, remove in sorted order
+ for my $id (uniq(@vals, sort keys %count)) {
+ my $count = $count{$id};
+ if ($count == 0) {
+ # new == old, no change needed
+ }
+ elsif ($count > 0) {
+ # new > old, need to add new
+ while ($count-- > 0) {
+ my ($ok, $msg) = $record->$add_method(
+ Type => $role,
+ PrincipalId => $id,
+ );
+ push @results, $msg;
+ }
+ }
+ elsif ($count < 0) {
+ # old > new, need to remove old
+ while ($count++ < 0) {
+ my ($ok, $msg) = $record->$del_method(
+ Type => $role,
+ PrincipalId => $id,
+ );
+ push @results, $msg;
+ }
+ }
+ }
+ }
+ }
+
+ return @results;
+}
+
+=head2 fix_custom_role_ids ( $record, $custom_roles )
+
+$record is the RT object (eg, an RT::Ticket) associated
+with custom roles.
+
+$custom_roles is a hashref where the keys are custom role
+IDs, names or email addresses and the values can be
+anything. Returns a new hashref where all the keys
+are replaced with "RT::CustomRole-ID" if they were
+not originally in that form, and the values are kept
+the same.
+
+=cut
+
+sub fix_custom_role_ids
+{
+ my ($record, $custom_roles) = @_;
+ my $ret = {};
+ return $ret unless $custom_roles;
+
+ foreach my $key (keys(%$custom_roles)) {
+ if ($key =~ /^RT::CustomRole-\d+$/) {
+ # Already in the correct form
+ $ret->{$key} = $custom_roles->{$key};
+ next;
+ }
+
+ my $cr = RT::CustomRole->new($record->CurrentUser);
+ next unless $cr->Load($key);
+ $ret->{'RT::CustomRole-' . $cr->Id} = $custom_roles->{$key};
+ }
+ return $ret;
+}
+
1;
diff --git a/lib/RT/Test/REST2.pm b/lib/RT/Test/REST2.pm
index f35d9ff529..78395e9aa0 100644
--- a/lib/RT/Test/REST2.pm
+++ b/lib/RT/Test/REST2.pm
@@ -103,6 +103,7 @@ sub mech { RT::Test::REST2::Mechanize->new }
$u->Create(
Name => 'test',
Password => 'password',
+ EmailAddress => 'test at rt.example',
Privileged => 1,
);
return $u;
diff --git a/t/rest2/assets.t b/t/rest2/assets.t
index 1ebc373002..4c1984f339 100644
--- a/t/rest2/assets.t
+++ b/t/rest2/assets.t
@@ -265,4 +265,38 @@ my ($asset_url, $asset_id);
}
}
+# Asset Search - Role Fields
+{
+
+ my $payload = {
+ Name => 'Asset creation using REST',
+ Description => 'Asset description',
+ Catalog => 'General assets',
+ Content => 'Testing asset creation using REST API.',
+ Owner => 'root at localhost',
+ HeldBy => 'root at example.com',
+ Contact => 'alice at example.com, bob at example.com',
+ };
+
+ my $res = $mech->post_json( "$rest_base_path/asset", $payload, 'Authorization' => $auth, );
+ is( $res->code, 201 );
+ ok( my $asset_url = $res->header('location') );
+ ok( my ($asset_id) = $asset_url =~ qr[/asset/(\d+)] );
+
+ $res = $mech->post_json(
+ "$rest_base_path/assets?fields=Owner,HeldBy,Contact",
+ [ { field => 'id', operator => '=', value => $asset_id } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 200 );
+ my $content = $mech->json_response;
+ is( scalar @{ $content->{items} }, 1 );
+
+ my $asset = $content->{items}->[0];
+ is( $asset->{Owner}{id}, 'root', 'Owner id in search result' );
+ is( $asset->{HeldBy}[0]{id}, 'root at example.com', 'HeldBy id in search result' );
+ is( $asset->{Contact}[0]{id}, 'alice at example.com', 'Contact id in search result' );
+ is( $asset->{Contact}[1]{id}, 'bob at example.com', 'Contact id in search result' );
+}
+
done_testing;
diff --git a/t/rest2/searches.t b/t/rest2/searches.t
new file mode 100644
index 0000000000..067ba1fd63
--- /dev/null
+++ b/t/rest2/searches.t
@@ -0,0 +1,215 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+
+my $mech = RT::Test::REST2->mech;
+my $auth = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Test::REST2->user;
+
+$user->PrincipalObj->GrantRight( Right => 'ModifySelf' );
+
+my $custom_role = RT::CustomRole->new( RT->SystemUser );
+my ( $ret, $msg ) = $custom_role->Create(
+ Name => 'Manager',
+ MaxValues => 0,
+);
+ok( $ret, "created custom role: $msg" );
+
+( $ret, $msg ) = $custom_role->AddToObject(1);
+ok( $ret, "added custom role to queue: $msg" );
+my $custom_role_type = $custom_role->GroupType;
+
+my $cf = RT::CustomField->new( RT->SystemUser );
+( $ret, $msg ) = $cf->Create( Name => 'Boss', Type => 'Freeform', LookupType => RT::User->CustomFieldLookupType );
+ok( $ret, "created custom field: $msg" );
+ok( $cf->AddToObject( RT::User->new( RT->SystemUser ) ) );
+
+ok( $user->SetCountry('US') );
+ok( $user->AddCustomFieldValue( Field => $cf, Value => 'root' ) );
+
+my $search1 = RT::SavedSearch->new($user);
+( $ret, $msg ) = $search1->Save(
+ Privacy => 'RT::User-' . $user->Id,
+ Type => 'Ticket',
+ Name => 'My tickets',
+ SearchParams => {
+ RowsPerPage => 50,
+ 'Format' => join( ',',
+ RT->Config->Get('DefaultSearchResultFormat'), '__Requestors.Country__',
+ '__CustomRole.{Manager}.CustomField.{Boss}__' ),
+ 'Query' => "Owner = '" . $user->Name . "'",
+ },
+);
+
+ok( $ret, "created $msg" );
+
+my $search2 = RT::SavedSearch->new( RT->SystemUser );
+( $ret, $msg ) = $search2->Save(
+ Privacy => 'RT::System-1',
+ Type => 'Ticket',
+ Name => 'Recently created tickets',
+ SearchParams => {
+ 'Format' => RT->Config->Get('DefaultSearchResultFormat'),
+ 'Query' => "Created >= 'yesterday'",
+ },
+);
+
+ok( $ret, "created $msg" );
+
+{
+ my $res = $mech->get( "$rest_base_path/searches", 'Authorization' => $auth, );
+ is( $res->code, 200, 'got /searches' );
+
+ my $content = $mech->json_response;
+ is( $content->{count}, 4, '4 searches' );
+ is( $content->{page}, 1, '1 page' );
+ is( $content->{per_page}, 20, '20 per_page' );
+ is( $content->{total}, 4, '4 total' );
+ is( scalar @{ $content->{items} }, 4, 'items count' );
+
+ for my $item ( @{ $content->{items} } ) {
+ ok( $item->{id}, 'search id' );
+ is( $item->{type}, 'search', 'search type' );
+ like( $item->{_url}, qr{$rest_base_path/search/$item->{id}}, 'search url' );
+ }
+
+ is( $content->{items}[-1]{id}, $search1->Id, 'search id' );
+}
+
+{
+ my $res = $mech->get( "$rest_base_path/search/" . $search2->Id, 'Authorization' => $auth, );
+ is( $res->code, 404, 'can not see system search without permission' );
+}
+
+$user->PrincipalObj->GrantRight( Right => 'SuperUser' );
+
+{
+ my $res = $mech->get( "$rest_base_path/search/" . $search2->Id, 'Authorization' => $auth, );
+ is( $res->code, 200, 'can see system search with permission' );
+}
+
+{
+ my $res = $mech->get( "$rest_base_path/searches", 'Authorization' => $auth, );
+ is( $res->code, 200, 'got /searches' );
+
+ my $content = $mech->json_response;
+ is( $content->{count}, 5, '5 searches' );
+ is( $content->{page}, 1, '1 page' );
+ is( $content->{per_page}, 20, '20 per_page' );
+ is( $content->{total}, 5, '5 total' );
+ is( scalar @{ $content->{items} }, 5, 'items count' );
+
+ for my $item ( @{ $content->{items} } ) {
+ ok( $item->{id}, 'search id' );
+ is( $item->{type}, 'search', 'search type' );
+ like( $item->{_url}, qr{$rest_base_path/search/$item->{id}}, 'search url' );
+ }
+
+ is( $content->{items}[3]{id}, $search1->Id, 'search id' );
+ is( $content->{items}[4]{id}, $search2->Id, 'search id' );
+}
+
+{
+ my $res = $mech->post_json(
+ "$rest_base_path/searches",
+ [ { field => 'Description', value => 'My tickets' } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 200, "got $rest_base_path/searches" );
+
+ my $content = $mech->json_response;
+ is( $content->{count}, 1, '1 search' );
+ is( $content->{page}, 1, '1 page' );
+ is( $content->{per_page}, 20, '20 per_page' );
+ is( $content->{total}, 1, '1 total' );
+ is( scalar @{ $content->{items} }, 1, 'items count' );
+
+ is( $content->{items}[0]{id}, $search1->Id, 'search id' );
+}
+
+# Single search
+{
+ my $res = $mech->get( "$rest_base_path/search/" . $search1->Id, 'Authorization' => $auth, );
+ is( $res->code, 200, "got $rest_base_path/search/" . $search1->Id );
+
+ my $content = $mech->json_response;
+ is( $content->{id}, $search1->id, 'id' );
+ is( $content->{Name}, 'SavedSearch', 'Name' );
+ is( $content->{Description}, 'My tickets', 'Description' );
+
+ my $links = $content->{_hyperlinks};
+ is( scalar @$links, 2, 'links count' );
+
+ is( $links->[0]{ref}, 'self', 'self link ref' );
+ is( $links->[0]{id}, $search1->Id, 'self link id' );
+ is( $links->[0]{type}, 'search', 'self link type' );
+ like( $links->[0]{_url}, qr[$rest_base_path/search/$links->[0]{id}$], 'self link url' );
+
+ is( $links->[1]{ref}, 'tickets', 'results link ref' );
+ is( $links->[1]{type}, 'results', 'results link type' );
+ like( $links->[1]{_url}, qr[$rest_base_path/tickets\?search=$content->{id}$], 'results link url' );
+
+ $res = $mech->get( "$rest_base_path/search/My tickets", 'Authorization' => $auth, );
+ is( $res->code, 200, "got $rest_base_path/search/" . $search1->Id );
+ is_deeply( $content, $mech->json_response, 'Access via search name' );
+}
+
+{
+ my $ticket1 = RT::Test->create_ticket(
+ Queue => 1,
+ Subject => 'test ticket',
+ Owner => $user->Id,
+ Requestor => $user->Id,
+ $custom_role_type => $user->Id
+ );
+ my $ticket2 = RT::Test->create_ticket( Queue => 1, Subject => 'test ticket' );
+ my $res = $mech->get( "$rest_base_path/tickets?search=" . $search1->Id, 'Authorization' => $auth, );
+ is( $res->code, 200, "got $rest_base_path/tickets?search=" . $search1->Id );
+
+ my $content = $mech->json_response;
+ is( $content->{count}, 1, '1 search' );
+ is( $content->{page}, 1, '1 page' );
+ is( $content->{per_page}, 50, '50 per_page' );
+ is( $content->{total}, 1, '1 total' );
+ is( scalar @{ $content->{items} }, 1, 'items count' );
+
+ my $item = $content->{items}[0];
+ is( $item->{id}, $ticket1->Id, 'ticket id' );
+ for my $field ( qw/Requestor Owner Status TimeLeft Subject Priority Created LastUpdated Told Queue/,
+ $custom_role_type )
+ {
+ ok( length $item->{$field}, "$field value not empty" );
+ }
+ is( $item->{Subject}, 'test ticket', 'Subject value' );
+ is( $item->{Queue}{Name}, 'General', 'Queue name' );
+ is( $item->{Requestor}[0]{id}, $user->Name, 'Requestor id' );
+ is( $item->{Requestor}[0]{Country}, 'US', 'Requestor Country' );
+
+ is( $item->{$custom_role_type}[0]{id}, $user->Name, 'Manager id' );
+ is( $item->{$custom_role_type}[0]{CustomFields}[0]{name}, 'Boss', 'Manager Boss' );
+ is( $item->{$custom_role_type}[0]{CustomFields}[0]{values}[0], 'root', 'Manager Boss name' );
+
+ $res = $mech->get( "$rest_base_path/tickets?search=My tickets", 'Authorization' => $auth, );
+ is( $res->code, 200, "got $rest_base_path/tickets?search=My tickets" );
+ is_deeply( $content, $mech->json_response, 'search tickets via search name' );
+
+ $res = $mech->get( "$rest_base_path/tickets?search=My tickets&per_page=10&fields=id", 'Authorization' => $auth, );
+ is( $res->code, 200, "got $rest_base_path/tickets?search=My tickets" );
+ $content = $mech->json_response;
+ is( $content->{count}, 1, '1 search' );
+ is( $content->{page}, 1, '1 page' );
+ is( $content->{per_page}, 10, '10 per_page' );
+ is( $content->{total}, 1, '1 total' );
+ is( scalar @{ $content->{items} }, 1, 'items count' );
+
+ $item = $content->{items}[0];
+ is( $item->{id}, $ticket1->Id, 'ticket id' );
+ for my $field ( qw/Requestor Owner Status TimeLeft Subject Priority Created LastUpdated Told Queue/,
+ $custom_role_type )
+ {
+ ok( !exists $item->{$field}, "$field not exists" );
+ }
+}
+
+done_testing;
diff --git a/t/rest2/ticket-correspond-customroles.t b/t/rest2/ticket-correspond-customroles.t
new file mode 100644
index 0000000000..f52cb5a947
--- /dev/null
+++ b/t/rest2/ticket-correspond-customroles.t
@@ -0,0 +1,254 @@
+use strict;
+use warnings;
+use RT::Test::REST2 tests => undef;
+use Test::Deep;
+use MIME::Base64;
+
+BEGIN {
+ plan skip_all => 'RT 4.4 required'
+ unless RT::Handle::cmp_version($RT::VERSION, '4.4.0') >= 0;
+}
+
+my $mech = RT::Test::REST2->mech;
+
+my $auth = RT::Test::REST2->authorization_header;
+my $rest_base_path = '/REST/2.0';
+my $user = RT::Test::REST2->user;
+
+# Set up a couple of custom roles
+my $queue = RT::Test->load_or_create_queue( Name => "General" );
+
+my $single = RT::CustomRole->new(RT->SystemUser);
+my ($ok, $msg) = $single->Create(Name => 'Single Member', MaxValues => 1);
+ok($ok, $msg);
+my $single_id = $single->Id;
+
+($ok, $msg) = $single->AddToObject($queue->id);
+ok($ok, $msg);
+
+my $multi = RT::CustomRole->new(RT->SystemUser);
+($ok, $msg) = $multi->Create(Name => 'Multi Member');
+ok($ok, $msg);
+my $multi_id = $multi->Id;
+
+($ok, $msg) = $multi->AddToObject($queue->id);
+ok($ok, $msg);
+
+$user->PrincipalObj->GrantRight( Right => $_ )
+ for qw/CreateTicket ShowTicket ModifyTicket OwnTicket AdminUsers SeeGroup SeeQueue/;
+
+# Ticket Creation
+my ($ticket_url, $ticket_id);
+{
+ my $payload = {
+ Subject => 'Ticket creation using REST',
+ Queue => 'General',
+ Content => 'Testing ticket creation using REST API.',
+ };
+
+ my $res = $mech->post_json("$rest_base_path/ticket",
+ $payload,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 201);
+ ok($ticket_url = $res->header('location'));
+ ok(($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)]);
+}
+
+# Ticket Display
+{
+ $user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
+
+ my $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+
+ my $content = $mech->json_response;
+
+ is($content->{id}, $ticket_id);
+ is($content->{Type}, 'ticket');
+ is($content->{Status}, 'new');
+ is($content->{Subject}, 'Ticket creation using REST');
+
+ ok(exists $content->{$_}, "Content exists for $_") for qw(AdminCc TimeEstimated Started Cc
+ LastUpdated TimeWorked Resolved
+ RT::CustomRole-1 RT::CustomRole-2
+ Created Due Priority EffectiveId);
+}
+
+diag "Correspond with custom roles";
+{
+ $user->PrincipalObj->GrantRight( Right => 'ReplyToTicket' );
+
+ my $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ my $content = $mech->json_response;
+
+ my ($hypermedia) = grep { $_->{ref} eq 'correspond' } @{ $content->{_hyperlinks} };
+ ok($hypermedia, 'got correspond hypermedia');
+ like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+
+ my $correspond_url = $mech->url_for_hypermedia('correspond');
+ my $comment_url = $correspond_url;
+ $comment_url =~ s/correspond/comment/;
+
+ $res = $mech->post_json($correspond_url,
+ {
+ Content => 'Hello from hypermedia!',
+ ContentType => 'text/plain',
+ CustomRoles => {
+ 'Single Member' => 'foo at bar.example',
+ 'Multi Member' => 'quux at cabbage.example',
+ },
+ },
+ 'Authorization' => $auth,
+ );
+ is($res->code, 201);
+ $content = $mech->json_response;
+
+ # Because CustomRoles are set in an unpredictable order, sort the
+ # responses so we have a predictable order.
+ @$content = sort { $a cmp $b } (@$content);
+ cmp_deeply($content, ['Added quux at cabbage.example as Multi Member for this ticket', re(qr/Correspondence added|Message recorded/), 'Single Member changed from Nobody to foo at bar.example']);
+ like($res->header('Location'), qr{$rest_base_path/transaction/\d+$});
+ $res = $mech->get($res->header('Location'),
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ $content = $mech->json_response;
+ is($content->{Type}, 'Correspond');
+ is($content->{TimeTaken}, 0);
+ is($content->{Object}{type}, 'ticket');
+ is($content->{Object}{id}, $ticket_id);
+
+ $res = $mech->get($mech->url_for_hypermedia('attachment'),
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ $content = $mech->json_response;
+ is($content->{Content}, encode_base64('Hello from hypermedia!'));
+ is($content->{ContentType}, 'text/plain');
+
+ # Load the ticket and check the custom roles
+ my $ticket = RT::Ticket->new($user);
+ $ticket->Load($ticket_id);
+
+ is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo at bar.example',
+ "Single Member role set correctly");
+ is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'quux at cabbage.example',
+ "Multi Member role set correctly");
+}
+
+diag "Comment with custom roles";
+{
+ $user->PrincipalObj->GrantRight( Right => 'CommentOnTicket' );
+
+ my $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ my $content = $mech->json_response;
+
+ my ($hypermedia) = grep { $_->{ref} eq 'comment' } @{ $content->{_hyperlinks} };
+ ok($hypermedia, 'got comment hypermedia');
+ like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/comment$]);
+
+ my $comment_url = $mech->url_for_hypermedia('comment');
+
+ $res = $mech->post_json($comment_url,
+ {
+ Content => 'Hello from hypermedia!',
+ ContentType => 'text/plain',
+ CustomRoles => {
+ 'Single Member' => 'foo-new at bar.example',
+ 'Multi Member' => 'quux-new at cabbage.example',
+ },
+ },
+ 'Authorization' => $auth,
+ );
+ is($res->code, 201);
+
+ # Load the ticket and check the custom roles
+ my $ticket = RT::Ticket->new($user);
+ $ticket->Load($ticket_id);
+
+ is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo-new at bar.example',
+ "Single Member role set correctly");
+ is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'quux-new at cabbage.example',
+ "Multi Member role updated correctly");
+
+ # Supply an array for multi-member role
+ $res = $mech->post_json($comment_url,
+ {
+ Content => 'Hello from hypermedia!',
+ ContentType => 'text/plain',
+ CustomRoles => {
+ 'Multi Member' => ['abacus at example.com', 'quux-new at cabbage.example'],
+ },
+ },
+ 'Authorization' => $auth,
+ );
+ is($res->code, 201);
+
+ is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo-new at bar.example',
+ "Single Member role unchanged");
+ is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'abacus at example.com, quux-new at cabbage.example',
+ "Multi Member role set correctly");
+
+ # Add an existing user to multi-member role
+ $res = $mech->post_json($comment_url,
+ {
+ Content => 'Hello from hypermedia!',
+ ContentType => 'text/plain',
+ CustomRoles => {
+ 'Multi Member' => 'abacus at example.com',
+ },
+ },
+ 'Authorization' => $auth,
+ );
+ is($res->code, 201);
+
+ is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'foo-new at bar.example',
+ "Single Member role unchanged");
+ is($ticket->RoleAddresses("RT::CustomRole-$multi_id"), 'abacus at example.com',
+ "Multi Member role unchanged");
+
+ # Supply an array for single-member role
+ $res = $mech->post_json($comment_url,
+ {
+ Content => 'Hello from hypermedia!',
+ ContentType => 'text/plain',
+ CustomRoles => {
+ 'Single Member' => ['abacus at example.com', 'quux-new at cabbage.example'],
+ },
+ },
+ 'Authorization' => $auth,
+ );
+ is($res->code, 201);
+ $content = $mech->json_response;
+ cmp_deeply($content, ['Comments added', 'Single Member changed from foo-new at bar.example to abacus at example.com'], "Got expected respose");
+ is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'abacus at example.com',
+ "Single Member role changed to first member of array");
+
+ # Try using a username instead of password
+ $res = $mech->post_json($comment_url,
+ {
+ Content => 'Hello from hypermedia!',
+ ContentType => 'text/plain',
+ CustomRoles => {
+ 'Single Member' => 'test',
+ },
+ },
+ 'Authorization' => $auth,
+ );
+ is($res->code, 201);
+ $content = $mech->json_response;
+ cmp_deeply($content, ['Comments added', 'Single Member changed from abacus at example.com to test'], "Got expected respose");
+ is($ticket->RoleAddresses("RT::CustomRole-$single_id"), 'test at rt.example',
+ "Single Member role changed");
+}
+
+done_testing;
diff --git a/t/rest2/ticket-customroles.t b/t/rest2/ticket-customroles.t
index e35378dc37..4a09314efb 100644
--- a/t/rest2/ticket-customroles.t
+++ b/t/rest2/ticket-customroles.t
@@ -558,5 +558,45 @@ $user->PrincipalObj->GrantRight( Right => $_ )
}, 'Later Single Member is Nobody');
}
-done_testing;
+# Ticket Search
+{
+
+ my $payload = {
+ Subject => 'Ticket creation using REST',
+ Queue => 'General',
+ Content => 'Testing ticket creation using REST API.',
+ $single->GroupType => 'single2 at example.com',
+ $multi->GroupType => 'multi at example.com, multi2 at example.com',
+ };
+
+ my $res = $mech->post_json( "$rest_base_path/ticket", $payload, 'Authorization' => $auth, );
+ is( $res->code, 201 );
+ ok( my $ticket_url = $res->header('location') );
+ ok( my ($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)] );
+
+ $res = $mech->get(
+ "$rest_base_path/tickets?query=id=$ticket_id&fields=" . join( ',', $single->GroupType, $multi->GroupType ),
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 200 );
+ my $content = $mech->json_response;
+ is( scalar @{ $content->{items} }, 1 );
+
+ my $ticket = $content->{items}->[0];
+ is( $ticket->{ $single->GroupType }{id}, 'single2 at example.com', 'Single Member id in search result' );
+ is( $ticket->{ $multi->GroupType }[0]{id}, 'multi at example.com', 'Multi Member id in search result' );
+ is( $ticket->{ $multi->GroupType }[1]{id}, 'multi2 at example.com', 'Multi Member id in search result' );
+
+ $res = $mech->get( "$rest_base_path/tickets?query=id=$ticket_id&fields=CustomRoles", 'Authorization' => $auth, );
+ is( $res->code, 200 );
+ $content = $mech->json_response;
+ is( scalar @{ $content->{items} }, 1 );
+
+ $ticket = $content->{items}->[0];
+ is( $ticket->{CustomRoles}{ $single->GroupType }{id}, 'single2 at example.com', 'Single Member id in search result' );
+ is( $ticket->{CustomRoles}{ $multi->GroupType }[0]{id}, 'multi at example.com', 'Multi Member id in search result' );
+ is( $ticket->{CustomRoles}{ $multi->GroupType }[1]{id}, 'multi2 at example.com', 'Multi Member id in search result' );
+}
+
+done_testing;
diff --git a/t/rest2/ticket-links.t b/t/rest2/ticket-links.t
index ab67c2946f..7717a251be 100644
--- a/t/rest2/ticket-links.t
+++ b/t/rest2/ticket-links.t
@@ -27,6 +27,14 @@ ok($ok, $msg);
($ok, $msg) = $child->AddLink(Type => 'RefersTo', Target => 'https://bestpractical.com');
ok($ok, $msg);
+my $article = RT::Article->new(RT->SystemUser);
+($ok, $msg) = $article->Create(Class => 'General', Name => 'article foo');
+ok($ok, $msg);
+my $article_id = $article->Id;
+
+($ok, $msg) = $article->AddLink(Type => 'RefersTo', Target => $parent->URI);
+ok($ok, $msg);
+
$user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
# Inspect existing ticket links (parent)
@@ -47,7 +55,12 @@ $user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
cmp_deeply($links{'depended-on-by'}, undef, 'no depended-on-by links');
cmp_deeply($links{'parent'}, undef, 'no parent links');
cmp_deeply($links{'refers-to'}, undef, 'no refers-to links');
- cmp_deeply($links{'referred-to-by'}, undef, 'no referred-to-by links');
+ cmp_deeply($links{'referred-to-by'}, [{
+ ref => 'referred-to-by',
+ type => 'article',
+ id => $article_id,
+ _url => re(qr{$rest_base_path/article/$article_id$}),
+ }], 'one referred-to-by link');
cmp_deeply($links{'child'}, [{
ref => 'child',
diff --git a/t/rest2/tickets-bulk.t b/t/rest2/tickets-bulk.t
index 09d8cb50b7..8153049769 100644
--- a/t/rest2/tickets-bulk.t
+++ b/t/rest2/tickets-bulk.t
@@ -176,6 +176,7 @@ my @ticket_ids;
],
'json response content'
);
+ $user->PrincipalObj->RevokeRight( Right => 'ModifyTicket' );
}
{
@@ -185,5 +186,80 @@ my @ticket_ids;
}
}
+$user->PrincipalObj->GrantRight( Right => 'ShowTicket' );
+
+{
+ diag "no ReplyToTicket right";
+ my $res = $mech->post_json(
+ "$rest_base_path/tickets/bulk/correspond",
+ [ { id => $ticket_ids[0], Content => 'test correspond', ContentType => 'text/plain' } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 201, "status code" );
+ is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Permission Denied", ] ], 'permission denied' );
+
+ diag "grant ReplyToTicket right";
+ $user->PrincipalObj->GrantRight( Right => 'ReplyToTicket' );
+
+ $res = $mech->post_json(
+ "$rest_base_path/tickets/bulk/correspond",
+ [ { id => $ticket_ids[0], Content => 'test correspond', ContentType => 'text/plain' } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 201, 'status code' );
+ is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Correspondence added", ] ], 'Correspondence added' );
+
+ $user->PrincipalObj->GrantRight( Right => 'ModifyTicket' );
+ $res = $mech->post_json(
+ "$rest_base_path/tickets/bulk/correspond",
+ [ { id => $ticket_ids[0], Content => 'test correspond', ContentType => 'text/plain', Status => 'new' } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 201, 'status code' );
+ is_deeply(
+ $mech->json_response,
+ [ [ $ticket_ids[0], "Correspondence added", "Status changed from 'open' to 'new'" ] ],
+ 'Correspondence added'
+ );
+ $user->PrincipalObj->RevokeRight( Right => $_ ) for qw/ReplyToTicket ModifyTicket/;
+}
+
+{
+ diag "no CommentOnTicket right";
+ my $res = $mech->post_json(
+ "$rest_base_path/tickets/bulk/comment",
+ [ { id => $ticket_ids[0], Content => 'test comment', ContentType => 'text/plain' } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 201, "status code" );
+ is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Permission Denied", ] ], 'permission denied' );
+
+ diag "grant CommentOnTicket right";
+ $user->PrincipalObj->GrantRight( Right => 'CommentOnTicket' );
+
+ $res = $mech->post_json(
+ "$rest_base_path/tickets/bulk/comment",
+ [ { id => $ticket_ids[0], Content => 'test comment', ContentType => 'text/plain' } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 201, 'status code' );
+ is_deeply( $mech->json_response, [ [ $ticket_ids[0], "Comments added", ] ], 'Comments added' );
+
+ # Do status change along with comment
+ $user->PrincipalObj->GrantRight( Right => $_ ) for qw/ShowTicket ModifyTicket/;
+ $res = $mech->post_json(
+ "$rest_base_path/tickets/bulk/comment",
+ [ { id => $ticket_ids[0], Content => 'test comment', ContentType => 'text/plain', Status => 'open' } ],
+ 'Authorization' => $auth,
+ );
+ is( $res->code, 201, 'status code' );
+ is_deeply(
+ $mech->json_response,
+ [ [ $ticket_ids[0], "Comments added", "Status changed from 'new' to 'open'" ] ],
+ 'Comments added'
+ );
+ $user->PrincipalObj->RevokeRight( Right => $_ ) for qw/CommentOnTicket ModifyTicket/;
+}
+
done_testing;
diff --git a/t/rest2/tickets.t b/t/rest2/tickets.t
index 1fdce910f2..7c6a378be2 100644
--- a/t/rest2/tickets.t
+++ b/t/rest2/tickets.t
@@ -199,7 +199,7 @@ my ($ticket_url, $ticket_id);
# Ticket Search - Fields
{
- my $res = $mech->get("$rest_base_path/tickets?query=id>0&fields=Status,Subject",
+ my $res = $mech->get("$rest_base_path/tickets?query=id>0&fields=Status,Subject,_hyperlinks",
'Authorization' => $auth,
);
is($res->code, 200);
@@ -209,7 +209,13 @@ my ($ticket_url, $ticket_id);
my $ticket = $content->{items}->[0];
is($ticket->{Subject}, 'Ticket creation using REST');
is($ticket->{Status}, 'new');
- is(scalar keys %$ticket, 5);
+
+ my $links = $ticket->{_hyperlinks};
+ is( @$links, 2, '2 links by default' );
+ like( $links->[0]{_url}, qr{$rest_base_path/ticket/1$}, 'Self link' );
+ like( $links->[1]{_url}, qr{$rest_base_path/ticket/1/history$}, 'History link' );
+
+ is(scalar keys %$ticket, 6);
}
# Ticket Search - Fields, sub objects, no right to see Queues
@@ -508,6 +514,112 @@ my ($ticket_url, $ticket_id);
is($content->{ContentType}, 'text/html');
}
+# Ticket Reply, JSON request, missing Content
+{
+ my $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ my $content = $mech->json_response;
+
+ my ($hypermedia) = grep { $_->{ref} eq 'correspond' } @{ $content->{_hyperlinks} };
+ ok($hypermedia, 'got correspond hypermedia');
+ like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+
+ $res = $mech->post($mech->url_for_hypermedia('correspond'),
+ 'Authorization' => $auth,
+ 'Content-Type' => 'application/json',
+ 'Content' => '{"Subject":"No body!"}',
+ );
+ is($res->code, 201);
+
+ cmp_deeply($mech->json_response, [re(qr/Correspondence added|Message recorded/)]);
+}
+
+# Ticket Reply, changing status
+{
+ my $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ my $content = $mech->json_response;
+
+ my ($hypermedia) = grep { $_->{ref} eq 'correspond' } @{ $content->{_hyperlinks} };
+ ok($hypermedia, 'got correspond hypermedia');
+ like($hypermedia->{_url}, qr[$rest_base_path/ticket/$ticket_id/correspond$]);
+
+ $res = $mech->post($mech->url_for_hypermedia('correspond'),
+ 'Authorization' => $auth,
+ 'Content-Type' => 'application/json',
+ 'Content' => '{"Subject":"I am a-changing the status!","ContentType":"text/plain","Content":"Foo","Status":"rejected"}',
+ );
+ is($res->code, 201);
+
+ cmp_deeply($mech->json_response, [re(qr/Correspondence added|Message recorded/), "Status changed from 'open' to 'rejected'"]);
+
+ like($res->header('Location'), qr{$rest_base_path/transaction/\d+$});
+ $res = $mech->get($res->header('Location'),
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ $content = $mech->json_response;
+ is($content->{Type}, 'Correspond');
+ is($content->{TimeTaken}, 0);
+ is($content->{Object}{type}, 'ticket');
+ is($content->{Object}{id}, $ticket_id);
+
+ $res = $mech->get($mech->url_for_hypermedia('attachment'),
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ $content = $mech->json_response;
+ is($content->{Content}, encode_base64('Foo')),
+ is($content->{ContentType}, 'text/plain');
+
+ # Check that ticket status was updated
+ $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ $content = $mech->json_response;
+ is($content->{Status}, 'rejected', "Ticket status really was changed");
+
+ # Try an invalid status
+ $res = $mech->post($mech->url_for_hypermedia('correspond'),
+ 'Authorization' => $auth,
+ 'Content-Type' => 'application/json',
+ 'Content' => '{"Subject":"I am a-changing the status!","ContentType":"text/plain","Content":"Foo","Status":"bahaha-youre-so-funny"}',
+ );
+ is($res->code, 201);
+
+ cmp_deeply($mech->json_response, [re(qr/Correspondence added|Message recorded/), "Status 'bahaha-youre-so-funny' isn't a valid status for this ticket."]);
+
+ $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ $content = $mech->json_response;
+ is($content->{Status}, 'open', "Ticket status really was not changed to illegal value");
+
+ # Comment and change status
+ $res = $mech->post($mech->url_for_hypermedia('comment'),
+ 'Authorization' => $auth,
+ 'Content-Type' => 'application/json',
+ 'Content' => '{"Subject":"I am a-changing the status in a comment!","ContentType":"text/plain","Content":"Foo","Status":"rejected"}',
+ );
+ is($res->code, 201);
+
+ cmp_deeply($mech->json_response, ['Comments added', "Status changed from 'open' to 'rejected'"]);
+
+ $res = $mech->get($ticket_url,
+ 'Authorization' => $auth,
+ );
+ is($res->code, 200);
+ $content = $mech->json_response;
+ is($content->{Status}, 'rejected', "Ticket status really was changed during a comment");
+
+}
+
# Ticket Sorted Search
{
my $ticket2 = RT::Ticket->new($RT::SystemUser);
@@ -712,4 +824,34 @@ my $json = JSON->new->utf8;
is( $value->LargeContent, $img_content, 'image file content');
}
+# Ticket Search - Role Fields
+{
+
+ my $payload = {
+ Subject => 'Ticket creation using REST',
+ Queue => 'General',
+ Content => 'Testing ticket creation using REST API.',
+ Requestor => 'alice at example.com',
+ Cc => 'alice at example.com, bob at example.com',
+ AdminCc => 'root at example.com',
+ };
+
+ my $res = $mech->post_json( "$rest_base_path/ticket", $payload, 'Authorization' => $auth, );
+ is( $res->code, 201 );
+ ok( my $ticket_url = $res->header('location') );
+ ok( my ($ticket_id) = $ticket_url =~ qr[/ticket/(\d+)] );
+
+ $res = $mech->get( "$rest_base_path/tickets?query=id=$ticket_id&fields=Requestor,Cc,AdminCc",
+ 'Authorization' => $auth, );
+ is( $res->code, 200 );
+ my $content = $mech->json_response;
+ is( scalar @{ $content->{items} }, 1 );
+
+ my $ticket = $content->{items}->[0];
+ is( $ticket->{Requestor}[0]{id}, 'alice at example.com', 'Requestor id in search result' );
+ is( $ticket->{Cc}[0]{id}, 'alice at example.com', 'Cc id in search result' );
+ is( $ticket->{Cc}[1]{id}, 'bob at example.com', 'Cc id in search result' );
+ is( $ticket->{AdminCc}[0]{id}, 'root at example.com', 'AdminCc id in search result' );
+}
+
done_testing;
--
To stop receiving notification emails like this one, please contact
sysadmin at bestpractical.com.
More information about the rt-commit
mailing list