[open-ils-commits] r938 - in servres/trunk/conifer: integration integration/linktool integration/linktool/templates syrup syrup/migrations (gfawcett)

svn at svn.open-ils.org svn at svn.open-ils.org
Mon Jul 26 22:45:17 EDT 2010


Author: gfawcett
Date: 2010-07-26 22:45:15 -0400 (Mon, 26 Jul 2010)
New Revision: 938

Added:
   servres/trunk/conifer/integration/linktool/templates/associate.xhtml
   servres/trunk/conifer/integration/linktool/templates/linktoolmaster.xhtml
   servres/trunk/conifer/integration/linktool/templates/whichsite.xhtml
   servres/trunk/conifer/syrup/external_groups.py
   servres/trunk/conifer/syrup/migrations/0004_auto__add_field_userprofile_external_memberships_checked.py
Modified:
   servres/trunk/conifer/integration/linktool/app.py
   servres/trunk/conifer/integration/linktool/templates/index.xhtml
   servres/trunk/conifer/integration/uwindsor.py
   servres/trunk/conifer/syrup/models.py
Log:
progress on sakai (linktool) association with reserves lists.

Still more to do on the instructor side, re: creating new reserves
Sites, or associating with existing ones.

Modified: servres/trunk/conifer/integration/linktool/app.py
===================================================================
--- servres/trunk/conifer/integration/linktool/app.py	2010-07-23 19:44:47 UTC (rev 937)
+++ servres/trunk/conifer/integration/linktool/app.py	2010-07-27 02:45:15 UTC (rev 938)
@@ -1,16 +1,19 @@
-from conifer.syrup.views._common import *
-from django.contrib.auth         import authenticate, login
-from conifer.syrup               import models
-from conifer.syrup.views         import genshi_namespace
+from conifer.here                    import HERE
 from conifer.plumbing.genshi_support import TemplateSet
+from django.http                     import (HttpResponse, HttpResponseRedirect,
+                                             HttpResponseNotFound,
+                                             HttpResponseForbidden)
+from django.contrib.auth             import authenticate, login
+from conifer.syrup import models
+from django.utils.translation    import ugettext as _
+from conifer.plumbing.hooksystem import gethook, callhook
 
 g = TemplateSet([HERE('integration/linktool/templates'),
                  HERE('templates')], 
-                genshi_namespace)
+                {'models': models, '_': _})
 
 
 def linktool_welcome(request, command=u''):
-    #return g.render('index.xhtml')
     user = authenticate(request=request)
     if user is None:
         return HttpResponseForbidden('You are not allowed here.')
@@ -18,8 +21,32 @@
         login(request, user)
         extsite = request.session['clew-site'] = request.GET['site']
         extrole = request.session['clew-role'] = request.GET['role']
+        related_sites = list(models.Site.objects.filter(group__external_id=extsite))
+        if len(related_sites) == 1:
+            return HttpResponse("<html><head/><body onload=\"top.location='%ssite/%s/';\">"
+                                "Redirecting..."
+                                "</body></html>" % (
+                    request.META['SCRIPT_NAME'] or '/',
+                    related_sites[0].id))
+        elif len(related_sites):
+            return g.render('whichsite.xhtml', **locals())
+        elif extrole == 'Instructor':
+            # This isn't quite right yet. I want to give the instructor a
+            # chance to associate with an existing unassociated site in the
+            # same term as the Sakai site. I don't want them to associate with
+            # a site that's in an older Term, but should give them the change
+            # to copy/reuse an old site. Or, they can make a brand-new Site if
+            # they want. 
 
-        
-        return HttpResponse("""<html><head/><body onload="top.location='%s';">"""
-                            """Redirecting to the library reserves system...</body></html>""" % (
-                request.META['SCRIPT_NAME'] or '/'))
+            # This reminds me that it should be a warning to edit an old site
+            # (one in a past Term). Otherwise, profs will add items to old
+            # sites, and think they are actually ordering stuff.
+
+            possibles = models.Site.taught_by(user)
+            if possibles:
+                return g.render('associate.xhtml', **locals())
+            else:
+                return g.render('create_new.xhtml', **locals())
+        else:
+            return g.render('choose_dest.xhtml', **locals())
+

