on site add/edit, fuzzy-lookup of site owner, if fuzzy-lookup hook is available.
authorgfawcett <gfawcett@6d9bc8c9-1ec2-4278-b937-99fde70a366f>
Mon, 27 Dec 2010 21:58:07 +0000 (21:58 +0000)
committergfawcett <gfawcett@6d9bc8c9-1ec2-4278-b937-99fde70a366f>
Mon, 27 Dec 2010 21:58:07 +0000 (21:58 +0000)
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
conifer/integration/uwindsor.py
conifer/static/edit_site.js
conifer/syrup/external_groups.py
conifer/syrup/integration.py
conifer/syrup/models.py
conifer/syrup/urls.py
conifer/syrup/views/sites.py
conifer/templates/admin/index.xhtml
conifer/templates/edit_site.xhtml

index c0a9fd3..b937c93 100644 (file)
@@ -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
index 7b39dde..e57b9c1 100644 (file)
@@ -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
index c705a70..416d2c7 100644 (file)
@@ -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 = $('<a class="fuzzychoice" href="#"/>');
+                                          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('<div>and ' + data.notshown + ' more.</div>');
+                                  }
+                          }, '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);
+});
index 3096d01..538a698 100644 (file)
@@ -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
index db75ea0..540a35e 100644 (file)
@@ -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
index 42970ba..c09ef78 100644 (file)
@@ -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('_')]:
index 8ad74fe..94adc69 100644 (file)
@@ -33,6 +33,7 @@ urlpatterns = patterns('conifer.syrup.views',
     (r'^site/(?P<site_id>\d+)/edit/permission/$', 'edit_site_permissions'),
     (r'^site/(?P<site_id>\d+)/feeds/(?P<feed_type>.*)$', 'site_feeds'),
     (r'^site/(?P<site_id>\d+)/join/$', 'site_join'),
+    (r'^site/.*fuzzy_user_lookup$', 'site_fuzzy_user_lookup'),
     (ITEM_PREFIX + r'$', 'item_detail'),
     (ITEM_PREFIX + r'dl/(?P<filename>.*)$', 'item_download'),
     (ITEM_PREFIX + r'meta$', 'item_metadata'),
index 838f95e..e8c4c96 100644 (file)
@@ -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')
+
index 930aea3..261eae8 100644 (file)
@@ -14,6 +14,9 @@ title = _('Administrative Options')
 <body>
   <h1>${title}</h1>
   <div class="itemadd">
+    <ul>
+         <li><a href="../site/new/">Create a new course site</a></li>
+       </ul>
   <ul>
     <li><a href="terms/">Terms</a></li>
     <li><a href="desks/">Service Desks</a></li>
@@ -27,9 +30,6 @@ title = _('Administrative Options')
   <!--   <li><a href="../zsearch/">Search Z39.50 Targets</a></li> -->
   <!-- </ul> -->
   <ul>
-    <li><a href="../site/new/">Create a new course site</a></li>
-  </ul>
-  <ul>
     <li py:if="gethook('department_course_catalogue')">
       <a href="update_depts_courses">Automatically update departments and courses</a>
     </li>
index 829741b..58cb12d 100644 (file)
@@ -3,6 +3,7 @@ if instance.id:
     title = _('Site setup')
 else:
     title = _('Create a new site')
+owner = instance.owner if instance.owner_id else None
 ?>
 <html xmlns="http://www.w3.org/1999/xhtml"
       xmlns:xi="http://www.w3.org/2001/XInclude"
@@ -12,6 +13,10 @@ else:
 <head>
   <title>${title}</title>
   <script type="text/javascript" src="${ROOT}/static/edit_site.js"/>
+  <style>
+       .fuzzychoice { display: block; margin: 0.5em 0;  font-size: 90%; }
+       #fuzzyview { display: block; margin: 0.5em 0;  font-size: 90%; }
+  </style>
 </head>
 <body>
   <div py:if="instance.id">${site_banner(instance)}</div>
@@ -21,7 +26,7 @@ else:
       <li py:for="err in nfe">${err}</li>
     </ul>
   </div>
-  <form action="." method="POST">
+  <form action="." method="POST" autocomplete="off">
     <tr py:def="field_row(field, example=None)">
       <th>${field.label}</th>
       <td>
@@ -33,10 +38,34 @@ else:
       <td class="example" py:if="example">e.g., ${example}</td>
     </tr>
     <table class="metadata_table">
-    ${field_row(form.owner)}
+         <py:if test="owner_mode=='select'">
+             ${field_row(form.owner)}
+         </py:if>
+         <tr py:if="owner_mode=='lookup'">
+               <th>Primary Instructor</th>
+               <td>
+                 <input type="hidden" id="owner" name="owner" value="${form.owner.data}"/>
+                 <div id="fuzzyedit"
+                          style="display: ${'none' if owner else 'block'}">
+                       <div style="font-size: 80%; margin: 0.5em 0;">Type a partial name or userid into the box; then select one of the matches.</div>
+                       <input type="text" id="fuzzyinput" autocomplete="off" value="${owner.username if owner else ''}"/>
+                       <div id="fuzzypanel">
+                       </div>
+                 </div>
+                 <div id="fuzzyview" style="display: ${owner and 'block' or 'none'}">
+                       <span id="fuzzyname">
+                       <span py:if="owner">
+                         ${owner.get_full_name()} [${owner}]
+                       </span>
+                       </span>
+                       <input type="button" value="change" onclick="fuzzyLookup.edit();"
+                                        style="margin-left: 1em;"/>
+                 </div>
+               </td>
+         </tr>
+    ${field_row(form.course)}
     ${field_row(form.start_term)}
     ${field_row(form.end_term)}
-    ${field_row(form.course)}
     ${field_row(form.service_desk)}
 
     <!-- ${field_row(form.department)} -->