From fcd4229f895e98de2164012c1730f2479da40096 Mon Sep 17 00:00:00 2001 From: Galen Charlton Date: Fri, 26 May 2017 20:41:38 +0000 Subject: [PATCH] LP#1701001: carve out a reusable patron search service This patch moves the patron search service and the base patron search controller into a separate, reusable file. The core patron search service is available for injection as patronSvc from the new egPatronSearchMod, while BasePatronSearchCtrl now exists as a base controller for the patron search form that can be extended as needed by doing something like this: module.controller('DerivedPatronSearchCtrl', [ '$scope', '$controller', function ($scope, $controller) { // Initialize the super class and extend it. angular.extend(this, $controller('BasePatronSearchCtrl', {$scope: $scope})); ... Signed-off-by: Galen Charlton Signed-off-by: Chris Sharp Signed-off-by: Jason Etheridge --- Open-ILS/src/templates/staff/circ/patron/index.tt2 | 1 + Open-ILS/src/templates/staff/circ/renew/index.tt2 | 1 + .../web/js/ui/default/staff/circ/patron/app.js | 663 +------------------- .../js/ui/default/staff/services/patron_search.js | 685 +++++++++++++++++++++ .../web/js/ui/default/staff/test/karma.conf.js | 1 + 5 files changed, 693 insertions(+), 658 deletions(-) create mode 100644 Open-ILS/web/js/ui/default/staff/services/patron_search.js diff --git a/Open-ILS/src/templates/staff/circ/patron/index.tt2 b/Open-ILS/src/templates/staff/circ/patron/index.tt2 index d2b94ed247..d2908d39aa 100644 --- a/Open-ILS/src/templates/staff/circ/patron/index.tt2 +++ b/Open-ILS/src/templates/staff/circ/patron/index.tt2 @@ -18,6 +18,7 @@ [% INCLUDE 'staff/circ/share/hold_strings.tt2' %] + diff --git a/Open-ILS/src/templates/staff/circ/renew/index.tt2 b/Open-ILS/src/templates/staff/circ/renew/index.tt2 index 415556b242..55a91e8a5c 100644 --- a/Open-ILS/src/templates/staff/circ/renew/index.tt2 +++ b/Open-ILS/src/templates/staff/circ/renew/index.tt2 @@ -10,6 +10,7 @@ [% INCLUDE 'staff/circ/share/circ_strings.tt2' %] + diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js index 1452ce5f43..cd8a36e7aa 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js @@ -5,7 +5,8 @@ */ angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', - 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'ngToast']) + 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'ngToast', + 'egPatronSearchMod']) .config(['ngToastProvider', function(ngToastProvider) { ngToastProvider.configure({ @@ -224,366 +225,6 @@ angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', }) /** - * Patron service - */ -.factory('patronSvc', - ['$q','$timeout','$location','egCore','egUser','$locale', -function($q , $timeout , $location , egCore, egUser , $locale) { - - var service = { - // cached patron search results - patrons : [], - - // currently selected patron object - current : null, - - // patron circ stats (overdues, fines, holds) - patron_stats : null, - - // event types manually overridden, which should always be - // overridden for checkouts to this patron for this instance of - // the interface. - checkout_overrides : {}, - //holds the searched barcode - search_barcode : null, - }; - - // when we change the default patron, we need to clear out any - // data collected on that patron - service.resetPatronLists = function() { - service.checkouts = []; - service.items_out = [] - service.items_out_ids = []; - service.holds = []; - service.hold_ids = []; - service.checkout_overrides = {}; - service.patron_stats = null; - service.noncat_ids = []; - service.hasAlerts = false; - service.patronExpired = false; - service.patronExpiresSoon = false; - service.invalidAddresses = false; - } - service.resetPatronLists(); // initialize - - // Returns true if the last alerted patron matches the current - // patron. Otherwise, the last alerted patron is set to the - // current patron and false is returned. - service.alertsShown = function() { - var key = 'eg.circ.last_alerted_patron'; - var last_id = egCore.hatch.getSessionItem(key); - if (last_id && last_id == service.current.id()) return true; - egCore.hatch.setSessionItem(key, service.current.id()); - return false; - } - - // shortcut to force-reload the current primary - service.refreshPrimary = function() { - if (!service.current) return $q.when(); - return service.setPrimary(service.current.id(), null, true); - } - - // clear the currently focused user - service.clearPrimary = function() { - // reset with no patron - service.resetPatronLists(); - service.current = null; - service.patron_stats = null; - return $q.when(); - } - - // sets the primary display user, fetching data as necessary. - service.setPrimary = function(id, user, force) { - var user_id = id ? id : (user ? user.id() : null); - - console.debug('setting primary user to: ' + user_id); - - if (!user_id) return $q.reject(); - - // when loading a new patron, update the last patron setting - if (!service.current || service.current.id() != user_id) - egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id); - - // avoid running multiple retrievals for the same patron, which - // can happen during dbl-click by maintaining a single running - // data retrieval promise - if (service.primaryUserPromise) { - if (service.primaryUserId == user_id) { - return service.primaryUserPromise.promise; - } else { - service.primaryUserPromise = null; - } - } - - service.primaryUserPromise = $q.defer(); - service.primaryUserId = user_id; - - service.getPrimary(id, user, force) - .then(function() { - service.checkAlerts(); - var p = service.primaryUserPromise; - service.primaryUserId = null; - // clear before resolution just to be safe. - service.primaryUserPromise = null; - p.resolve(); - }); - - return service.primaryUserPromise.promise; - } - - service.getPrimary = function(id, user, force) { - - if (user) { - if (!force && service.current && - service.current.id() == user.id()) { - if (service.patron_stats) { - return $q.when(); - } else { - return service.fetchUserStats(); - } - } - - service.resetPatronLists(); - service.current = user; - service.localFlesh(user); - return service.fetchUserStats(); - - } else if (id) { - if (!force && service.current && service.current.id() == id) { - if (service.patron_stats) { - return $q.when(); - } else { - return service.fetchUserStats(); - } - } - - service.resetPatronLists(); - - return egUser.get(id).then( - function(user) { - service.current = user; - service.localFlesh(user); - return service.fetchUserStats(); - }, - function(err) { - console.error( - "unable to fetch user "+id+': '+js2JSON(err)) - } - ); - } else { - - // fetching a null user clears the primary user. - // NOTE: this should probably reject() and log an error, - // but calling clear for backwards compat for now. - return service.clearPrimary(); - } - } - - // flesh some additional user fields locally - service.localFlesh = function(user) { - if (!angular.isObject(typeof user.home_ou())) - user.home_ou(egCore.org.get(user.home_ou())); - - angular.forEach( - user.standing_penalties(), - function(penalty) { - if (!angular.isObject(penalty.org_unit())) - penalty.org_unit(egCore.org.get(penalty.org_unit())); - } - ); - - // stat_cat_entries == stat_cat_entry_user_map - angular.forEach(user.stat_cat_entries(), function(map) { - if (angular.isObject(map.stat_cat())) return; - // At page load, we only retrieve org-visible stat cats. - // For the common case, ignore entries for remote stat cats. - var cat = egCore.env.actsc.map[map.stat_cat()]; - if (cat) { - map.stat_cat(cat); - cat.owner(egCore.org.get(cat.owner())); - } - }); - } - - // resolves to true if the patron account has expired or will - // expire soon, based on YAOUS circ.patron_expires_soon_warning - // note: returning a promise is no longer strictly necessary - // (no more async activity) if the calling function is changed too. - service.testExpire = function() { - - var expire = Date.parse(service.current.expire_date()); - if (expire < new Date()) { - return $q.when(service.patronExpired = true); - } - - var soon = egCore.env.aous['circ.patron_expires_soon_warning']; - if (Number(soon)) { - var preExpire = new Date(); - preExpire.setDate(preExpire.getDate() + Number(soon)); - if (expire < preExpire) - return $q.when(service.patronExpiresSoon = true); - } - - return $q.when(false); - } - - // resolves to true if the patron account has any invalid addresses. - service.testInvalidAddrs = function() { - - if (service.invalidAddresses) - return $q.when(true); - - var fail = false; - - angular.forEach( - service.current.addresses(), - function(addr) { if (addr.valid() == 'f') fail = true } - ); - - return $q.when(fail); - } - //resolves to true if the patron was fetched with an inactive card - service.fetchedWithInactiveCard = function() { - var bc = service.search_barcode - var cards = service.current.cards(); - var card = cards.filter(function(c) { return c.barcode() == bc })[0]; - return (card && card.active() == 'f'); - } - // resolves to true if there is any aspect of the patron account - // which should produce a message in the alerts panel - service.checkAlerts = function() { - - if (service.hasAlerts) // already checked - return $q.when(true); - - var deferred = $q.defer(); - var p = service.current; - - if (service.alert_penalties.length || - p.alert_message() || - p.active() == 'f' || - p.barred() == 't' || - service.patron_stats.holds.ready) { - - service.hasAlerts = true; - } - - // see if the user was retrieved with an inactive card - if(service.fetchedWithInactiveCard()){ - service.hasAlerts = true; - } - - // regardless of whether we know of alerts, we still need - // to test/fetch the expire data for display - service.testExpire().then(function(bool) { - if (bool) service.hasAlerts = true; - deferred.resolve(service.hasAlerts); - }); - - service.testInvalidAddrs().then(function(bool) { - if (bool) service.invalidAddresses = true; - deferred.resolve(service.invalidAddresses); - }); - - return deferred.promise; - } - - service.fetchGroupFines = function() { - return egCore.net.request( - 'open-ils.actor', - 'open-ils.actor.usergroup.members.balance_owed', - egCore.auth.token(), service.current.usrgroup() - ).then(function(list) { - var total = 0; - angular.forEach(list, function(u) { - total += 100 * Number(u.balance_owed) - }); - service.patron_stats.fines.group_balance_owed = total / 100; - }); - } - - service.getUserStats = function(id) { - return egCore.net.request( - 'open-ils.actor', - 'open-ils.actor.user.opac.vital_stats.authoritative', - egCore.auth.token(), id - ).then( - function(stats) { - // force numeric to ensure correct boolean handling in templates - stats.fines.balance_owed = Number(stats.fines.balance_owed); - stats.checkouts.overdue = Number(stats.checkouts.overdue); - stats.checkouts.claims_returned = - Number(stats.checkouts.claims_returned); - stats.checkouts.lost = Number(stats.checkouts.lost); - stats.checkouts.out = Number(stats.checkouts.out); - stats.checkouts.total_out = - stats.checkouts.out + stats.checkouts.overdue; - - stats.checkouts.total_out += Number(stats.checkouts.long_overdue); - - if (!egCore.env.aous['circ.do_not_tally_claims_returned']) - stats.checkouts.total_out += stats.checkouts.claims_returned; - - if (egCore.env.aous['circ.tally_lost']) - stats.checkouts.total_out += stats.checkouts.lost - - return stats; - } - ); - } - - // Fetches the IDs of any active non-cat checkouts for the current - // user. Also sets the patron_stats non_cat count value to match. - service.getUserNonCats = function(id) { - return egCore.net.request( - 'open-ils.circ', - 'open-ils.circ.open_non_cataloged_circulation.user.authoritative', - egCore.auth.token(), id - ).then(function(noncat_ids) { - service.noncat_ids = noncat_ids; - service.patron_stats.checkouts.noncat = noncat_ids.length; - }); - } - - // grab additional circ info - service.fetchUserStats = function() { - return service.getUserStats(service.current.id()) - .then(function(stats) { - service.patron_stats = stats - service.alert_penalties = service.current.standing_penalties() - .filter(function(pen) { - return pen.standing_penalty().staff_alert() == 't' - }); - - service.summary_stat_cats = []; - angular.forEach(service.current.stat_cat_entries(), - function(map) { - if (angular.isObject(map.stat_cat()) && - map.stat_cat().usr_summary() == 't') { - service.summary_stat_cats.push(map); - } - } - ); - - // run these two in parallel - var p1 = service.getUserNonCats(service.current.id()); - var p2 = service.fetchGroupFines(); - return $q.all([p1, p2]); - }); - } - - // Avoid using parens [e.g. (1.23)] to indicate negative numbers, - // which is the Angular default. - // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis - // FIXME: This change needs to be moved into a project-wide collection - // of locale overrides. - $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-'; - $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = ''; - - return service; -}]) - -/** * Manages tabbed patron view. * This is the parent scope of all patron tab scopes. * @@ -888,20 +529,13 @@ function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) { .controller('PatronSearchCtrl', ['$scope','$q','$routeParams','$timeout','$window','$location','egCore', '$filter','egUser', 'patronSvc','egGridDataProvider','$document', - 'egPatronMerge','egProgressDialog', + 'egPatronMerge','egProgressDialog','$controller', function($scope, $q, $routeParams, $timeout, $window, $location, egCore, $filter, egUser, patronSvc , egGridDataProvider , $document, - egPatronMerge , egProgressDialog) { + egPatronMerge , egProgressDialog, $controller) { + angular.extend(this, $controller('BasePatronSearchCtrl', {$scope : $scope})); $scope.initTab('search'); - $scope.focusMe = true; - $scope.searchArgs = { - // default to searching globally - home_ou : egCore.org.tree() - }; - - // last used patron search form element - var lastFormElement; $scope.gridControls = { activateItem : function(item) { @@ -910,71 +544,6 @@ function($scope, $q, $routeParams, $timeout, $window, $location, egCore, selectedItems : function() {return []} } - // Handle URL-encoded searches - if ($location.search().search) { - console.log('URL search = ' + $location.search().search); - patronSvc.urlSearch = {search : JSON2js($location.search().search)}; - - // why the double-JSON encoded sort? - if (patronSvc.urlSearch.search.search_sort) { - patronSvc.urlSearch.sort = - JSON2js(patronSvc.urlSearch.search.search_sort); - } else { - patronSvc.urlSearch.sort = []; - } - delete patronSvc.urlSearch.search.search_sort; - - // include inactive patrons if "inactive" param - if ($location.search().inactive) { - patronSvc.urlSearch.inactive = $location.search().inactive; - } - } - - var propagate; - var propagate_inactive; - if (patronSvc.lastSearch) { - propagate = patronSvc.lastSearch.search; - // home_ou needs to be treated specially - propagate.home_ou = { - value : patronSvc.lastSearch.home_ou, - group : 0 - }; - } else if (patronSvc.urlSearch) { - propagate = patronSvc.urlSearch.search; - if (patronSvc.urlSearch.inactive) { - propagate_inactive = patronSvc.urlSearch.inactive; - } - } - - if (egCore.env.pgt) { - $scope.profiles = egCore.env.pgt.list; - } else { - egCore.pcrud.search('pgt', {parent : null}, - {flesh : -1, flesh_fields : {pgt : ['children']}} - ).then( - function(tree) { - egCore.env.absorbTree(tree, 'pgt') - $scope.profiles = egCore.env.pgt.list; - } - ); - } - - if (propagate) { - // populate the search form with our cached / preexisting search info - angular.forEach(propagate, function(val, key) { - if (key == 'profile') - val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0]; - if (key == 'home_ou') - val.value = egCore.org.get(val.value); - $scope.searchArgs[key] = val.value; - }); - if (propagate_inactive) { - $scope.searchArgs.inactive = propagate_inactive; - } - } - - var provider = egGridDataProvider.instance({}); - $scope.$watch( function() {return $scope.gridControls.selectedItems()}, function(list) { @@ -983,228 +552,6 @@ function($scope, $q, $routeParams, $timeout, $window, $location, egCore, }, true ); - - provider.get = function(offset, count) { - var deferred = $q.defer(); - - var fullSearch; - if (patronSvc.urlSearch) { - fullSearch = patronSvc.urlSearch; - // enusre the urlSearch only runs once. - delete patronSvc.urlSearch; - - } else { - patronSvc.search_barcode = $scope.searchArgs.card; - - var search = compileSearch($scope.searchArgs); - if (Object.keys(search) == 0) return $q.when(); - - var home_ou = search.home_ou; - delete search.home_ou; - var inactive = search.inactive; - delete search.inactive; - - fullSearch = { - search : search, - sort : compileSort(), - inactive : inactive, - home_ou : home_ou, - }; - } - - fullSearch.count = count; - fullSearch.offset = offset; - - if (patronSvc.lastSearch) { - // search repeated, return the cached results - if (angular.equals(fullSearch, patronSvc.lastSearch)) { - console.log('patron search returning ' + - patronSvc.patrons.length + ' cached results'); - - // notify has to happen after returning the promise - $timeout( - function() { - angular.forEach(patronSvc.patrons, function(user) { - deferred.notify(user); - }); - deferred.resolve(); - } - ); - return deferred.promise; - } - } - - patronSvc.lastSearch = fullSearch; - - if (fullSearch.search.id) { - // search by user id performs a direct ID lookup - var userId = fullSearch.search.id.value; - $timeout( - function() { - egUser.get(userId).then(function(user) { - patronSvc.localFlesh(user); - patronSvc.patrons = [user]; - deferred.notify(user); - deferred.resolve(); - }); - } - ); - return deferred.promise; - } - - if (!Object.keys(fullSearch.search).length) { - // Empty searches are rejected by the server. Avoid - // running the the empty search that runs on page load. - return $q.when(); - } - - egProgressDialog.open(); // Indeterminate - - patronSvc.patrons = []; - var which_sound = 'success'; - egCore.net.request( - 'open-ils.actor', - 'open-ils.actor.patron.search.advanced.fleshed', - egCore.auth.token(), - fullSearch.search, - fullSearch.count, - fullSearch.sort, - fullSearch.inactive, - fullSearch.home_ou, - egUser.defaultFleshFields, - fullSearch.offset - - ).then( - function() { - deferred.resolve(); - }, - function() { // onerror - which_sound = 'error'; - }, - function(user) { - // hide progress bar as soon as the first result appears. - egProgressDialog.close(); - patronSvc.localFlesh(user); // inline - patronSvc.patrons.push(user); - deferred.notify(user); - } - )['finally'](function() { // close on 0-hits or error - if (which_sound == 'success' && patronSvc.patrons.length == 0) { - which_sound = 'warning'; - } - egCore.audio.play(which_sound + '.patron.by_search'); - egProgressDialog.close(); - }); - - return deferred.promise; - }; - - $scope.patronSearchGridProvider = provider; - - // determine the tree depth of the profile group - $scope.pgt_depth = function(grp) { - var d = 0; - while (grp = egCore.env.pgt.map[grp.parent()]) d++; - return d; - } - - $scope.clearForm = function () { - $scope.searchArgs={}; - if (lastFormElement) lastFormElement.focus(); - } - - $scope.applyShowExtras = function($event, bool) { - if (bool) { - $scope.showExtras = true; - egCore.hatch.setItem('eg.circ.patron.search.show_extras', true); - } else { - $scope.showExtras = false; - egCore.hatch.removeItem('eg.circ.patron.search.show_extras'); - } - if (lastFormElement) lastFormElement.focus(); - $event.preventDefault(); - } - - egCore.hatch.getItem('eg.circ.patron.search.show_extras') - .then(function(val) {$scope.showExtras = val}); - - // map form arguments into search params - function compileSearch(args) { - var search = {}; - angular.forEach(args, function(val, key) { - if (!val) return; - if (key == 'profile' && args.profile) { - search.profile = {value : args.profile.id(), group : 0}; - } else if (key == 'home_ou' && args.home_ou) { - search.home_ou = args.home_ou.id(); // passed separately - } else if (key == 'inactive') { - search.inactive = val; - } else { - search[key] = {value : val, group : 0}; - } - if (key.match(/phone|ident/)) { - search[key].group = 2; - } else { - if (key.match(/street|city|state|post_code/)) { - search[key].group = 1; - } else if (key == 'card') { - search[key].group = 3 - } - } - }); - - return search; - } - - function compileSort() { - - if (!provider.sort.length) { - return [ // default - "family_name ASC", - "first_given_name ASC", - "second_given_name ASC", - "dob DESC" - ]; - } - - var sort = []; - angular.forEach( - provider.sort, - function(sortdef) { - if (angular.isObject(sortdef)) { - var name = Object.keys(sortdef)[0]; - var dir = sortdef[name]; - sort.push(name + ' ' + dir); - } else { - sort.push(sortdef); - } - } - ); - - return sort; - } - - $scope.setLastFormElement = function() { - lastFormElement = $document[0].activeElement; - } - - // search form submit action; tells the results grid to - // refresh itself. - $scope.search = function(args) { // args === $scope.searchArgs - if (args && Object.keys(args).length) - $scope.gridControls.refresh(); - if (lastFormElement) lastFormElement.focus(); - } - - // TODO: move this into the (forthcoming) grid row activate action - $scope.onPatronDblClick = function($event, user) { - $location.path('/circ/patron/' + user.id() + '/checkout'); - } - - if (patronSvc.urlSearch) { - // force the grid to load the url-based search on page load - provider.refresh(); - } $scope.need_two_selected = function() { var items = $scope.gridControls.selectedItems(); diff --git a/Open-ILS/web/js/ui/default/staff/services/patron_search.js b/Open-ILS/web/js/ui/default/staff/services/patron_search.js new file mode 100644 index 0000000000..48859d850f --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/patron_search.js @@ -0,0 +1,685 @@ +/** + * Patron Search module + */ + +angular.module('egPatronSearchMod', ['ngRoute', 'ui.bootstrap', + 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod']) + +/** + * Patron service + */ +.factory('patronSvc', + ['$q','$timeout','$location','egCore','egUser','$locale', +function($q , $timeout , $location , egCore, egUser , $locale) { + + var service = { + // cached patron search results + patrons : [], + + // currently selected patron object + current : null, + + // patron circ stats (overdues, fines, holds) + patron_stats : null, + + // event types manually overridden, which should always be + // overridden for checkouts to this patron for this instance of + // the interface. + checkout_overrides : {}, + //holds the searched barcode + search_barcode : null, + }; + + // when we change the default patron, we need to clear out any + // data collected on that patron + service.resetPatronLists = function() { + service.checkouts = []; + service.items_out = [] + service.items_out_ids = []; + service.holds = []; + service.hold_ids = []; + service.checkout_overrides = {}; + service.patron_stats = null; + service.noncat_ids = []; + service.hasAlerts = false; + service.patronExpired = false; + service.patronExpiresSoon = false; + service.invalidAddresses = false; + } + service.resetPatronLists(); // initialize + + // Returns true if the last alerted patron matches the current + // patron. Otherwise, the last alerted patron is set to the + // current patron and false is returned. + service.alertsShown = function() { + var key = 'eg.circ.last_alerted_patron'; + var last_id = egCore.hatch.getSessionItem(key); + if (last_id && last_id == service.current.id()) return true; + egCore.hatch.setSessionItem(key, service.current.id()); + return false; + } + + // shortcut to force-reload the current primary + service.refreshPrimary = function() { + if (!service.current) return $q.when(); + return service.setPrimary(service.current.id(), null, true); + } + + // clear the currently focused user + service.clearPrimary = function() { + // reset with no patron + service.resetPatronLists(); + service.current = null; + service.patron_stats = null; + return $q.when(); + } + + // sets the primary display user, fetching data as necessary. + service.setPrimary = function(id, user, force) { + var user_id = id ? id : (user ? user.id() : null); + + console.debug('setting primary user to: ' + user_id); + + if (!user_id) return $q.reject(); + + // when loading a new patron, update the last patron setting + if (!service.current || service.current.id() != user_id) + egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id); + + // avoid running multiple retrievals for the same patron, which + // can happen during dbl-click by maintaining a single running + // data retrieval promise + if (service.primaryUserPromise) { + if (service.primaryUserId == user_id) { + return service.primaryUserPromise.promise; + } else { + service.primaryUserPromise = null; + } + } + + service.primaryUserPromise = $q.defer(); + service.primaryUserId = user_id; + + service.getPrimary(id, user, force) + .then(function() { + service.checkAlerts(); + var p = service.primaryUserPromise; + service.primaryUserId = null; + // clear before resolution just to be safe. + service.primaryUserPromise = null; + p.resolve(); + }); + + return service.primaryUserPromise.promise; + } + + service.getPrimary = function(id, user, force) { + + if (user) { + if (!force && service.current && + service.current.id() == user.id()) { + if (service.patron_stats) { + return $q.when(); + } else { + return service.fetchUserStats(); + } + } + + service.resetPatronLists(); + service.current = user; + service.localFlesh(user); + return service.fetchUserStats(); + + } else if (id) { + if (!force && service.current && service.current.id() == id) { + if (service.patron_stats) { + return $q.when(); + } else { + return service.fetchUserStats(); + } + } + + service.resetPatronLists(); + + return egUser.get(id).then( + function(user) { + service.current = user; + service.localFlesh(user); + return service.fetchUserStats(); + }, + function(err) { + console.error( + "unable to fetch user "+id+': '+js2JSON(err)) + } + ); + } else { + + // fetching a null user clears the primary user. + // NOTE: this should probably reject() and log an error, + // but calling clear for backwards compat for now. + return service.clearPrimary(); + } + } + + // flesh some additional user fields locally + service.localFlesh = function(user) { + if (!angular.isObject(typeof user.home_ou())) + user.home_ou(egCore.org.get(user.home_ou())); + + angular.forEach( + user.standing_penalties(), + function(penalty) { + if (!angular.isObject(penalty.org_unit())) + penalty.org_unit(egCore.org.get(penalty.org_unit())); + } + ); + + // stat_cat_entries == stat_cat_entry_user_map + angular.forEach(user.stat_cat_entries(), function(map) { + if (angular.isObject(map.stat_cat())) return; + // At page load, we only retrieve org-visible stat cats. + // For the common case, ignore entries for remote stat cats. + var cat = egCore.env.actsc.map[map.stat_cat()]; + if (cat) { + map.stat_cat(cat); + cat.owner(egCore.org.get(cat.owner())); + } + }); + } + + // resolves to true if the patron account has expired or will + // expire soon, based on YAOUS circ.patron_expires_soon_warning + // note: returning a promise is no longer strictly necessary + // (no more async activity) if the calling function is changed too. + service.testExpire = function() { + + var expire = Date.parse(service.current.expire_date()); + if (expire < new Date()) { + return $q.when(service.patronExpired = true); + } + + var soon = egCore.env.aous['circ.patron_expires_soon_warning']; + if (Number(soon)) { + var preExpire = new Date(); + preExpire.setDate(preExpire.getDate() + Number(soon)); + if (expire < preExpire) + return $q.when(service.patronExpiresSoon = true); + } + + return $q.when(false); + } + + // resolves to true if the patron account has any invalid addresses. + service.testInvalidAddrs = function() { + + if (service.invalidAddresses) + return $q.when(true); + + var fail = false; + + angular.forEach( + service.current.addresses(), + function(addr) { if (addr.valid() == 'f') fail = true } + ); + + return $q.when(fail); + } + //resolves to true if the patron was fetched with an inactive card + service.fetchedWithInactiveCard = function() { + var bc = service.search_barcode + var cards = service.current.cards(); + var card = cards.filter(function(c) { return c.barcode() == bc })[0]; + return (card && card.active() == 'f'); + } + // resolves to true if there is any aspect of the patron account + // which should produce a message in the alerts panel + service.checkAlerts = function() { + + if (service.hasAlerts) // already checked + return $q.when(true); + + var deferred = $q.defer(); + var p = service.current; + + if (service.alert_penalties.length || + p.alert_message() || + p.active() == 'f' || + p.barred() == 't' || + service.patron_stats.holds.ready) { + + service.hasAlerts = true; + } + + // see if the user was retrieved with an inactive card + if(service.fetchedWithInactiveCard()){ + service.hasAlerts = true; + } + + // regardless of whether we know of alerts, we still need + // to test/fetch the expire data for display + service.testExpire().then(function(bool) { + if (bool) service.hasAlerts = true; + deferred.resolve(service.hasAlerts); + }); + + service.testInvalidAddrs().then(function(bool) { + if (bool) service.invalidAddresses = true; + deferred.resolve(service.invalidAddresses); + }); + + return deferred.promise; + } + + service.fetchGroupFines = function() { + return egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.usergroup.members.balance_owed', + egCore.auth.token(), service.current.usrgroup() + ).then(function(list) { + var total = 0; + angular.forEach(list, function(u) { + total += 100 * Number(u.balance_owed) + }); + service.patron_stats.fines.group_balance_owed = total / 100; + }); + } + + service.getUserStats = function(id) { + return egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.user.opac.vital_stats.authoritative', + egCore.auth.token(), id + ).then( + function(stats) { + // force numeric to ensure correct boolean handling in templates + stats.fines.balance_owed = Number(stats.fines.balance_owed); + stats.checkouts.overdue = Number(stats.checkouts.overdue); + stats.checkouts.claims_returned = + Number(stats.checkouts.claims_returned); + stats.checkouts.lost = Number(stats.checkouts.lost); + stats.checkouts.out = Number(stats.checkouts.out); + stats.checkouts.total_out = + stats.checkouts.out + stats.checkouts.overdue; + + stats.checkouts.total_out += Number(stats.checkouts.long_overdue); + + if (!egCore.env.aous['circ.do_not_tally_claims_returned']) + stats.checkouts.total_out += stats.checkouts.claims_returned; + + if (egCore.env.aous['circ.tally_lost']) + stats.checkouts.total_out += stats.checkouts.lost + + return stats; + } + ); + } + + // Fetches the IDs of any active non-cat checkouts for the current + // user. Also sets the patron_stats non_cat count value to match. + service.getUserNonCats = function(id) { + return egCore.net.request( + 'open-ils.circ', + 'open-ils.circ.open_non_cataloged_circulation.user.authoritative', + egCore.auth.token(), id + ).then(function(noncat_ids) { + service.noncat_ids = noncat_ids; + service.patron_stats.checkouts.noncat = noncat_ids.length; + }); + } + + // grab additional circ info + service.fetchUserStats = function() { + return service.getUserStats(service.current.id()) + .then(function(stats) { + service.patron_stats = stats + service.alert_penalties = service.current.standing_penalties() + .filter(function(pen) { + return pen.standing_penalty().staff_alert() == 't' + }); + + service.summary_stat_cats = []; + angular.forEach(service.current.stat_cat_entries(), + function(map) { + if (angular.isObject(map.stat_cat()) && + map.stat_cat().usr_summary() == 't') { + service.summary_stat_cats.push(map); + } + } + ); + + // run these two in parallel + var p1 = service.getUserNonCats(service.current.id()); + var p2 = service.fetchGroupFines(); + return $q.all([p1, p2]); + }); + } + + // Avoid using parens [e.g. (1.23)] to indicate negative numbers, + // which is the Angular default. + // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis + // FIXME: This change needs to be moved into a project-wide collection + // of locale overrides. + $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-'; + $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = ''; + + return service; +}]) + +/** + * Manages patron search + */ +.controller('BasePatronSearchCtrl', + ['$scope','$q','$routeParams','$timeout','$window','$location','egCore', + '$filter','egUser', 'patronSvc','egGridDataProvider','$document', + 'egProgressDialog', +function($scope, $q, $routeParams, $timeout, $window, $location, egCore, + $filter, egUser, patronSvc , egGridDataProvider , $document, + egProgressDialog) { + + $scope.focusMe = true; + $scope.searchArgs = { + // default to searching globally + home_ou : egCore.org.tree() + }; + + // last used patron search form element + var lastFormElement; + + $scope.gridControls = { + selectedItems : function() {return []} + } + + // Handle URL-encoded searches + if ($location.search().search) { + console.log('URL search = ' + $location.search().search); + patronSvc.urlSearch = {search : JSON2js($location.search().search)}; + + // why the double-JSON encoded sort? + if (patronSvc.urlSearch.search.search_sort) { + patronSvc.urlSearch.sort = + JSON2js(patronSvc.urlSearch.search.search_sort); + } else { + patronSvc.urlSearch.sort = []; + } + delete patronSvc.urlSearch.search.search_sort; + + // include inactive patrons if "inactive" param + if ($location.search().inactive) { + patronSvc.urlSearch.inactive = $location.search().inactive; + } + } + + var propagate; + var propagate_inactive; + if (patronSvc.lastSearch) { + propagate = patronSvc.lastSearch.search; + // home_ou needs to be treated specially + propagate.home_ou = { + value : patronSvc.lastSearch.home_ou, + group : 0 + }; + } else if (patronSvc.urlSearch) { + propagate = patronSvc.urlSearch.search; + if (patronSvc.urlSearch.inactive) { + propagate_inactive = patronSvc.urlSearch.inactive; + } + } + + if (egCore.env.pgt) { + $scope.profiles = egCore.env.pgt.list; + } else { + egCore.pcrud.search('pgt', {parent : null}, + {flesh : -1, flesh_fields : {pgt : ['children']}} + ).then( + function(tree) { + egCore.env.absorbTree(tree, 'pgt') + $scope.profiles = egCore.env.pgt.list; + } + ); + } + + if (propagate) { + // populate the search form with our cached / preexisting search info + angular.forEach(propagate, function(val, key) { + if (key == 'profile') + val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0]; + if (key == 'home_ou') + val.value = egCore.org.get(val.value); + $scope.searchArgs[key] = val.value; + }); + if (propagate_inactive) { + $scope.searchArgs.inactive = propagate_inactive; + } + } + + var provider = egGridDataProvider.instance({}); + + provider.get = function(offset, count) { + var deferred = $q.defer(); + + var fullSearch; + if (patronSvc.urlSearch) { + fullSearch = patronSvc.urlSearch; + // enusre the urlSearch only runs once. + delete patronSvc.urlSearch; + + } else { + patronSvc.search_barcode = $scope.searchArgs.card; + + var search = compileSearch($scope.searchArgs); + if (Object.keys(search) == 0) return $q.when(); + + var home_ou = search.home_ou; + delete search.home_ou; + var inactive = search.inactive; + delete search.inactive; + + fullSearch = { + search : search, + sort : compileSort(), + inactive : inactive, + home_ou : home_ou, + }; + } + + fullSearch.count = count; + fullSearch.offset = offset; + + if (patronSvc.lastSearch) { + // search repeated, return the cached results + if (angular.equals(fullSearch, patronSvc.lastSearch)) { + console.log('patron search returning ' + + patronSvc.patrons.length + ' cached results'); + + // notify has to happen after returning the promise + $timeout( + function() { + angular.forEach(patronSvc.patrons, function(user) { + deferred.notify(user); + }); + deferred.resolve(); + } + ); + return deferred.promise; + } + } + + patronSvc.lastSearch = fullSearch; + + if (fullSearch.search.id) { + // search by user id performs a direct ID lookup + var userId = fullSearch.search.id.value; + $timeout( + function() { + egUser.get(userId).then(function(user) { + patronSvc.localFlesh(user); + patronSvc.patrons = [user]; + deferred.notify(user); + deferred.resolve(); + }); + } + ); + return deferred.promise; + } + + if (!Object.keys(fullSearch.search).length) { + // Empty searches are rejected by the server. Avoid + // running the the empty search that runs on page load. + return $q.when(); + } + + egProgressDialog.open(); // Indeterminate + + patronSvc.patrons = []; + var which_sound = 'success'; + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.patron.search.advanced.fleshed', + egCore.auth.token(), + fullSearch.search, + fullSearch.count, + fullSearch.sort, + fullSearch.inactive, + fullSearch.home_ou, + egUser.defaultFleshFields, + fullSearch.offset + + ).then( + function() { + deferred.resolve(); + }, + function() { // onerror + which_sound = 'error'; + }, + function(user) { + // hide progress bar as soon as the first result appears. + egProgressDialog.close(); + patronSvc.localFlesh(user); // inline + patronSvc.patrons.push(user); + deferred.notify(user); + } + )['finally'](function() { // close on 0-hits or error + if (which_sound == 'success' && patronSvc.patrons.length == 0) { + which_sound = 'warning'; + } + egCore.audio.play(which_sound + '.patron.by_search'); + egProgressDialog.close(); + }); + + return deferred.promise; + }; + + $scope.patronSearchGridProvider = provider; + + // determine the tree depth of the profile group + $scope.pgt_depth = function(grp) { + var d = 0; + while (grp = egCore.env.pgt.map[grp.parent()]) d++; + return d; + } + + $scope.clearForm = function () { + $scope.searchArgs={}; + if (lastFormElement) lastFormElement.focus(); + } + + $scope.applyShowExtras = function($event, bool) { + if (bool) { + $scope.showExtras = true; + egCore.hatch.setItem('eg.circ.patron.search.show_extras', true); + } else { + $scope.showExtras = false; + egCore.hatch.removeItem('eg.circ.patron.search.show_extras'); + } + if (lastFormElement) lastFormElement.focus(); + $event.preventDefault(); + } + + egCore.hatch.getItem('eg.circ.patron.search.show_extras') + .then(function(val) {$scope.showExtras = val}); + + // map form arguments into search params + function compileSearch(args) { + var search = {}; + angular.forEach(args, function(val, key) { + if (!val) return; + if (key == 'profile' && args.profile) { + search.profile = {value : args.profile.id(), group : 0}; + } else if (key == 'home_ou' && args.home_ou) { + search.home_ou = args.home_ou.id(); // passed separately + } else if (key == 'inactive') { + search.inactive = val; + } else { + search[key] = {value : val, group : 0}; + } + if (key.match(/phone|ident/)) { + search[key].group = 2; + } else { + if (key.match(/street|city|state|post_code/)) { + search[key].group = 1; + } else if (key == 'card') { + search[key].group = 3 + } + } + }); + + return search; + } + + function compileSort() { + + if (!provider.sort.length) { + return [ // default + "family_name ASC", + "first_given_name ASC", + "second_given_name ASC", + "dob DESC" + ]; + } + + var sort = []; + angular.forEach( + provider.sort, + function(sortdef) { + if (angular.isObject(sortdef)) { + var name = Object.keys(sortdef)[0]; + var dir = sortdef[name]; + sort.push(name + ' ' + dir); + } else { + sort.push(sortdef); + } + } + ); + + return sort; + } + + $scope.setLastFormElement = function() { + lastFormElement = $document[0].activeElement; + } + + // search form submit action; tells the results grid to + // refresh itself. + $scope.search = function(args) { // args === $scope.searchArgs + if (args && Object.keys(args).length) + $scope.gridControls.refresh(); + if (lastFormElement) lastFormElement.focus(); + } + + // TODO: move this into the (forthcoming) grid row activate action + $scope.onPatronDblClick = function($event, user) { + $location.path('/circ/patron/' + user.id() + '/checkout'); + } + + if (patronSvc.urlSearch) { + // force the grid to load the url-based search on page load + provider.refresh(); + } + + $scope.need_two_selected = function() { + var items = $scope.gridControls.selectedItems(); + return (items.length == 2) ? false : true; + } + +}]) + diff --git a/Open-ILS/web/js/ui/default/staff/test/karma.conf.js b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js index 87d673130d..049f677dff 100644 --- a/Open-ILS/web/js/ui/default/staff/test/karma.conf.js +++ b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js @@ -44,6 +44,7 @@ module.exports = function(config){ 'services/ui.js', 'services/grid.js', 'services/op_change.js', + 'services/patron_search.js', 'services/navbar.js', 'services/date.js', // load app scripts 'app.js', -- 2.11.0