Added: servres/trunk/conifer/integration/linktool/templates/associate.xhtml
===================================================================
--- servres/trunk/conifer/integration/linktool/templates/associate.xhtml	                        (rev 0)
+++ servres/trunk/conifer/integration/linktool/templates/associate.xhtml	2010-07-27 02:45:15 UTC (rev 938)
@@ -0,0 +1,18 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:xi="http://www.w3.org/2001/XInclude"
+      xmlns:py="http://genshi.edgewall.org/">
+  <xi:include href="linktoolmaster.xhtml"/>
+  <head/>
+  <body>
+    <h1 style="padding-top: 1em;">Associate?</h1>
+    <p>There is currently no set of reserves materials associated with
+    this site. As an instructor in this site, you can choose one of
+    the following options:</p>
+    <h2>todo: finish this...</h2>
+    <ul>
+      <li py:for="site in possibles">
+	${site}
+      </li>
+    </ul>
+  </body>
+</html>

Modified: servres/trunk/conifer/integration/linktool/templates/index.xhtml
===================================================================
--- servres/trunk/conifer/integration/linktool/templates/index.xhtml	2010-07-23 19:44:47 UTC (rev 937)
+++ servres/trunk/conifer/integration/linktool/templates/index.xhtml	2010-07-27 02:45:15 UTC (rev 938)
@@ -4,8 +4,13 @@
 <xi:include href="master.xhtml"/>
 <head>
   <title>testing</title>
+  <script type="text/javascript"
+	  py:if="False">
+    window.onload = function() { top.location='${request.META['SCRIPT_NAME'] or '/'}'; };
+  </script>
 </head>
 <body>
-<h1>testing</h1>
+<h1>Welcome</h1>
+${repr(list(related_sites))}
 </body>
 </html>

Added: servres/trunk/conifer/integration/linktool/templates/linktoolmaster.xhtml
===================================================================
--- servres/trunk/conifer/integration/linktool/templates/linktoolmaster.xhtml	                        (rev 0)
+++ servres/trunk/conifer/integration/linktool/templates/linktoolmaster.xhtml	2010-07-27 02:45:15 UTC (rev 938)
@@ -0,0 +1,15 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:xi="http://www.w3.org/2001/XInclude"
+      xmlns:py="http://genshi.edgewall.org/"
+      py:strip="True">
+  <xi:include href="master.xhtml"/>
+  <py:match path="div[@id='header']" once="true"/>
+  <py:match path="ul[@id='tabbar']" once="true"/>
+  <py:match path="div[@id='brandheader']" once="true"/>
+  <py:match path="head" once="True">
+    <head py:attrs="select('@*')">
+      ${select('*')}
+      <base target="_top"/>
+    </head>
+  </py:match>
+</html>

Added: servres/trunk/conifer/integration/linktool/templates/whichsite.xhtml
===================================================================
--- servres/trunk/conifer/integration/linktool/templates/whichsite.xhtml	                        (rev 0)
+++ servres/trunk/conifer/integration/linktool/templates/whichsite.xhtml	2010-07-27 02:45:15 UTC (rev 938)
@@ -0,0 +1,15 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:xi="http://www.w3.org/2001/XInclude"
+      xmlns:py="http://genshi.edgewall.org/">
+  <xi:include href="linktoolmaster.xhtml"/>
+  <head/>
+  <body>
+    <h1 style="padding-top: 1em;">Please choose a set of reserves materials</h1>
+    <p>There is more than one set of reserves materials related to this
+    site. Please choose from the list below:</p>
+    <ul>
+      <li py:for="site in related_sites"
+	  style="margin-bottom: 1em;"><a href="${site.site_url()}">${site}</a></li>
+    </ul>
+  </body>
+</html>

Modified: servres/trunk/conifer/integration/uwindsor.py
===================================================================
--- servres/trunk/conifer/integration/uwindsor.py	2010-07-23 19:44:47 UTC (rev 937)
+++ servres/trunk/conifer/integration/uwindsor.py	2010-07-27 02:45:15 UTC (rev 938)
@@ -121,3 +121,8 @@
     return uwindsor_campus_info.call('person_lookup', userid)
 
 
+def external_memberships(userid, include_titles=False):
+    memberships = uwindsor_campus_info.call('membership_ids', userid)
+    for m in memberships:
+        m['role'] = 'INSTR' if m['role'] == 'Instructor' else 'STUDT'
+    return memberships

