From 8c7bb0f8568180c3760a0c6cbde12dd5d21bbfdb Mon Sep 17 00:00:00 2001 From: gfawcett Date: Mon, 27 Dec 2010 21:58:07 +0000 Subject: [PATCH] on site add/edit, fuzzy-lookup of site owner, if fuzzy-lookup hook is available. For UWindsor, I'm using an external program called SpeedLookup for the fuzzy search. If the program isn't found on the system, fuzzy search will be disabled. git-svn-id: svn://svn.open-ils.org/ILS-Contrib/servres/trunk@1120 6d9bc8c9-1ec2-4278-b937-99fde70a366f --- conifer/integration/cas.py | 37 +++------------ conifer/integration/uwindsor.py | 67 ++++++++++++++++++++++----- conifer/static/edit_site.js | 90 +++++++++++++++++++++++++++++++------ conifer/syrup/external_groups.py | 10 ++--- conifer/syrup/integration.py | 10 +++++ conifer/syrup/models.py | 33 ++++++++++++++ conifer/syrup/urls.py | 1 + conifer/syrup/views/sites.py | 33 +++++++++++++- conifer/templates/admin/index.xhtml | 6 +-- conifer/templates/edit_site.xhtml | 35 +++++++++++++-- 10 files changed, 252 insertions(+), 70 deletions(-) diff --git a/conifer/integration/cas.py b/conifer/integration/cas.py index c0a9fd3..b937c93 100644 --- a/conifer/integration/cas.py +++ b/conifer/integration/cas.py @@ -8,9 +8,10 @@ # # You will probably also want to define two customization hooks: # external_person_lookup and user_needs_decoration. See: -# conifer/syrup/integration.py. +# conifer/syrup/integration.py and 'maybe_decorate' in +# conifer/syrup/models.py. + -from conifer.plumbing.hooksystem import gethook, callhook import django_cas.backends @@ -20,34 +21,6 @@ class CASBackend(django_cas.backends.CASBackend): """Authenticates CAS ticket and retrieves user data""" user = super(CASBackend, self).authenticate(ticket, service) - if user and gethook('external_person_lookup'): - decorate_user(user) + if user: + user.maybe_decorate() return user - - -# TODO is this really CAS specific? Wouldn't linktool (for example) -# also need such a decorator? - -def decorate_user(user): - dectest = gethook('user_needs_decoration', default=_user_needs_decoration) - if not dectest(user): - return - - dir_entry = callhook('external_person_lookup', user.username) - if dir_entry is None: - return - - user.first_name = dir_entry['given_name'] - user.last_name = dir_entry['surname'] - user.email = dir_entry.get('email', user.email) - user.save() - - if 'patron_id' in dir_entry: - # note, we overrode user.get_profile() to automatically create - # missing profiles. See models.py. - user.get_profile().ils_userid = dir_entry['patron_id'] - profile.save() - - -def _user_needs_decoration(user): - return user.last_name is not None diff --git a/conifer/integration/uwindsor.py b/conifer/integration/uwindsor.py index 7b39dde..e57b9c1 100644 --- a/conifer/integration/uwindsor.py +++ b/conifer/integration/uwindsor.py @@ -1,18 +1,19 @@ # See conifer/syrup/integration.py for documentation. -from datetime import date -from django.conf import settings from conifer.libsystems import ezproxy -from conifer.libsystems.evergreen.support import initialize, E1 from conifer.libsystems import marcxml as M from conifer.libsystems.evergreen import item_status as I +from conifer.libsystems.evergreen.support import initialize, E1 from conifer.libsystems.z3950 import pyz3950_search as PZ -from xml.etree import ElementTree as ET -import re -import uwindsor_campus_info +from datetime import date +from django.conf import settings from memoization import memoize +from xml.etree import ElementTree as ET import csv +import os +import re import subprocess +import uwindsor_campus_info # USE_Z3950: if True, use Z39.50 for catalogue search; if False, use OpenSRF. # Don't set this value directly here: rather, if there is a valid Z3950_CONFIG @@ -42,6 +43,7 @@ def term_catalogue(): 'start-date', 'end-date'), where the dates are instances of the datetime.date class. """ + # TODO: make this algorithmic. return [ ('2011S', '2011 Summer', date(2011,5,1), date(2011,9,1)), ('2011F', '2011 Fall', date(2011,9,1), date(2011,12,31)), @@ -210,17 +212,58 @@ def external_person_lookup(userid): """ return uwindsor_campus_info.call('person_lookup', userid) -def decode_role(role): + +def external_memberships(userid): + """ + Given a userid, return a list of dicts, representing the user's + memberships in known external groups. Each dict must include the + following key/value pairs: + 'group': a group-code, externally defined; + 'role': the user's role in that group, one of (INSTR, ASSIST, STUDT). + """ + memberships = uwindsor_campus_info.call('membership_ids', userid) + for m in memberships: + m['role'] = _decode_role(m['role']) + return memberships + +def _decode_role(role): if role == 'Instructor': return 'INSTR' else: return 'STUDT' -def external_memberships(userid, include_titles=False): - memberships = uwindsor_campus_info.call('membership_ids', userid) - for m in memberships: - m['role'] = decode_role(m['role']) - return memberships +FUZZY_LOOKUP_BIN = '/usr/local/bin/SpeedLookup' + +if os.path.isfile(FUZZY_LOOKUP_BIN): + + def fuzzy_person_lookup(query, include_students=False): + """ + Given a query, return a list of users who probably match the + query. The result is a list of (userid, display), where userid + is the campus userid of the person, and display is a string + suitable for display in a results-list. Include_students + indicates that students, and not just faculty/staff, should be + included in the results. + """ + + cmd = [FUZZY_LOOKUP_BIN, query] + if include_students: + cmd.append('students') + + p = subprocess.Popen(cmd, stdout=subprocess.PIPE) + try: + rdr = csv.reader(p.stdout) + rdr.next() # skip header row, + data = list(rdr) # eagerly fetch the rest + finally: + p.stdout.close() + + out = [] + for uid, sn, given, role, dept, mail in data: + display = '%s %s. %s, %s. <%s>. [%s]' % (given, sn, role, dept, mail, uid) + out.append((uid, display)) + return out + #-------------------------------------------------- # proxy server integration diff --git a/conifer/static/edit_site.js b/conifer/static/edit_site.js index c705a70..416d2c7 100644 --- a/conifer/static/edit_site.js +++ b/conifer/static/edit_site.js @@ -1,16 +1,78 @@ -function do_init() { - if ($('#id_code').attr('tagName') == 'SELECT') { - // code is a SELECT, so we add a callback to lookup titles. - $('#id_code').change(function() { - $('#id_title')[0].disabled=true; - $.getJSON(ROOT + '/site/new/ajax_title', {course_code: $(this).val()}, - function(resp) { - $('#id_title').val(resp.title) - $('#id_title')[0].disabled=false; - - }); - }); - } +var fuzzyLookup = { + + lastText: null, + lastPress: null, + waiting: false, + + lookup: function() { + var text = $(this).val(); + if (text != fuzzyLookup.lastText) { + fuzzyLookup.lastText = text; + if (text.length < 3) { + return; + } + fuzzyLookup.lastPress = new Date(); + } + }, + + minWait: 500, + + interval: function() { + var now = new Date(); + + if (fuzzyLookup.lastPress == null || (now - fuzzyLookup.lastPress) < fuzzyLookup.minWait) { + return; + } + + if (fuzzyLookup.waiting) { + return; + } + + fuzzyLookup.waiting = true; + $('#fuzzyinput').css({backgroundColor: 'yellow'}); // debugging + $.post('site_fuzzy_user_lookup', {'q': fuzzyLookup.lastText}, + function(data) { + fuzzyLookup.waiting = false; + fuzzyLookup.lastPress = null; + $('#fuzzyinput').css({backgroundColor: 'white'}); // debugging + $('#fuzzypanel').text(''); + if (data.results.length == 0) { + $('#fuzzypanel').append('No matches.'); + } + $.each(data.results, function(i,val) { + var link = $(''); + link.text(val[1]); + link.data('userid', val[0]); + link.data('display', val[1]); + link.click(fuzzyLookup.pick); + $('#fuzzypanel').append(link); + }); + if (data.notshown > 0) { + $('#fuzzypanel').append('
and ' + data.notshown + ' more.
'); + } + }, 'json'); + }, + + pick: function(uid) { + $('#fuzzyedit').hide(); + $('#fuzzyview').show(); + + var inp = $('#owner'); + inp.val($(this).data('userid')); + + $('#fuzzyname').text($(this).data('display')); + }, + + edit: function() { + $('#fuzzyview').hide(); + $('#fuzzyedit').show(); + $('#fuzzyinput').focus(); + fuzzyLookup.lastText = $('#fuzzyinput').val(); + fuzzyLookup.lastPress = new Date(new Date() - 450); + } } -$(do_init); +$(function() { + $('#fuzzyinput').keyup(fuzzyLookup.lookup); + setInterval(fuzzyLookup.interval, 250); +}); diff --git a/conifer/syrup/external_groups.py b/conifer/syrup/external_groups.py index 3096d01..538a698 100644 --- a/conifer/syrup/external_groups.py +++ b/conifer/syrup/external_groups.py @@ -23,11 +23,11 @@ def reconcile_user_memberships(user): # outside of our scope.) # The 'external_memberships' hook function must return a list of - # (groupcode, role) tuples (assuming the hook function has been defined; - # otherwise, the hook system will return None). All of our membership - # comparisons are based on groupcodes, which internally are stored in the - # Group.external_id attribute. We only consider roles if we are adding a - # user to a group. + # group-membership objects (assuming the hook function has been + # defined; otherwise, the hook system will return None). All of + # our membership comparisons are based on groupcodes, which + # internally are stored in the Group.external_id attribute. We + # only consider roles if we are adding a user to a group. # This design assumes (but does not assert) that each groupcode is # associated with exactly zero or one internal Groups. Specifically, you diff --git a/conifer/syrup/integration.py b/conifer/syrup/integration.py index db75ea0..540a35e 100644 --- a/conifer/syrup/integration.py +++ b/conifer/syrup/integration.py @@ -110,6 +110,16 @@ def external_person_lookup(userid): """ @disable +def external_memberships(userid): + """ + Given a userid, return a list of dicts, + representing the user's memberships in known external groups. + Each dict must include the following key/value pairs: + 'group': a group-code, externally defined; + 'role': the user's role in that group, one of (INSTR, ASSIST, STUDT). + """ + +@disable def user_needs_decoration(user_obj): """ User objects are sometimes created automatically, with only a diff --git a/conifer/syrup/models.py b/conifer/syrup/models.py index 42970ba..c09ef78 100644 --- a/conifer/syrup/models.py +++ b/conifer/syrup/models.py @@ -88,6 +88,39 @@ class UserExtensionMixin(object): def external_memberships(self): return callhook('external_memberships', self.username) or [] + def maybe_decorate(self): + """ + If necessary, and if possible, fill in missing personal + information about this user from an external diectory. + """ + + # can we look up users externally? + if not gethook('external_person_lookup'): + return + + # does this user need decorating? + dectest = gethook('user_needs_decoration', + default=lambda user: user.last_name == '') + if not dectest(self): + return + + # can we find this user in the external directory? + dir_entry = callhook('external_person_lookup', self.username) + if dir_entry is None: + return + + self.first_name = dir_entry['given_name'] + self.last_name = dir_entry['surname'] + self.email = dir_entry.get('email', self.email) + self.save() + + if 'patron_id' in dir_entry: + # note, we overrode user.get_profile() to automatically create + # missing profiles. + self.get_profile().ils_userid = dir_entry['patron_id'] + profile.save() + + for k,v in [(k,v) for k,v in UserExtensionMixin.__dict__.items() \ if not k.startswith('_')]: diff --git a/conifer/syrup/urls.py b/conifer/syrup/urls.py index 8ad74fe..94adc69 100644 --- a/conifer/syrup/urls.py +++ b/conifer/syrup/urls.py @@ -33,6 +33,7 @@ urlpatterns = patterns('conifer.syrup.views', (r'^site/(?P\d+)/edit/permission/$', 'edit_site_permissions'), (r'^site/(?P\d+)/feeds/(?P.*)$', 'site_feeds'), (r'^site/(?P\d+)/join/$', 'site_join'), + (r'^site/.*fuzzy_user_lookup$', 'site_fuzzy_user_lookup'), (ITEM_PREFIX + r'$', 'item_detail'), (ITEM_PREFIX + r'dl/(?P.*)$', 'item_download'), (ITEM_PREFIX + r'meta$', 'item_metadata'), diff --git a/conifer/syrup/views/sites.py b/conifer/syrup/views/sites.py index 838f95e..e8c4c96 100644 --- a/conifer/syrup/views/sites.py +++ b/conifer/syrup/views/sites.py @@ -41,13 +41,32 @@ def edit_site(request, site_id): def _add_or_edit_site(request, instance=None): is_add = (instance is None) + + # Are we looking up owners, or selecting them from a fixed list? + owner_mode = 'lookup' if gethook('fuzzy_person_lookup') else 'select' + if is_add: instance = models.Site() if request.method != 'POST': form = NewSiteForm(instance=instance) return g.render('edit_site.xhtml', **locals()) else: - form = NewSiteForm(request.POST, instance=instance) + POST = request.POST.copy() # because we may mutate it. + if owner_mode == 'lookup': + # then the owner may be a username instead of an ID, and + # the user may not exist in the local database. + userid = POST.get('owner', '').strip() + if userid and not userid.isdigit(): + try: + user = User.objects.get(username=userid) + except User.DoesNotExist: + user = User.objects.create(username=userid) + user.save() + user.maybe_decorate() + user.save() + POST['owner'] = user.id + + form = NewSiteForm(POST, instance=instance) if not form.is_valid(): return g.render('edit_site.xhtml', **locals()) else: @@ -136,3 +155,15 @@ def site_join(request, site_id): group=group, role='STUDT') mbr.save() return HttpResponseRedirect(site.site_url()) + + +@admin_only +def site_fuzzy_user_lookup(request): + query = request.POST.get('q').lower().strip() + results = callhook('fuzzy_person_lookup', query) or [] + limit = 10 + resp = {'results': results[:limit], + 'notshown': max(0, len(results) - limit)} + return HttpResponse(simplejson.dumps(resp), + content_type='application/json') + diff --git a/conifer/templates/admin/index.xhtml b/conifer/templates/admin/index.xhtml index 930aea3..261eae8 100644 --- a/conifer/templates/admin/index.xhtml +++ b/conifer/templates/admin/index.xhtml @@ -14,6 +14,9 @@ title = _('Administrative Options')

${title}

+