Browse Source
This patch adds a catalog concern management page to the staff client accessible via the cataloging home page and a new 'Pending catalog concerns' link on the front page. This includes added the requisit ticket_updates api endpoints and notice triggers and templates for notifying patrons of changes to their reported concerns. Test plan 1) Enable the `OpacCatalogConcerns` system preference 2) Catalog concern management is tied to your users ability to edit the catalog, `editcatalogue`. 3) Confirm that you can see 'Catalog concerns' listed on the cataloging home page if you have the `editcatalogue` permission and not if you do not. 4) Add a new concern as an opac user. 5) Confirm that once a concern is present in the system you see a count of 'catalog concerns pending' on the intranet main page if you have the `editcatalogue` permission. 6) Click through either the cataloging home page or pending concerns link on the main page to view the new concerns management page. 7) Confirm the table displays as one would expect. 8) Confirm clicking on details or the concern title exposes a 'details' modal with the option to add an update or resolve the concern. 9) Verify that if selecting 'notify' when updateing or resolving a concern triggers a notice to be sent to the opac user who first reported the issue. Signed-off-by: David Nind <david@davidnind.com> Signed-off-by: Helen Oliver <HOliver@tavi-port.ac.uk> Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com> Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>23.05.x
19 changed files with 933 additions and 8 deletions
@ -0,0 +1,37 @@ |
|||
--- |
|||
type: object |
|||
properties: |
|||
update_id: |
|||
type: integer |
|||
description: Internal ticket update identifier |
|||
readOnly: true |
|||
ticket_id: |
|||
type: integer |
|||
description: Internal ticket identifier |
|||
readOnly: true |
|||
user: |
|||
type: |
|||
- object |
|||
- "null" |
|||
description: The object representing the patron who added the update |
|||
readOnly: true |
|||
user_id: |
|||
type: integer |
|||
description: Internal identifier for the patron who added the update |
|||
date: |
|||
type: |
|||
- string |
|||
- "null" |
|||
format: date-time |
|||
description: Date the ticket update was reported |
|||
readOnly: true |
|||
message: |
|||
type: string |
|||
description: Ticket update details |
|||
public: |
|||
type: boolean |
|||
description: Is this update intended to be sent to the patron |
|||
additionalProperties: true |
|||
required: |
|||
- message |
|||
- public |
@ -0,0 +1,37 @@ |
|||
#!/usr/bin/perl |
|||
|
|||
# This file is part of Koha. |
|||
# |
|||
# Koha is free software; you can redistribute it and/or modify it |
|||
# under the terms of the GNU General Public License as published by |
|||
# the Free Software Foundation; either version 3 of the License, or |
|||
# (at your option) any later version. |
|||
# |
|||
# Koha 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 Koha; if not, see <http://www.gnu.org/licenses>. |
|||
|
|||
use Modern::Perl; |
|||
|
|||
use CGI qw ( -utf8 ); |
|||
use C4::Context; |
|||
use C4::Auth qw( get_template_and_user ); |
|||
use C4::Output qw( output_html_with_http_headers ); |
|||
|
|||
my $query = CGI->new; |
|||
my ( $template, $loggedinuser, $cookie ) = get_template_and_user( |
|||
{ |
|||
template_name => "cataloguing/concerns.tt", |
|||
query => $query, |
|||
type => "intranet", |
|||
flagsrequired => { cataloguing => '*' }, |
|||
} |
|||
); |
|||
|
|||
output_html_with_http_headers $query, $cookie, $template->output; |
|||
|
|||
1; |
@ -0,0 +1,306 @@ |
|||
[% USE raw %] |
|||
[% USE Asset %] |
|||
[% SET footerjs = 1 %] |
|||
[% USE TablesSettings %] |
|||
[% INCLUDE 'doc-head-open.inc' %] |
|||
<title> |
|||
Catalog concerns › Tools › Koha |
|||
</title> |
|||
[% INCLUDE 'doc-head-close.inc' %] |
|||
</head> |
|||
|
|||
<body id="cat_concerns" class="cat"> |
|||
[% INCLUDE 'header.inc' %] |
|||
[% INCLUDE 'cataloging-search.inc' %] |
|||
|
|||
<nav id="breadcrumbs" aria-label="Breadcrumb" class="breadcrumb"> |
|||
<ol> |
|||
<li> |
|||
<a href="/cgi-bin/koha/mainpage.pl">Home</a> |
|||
</li> |
|||
<li> |
|||
<a href="/cgi-bin/koha/cataloguing/cataloging-home.pl">Cataloging</a> |
|||
</li> |
|||
<li> |
|||
<a href="#" aria-current="page"> |
|||
Catalog concerns |
|||
</a> |
|||
</li> |
|||
</ol> |
|||
</nav> |
|||
|
|||
<div class="main container-fluid"> |
|||
<div class="row"> |
|||
<div class="col-sm-10 col-sm-push-2"> |
|||
<main> |
|||
<h1>Concerns</h1> |
|||
|
|||
<div class="page-section"> |
|||
<fieldset class="action" style="cursor:pointer;"> |
|||
<a id="hideResolved"><i class="fa fa-minus-square"></i> Hide resolved</a> |
|||
| <a id="showAll"><i class="fa fa-bars"></i> Show all</a> |
|||
</fieldset> |
|||
|
|||
<table id="table_concerns"> |
|||
<thead> |
|||
<tr> |
|||
<th>Reported</th> |
|||
<th>Details</th> |
|||
<th>Title</th> |
|||
<th>Status</th> |
|||
<th data-class-name="actions noExport">Actions</th> |
|||
</tr> |
|||
</thead> |
|||
</table> |
|||
</div> |
|||
</main> |
|||
</div> <!-- /.col-sm-10.col-sm-push-2 --> |
|||
|
|||
<div class="col-sm-2 col-sm-pull-10"> |
|||
<aside> |
|||
[% INCLUDE 'cat-menu.inc' %] |
|||
</aside> |
|||
</div> <!-- /.col-sm-2.col-sm-pull-10 --> |
|||
</div> <!-- /.row --> |
|||
|
|||
<!-- Display updates concern modal --> |
|||
<div class="modal" id="ticketDetailsModal" tabindex="-1" role="dialog" aria-labelledby="ticketDetailsLabel"> |
|||
<div class="modal-dialog modal-lg" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<button type="button" class="closebtn" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> |
|||
<h4 class="modal-title" id="displayUpdateLabel">Ticket details</h4> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<div id="concern-details"></div> |
|||
<fieldset class="rows"> |
|||
<ol> |
|||
<li> |
|||
<label for="message">Update: </label> |
|||
<textarea id="update_message" name="message"></textarea> |
|||
</li> |
|||
<li> |
|||
<label for="public">Notify: </label> |
|||
<input type="checkbox" name="public" id="public"> |
|||
</li> |
|||
</ol> |
|||
</fieldset> |
|||
</div> <!-- /.modal-body --> |
|||
<div class="modal-footer"> |
|||
<input type="hidden" name="ticket_id" id="ticket_id"> |
|||
<button type="button" class="btn btn-default" id="resolveTicket">Resolve</button> |
|||
<button type="submit" class="btn btn-primary" id="updateTicket">Comment</button> |
|||
</div> <!-- /.modal-footer --> |
|||
</div> <!-- /.modal-content --> |
|||
</div> <!-- /.modal-dialog --> |
|||
</div> <!-- /#displayUpdateModal --> |
|||
|
|||
[% MACRO jsinclude BLOCK %] |
|||
[% INCLUDE 'datatables.inc' %] |
|||
[% INCLUDE 'columns_settings.inc' %] |
|||
[% INCLUDE 'js-date-format.inc' %] |
|||
[% INCLUDE 'js-patron-format.inc' %] |
|||
[% INCLUDE 'js-biblio-format.inc' %] |
|||
<script> |
|||
$(document).ready(function() { |
|||
|
|||
var logged_in_user_borrowernumber = "[% logged_in_user.borrowernumber | html %]"; |
|||
|
|||
var table_settings = [% TablesSettings.GetTableSettings('cataloguing', 'concerns', 'table_concerns', 'json') | $raw %]; |
|||
|
|||
var tickets_url = '/api/v1/tickets'; |
|||
var tickets = $("#table_concerns").kohaTable({ |
|||
"ajax": { |
|||
"url": tickets_url |
|||
}, |
|||
"embed": [ |
|||
"reporter", |
|||
"resolver", |
|||
"biblio", |
|||
"updates+count", |
|||
], |
|||
'emptyTable': '<div class="dialog message">' + _("Congratulations, there are no catalog concerns.") + '</div>', |
|||
"columnDefs": [{ |
|||
"targets": [0, 1, 2, 3], |
|||
"render": function(data, type, row, meta) { |
|||
if (type == 'display') { |
|||
if (data != null) { |
|||
return data.escapeHtml(); |
|||
} else { |
|||
return ""; |
|||
} |
|||
} |
|||
return data; |
|||
} |
|||
}], |
|||
"columns": [{ |
|||
"data": "reported_date:reporter.firstname", |
|||
"render": function(data, type, row, meta) { |
|||
let reported = '<span class="date clearfix">' + $datetime(row.reported_date) + '</span>'; |
|||
reported += '<span class="reporter clearfix">' + $patron_to_html(row.reporter, { |
|||
display_cardnumber: false, |
|||
url: true |
|||
}) + '</span>'; |
|||
return reported; |
|||
}, |
|||
"searchable": true, |
|||
"orderable": true |
|||
}, |
|||
{ |
|||
"data": "title:body", |
|||
"render": function(data, type, row, meta) { |
|||
let result = '<a role="button" href="#" data-toggle="modal" data-target="#ticketDetailsModal" data-concern="' + encodeURIComponent(row.ticket_id) + '">' + row.title + '</a>'; |
|||
if (row.updates_count) { |
|||
result += '<span class="pull-right"><a role="button" href="#" data-toggle="modal" data-target="#ticketDetailsModal" data-concern="' + encodeURIComponent(row.ticket_id) + '"><i class="fa fa-comment" aria-hidden="true"></i> ' + row.updates_count + '</a></span>'; |
|||
} |
|||
result += '<div id="detail_' + row.ticket_id + '" class="hidden">' + row.body + '</div>'; |
|||
return result; |
|||
}, |
|||
"searchable": true, |
|||
"orderable": true |
|||
}, |
|||
{ |
|||
"data": "biblio.title", |
|||
"render": function(data, type, row, meta) { |
|||
return $biblio_to_html(row.biblio, { |
|||
link: 1 |
|||
}); |
|||
}, |
|||
"searchable": true, |
|||
"orderable": true |
|||
}, |
|||
{ |
|||
"data": "resolver.firstname:resolver.surname:resolved_date", |
|||
"render": function(data, type, row, meta) { |
|||
let result = ''; |
|||
if (row.resolved_date) { |
|||
result += _("Resolved by:") + ' <span>' + $patron_to_html(row.resolver, { |
|||
display_cardnumber: false, |
|||
url: true |
|||
}) + '</span>'; |
|||
result += '<span class="clearfix">' + $datetime(row.resolved_date) + '</span>'; |
|||
} else { |
|||
result += _("Open"); |
|||
} |
|||
return result; |
|||
}, |
|||
"searchable": true, |
|||
"orderable": true |
|||
}, |
|||
{ |
|||
"data": function(row, type, val, meta) { |
|||
let result = '<a class="btn btn-default btn-xs" role="button" href="#" data-toggle="modal" data-target="#ticketDetailsModal" data-concern="' + encodeURIComponent(row.ticket_id) + '"><i class="fa fa-eye" aria-hidden="true"></i> ' + _("Details") + '</a>'; |
|||
return result; |
|||
}, |
|||
"searchable": false, |
|||
"orderable": false |
|||
}, |
|||
] |
|||
}, table_settings, 1); |
|||
|
|||
$('#hideResolved').on("click", function() { |
|||
// It would be great if we could pass null here but it gets stringified |
|||
tickets.DataTable().columns('3').search('special:undefined').draw(); |
|||
}); |
|||
|
|||
$('#showAll').on("click", function() { |
|||
tickets.DataTable().columns('3').search('').draw(); |
|||
}); |
|||
|
|||
$('#ticketDetailsModal').on('show.bs.modal', function(event) { |
|||
let modal = $(this); |
|||
let button = $(event.relatedTarget); |
|||
let ticket_id = button.data('concern'); |
|||
modal.find('.modal-footer input').val(ticket_id); |
|||
|
|||
let detail = $('#detail_' + ticket_id).text(); |
|||
|
|||
let display = '<div class="list-group">'; |
|||
display += '<div class="list-group-item">'; |
|||
display += '<span class="wrapfix">' + detail + '</span>'; |
|||
display += '</div>'; |
|||
display += '<div id="concern-updates" class="list-group-item">'; |
|||
display += '<span>Loading updates . . .</span>'; |
|||
display += '</div>'; |
|||
display += '</div>'; |
|||
|
|||
let details = modal.find('#concern-details'); |
|||
details.html(display); |
|||
|
|||
$.ajax({ |
|||
url: "/api/v1/tickets/" + ticket_id + "/updates", |
|||
method: "GET", |
|||
headers: { |
|||
"x-koha-embed": "user" |
|||
}, |
|||
}).success(function(data) { |
|||
let updates_display = $('#concern-updates'); |
|||
let updates = ''; |
|||
data.forEach(function(item, index) { |
|||
updates += '<div class="list-group-item">'; |
|||
updates += '<span class="wrapfix">' + item.message + '</span>'; |
|||
updates += '<span class="clearfix">' + $patron_to_html(item.user, { |
|||
display_cardnumber: false, |
|||
url: true |
|||
}) + ' (' + $datetime(item.date) + ')</span>'; |
|||
updates += '</div>'; |
|||
}); |
|||
updates_display.html(updates); |
|||
}).error(function() { |
|||
|
|||
}); |
|||
}); |
|||
|
|||
$('#ticketDetailsModal').on('click', '#updateTicket', function(e) { |
|||
let ticket_id = $('#ticket_id').val(); |
|||
let params = { |
|||
'public': $('#public').is(":checked"), |
|||
message: $('#update_message').val(), |
|||
user_id: logged_in_user_borrowernumber |
|||
}; |
|||
|
|||
$.ajax({ |
|||
url: "/api/v1/tickets/" + ticket_id + "/updates", |
|||
method: "POST", |
|||
data: JSON.stringify(params), |
|||
ontentType: "application/json; charset=utf-8" |
|||
}).success(function() { |
|||
$("#ticketDetailsModal").modal('hide'); |
|||
tickets.DataTable().ajax.reload(function(data) { |
|||
$("#concern_action_result_dialog").hide(); |
|||
$("#concern_delete_success").html(_("Concern #%s updated successfully.").format(ticket_id)).show(); |
|||
}); |
|||
}).error(function() { |
|||
$("#concern_update_error").html(_("Error resolving concern #%s. Check the logs.").format(ticket_id)).show(); |
|||
}); |
|||
}); |
|||
|
|||
$('#ticketDetailsModal').on('click', '#resolveTicket', function(e) { |
|||
let ticket_id = $('#ticket_id').val(); |
|||
let params = { |
|||
'public': $('#public').is(":checked"), |
|||
message: $('#update_message').val(), |
|||
user_id: logged_in_user_borrowernumber, |
|||
state: 'resolved' |
|||
}; |
|||
|
|||
$.ajax({ |
|||
url: "/api/v1/tickets/" + ticket_id + "/updates", |
|||
method: "POST", |
|||
data: JSON.stringify(params), |
|||
ontentType: "application/json; charset=utf-8" |
|||
}).success(function() { |
|||
$("#ticketDetailsModal").modal('hide'); |
|||
tickets.DataTable().ajax.reload(function(data) { |
|||
$("#concern_action_result_dialog").hide(); |
|||
$("#concern_delete_success").html(_("Concern #%s updated successfully.").format(ticket_id)).show(); |
|||
}); |
|||
}).error(function() { |
|||
$("#concern_update_error").html(_("Error resolving concern #%s. Check the logs.").format(ticket_id)).show(); |
|||
}); |
|||
}); |
|||
|
|||
}); |
|||
</script> |
|||
[% END %] |
|||
[% INCLUDE 'intranet-bottom.inc' %] |
@ -0,0 +1,202 @@ |
|||
#!/usr/bin/env perl |
|||
|
|||
# This file is part of Koha. |
|||
# |
|||
# Koha is free software; you can redistribute it and/or modify it |
|||
# under the terms of the GNU General Public License as published by |
|||
# the Free Software Foundation; either version 3 of the License, or |
|||
# (at your option) any later version. |
|||
# |
|||
# Koha 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 Koha; if not, see <http://www.gnu.org/licenses>. |
|||
|
|||
use Modern::Perl; |
|||
|
|||
use Test::More tests => 2; |
|||
use Test::Mojo; |
|||
|
|||
use t::lib::TestBuilder; |
|||
use t::lib::Mocks; |
|||
|
|||
use Koha::Tickets; |
|||
use Koha::Database; |
|||
|
|||
my $schema = Koha::Database->new->schema; |
|||
my $builder = t::lib::TestBuilder->new; |
|||
|
|||
my $t = Test::Mojo->new('Koha::REST::V1'); |
|||
t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); |
|||
|
|||
subtest 'list_updates() tests' => sub { |
|||
|
|||
plan tests => 14; |
|||
|
|||
$schema->storage->txn_begin; |
|||
|
|||
Koha::Tickets->search->delete; |
|||
|
|||
my $librarian = $builder->build_object( |
|||
{ |
|||
class => 'Koha::Patrons', |
|||
value => { flags => 2**2 } # catalogue flag = 2 |
|||
} |
|||
); |
|||
my $password = 'thePassword123'; |
|||
$librarian->set_password( { password => $password, skip_validation => 1 } ); |
|||
my $userid = $librarian->userid; |
|||
|
|||
my $patron = $builder->build_object( |
|||
{ |
|||
class => 'Koha::Patrons', |
|||
value => { flags => 0 } |
|||
} |
|||
); |
|||
|
|||
$patron->set_password( { password => $password, skip_validation => 1 } ); |
|||
my $unauth_userid = $patron->userid; |
|||
|
|||
my $ticket = $builder->build_object( { class => 'Koha::Tickets' } ); |
|||
my $ticket_id = $ticket->id; |
|||
|
|||
## Authorized user tests |
|||
# No updates, so empty array should be returned |
|||
$t->get_ok("//$userid:$password@/api/v1/tickets/$ticket_id/updates") |
|||
->status_is(200)->json_is( [] ); |
|||
|
|||
my $update = $builder->build_object( |
|||
{ |
|||
class => 'Koha::Ticket::Updates', |
|||
value => { ticket_id => $ticket_id } |
|||
} |
|||
); |
|||
|
|||
# One ticket update added, should get returned |
|||
$t->get_ok("//$userid:$password@/api/v1/tickets/$ticket_id/updates") |
|||
->status_is(200)->json_is( [ $update->to_api ] ); |
|||
|
|||
my $update_2 = $builder->build_object( |
|||
{ |
|||
class => 'Koha::Ticket::Updates', |
|||
value => { ticket_id => $ticket_id } |
|||
} |
|||
); |
|||
my $update_3 = $builder->build_object( |
|||
{ |
|||
class => 'Koha::Ticket::Updates', |
|||
value => { ticket_id => $ticket_id } |
|||
} |
|||
); |
|||
|
|||
# Two ticket updates added, they should both be returned |
|||
$t->get_ok("//$userid:$password@/api/v1/tickets/$ticket_id/updates") |
|||
->status_is(200) |
|||
->json_is( [ $update->to_api, $update_2->to_api, $update_3->to_api, ] ); |
|||
|
|||
# Warn on unsupported query parameter |
|||
$t->get_ok( |
|||
"//$userid:$password@/api/v1/tickets/$ticket_id/updates?ticket_blah=blah" |
|||
)->status_is(400)->json_is( |
|||
[ |
|||
{ |
|||
path => '/query/ticket_blah', |
|||
message => 'Malformed query string' |
|||
} |
|||
] |
|||
); |
|||
|
|||
# Unauthorized access |
|||
$t->get_ok("//$unauth_userid:$password@/api/v1/tickets")->status_is(403); |
|||
|
|||
$schema->storage->txn_rollback; |
|||
}; |
|||
|
|||
subtest 'add_update() tests' => sub { |
|||
|
|||
plan tests => 17; |
|||
|
|||
$schema->storage->txn_begin; |
|||
|
|||
my $librarian = $builder->build_object( |
|||
{ |
|||
class => 'Koha::Patrons', |
|||
value => { flags => 2**9 } # editcatalogue flag = 9 |
|||
} |
|||
); |
|||
my $password = 'thePassword123'; |
|||
$librarian->set_password( { password => $password, skip_validation => 1 } ); |
|||
my $userid = $librarian->userid; |
|||
|
|||
my $patron = $builder->build_object( |
|||
{ |
|||
class => 'Koha::Patrons', |
|||
value => { flags => 0 } |
|||
} |
|||
); |
|||
|
|||
$patron->set_password( { password => $password, skip_validation => 1 } ); |
|||
my $unauth_userid = $patron->userid; |
|||
|
|||
my $ticket = $builder->build_object( { class => 'Koha::Tickets' } ); |
|||
my $ticket_id = $ticket->id; |
|||
|
|||
my $update = { |
|||
message => "First ticket update", |
|||
public => Mojo::JSON->false |
|||
}; |
|||
|
|||
# Unauthorized attempt to write |
|||
$t->post_ok( |
|||
"//$unauth_userid:$password@/api/v1/tickets/$ticket_id/updates" => |
|||
json => $update )->status_is(403); |
|||
|
|||
# Authorized attempt to write |
|||
my $update_id = |
|||
$t->post_ok( |
|||
"//$userid:$password@/api/v1/tickets/$ticket_id/updates" => json => |
|||
$update )->status_is( 201, 'SWAGGER3.2.1' )->header_like( |
|||
Location => qr|^\/api\/v1\/tickets/\d*|, |
|||
'SWAGGER3.4.1' |
|||
)->json_is( '/message' => $update->{message} ) |
|||
->json_is( '/public' => $update->{public} ) |
|||
->json_is( '/user_id' => $librarian->id )->tx->res->json->{update_id}; |
|||
|
|||
# Authorized attempt to create with null id |
|||
$update->{update_id} = undef; |
|||
$t->post_ok( |
|||
"//$userid:$password@/api/v1/tickets/$ticket_id/updates" => json => |
|||
$update )->status_is(400)->json_has('/errors'); |
|||
|
|||
# Authorized attempt to create with existing id |
|||
$update->{update_id} = $update_id; |
|||
$t->post_ok( |
|||
"//$userid:$password@/api/v1/tickets/$ticket_id/updates" => json => |
|||
$update )->status_is(400)->json_is( |
|||
"/errors" => [ |
|||
{ |
|||
message => "Read-only.", |
|||
path => "/body/update_id" |
|||
} |
|||
] |
|||
); |
|||
|
|||
# Authorized attempt to write missing data |
|||
my $update_with_missing_field = { message => "Another ticket update" }; |
|||
|
|||
$t->post_ok( |
|||
"//$userid:$password@/api/v1/tickets/$ticket_id/updates" => json => |
|||
$update_with_missing_field )->status_is(400)->json_is( |
|||
"/errors" => [ |
|||
{ |
|||
message => "Missing property.", |
|||
path => "/body/public" |
|||
} |
|||
] |
|||
); |
|||
|
|||
$schema->storage->txn_rollback; |
|||
}; |
Loading…
Reference in new issue