Added: servres/trunk/conifer/syrup/external_groups.py
===================================================================
--- servres/trunk/conifer/syrup/external_groups.py	                        (rev 0)
+++ servres/trunk/conifer/syrup/external_groups.py	2010-07-27 02:45:15 UTC (rev 938)
@@ -0,0 +1,93 @@
+# -*- encoding: utf-8 -*-
+
+from conifer.syrup.models import *
+from conifer.plumbing.hooksystem import callhook
+
+VALID_ROLES = [code for code, desc in Membership.ROLE_CHOICES]
+
+#-----------------------------------------------------------------------------
+
+def reconcile_user_memberships(user):
+    """
+    Polling an externally-defined system to find out what externally-defined
+    groups this user belongs to, perform a series of "adds" and "drops" in any
+    internal groups that mirror those external groups.
+    """
+
+    # This function may be called frequently, so it's important that it runs
+    # efficiently. In the case where no group-memberships change, this
+    # function should execute no more than two SQL queries: one to fetch the
+    # list of 'interesting' groups, and another to fetch the list of the
+    # user's current memberships. (Of course it's also important that the
+    # external hook-function runs as efficiently as possible, but that's
+    # 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.
+
+    # This design assumes (but does not assert) that each groupcode is
+    # associated with exactly zero or one internal Groups. Specifically, you
+    # will get unexpected results if (a) there are multiple Groups with the
+    # same code, and (b) the user is currently a member of some of those
+    # Groups, but not others.
+
+    _externals  = callhook('external_memberships', user.username) or []
+    externals   = [(e['group'], e['role']) for e in _externals]
+    extgroups   = set(g for g, role in externals)
+    role_lookup = dict(externals) # a map of groupcodes to roles.
+    assert all((role in VALID_ROLES) for g, role in externals)
+
+    # What group-codes are currently 'of interest' (i.e., in use in the
+    # system) and in which groups is the user already known to be a member?
+
+    _base       = Group.objects.filter(external_id__isnull=False)
+    of_interest = set(r[0] for r in _base.values_list('external_id'))
+    current     = set(r[0] for r in _base.filter(membership__user=user) \
+                          .values_list('external_id'))
+
+    # to_add:  external ∩ of_interest ∩ ¬current
+    # to_drop: current  ∩ of_interest ∩ ¬external
+
+    to_add  = extgroups.intersection(of_interest).difference(current)
+    to_drop = current.intersection(of_interest).difference(extgroups)
+
+    # Since we assert that external groupcodes can be associated with no more
+    # than one internal Group, an external groupcode can be used as a map to
+    # exactly zero or one internal Groups. We take care that 'to_add_groups'
+    # does not include any groups in which the user is already a member, which
+    # is imposible under this assertion. This is just a bit of defensive
+    # programming, in case the larger algorithm is changed.
+
+    to_add_groups = Group.objects.filter(
+        ~Q(membership__user=user), # "user is not already a member"
+         external_id__in=to_add)
+
+    # process the adds and drops.
+
+    for group in to_add_groups:
+        Membership.objects.create(group=group, user=user, 
+                                  role=role_lookup[group.external_id])
+
+    to_drop_memberships = Membership.objects.filter(
+        group__external_id__in=to_drop, 
+        user=user)
+    to_drop_memberships.delete()
+
+    return (to_add, to_drop)
+
+
+
+if __name__ == '__main__':    
+    from django.db import connection
+    from pprint    import pprint
+
+    user      = User.objects.get(username='fawcett')
+    add, drop = reconcile_user_memberships(user)
+    print (add, drop)
+
+    for q in connection.queries:
+        pprint(q)

