]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/web/js/ui/default/staff/circ/patron/app.js
d1ebcfc9a65b08f171760ada5c99f6feeed4f7a3
[working/Evergreen.git] / Open-ILS / web / js / ui / default / staff / circ / patron / app.js
1 /**
2  * Patron App
3  *
4  * Search, checkout, items out, holds, bills, edit, etc.
5  */
6
7 angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 
8     'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
9
10 .config(function($routeProvider, $locationProvider, $compileProvider) {
11     $locationProvider.html5Mode(true);
12     $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
13
14     // data loaded at startup which only requires an authtoken goes
15     // here. this allows the requests to be run in parallel instead of
16     // waiting until startup has completed.
17     var resolver = {delay : ['egCore','egUser', function(egCore , egUser) {
18
19         // fetch the org settings we care about during egStartup
20         // and toss them into egCore.env as egCore.env.aous[name] = value.
21         // note: only load settings here needed by all tabs; load tab-
22         // specific settings from within their respective controllers
23         egCore.env.classLoaders.aous = function() {
24             return egCore.org.settings([
25                 'circ.obscure_dob',
26                 'ui.circ.show_billing_tab_on_bills',
27                 'circ.patron_expires_soon_warning',
28                 'ui.circ.items_out.lost',
29                 'ui.circ.items_out.longoverdue',
30                 'ui.circ.items_out.claimsreturned'
31             ]).then(function(settings) { 
32                 // local settings are cached within egOrg.  Caching them
33                 // again in egEnv just simplifies the syntax for access.
34                 egCore.env.aous = settings;
35             });
36         }
37
38         // local stat cats are displayed in the summary bar on each page.
39         egCore.env.classLoaders.actsc = function() {
40             return egCore.pcrud.search('actsc', 
41                 {owner : egCore.org.ancestors(
42                     egCore.auth.user().ws_ou(), true)},
43                 {}, {atomic : true}
44             ).then(function(cats) {
45                 egCore.env.absorbList(cats, 'actsc');
46             });
47         }
48
49         egCore.env.loadClasses.push('aous');
50         egCore.env.loadClasses.push('actsc');
51
52         // app-globally modify the default flesh fields for 
53         // fleshed user retrieval.
54         if (egUser.defaultFleshFields.indexOf('profile') == -1) {
55             egUser.defaultFleshFields = egUser.defaultFleshFields.concat([
56                 'profile',
57                 'net_access_level',
58                 'ident_type',
59                 'ident_type2',
60                 'cards'
61             ]);
62         }
63
64         return egCore.startup.go()
65     }]};
66
67     $routeProvider.when('/circ/patron/search', {
68         templateUrl: './circ/patron/t_search',
69         controller: 'PatronSearchCtrl',
70         resolve : resolver
71     });
72
73     $routeProvider.when('/circ/patron/bcsearch', {
74         templateUrl: './circ/patron/t_bcsearch',
75         controller: 'PatronBarcodeSearchCtrl',
76         resolve : resolver
77     });
78
79     $routeProvider.when('/circ/patron/credentials', {
80         templateUrl: './circ/patron/t_credentials',
81         controller: 'PatronVerifyCredentialsCtrl',
82         resolve : resolver
83     });
84
85     $routeProvider.when('/circ/patron/last', {
86         templateUrl: './circ/patron/t_last_patron',
87         controller: 'PatronFetchLastCtrl',
88         resolve : resolver
89     });
90
91     // the following require a patron ID
92
93     $routeProvider.when('/circ/patron/:id/alerts', {
94         templateUrl: './circ/patron/t_alerts',
95         controller: 'PatronAlertsCtrl',
96         resolve : resolver
97     });
98
99     $routeProvider.when('/circ/patron/:id/checkout', {
100         templateUrl: './circ/patron/t_checkout',
101         controller: 'PatronCheckoutCtrl',
102         resolve : resolver
103     });
104
105     $routeProvider.when('/circ/patron/:id/items_out', {
106         templateUrl: './circ/patron/t_items_out',
107         controller: 'PatronItemsOutCtrl',
108         resolve : resolver
109     });
110
111     $routeProvider.when('/circ/patron/:id/holds', {
112         templateUrl: './circ/patron/t_holds',
113         controller: 'PatronHoldsCtrl',
114         resolve : resolver
115     });
116
117     $routeProvider.when('/circ/patron/:id/holds/create', {
118         templateUrl: './circ/patron/t_holds_create',
119         controller: 'PatronHoldsCreateCtrl',
120         resolve : resolver
121     });
122
123     $routeProvider.when('/circ/patron/:id/holds/:hold_id', {
124         templateUrl: './circ/patron/t_holds',
125         controller: 'PatronHoldsCtrl',
126         resolve : resolver
127     });
128
129     $routeProvider.when('/circ/patron/:id/hold/:hold_id', {
130         templateUrl: './circ/patron/t_hold_details',
131         controller: 'PatronHoldDetailsCtrl',
132         resolve : resolver
133     });
134
135     $routeProvider.when('/circ/patron/:id/bills', {
136         templateUrl: './circ/patron/t_bills',
137         controller: 'PatronBillsCtrl',
138         resolve : resolver
139     });
140
141     $routeProvider.when('/circ/patron/:id/bill/:xact_id', {
142         templateUrl: './circ/patron/t_xact_details',
143         controller: 'XactDetailsCtrl',
144         resolve : resolver
145     });
146
147     $routeProvider.when('/circ/patron/:id/bill_history/:history_tab', {
148         templateUrl: './circ/patron/t_bill_history',
149         controller: 'BillHistoryCtrl',
150         resolve : resolver
151     });
152
153     $routeProvider.when('/circ/patron/:id/messages', {
154         templateUrl: './circ/patron/t_messages',
155         controller: 'PatronMessagesCtrl',
156         resolve : resolver
157     });
158
159     $routeProvider.when('/circ/patron/:id/edit', {
160         templateUrl: './circ/patron/t_edit',
161         controller: 'PatronEditCtrl',
162         resolve : resolver
163     });
164
165     $routeProvider.when('/circ/patron/:id/credentials', {
166         templateUrl: './circ/patron/t_credentials',
167         controller: 'PatronVerifyCredentialsCtrl',
168         resolve : resolver
169     });
170
171     $routeProvider.when('/circ/patron/:id/notes', {
172         templateUrl: './circ/patron/t_notes',
173         controller: 'PatronNotesCtrl',
174         resolve : resolver
175     });
176
177     $routeProvider.when('/circ/patron/:id/triggered_events', {
178         templateUrl: './circ/patron/t_triggered_events',
179         controller: 'PatronTriggeredEventsCtrl',
180         resolve : resolver
181     });
182
183     $routeProvider.when('/circ/patron/:id/edit_perms', {
184         templateUrl: './circ/patron/t_edit_perms',
185         controller: 'PatronPermsCtrl',
186         resolve : resolver
187     });
188
189     $routeProvider.when('/circ/patron/:id/group', {
190         templateUrl: './circ/patron/t_group',
191         controller: 'PatronGroupCtrl',
192         resolve : resolver
193     });
194
195     $routeProvider.when('/circ/patron/:id/stat_cats', {
196         templateUrl: './circ/patron/t_stat_cats',
197         controller: 'PatronStatCatsCtrl',
198         resolve : resolver
199     });
200
201     $routeProvider.otherwise({redirectTo : '/circ/patron/search'});
202 })
203
204 /**
205  * Patron service
206  */
207 .factory('patronSvc',
208        ['$q','$timeout','$location','egCore','egUser','$locale',
209 function($q , $timeout , $location , egCore,  egUser , $locale) {
210
211     var service = {
212         // cached patron search results
213         patrons : [],
214
215         // currently selected patron object
216         current : null, 
217
218         // patron circ stats (overdues, fines, holds)
219         patron_stats : null,
220
221         // event types manually overridden, which should always be
222         // overridden for checkouts to this patron for this instance of
223         // the interface.
224         checkout_overrides : {},
225     };
226
227     // when we change the default patron, we need to clear out any
228     // data collected on that patron
229     service.resetPatronLists = function() {
230         service.checkouts = [];
231         service.items_out = []
232         service.items_out_ids = [];
233         service.holds = [];
234         service.hold_ids = [];
235         service.checkout_overrides = {};
236         service.patron_stats = null;
237         service.hasAlerts = false;
238         service.alertsShown = false;
239         service.patronExpired = false;
240         service.patronExpiresSoon = false;
241         service.retrievedWithInactive = false;
242         service.invalidAddresses = false;
243     }
244     service.resetPatronLists();  // initialize
245
246     // shortcut to force-reload the current primary
247     service.refreshPrimary = function() {
248         if (!service.current) return $q.when();
249         return service.setPrimary(service.current.id(), null, true);
250     }
251
252     // clear the currently focused user
253     service.clearPrimary = function() {
254         // reset with no patron
255         service.resetPatronLists();
256         service.current = null;
257         service.patron_stats = null;
258         return $q.when();
259     }
260
261     // sets the primary display user, fetching data as necessary.
262     service.setPrimary = function(id, user, force) {
263         var user_id = id ? id : (user ? user.id() : null);
264
265         console.debug('setting primary user to: ' + user_id);
266
267         if (!user_id) return $q.reject();
268
269         // when loading a new patron, update the last patron setting
270         if (!service.current || service.current.id() != user_id)
271             egCore.hatch.setLocalItem('eg.circ.last_patron', user_id);
272
273         // avoid running multiple retrievals for the same patron, which
274         // can happen during dbl-click by maintaining a single running
275         // data retrieval promise
276         if (service.primaryUserPromise) {
277             if (service.primaryUserId == user_id) {
278                 return service.primaryUserPromise.promise;
279             } else {
280                 service.primaryUserPromise = null;
281             }
282         }
283
284         service.primaryUserPromise = $q.defer();
285         service.primaryUserId = user_id;
286
287         service.getPrimary(id, user, force)
288         .then(function() {
289             var p = service.primaryUserPromise;
290             service.primaryUserId = null;
291             // clear before resolution just to be safe.
292             service.primaryUserPromise = null;
293             p.resolve();
294         });
295
296         return service.primaryUserPromise.promise;
297     }
298
299     service.getPrimary = function(id, user, force) {
300
301         if (user) {
302             if (!force && service.current && 
303                 service.current.id() == user.id()) {
304                 if (service.patron_stats) {
305                     return $q.when();
306                 } else {
307                     return service.fetchUserStats();
308                 }
309             }
310
311             service.resetPatronLists();
312             service.current = user;
313             service.localFlesh(user);
314             return service.fetchUserStats();
315
316         } else if (id) {
317             if (!force && service.current && service.current.id() == id) {
318                 if (service.patron_stats) {
319                     return $q.when();
320                 } else {
321                     return service.fetchUserStats();
322                 }
323             }
324
325             service.resetPatronLists();
326
327             return egUser.get(id).then(
328                 function(user) {
329                     service.current = user;
330                     service.localFlesh(user);
331                     return service.fetchUserStats();
332                 },
333                 function(err) {
334                     console.error(
335                         "unable to fetch user "+id+': '+js2JSON(err))
336                 }
337             );
338         } else {
339
340             // fetching a null user clears the primary user.
341             // NOTE: this should probably reject() and log an error, 
342             // but calling clear for backwards compat for now.
343             return service.clearPrimary();
344         }
345     }
346
347     // flesh some additional user fields locally
348     service.localFlesh = function(user) {
349         if (!angular.isObject(typeof user.home_ou()))
350             user.home_ou(egCore.org.get(user.home_ou()));
351
352         angular.forEach(
353             user.standing_penalties(),
354             function(penalty) {
355                 if (!angular.isObject(penalty.org_unit()))
356                     penalty.org_unit(egCore.org.get(penalty.org_unit()));
357             }
358         );
359
360         // stat_cat_entries == stat_cat_entry_user_map
361         angular.forEach(user.stat_cat_entries(), function(map) {
362             if (angular.isObject(map.stat_cat())) return;
363             // At page load, we only retrieve org-visible stat cats.
364             // For the common case, ignore entries for remote stat cats.
365             var cat = egCore.env.actsc.map[map.stat_cat()];
366             if (cat) {
367                 map.stat_cat(cat);
368                 cat.owner(egCore.org.get(cat.owner()));
369             }
370         });
371     }
372
373     // resolves to true if the patron account has expired or will
374     // expire soon, based on YAOUS circ.patron_expires_soon_warning
375     // note: returning a promise is no longer strictly necessary
376     // (no more async activity) if the calling function is changed too.
377     service.testExpire = function() {
378
379         var expire = Date.parse(service.current.expire_date());
380         if (expire < new Date()) {
381             return $q.when(service.patronExpired = true);
382         }
383
384         var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
385         if (Number(soon)) {
386             var preExpire = new Date();
387             preExpire.setDate(preExpire.getDate() + Number(soon));
388             if (expire < preExpire) 
389                 return $q.when(service.patronExpiresSoon = true);
390         }
391
392         return $q.when(false);
393     }
394
395     // resolves to true if the patron account has any invalid addresses.
396     service.testInvalidAddrs = function() {
397
398         if (service.invalidAddresses)
399             return $q.when(true);
400
401         var fail = false;
402
403         angular.forEach(
404             service.current.addresses(), 
405             function(addr) { if (addr.valid() == 'f') fail = true }
406         );
407
408         return $q.when(fail);
409     }
410
411     // resolves to true if there is any aspect of the patron account
412     // which should produce a message in the alerts panel
413     service.checkAlerts = function() {
414
415         if (service.hasAlerts) // already checked
416             return $q.when(true); 
417
418         var deferred = $q.defer();
419         var p = service.current;
420
421         if (service.alert_penalties.length ||
422             p.alert_message() ||
423             p.active() == 'f' ||
424             p.barred() == 't' ||
425             service.patron_stats.holds.ready) {
426
427             service.hasAlerts = true;
428         }
429
430         // see if the user was retrieved with an inactive card
431         if (bc = $location.search().card) {
432             var card = p.cards().filter(
433                 function(c) { return c.barcode() == bc })[0];
434
435             if (card && card.active() == 'f') {
436                 service.hasAlerts = true;
437                 service.retrievedWithInactive = true;
438             }
439         }
440
441         // regardless of whether we know of alerts, we still need 
442         // to test/fetch the expire data for display
443         service.testExpire().then(function(bool) {
444             if (bool) service.hasAlerts = true;
445             deferred.resolve(service.hasAlerts);
446         });
447
448         service.testInvalidAddrs().then(function(bool) {
449             if (bool) service.invalidAddresses = true;
450             deferred.resolve(service.invalidAddresses);
451         });
452
453         return deferred.promise;
454     }
455
456     service.fetchGroupFines = function() {
457         return egCore.net.request(
458             'open-ils.actor',
459             'open-ils.actor.usergroup.members.balance_owed',
460             egCore.auth.token(), service.current.usrgroup()
461         ).then(function(list) {
462             var total = 0;
463             angular.forEach(list, function(u) { 
464                 total += 100 * Number(u.balance_owed)
465             });
466             service.patron_stats.fines.group_balance_owed = total / 100;
467         });
468     }
469
470     service.getUserStats = function(id) {
471         return egCore.net.request(
472             'open-ils.actor',
473             'open-ils.actor.user.opac.vital_stats.authoritative', 
474             egCore.auth.token(), id
475         ).then(
476             function(stats) {
477                 // force numeric to ensure correct boolean handling in templates
478                 stats.fines.balance_owed = Number(stats.fines.balance_owed);
479                 stats.checkouts.overdue = Number(stats.checkouts.overdue);
480                 stats.checkouts.claims_returned = 
481                     Number(stats.checkouts.claims_returned);
482                 stats.checkouts.lost = Number(stats.checkouts.lost);
483                 stats.checkouts.out = Number(stats.checkouts.out);
484                 stats.checkouts.total_out = 
485                     stats.checkouts.out + stats.checkouts.overdue;
486                 return stats;
487             }
488         );
489     }
490
491
492     // grab additional circ info
493     service.fetchUserStats = function() {
494         return service.getUserStats(service.current.id())
495         .then(function(stats) {
496             service.patron_stats = stats
497             service.alert_penalties = service.current.standing_penalties()
498                 .filter(function(pen) { 
499                 return pen.standing_penalty().staff_alert() == 't' 
500             });
501
502             service.summary_stat_cats = [];
503             angular.forEach(service.current.stat_cat_entries(), 
504                 function(map) {
505                     if (angular.isObject(map.stat_cat()) &&
506                         map.stat_cat().usr_summary() == 't') {
507                         service.summary_stat_cats.push(map);
508                     }
509                 }
510             );
511
512             return service.fetchGroupFines();
513         });
514     }
515
516     // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
517     // which is the Angular default.
518     // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
519     // FIXME: This change needs to be moved into a project-wide collection
520     // of locale overrides.
521     $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
522     $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
523
524     return service;
525 }])
526
527 /**
528  * Manages tabbed patron view.
529  * This is the parent scope of all patron tab scopes.
530  *
531  * */
532 .controller('PatronCtrl',
533        ['$scope','$q','$location','$filter','egCore','egUser','patronSvc',
534 function($scope,  $q,  $location , $filter,  egCore,  egUser,  patronSvc) {
535
536     // returns true if a redirect occurs
537     function redirectToAlertPanel() {
538
539         $scope.alert_penalties = 
540             function() {return patronSvc.alert_penalties}
541
542         if (patronSvc.alertsShown) return false;
543         patronSvc.alertsShown = true;
544
545         // if the patron has any unshown alerts, show them now
546         if (patronSvc.hasAlerts && 
547             !$location.path().match(/alerts$/)) {
548
549             $location
550                 .path('/circ/patron/' + patronSvc.current.id() + '/alerts')
551                 .search('card', null);
552             return true;
553         }
554
555         // no alert required.  If the patron has fines and the show-bills
556         // OUS is applied, direct to the bills page.
557         if ($scope.patron_stats().fines.balance_owed > 0 // TODO: != 0 ?
558             && egCore.env.aous['ui.circ.show_billing_tab_on_bills']
559             && !$location.path().match(/bills$/)) {
560
561             $location
562                 .path('/circ/patron/' + patronSvc.current.id() + '/bills')
563                 .search('card', null);
564
565             return true;
566         }
567
568         return false;
569     }
570
571     // called after each route-specified controller is instantiated.
572     // this doubles as a way to inform the top-level controller that
573     // egStartup.go() has completed, which means we are clear to 
574     // fetch the patron, etc.
575     $scope.initTab = function(tab, patron_id) {
576         console.log('init tab ' + tab);
577         $scope.tab = tab;
578         $scope.aous = egCore.env.aous;
579
580         if (patron_id) {
581             $scope.patron_id = patron_id;
582             return patronSvc.setPrimary($scope.patron_id)
583             .then(function() {return patronSvc.checkAlerts()})
584             .then(redirectToAlertPanel);
585         }
586         return $q.when();
587     }
588
589     $scope.patron = function() { return patronSvc.current }
590     $scope.patron_stats = function() { return patronSvc.patron_stats }
591     $scope.summary_stat_cats = function() { return patronSvc.summary_stat_cats }
592
593     $scope.print_address = function(addr) {
594         egCore.print.print({
595             context : 'default', 
596             template : 'patron_address', 
597             scope : {
598                 patron : egCore.idl.toHash(patronSvc.current),
599                 address : egCore.idl.toHash(addr)
600             }
601         });
602     }
603
604     $scope.toggle_expand_summary = function() {
605         if ($scope.collapsePatronSummary) {
606             $scope.collapsePatronSummary = false;
607             egCore.hatch.removeItem('eg.circ.patron.summary.collapse');
608         } else {
609             $scope.collapsePatronSummary = true;
610             egCore.hatch.setItem('eg.circ.patron.summary.collapse', true);
611         }
612     }
613     
614     // always expand the patron summary in the search UI, regardless
615     // of stored preference.
616     $scope.collapse_summary = function() {
617         return $scope.tab != 'search' && $scope.collapsePatronSummary;
618     }
619
620     egCore.hatch.getItem('eg.circ.patron.summary.collapse')
621     .then(function(val) {$scope.collapsePatronSummary = Boolean(val)});
622 }])
623
624 .controller('PatronBarcodeSearchCtrl',
625        ['$scope','$location','egCore','egConfirmDialog','egUser','patronSvc',
626 function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) {
627     $scope.selectMe = true; // focus text input
628     patronSvc.clearPrimary(); // clear the default user
629
630     // jump to the patron checkout UI
631     function loadPatron(user_id) {
632         $location
633         .path('/circ/patron/' + user_id + '/checkout')
634         .search('card', $scope.args.barcode);
635     }
636
637     // create an opt-in=yes response for the loaded user
638     function createOptIn(user_id) {
639         egCore.net.request(
640             'open-ils.actor',
641             'open-ils.actor.user.org_unit_opt_in.create',
642             egCore.auth.token(), user_id).then(function(resp) {
643                 if (evt = egCore.evt.parse(resp)) return alert(evt);
644                 loadPatron(user_id);
645             }
646         );
647     }
648
649     $scope.submitBarcode = function(args) {
650         $scope.bcNotFound = null;
651         if (!args.barcode) return;
652
653         // blur so next time it's set to true it will re-apply select()
654         $scope.selectMe = false;
655
656         var user_id;
657
658         // lookup barcode
659         egCore.net.request(
660             'open-ils.actor',
661             'open-ils.actor.get_barcodes',
662             egCore.auth.token(), egCore.auth.user().ws_ou(), 
663             'actor', args.barcode)
664
665         .then(function(resp) { // get_barcodes
666
667             if (evt = egCore.evt.parse(resp)) {
668                 alert(evt); // FIXME
669                 return;
670             }
671
672             if (!resp || !resp[0]) {
673                 $scope.bcNotFound = args.barcode;
674                 $scope.selectMe = true;
675                 return;
676             }
677
678             // see if an opt-in request is needed
679             user_id = resp[0].id;
680             return egCore.net.request(
681                 'open-ils.actor',
682                 'open-ils.actor.user.org_unit_opt_in.check',
683                 egCore.auth.token(), user_id);
684
685         }).then(function(optInResp) { // opt_in_check
686
687             if (evt = egCore.evt.parse(optInResp)) {
688                 alert(evt); // FIXME
689                 return;
690             }
691            
692             if (optInResp == 1) {
693                 // opt-in handled or not needed
694                 return loadPatron(user_id);
695             }
696
697             // opt-in needed, show the opt-in dialog
698             egUser.get(user_id, {useFields : []})
699
700             .then(function(user) { // retrieve user
701                 egConfirmDialog.open(
702                     egCore.strings.OPT_IN_DIALOG, '',
703                     {   org : egCore.org.get(user.home_ou()),
704                         user : user,
705                         ok : function() { createOptIn(user.id()) },
706                         cancel : function() {}
707                     }
708                 );
709             })
710         });
711     }
712 }])
713
714
715 /**
716  * Manages patron search
717  */
718 .controller('PatronSearchCtrl',
719        ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
720        '$filter','egUser', 'patronSvc','egGridDataProvider',
721 function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
722         $filter,  egUser,  patronSvc , egGridDataProvider) {
723
724     $scope.initTab('search');
725     $scope.focusMe = true;
726     $scope.searchArgs = {
727         // default to searching globally
728         home_ou : egCore.org.tree()
729     };
730
731     $scope.gridControls = {
732         activateItem : function(item) {
733             $location.path('/circ/patron/' + item.id() + '/checkout');
734         },
735         selectedItems : function() {return []}
736     }
737
738     // Handle URL-encoded searches
739     if ($location.search().search) {
740         patronSvc.urlSearch = {search : JSON2js($location.search().search)};
741
742         // why the double-JSON encoded sort?
743         patronSvc.urlSearch.sort = 
744             JSON2js(patronSvc.urlSearch.search.search_sort);
745         delete patronSvc.urlSearch.search.search_sort;
746     }
747
748     var propagate;
749     if (patronSvc.lastSearch) {
750         propagate = patronSvc.lastSearch.search;
751     } else if (patronSvc.urlSearch) {
752         propagate = patronSvc.urlSearch.search;
753     }
754
755     if (propagate) {
756         // populate the search form with our cached / preexisting search info
757         angular.forEach(propagate, function(val, key) {
758             $scope.searchArgs[key] = val.value;
759         });
760     }
761
762     var provider = egGridDataProvider.instance({});
763
764     $scope.$watch(
765         function() {return $scope.gridControls.selectedItems()},
766         function(list) {
767             if (list[0]) 
768                 patronSvc.setPrimary(null, list[0]);
769         },
770         true
771     );
772         
773     provider.get = function(offset, count) {
774         var deferred = $q.defer();
775
776         var fullSearch;
777         if (patronSvc.urlSearch) {
778             fullSearch = patronSvc.urlSearch;
779             // enusre the urlSearch only runs once.
780             delete patronSvc.urlSearch;
781
782         } else {
783
784             var search = compileSearch($scope.searchArgs);
785             if (Object.keys(search) == 0) return $q.when();
786
787             var home_ou = search.home_ou;
788             delete search.home_ou;
789             var inactive = search.inactive;
790             delete search.inactive;
791
792             fullSearch = {
793                 search : search,
794                 sort : compileSort(),
795                 inactive : inactive,
796                 home_ou : home_ou,
797             };
798         }
799
800         fullSearch.count = count;
801         fullSearch.offset = offset;
802
803         if (patronSvc.lastSearch) {
804             // search repeated, return the cached results
805             if (angular.equals(fullSearch, patronSvc.lastSearch)) {
806                 console.log('patron search returning ' + 
807                     patronSvc.patrons.length + ' cached results');
808                 
809                 // notify has to happen after returning the promise
810                 $timeout(
811                     function() {
812                         angular.forEach(patronSvc.patrons, function(user) {
813                             deferred.notify(user);
814                         });
815                         deferred.resolve();
816                     }
817                 );
818                 return deferred.promise;
819             }
820         }
821
822         patronSvc.lastSearch = fullSearch;
823
824         if (fullSearch.search.id) {
825             // search by user id performs a direct ID lookup
826             var userId = fullSearch.search.id.value;
827             $timeout(
828                 function() {
829                     egUser.get(userId).then(function(user) {
830                         patronSvc.localFlesh(user);
831                         patronSvc.patrons = [user];
832                         deferred.notify(user);
833                         deferred.resolve();
834                     });
835                 }
836             );
837             return deferred.promise;
838         }
839
840         patronSvc.patrons = [];
841         egCore.net.request(
842             'open-ils.actor',
843             'open-ils.actor.patron.search.advanced.fleshed',
844             egCore.auth.token(), 
845             fullSearch.search, 
846             fullSearch.count,
847             fullSearch.sort,
848             fullSearch.inactive,
849             fullSearch.home_ou,
850             egUser.defaultFleshFields,
851             fullSearch.offset
852
853         ).then(
854             function() { deferred.resolve() },
855             null, // onerror
856             function(user) {
857                 patronSvc.localFlesh(user); // inline
858                 patronSvc.patrons.push(user);
859                 deferred.notify(user);
860             }
861         );
862
863         return deferred.promise;
864     };
865
866     $scope.patronSearchGridProvider = provider;
867
868     if (egCore.env.pgt) {
869         $scope.profiles = egCore.env.pgt.list;
870     } else {
871         egCore.pcrud.search('pgt', {parent : null}, 
872             {flesh : -1, flesh_fields : {pgt : ['children']}}
873         ).then(
874             function(tree) {
875                 egCore.env.absorbTree(tree, 'pgt')
876                 $scope.profiles = egCore.env.pgt.list;
877             }
878         );
879     }
880
881     // determine the tree depth of the profile group
882     $scope.pgt_depth = function(grp) {
883         var d = 0;
884         while (grp = egCore.env.pgt.map[grp.parent()]) d++;
885         return d;
886     }
887
888     $scope.applyShowExtras = function($event, bool) {
889         if (bool) {
890             $scope.showExtras = true;
891             egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
892         } else {
893             $scope.showExtras = false;
894             egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
895         }
896         $event.preventDefault();
897     }
898
899     egCore.hatch.getItem('eg.prefs.circ.patron.search.showExtras')
900     .then(function(val) {$scope.showExtras = val});
901
902     // map form arguments into search params
903     function compileSearch(args) {
904         var search = {};
905         angular.forEach(args, function(val, key) {
906             if (!val) return;
907             if (key == 'profile' && args.profile) {
908                 search.profile = {value : args.profile.id(), group : 0};
909             } else if (key == 'home_ou' && args.home_ou) {
910                 search.home_ou = args.home_ou.id(); // passed separately
911             } else if (key == 'inactive') {
912                 search.inactive = val;
913             } else {
914                 search[key] = {value : val, group : 0};
915             }
916             if (key.match(/phone|ident/)) {
917                 search[key].group = 2;
918             } else {
919                 if (key.match(/street|city|state|post_code/)) {
920                     search[key].group = 1;
921                 } else if (key == 'card') {
922                     search[key].group = 3
923                 }
924             }
925         });
926
927         return search;
928     }
929
930     function compileSort() {
931
932         if (!provider.sort.length) {
933             return [ // default
934                 "family_name ASC",
935                 "first_given_name ASC",
936                 "second_given_name ASC",
937                 "dob DESC"
938             ];
939         }
940
941         var sort = [];
942         angular.forEach(
943             provider.sort,
944             function(sortdef) {
945                 if (angular.isObject(sortdef)) {
946                     var name = Object.keys(sortdef)[0];
947                     var dir = sortdef[name];
948                     sort.push(name + ' ' + dir);
949                 } else {
950                     sort.push(sortdef);
951                 }
952             }
953         );
954
955         return sort;
956     }
957
958     // search form submit action; tells the results grid to
959     // refresh itself.
960     $scope.search = function(args) { // args === $scope.searchArgs
961         if (args && Object.keys(args).length) 
962             $scope.gridControls.refresh();
963     }
964
965     // TODO: move this into the (forthcoming) grid row activate action
966     $scope.onPatronDblClick = function($event, user) {
967         $location.path('/circ/patron/' + user.id() + '/checkout');
968     }
969
970     if (patronSvc.urlSearch) {
971         // force the grid to load the url-based search on page load
972         provider.refresh();
973     }
974    
975 }])
976
977 /**
978  * Manages messages
979  */
980 .controller('PatronMessagesCtrl',
981        ['$scope','$q','$routeParams','egCore','$modal','patronSvc','egCirc',
982 function($scope , $q , $routeParams,  egCore , $modal , patronSvc , egCirc) {
983     $scope.initTab('messages', $routeParams.id);
984     var usr_id = $routeParams.id;
985
986     // setup date filters
987     var start = new Date(); // now - 1 year
988     start.setFullYear(start.getFullYear() - 1),
989     $scope.dates = {
990         start_date : start,
991         end_date : new Date()
992     }
993
994     function date_range() {
995         var start = $scope.dates.start_date.toISOString().replace(/T.*/,'');
996         var end = $scope.dates.end_date.toISOString().replace(/T.*/,'');
997         var today = new Date().toISOString().replace(/T.*/,'');
998         if (end == today) end = 'now';
999         return [start, end];
1000     }
1001
1002     // grid queries
1003    
1004     var activeGrid = $scope.activeGridControls = {
1005         setSort : function() {
1006             return ['set_date'];
1007         },
1008         setQuery : function() {
1009             return {
1010                 usr : usr_id,
1011                 '-or' : [
1012                     {stop_date : null},
1013                     {stop_date : {'>' : 'now'}}
1014                 ]
1015             }
1016         }
1017     }
1018
1019     var archiveGrid = $scope.archiveGridControls = {
1020         setSort : function() {
1021             return ['set_date'];
1022         },
1023         setQuery : function() {
1024             return {
1025                 usr : usr_id, 
1026                 stop_date : {'<=' : 'now'},
1027                 set_date : {between : date_range()}
1028             };
1029         }
1030     };
1031
1032     $scope.removePenalty = function(selected) {
1033         // the grid stores flattened penalties.  Fetch penalty objects first
1034
1035         var ids = selected.map(function(s){ return s.id });
1036         egCore.pcrud.search('ausp', 
1037             {id : ids}, {}, 
1038             {atomic : true, authoritative : true}
1039
1040         // then delete them
1041         ).then(function(penalties) {
1042             return egCore.pcrud.remove(penalties);
1043
1044         // then refresh the grid
1045         }).then(function() {
1046             activeGrid.refresh();
1047         });
1048     }
1049
1050     $scope.archivePenalty = function(selected) {
1051         // the grid stores flattened penalties.  Fetch penalty objects first
1052
1053         var ids = selected.map(function(s){ return s.id });
1054         egCore.pcrud.search('ausp', 
1055             {id : ids}, {}, 
1056             {atomic : true, authoritative : true}
1057
1058         // then delete them
1059         ).then(function(penalties) {
1060             angular.forEach(penalties, function(p){ p.stop_date('now') });
1061             return egCore.pcrud.update(penalties);
1062
1063         // then refresh the grid
1064         }).then(function() {
1065             activeGrid.refresh();
1066             archiveGrid.refresh();
1067         });
1068     }
1069
1070     // leverage egEnv for caching
1071     function fetchPenaltyTypes() {
1072         if (egCore.env.csp) 
1073             return $q.when(egCore.env.csp.list);
1074         return egCore.pcrud.search(
1075             // id <= 100 are reserved for system use
1076             'csp', {id : {'>': 100}}, {}, {atomic : true})
1077         .then(function(penalties) {
1078             egCore.env.absorbList(penalties, 'csp');
1079             return penalties;
1080         });
1081     }
1082
1083     $scope.createPenalty = function() {
1084         egCirc.create_penalty(usr_id).then(function() {
1085             activeGrid.refresh();
1086             // force a refresh of the user, since they may now
1087             // have blocking penalties, etc.
1088             patronSvc.setPrimary(patronSvc.current.id(), null, true);
1089         });
1090     }
1091
1092     $scope.editPenalty = function(selected) {
1093         if (selected.length == 0) return;
1094
1095         // grab the penalty from the user object
1096         var penalty = patronSvc.current.standing_penalties().filter(
1097             function(p) {return p.id() == selected[0].id})[0];
1098
1099         egCirc.edit_penalty(penalty).then(function() {
1100             activeGrid.refresh();
1101             // force a refresh of the user, since they may now
1102             // have blocking penalties, etc.
1103             patronSvc.setPrimary(patronSvc.current.id(), null, true);
1104         });
1105     }
1106 }])
1107
1108
1109 /**
1110  * Link to patron edit UI
1111  */
1112 .controller('PatronEditCtrl',
1113        ['$scope','$routeParams','$location','egCore','patronSvc',
1114 function($scope,  $routeParams,  $location , egCore , patronSvc) {
1115     $scope.initTab('edit', $routeParams.id);
1116
1117     var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/register');
1118     url += '?usr=' + encodeURIComponent($routeParams.id);
1119
1120     $scope.funcs = {
1121         on_save : function() {
1122             patronSvc.refreshPrimary();
1123         }
1124     }
1125
1126     $scope.patron_edit_url = url;
1127 }])
1128
1129 /**
1130  * Credentials tester
1131  */
1132 .controller('PatronVerifyCredentialsCtrl',
1133        ['$scope','$routeParams','$location','egCore',
1134 function($scope,  $routeParams , $location , egCore) {
1135     $scope.verified = null;
1136     $scope.focusMe = true;
1137
1138     // called with a patron, pre-populate the form args
1139     $scope.initTab('other', $routeParams.id).then(
1140         function() {
1141             if ($scope.patron()) {
1142                 $scope.prepop = true;
1143                 $scope.username = $scope.patron().usrname();
1144                 $scope.barcode = $scope.patron().card().barcode();
1145             }
1146         }
1147     );
1148
1149     // verify login credentials
1150     $scope.verify = function() {
1151         $scope.verified = null;
1152         $scope.notFound = false;
1153
1154         egCore.net.request(
1155             'open-ils.actor',
1156             'open-ils.actor.verify_user_password',
1157             egCore.auth.token(), $scope.barcode,
1158             $scope.username, hex_md5($scope.password || '')
1159
1160         ).then(function(resp) {
1161             $scope.focusMe = true;
1162             if (evt = egCore.evt.parse(resp)) {
1163                 alert(evt);
1164             } else if (resp == 1) {
1165                 $scope.verified = true;
1166             } else {
1167                 $scope.verified = false;
1168             }
1169         });
1170     }
1171
1172     // load the main patron UI for the provided username or barcode
1173     $scope.load = function($event) {
1174         $scope.notFound = false;
1175         $scope.verified = null;
1176
1177         egCore.net.request(
1178             'open-ils.actor',
1179             'open-ils.actor.user.retrieve_id_by_barcode_or_username',
1180             egCore.auth.token(), $scope.barcode, $scope.username
1181
1182         ).then(function(resp) {
1183
1184             if (Number(resp)) {
1185                 $location.path('/circ/patron/' + resp + '/checkout');
1186                 return;
1187             }
1188
1189             // something went wrong...
1190             $scope.focusMe = true;
1191             if (evt = egCore.evt.parse(resp)) {
1192                 if (evt.textcode == 'ACTOR_USR_NOT_FOUND') {
1193                     $scope.notFound = true;
1194                     return;
1195                 }
1196                 return alert(evt);
1197             } else {
1198                 alert(resp);
1199             }
1200         });
1201
1202         // load() button sits within the verify form.  
1203         // avoid submitting the verify() form action on load()
1204         $event.preventDefault();
1205     }
1206 }])
1207
1208 .controller('PatronAlertsCtrl',
1209        ['$scope','$routeParams','$location','egCore','patronSvc',
1210 function($scope,  $routeParams , $location , egCore , patronSvc) {
1211
1212     $scope.initTab('other', $routeParams.id)
1213     .then(function() {
1214         $scope.patronExpired = patronSvc.patronExpired;
1215         $scope.patronExpiresSoon = patronSvc.patronExpiresSoon;
1216         $scope.retrievedWithInactive = patronSvc.retrievedWithInactive;
1217         $scope.invalidAddresses = patronSvc.invalidAddresses;
1218     });
1219
1220 }])
1221
1222 .controller('PatronNotesCtrl',
1223        ['$scope','$routeParams','$location','egCore','patronSvc','$modal',
1224 function($scope,  $routeParams , $location , egCore , patronSvc , $modal) {
1225     $scope.initTab('other', $routeParams.id);
1226     var usr_id = $routeParams.id;
1227
1228     // fetch the notes
1229     function refreshPage() {
1230         $scope.notes = [];
1231         egCore.pcrud.search('aun', 
1232             {usr : usr_id}, 
1233             {flesh : 1, flesh_fields : {aun : ['creator']}}, 
1234             {authoritative : true})
1235         .then(null, null, function(note) {
1236             $scope.notes.push(note);
1237         });
1238     }
1239
1240     // open the new-note dialog and create the note
1241     $scope.newNote = function() {
1242         $modal.open({
1243             templateUrl: './circ/patron/t_new_note_dialog',
1244             controller: 
1245                 ['$scope', '$modalInstance',
1246             function($scope, $modalInstance) {
1247                 $scope.focusNote = true;
1248                 $scope.args = {};
1249                 $scope.ok = function(count) { $modalInstance.close($scope.args) }
1250                 $scope.cancel = function () { $modalInstance.dismiss() }
1251             }],
1252         }).result.then(
1253             function(args) {
1254                 if (!args.value) return;
1255                 var note = new egCore.idl.aun();
1256                 note.usr(usr_id);
1257                 note.title(args.title);
1258                 note.value(args.value);
1259                 note.pub(args.pub ? 't' : 'f');
1260                 note.creator(egCore.auth.user().id());
1261                 egCore.pcrud.create(note).then(function() {refreshPage()});
1262             }
1263         );
1264     }
1265
1266     // delete the selected note
1267     $scope.deleteNote = function(note) {
1268         egCore.pcrud.remove(note).then(function() {refreshPage()});
1269     }
1270
1271     // print the selected note
1272     $scope.printNote = function(note) {
1273         var hash = egCore.idl.toHash(note);
1274         hash.usr = egCore.idl.toHash($scope.patron());
1275         egCore.print.print({
1276             context : 'default', 
1277             template : 'patron_note', 
1278             scope : {note : hash}
1279         });
1280     }
1281
1282     // perform the initial note fetch
1283     refreshPage();
1284 }])
1285
1286 .controller('PatronGroupCtrl',
1287        ['$scope','$routeParams','$q','$window','$location','egCore',
1288         'patronSvc','$modal','egPromptDialog','egConfirmDialog',
1289 function($scope,  $routeParams , $q , $window , $location , egCore ,
1290          patronSvc , $modal , egPromptDialog , egConfirmDialog) {
1291
1292     var usr_id = $routeParams.id;
1293
1294     $scope.totals = {owed : 0, total_out : 0, overdue : 0}
1295
1296     var grid = $scope.gridControls = {
1297         activateItem : function(item) {
1298             $location.path('/circ/patron/' + item.id + '/checkout');
1299         },
1300         itemRetrieved : function(item) {
1301
1302             if (item.id == patronSvc.current.id()) {
1303                 item.stats = patronSvc.patron_stats;
1304
1305             } else {
1306                 // flesh stats for other group members
1307                 patronSvc.getUserStats(item.id).then(function(stats) {
1308                     item.stats = stats;
1309                     $scope.totals.total_out += stats.checkouts.total_out; 
1310                     $scope.totals.overdue += stats.checkouts.overdue; 
1311                 });
1312             }
1313         },
1314         setSort : function() {
1315             return ['create_date'];
1316         }
1317     }
1318
1319     $scope.initTab('other', $routeParams.id)
1320     .then(function(redirect) {
1321         // if we are redirecting to the alerts page, avoid updating the
1322         // grid query.
1323         if (redirect) return;
1324         // let initTab() fetch the user first so we can know the usrgroup
1325
1326         grid.setQuery({
1327             usrgroup : patronSvc.current.usrgroup(),
1328             deleted : 'f'
1329         });
1330         $scope.totals.owed = patronSvc.patron_stats.fines.group_balance_owed;
1331     });
1332
1333     $scope.removeFromGroup = function(selected) {
1334         var promises = [];
1335         angular.forEach(selected, function(user) {
1336             console.debug('removing user ' + user.id + ' from group');
1337
1338             promises.push(
1339                 egCore.net.request(
1340                     'open-ils.actor',
1341                     'open-ils.actor.usergroup.new',
1342                     egCore.auth.token(), user.id, true
1343                 )
1344             );
1345         });
1346
1347         $q.all(promises).then(function() {grid.refresh()});
1348     }
1349
1350     function addUserToGroup(user) {
1351         user.usrgroup(patronSvc.current.usrgroup());
1352         user.ischanged(true);
1353         egCore.net.request(
1354             'open-ils.actor',
1355             'open-ils.actor.patron.update',
1356             egCore.auth.token(), user
1357
1358         ).then(function() {grid.refresh()});
1359     }
1360
1361     // fetch each user ("selected" has flattened users)
1362     // update the usrgroup, then update the user object
1363     // After all updates are complete, refresh the grid.
1364     function moveUsersToGroup(target_user, selected) {
1365         var promises = [];
1366
1367         angular.forEach(selected, function(user) {
1368             promises.push(
1369                 egCore.pcrud.retrieve('au', user.id)
1370                 .then(function(u) {
1371                     u.usrgroup(target_user.usrgroup());
1372                     u.ischanged(true);
1373                     return egCore.net.request(
1374                         'open-ils.actor',
1375                         'open-ils.actor.patron.update',
1376                         egCore.auth.token(), u
1377                     );
1378                 })
1379             );
1380         });
1381
1382         $q.all(promises).then(function() {grid.refresh()});
1383     }
1384
1385     function showMoveToGroupConfirm(barcode, selected) {
1386
1387         // find the user
1388         egCore.pcrud.search('ac', {barcode : barcode})
1389
1390         // fetch the fleshed user
1391         .then(function(card) {
1392
1393             if (!card) return; // TODO: warn user
1394
1395             egCore.pcrud.retrieve('au', card.usr())
1396             .then(function(user) {
1397                 user.card(card);
1398                 $modal.open({
1399                     templateUrl: './circ/patron/t_move_to_group_dialog',
1400                     controller: [
1401                                 '$scope','$modalInstance',
1402                         function($scope , $modalInstance) {
1403                             $scope.user = user;
1404                             $scope.outbound = Boolean(selected);
1405                             $scope.ok = 
1406                                 function(count) { $modalInstance.close() }
1407                             $scope.cancel = 
1408                                 function () { $modalInstance.dismiss() }
1409                         }
1410                     ]
1411                 }).result.then(function() {
1412                     if (selected) {
1413                         moveUsersToGroup(user, selected);
1414                     } else {
1415                         addUserToGroup(user);
1416                     }
1417                 });
1418             });
1419         });
1420     }
1421
1422     // selected == move selected patrons to another patron's group
1423     // !selected == patron from a different group moves into our group
1424     function moveToGroup(selected) {
1425         egPromptDialog.open(
1426             egCore.strings.GROUP_ADD_USER, '',
1427             {ok : function(value) {
1428                 if (value) 
1429                     showMoveToGroupConfirm(value, selected);
1430             }}
1431         );
1432     }
1433
1434     $scope.moveToGroup = function() { moveToGroup() };
1435     $scope.moveToAnotherGroup = function(selected) { moveToGroup(selected) };
1436
1437     $scope.cloneUser = function(selected) {
1438         if (!selected.length) return;
1439         var url = $location.absUrl().replace(
1440             /\/patron\/.*/, 
1441             '/patron/register/clone/' + selected[0].id);
1442         $window.open(url, '_blank').focus();
1443     }
1444
1445     $scope.retrieveSelected = function(selected) {
1446         if (!selected.length) return;
1447         var url = $location.absUrl().replace(
1448             /\/patron\/.*/, 
1449             '/patron/' + selected[0].id + '/checkout');
1450         $window.open(url, '_blank').focus();
1451     }
1452
1453 }])
1454
1455 .controller('PatronStatCatsCtrl',
1456        ['$scope','$routeParams','$q','egCore','patronSvc',
1457 function($scope,  $routeParams , $q , egCore , patronSvc) {
1458     $scope.initTab('other', $routeParams.id)
1459     .then(function(redirect) {
1460         // Entries for org-visible stat cats are fleshed.  Any others
1461         // have to be fleshed within.
1462
1463         var to_flesh = {};
1464         angular.forEach(patronSvc.current.stat_cat_entries(), 
1465             function(entry) {
1466                 if (!angular.isObject(entry.stat_cat())) {
1467                     to_flesh[entry.stat_cat()] = entry;
1468                 }
1469             }
1470         );
1471
1472         if (!Object.keys(to_flesh).length) return;
1473
1474         egCore.pcrud.search('actsc', {id : Object.keys(to_flesh)})
1475         .then(null, null, function(cat) { // stream
1476             cat.owner(egCore.org.get(cat.owner())); // owner flesh
1477             to_flesh[cat.id()].stat_cat(cat);
1478         });
1479     });
1480 }])
1481
1482 .controller('PatronFetchLastCtrl',
1483        ['$scope','$location','egCore',
1484 function($scope , $location , egCore) {
1485
1486     var id = egCore.hatch.getLocalItem('eg.circ.last_patron');
1487     if (id) return $location.path('/circ/patron/' + id + '/checkout');
1488
1489     $scope.no_last = true;
1490 }])
1491
1492 .controller('PatronTriggeredEventsCtrl',
1493        ['$scope','$routeParams','$location','egCore','patronSvc',
1494 function($scope,  $routeParams,  $location , egCore , patronSvc) {
1495     $scope.initTab('other', $routeParams.id);
1496
1497     var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
1498     url += '?patron_id=' + encodeURIComponent($routeParams.id);
1499
1500     $scope.triggered_events_url = url;
1501     $scope.funcs = {};
1502 }])
1503
1504 .controller('PatronPermsCtrl',
1505        ['$scope','$routeParams','$window','$location','egCore',
1506 function($scope , $routeParams , $window , $location , egCore) {
1507     $scope.initTab('other', $routeParams.id);
1508
1509     var url = $location.absUrl().replace(
1510         /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
1511
1512     url += '?usr=' + encodeURIComponent($routeParams.id);
1513
1514     // user_edit does not load the session via cookie.  It uses URL 
1515     // params or xulG instead.  Pass via xulG.
1516     $scope.funcs = {
1517         ses : egCore.auth.token(),
1518         on_patron_save : function() {
1519             $scope.funcs.reload();
1520         }
1521     }
1522
1523     $scope.user_perms_url = url;
1524 }])
1525