Added: servres/trunk/conifer/syrup/migrations/0004_auto__add_field_userprofile_external_memberships_checked.py
===================================================================
--- servres/trunk/conifer/syrup/migrations/0004_auto__add_field_userprofile_external_memberships_checked.py	                        (rev 0)
+++ servres/trunk/conifer/syrup/migrations/0004_auto__add_field_userprofile_external_memberships_checked.py	2010-07-27 02:45:15 UTC (rev 938)
@@ -0,0 +1,171 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding field 'UserProfile.external_memberships_checked'
+        db.add_column('syrup_userprofile', 'external_memberships_checked', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'UserProfile.external_memberships_checked'
+        db.delete_column('syrup_userprofile', 'external_memberships_checked')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'syrup.config': {
+            'Meta': {'object_name': 'Config'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}),
+            'value': ('django.db.models.fields.CharField', [], {'max_length': '8192'})
+        },
+        'syrup.course': {
+            'Meta': {'object_name': 'Course'},
+            'code': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '64'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'department': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.Department']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '1024'})
+        },
+        'syrup.department': {
+            'Meta': {'object_name': 'Department'},
+            'active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'service_desk': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.ServiceDesk']"})
+        },
+        'syrup.group': {
+            'Meta': {'object_name': 'Group'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'external_id': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '2048', 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.Site']"})
+        },
+        'syrup.item': {
+            'Meta': {'object_name': 'Item'},
+            'author': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8192', 'null': 'True', 'blank': 'True'}),
+            'bib_id': ('django.db.models.fields.CharField', [], {'max_length': '256', 'null': 'True', 'blank': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'fileobj': ('django.db.models.fields.files.FileField', [], {'default': 'None', 'max_length': '255', 'null': 'True', 'blank': 'True'}),
+            'fileobj_mimetype': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'item_type': ('django.db.models.fields.CharField', [], {'max_length': '7'}),
+            'itemtype': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1', 'null': 'True', 'blank': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'marcxml': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'parent_heading': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.Item']", 'null': 'True', 'blank': 'True'}),
+            'published': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}),
+            'publisher': ('django.db.models.fields.CharField', [], {'max_length': '8192', 'null': 'True', 'blank': 'True'}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.Site']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '8192', 'db_index': 'True'}),
+            'url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'})
+        },
+        'syrup.membership': {
+            'Meta': {'unique_together': "(('group', 'user'),)", 'object_name': 'Membership'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.Group']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'role': ('django.db.models.fields.CharField', [], {'default': "'STUDT'", 'max_length': '6'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'syrup.servicedesk': {
+            'Meta': {'object_name': 'ServiceDesk'},
+            'active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'external_id': ('django.db.models.fields.CharField', [], {'max_length': '256', 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'})
+        },
+        'syrup.site': {
+            'Meta': {'unique_together': "(('course', 'term', 'owner'),)", 'object_name': 'Site'},
+            'access': ('django.db.models.fields.CharField', [], {'default': "'ANON'", 'max_length': '5'}),
+            'course': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.Course']"}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'service_desk': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.ServiceDesk']"}),
+            'term': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['syrup.Term']"})
+        },
+        'syrup.term': {
+            'Meta': {'object_name': 'Term'},
+            'code': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '64'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'finish': ('django.db.models.fields.DateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'start': ('django.db.models.fields.DateField', [], {})
+        },
+        'syrup.userprofile': {
+            'Meta': {'object_name': 'UserProfile'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'external_memberships_checked': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ils_userid': ('django.db.models.fields.CharField', [], {'max_length': '50', 'null': 'True', 'blank': 'True'}),
+            'last_email_notice': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'null': 'True', 'blank': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'wants_email_notices': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'})
+        },
+        'syrup.z3950target': {
+            'Meta': {'object_name': 'Z3950Target'},
+            'active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'database': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'host': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'port': ('django.db.models.fields.IntegerField', [], {'default': '210'}),
+            'syntax': ('django.db.models.fields.CharField', [], {'default': "'USMARC'", 'max_length': '10'})
+        }
+    }
+
+    complete_apps = ['syrup']

Modified: servres/trunk/conifer/syrup/models.py
===================================================================
--- servres/trunk/conifer/syrup/models.py	2010-07-23 19:44:47 UTC (rev 937)
+++ servres/trunk/conifer/syrup/models.py	2010-07-27 02:45:15 UTC (rev 938)
@@ -1,10 +1,11 @@
 import random
 import re
 
+from collections                     import defaultdict
 from conifer.libsystems              import marcxml as MX
 from conifer.plumbing.genshi_support import get_request
 from conifer.plumbing.hooksystem     import *
-from datetime                        import datetime
+from datetime                        import datetime, timedelta
 from django.conf                     import settings
 from django.contrib.auth.models      import AnonymousUser, User
 from django.db                       import models as m
@@ -40,7 +41,9 @@
 # candidate).
 
 class UserExtensionMixin(object):
+
     def sites(self):
+        self.maybe_refresh_external_memberships()
         return Site.objects.filter(group__membership__user=self.id)
 
     def can_create_sites(self):
@@ -50,6 +53,12 @@
     def get_list_name(self):
         return '%s, %s' % (self.last_name, self.first_name)
 
+    # this is an override of User.get_profile. The original version will not
+    # create a UserProfile which does not already exist.
+    def get_profile(self):
+        profile, just_created = UserProfile.objects.get_or_create(user=self)
+        return profile
+
     @classmethod
     def active_instructors(cls):
         """Return a queryset of all active instructors."""
@@ -58,7 +67,25 @@
             .order_by('-last_name','-first_name').distinct()
 
 
+    # --------------------------------------------------
+    # Membership in external groups
 
+    EXT_MEMBERSHIP_CHECK_FREQUENCY = timedelta(seconds=3600)
+
+    def maybe_refresh_external_memberships(self):
+        profile = self.get_profile()
+        last_checked = profile.external_memberships_checked
+        if (not last_checked or last_checked < 
+            (datetime.now() - self.EXT_MEMBERSHIP_CHECK_FREQUENCY)):
+            added, dropped = external_groups.reconcile_user_memberships(self)
+            profile.external_memberships_checked = datetime.now()
+            profile.save()
+            return (added or dropped)
+
+    def external_memberships(self):
+        return callhook('external_memberships', self.username) or []
+
+
 for k,v in [(k,v) for k,v in UserExtensionMixin.__dict__.items() \
                 if not k.startswith('_')]:
     setattr(User, k, v)
@@ -77,6 +104,9 @@
     last_email_notice   = m.DateTimeField(
         default=datetime.now, blank=True, null=True)
 
+    # when did we last check user's membership in externally-defined groups?
+    external_memberships_checked = m.DateTimeField(blank=True, null=True)
+
     def __unicode__(self):
         return 'UserProfile(%s)' % self.user
 
@@ -217,7 +247,8 @@
             dct.setdefault(item.parent_heading, []).append(item)
         for lst in dct.values():
             # TODO: what's the sort order?
-            lst.sort(key=lambda item: (item.item_type=='HEADING', item.title)) # sort in place
+            lst.sort(key=lambda item: (item.item_type=='HEADING',
+                                       item.title)) # sort in place
         # walk the tree
         out = []
         def walk(parent, accum):
@@ -298,6 +329,12 @@
             or user.is_staff \
             or self.is_member(user)
 
+    @classmethod
+    def taught_by(cls, user):
+        """Return a set of Sites for which this user is an Instructor."""
+        return cls.objects.filter(group__membership__user=user,
+                                  group__membership__role='INSTR')
+
     #--------------------------------------------------
 
     @classmethod
@@ -340,6 +377,10 @@
     # TODO: add constraints to ensure that each Site has
     # exactly one Group with external_id=NULL, and that (site,
     # external_id) is unique forall external_id != NULL.
+    
+    # TODO: On second thought, for now make it:
+    # external_id is unique forall external_id != NULL. 
+    # That is, only one Site may use a given external group.
 
     site = m.ForeignKey(Site)
     external_id = m.CharField(null=True, blank=True,
@@ -358,11 +399,13 @@
     user = m.ForeignKey(User)
     group = m.ForeignKey(Group)
 
+    ROLE_CHOICES = (
+        ('INSTR', _('Instructor')),
+        ('ASSIST', _('Assistant/Support')),
+        ('STUDT', _('Student')))
+
     role = m.CharField(
-        choices = (
-            ('INSTR', _('Instructor')),
-            ('ASSIST', _('Assistant/Support')),
-            ('STUDT', _('Student'))),
+        choices = ROLE_CHOICES,
         default = 'STUDT',
         max_length = 6)
 
@@ -494,7 +537,7 @@
     # MARC
     def marc_as_dict(self):
         return MX.record_to_dictionary(self.marcxml)
-        
+
     def marc_dc_subset(self):
         return json.dumps(self.marc_as_dict())
 
@@ -554,8 +597,9 @@
         else:
             lib, desk, avail = stat
             return (avail > 0,
-                    '%d of %d copies available at reserves desk; %d total copies in library system' % (
-                    avail, desk, lib))
+                    '%d of %d copies available at reserves desk; '
+                    '%d total copies in library system'
+                    % (avail, desk, lib))
 
     # TODO: stuff I'm not sure about yet. I don't think it belongs here.
 
@@ -588,8 +632,8 @@
         else:
             return (Q(site__access__in=('LOGIN','ANON')) \
                         | Q(site__group__membership__user=user))
-                     
-       
+
+
 #------------------------------------------------------------
 # TODO: move this to a utility module.
 
@@ -616,3 +660,8 @@
         if callable(v):
             setattr(conifer.syrup.integration, k, v)
 
+
+#-----------------------------------------------------------------------------
+# this can't be imported until Membership is defined...
+
+import external_groups



More information about the open-ils-commits mailing list