Merge branch 'master' of git://github.com/melinath/philo
authorJoseph Spiros <joseph.spiros@ithinksw.com>
Wed, 6 Apr 2011 22:15:36 +0000 (18:15 -0400)
committerJoseph Spiros <joseph.spiros@ithinksw.com>
Wed, 6 Apr 2011 22:15:36 +0000 (18:15 -0400)
* 'master' of git://github.com/melinath/philo: (31 commits)
  Improved TemplateValidator error reporting to include origins and line numbers in TemplateSyntaxError reports.
  Resolves issue 68 by treating any incorrectly-passed-in values for JSON fields as json strings rather than as python objects.
  Resolved issue #120 by removing detection of containers which would not be rendered.
  Made uuid of event autogenerated. Fully resolves issue #119. Also had to reset julian migrations again.
  Julian improvements: Made calendar events optional and made calendar feed uuids auto-generated. Reset the migrations because south wasn't cooperating.
  Adjusted 14082 hack again... "fixed" the ModelFormMetaclass.__new__ method and wrote EntityFormMetaclass as if there was no issue.
  Moved the EventQuerySet to its own class instead of nesting it in the Event model.
  Removed python 2.4 compatibility workaround use, since this is removed in Django 1.3 and philo doesn't support python 2.4 anyway.
  Resolves issue #71 for the admin interface by working around the use of modelform_factory. If any code uses modelform_factory elsewhere with an entity form, the same issue will arise. Also compatible with 1.2.X.
  Improved proxy-field hiding to be 1.3-proof. Unfortunately, requires a  attribute on the proxy admin, but it seems like the best way. Still backwards-compatible with 1.2.X.
  Removed shipherd navigation_host filter exception reraising. Tweaked LazyNode - turns out the / needs to be prepended *before* trying to check if the node handles_subpath.
  Added some convenient methods to event querysets.
  Switched Template.__unicode__ to only return self.name for legibility.
  Corrected node_view redirection of trailing slashes to non-trailing slashes to rely on request._cached_node_path instead of request.path. Tweaked LazyNode to only return a found node if it handles the found subpath.
  Added feed_length to BlogViewAdmin.
  Solidified AJAX API.
  Prettified results page. Added search results templates for grappelli and the normal admin.
  Refactored weight code to split the work over Search, ResultURL, and Click models. This is probably somewhat less efficient, but it makes more intuitive sense. It also allows for weight caching on instances. Initial work on a results action/view for the SearchAdmin. Set SearchView to have SearchForm set as an attribute on itself rather than blindly using it.
  First attempts at a get_favored_results method to find what people are generally selecting. Some minor aesthetic changes. Changed ajax api template fetch to call get_template with the intent of passing an argument as to whether it should be prepped for ajax.
  Updated CalendarView urlpatterns to use the new feed_patterns method.
  ...

27 files changed:
admin/base.py
contrib/julian/__init__.py [new file with mode: 0644]
contrib/julian/admin.py [new file with mode: 0644]
contrib/julian/feedgenerator.py [new file with mode: 0644]
contrib/julian/migrations/0001_initial.py [new file with mode: 0644]
contrib/julian/migrations/__init__.py [new file with mode: 0644]
contrib/julian/models.py [new file with mode: 0644]
contrib/penfield/admin.py
contrib/shipherd/templatetags/shipherd.py
contrib/sobol/__init__.py [new file with mode: 0644]
contrib/sobol/admin.py [new file with mode: 0644]
contrib/sobol/forms.py [new file with mode: 0644]
contrib/sobol/models.py [new file with mode: 0644]
contrib/sobol/search.py [new file with mode: 0644]
contrib/sobol/templates/admin/sobol/search/grappelli_results.html [new file with mode: 0644]
contrib/sobol/templates/admin/sobol/search/results.html [new file with mode: 0644]
contrib/sobol/templates/search/googlesearch.html [new file with mode: 0644]
contrib/sobol/utils.py [new file with mode: 0644]
contrib/waldo/tokens.py
forms/entities.py
middleware.py
models/base.py
models/fields/__init__.py
models/pages.py
urls.py
validators.py
views.py

index 8151461..75fa336 100644 (file)
@@ -31,54 +31,50 @@ class AttributeInline(generic.GenericTabularInline):
                template = 'admin/philo/edit_inline/tabular_attribute.html'
 
 
-def hide_proxy_fields(cls, attname, proxy_field_set):
-       val_set = set(getattr(cls, attname))
-       if proxy_field_set & val_set:
-               cls._hidden_attributes[attname] = list(val_set)
-               setattr(cls, attname, list(val_set - proxy_field_set))
+# HACK to bypass model validation for proxy fields
+class SpoofedHiddenFields(object):
+       def __init__(self, proxy_fields, value):
+               self.value = value
+               self.spoofed = list(set(value) - set(proxy_fields))
+       
+       def __get__(self, instance, owner):
+               if instance is None:
+                       return self.spoofed
+               return self.value
+
+
+class SpoofedAddedFields(SpoofedHiddenFields):
+       def __init__(self, proxy_fields, value):
+               self.value = value
+               self.spoofed = list(set(value) | set(proxy_fields))
+
+
+def hide_proxy_fields(cls, attname):
+       val = getattr(cls, attname, [])
+       proxy_fields = getattr(cls, 'proxy_fields')
+       if val:
+               setattr(cls, attname, SpoofedHiddenFields(proxy_fields, val))
+
+def add_proxy_fields(cls, attname):
+       val = getattr(cls, attname, [])
+       proxy_fields = getattr(cls, 'proxy_fields')
+       setattr(cls, attname, SpoofedAddedFields(proxy_fields, val))
 
 
 class EntityAdminMetaclass(admin.ModelAdmin.__metaclass__):
        def __new__(cls, name, bases, attrs):
-               # HACK to bypass model validation for proxy fields by masking them as readonly fields
                new_class = super(EntityAdminMetaclass, cls).__new__(cls, name, bases, attrs)
-               form = getattr(new_class, 'form', None)
-               if form:
-                       opts = form._meta
-                       if issubclass(form, EntityForm) and opts.model:
-                               proxy_fields = proxy_fields_for_entity_model(opts.model).keys()
-                               
-                               # Store readonly fields iff they have been declared.
-                               if 'readonly_fields' in attrs or not hasattr(new_class, '_real_readonly_fields'):
-                                       new_class._real_readonly_fields = new_class.readonly_fields
-                               
-                               readonly_fields = new_class.readonly_fields
-                               new_class.readonly_fields = list(set(readonly_fields) | set(proxy_fields))
-                               
-                               # Additional HACKS to handle raw_id_fields and other attributes that the admin
-                               # uses model._meta.get_field to validate.
-                               new_class._hidden_attributes = {}
-                               proxy_fields = set(proxy_fields)
-                               hide_proxy_fields(new_class, 'raw_id_fields', proxy_fields)
-               #END HACK
+               hide_proxy_fields(new_class, 'raw_id_fields')
+               add_proxy_fields(new_class, 'readonly_fields')
                return new_class
-
+# END HACK
 
 class EntityAdmin(admin.ModelAdmin):
        __metaclass__ = EntityAdminMetaclass
        form = EntityForm
        inlines = [AttributeInline]
        save_on_top = True
-       
-       def __init__(self, *args, **kwargs):
-               # HACK PART 2 restores the actual readonly fields etc. on __init__.
-               if hasattr(self, '_real_readonly_fields'):
-                       self.readonly_fields = self.__class__._real_readonly_fields
-               if hasattr(self, '_hidden_attributes'):
-                       for name, value in self._hidden_attributes.items():
-                               setattr(self, name, value)
-               # END HACK
-               super(EntityAdmin, self).__init__(*args, **kwargs)
+       proxy_fields = []
        
        def formfield_for_dbfield(self, db_field, **kwargs):
                """
diff --git a/contrib/julian/__init__.py b/contrib/julian/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/contrib/julian/admin.py b/contrib/julian/admin.py
new file mode 100644 (file)
index 0000000..8f104e2
--- /dev/null
@@ -0,0 +1,80 @@
+from django.contrib import admin
+from philo.admin import EntityAdmin, COLLAPSE_CLASSES
+from philo.contrib.julian.models import Location, Event, Calendar, CalendarView
+
+
+class LocationAdmin(EntityAdmin):
+       pass
+
+
+class EventAdmin(EntityAdmin):
+       fieldsets = (
+               (None, {
+                       'fields': ('name', 'slug', 'description', 'tags', 'owner')
+               }),
+               ('Location', {
+                       'fields': ('location_content_type', 'location_pk')
+               }),
+               ('Time', {
+                       'fields': (('start_date', 'start_time'), ('end_date', 'end_time'),),
+               }),
+               ('Advanced', {
+                       'fields': ('parent_event', 'site',),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       filter_horizontal = ['tags']
+       raw_id_fields = ['parent_event']
+       related_lookup_fields = {
+               'fk': raw_id_fields,
+               'generic': [["location_content_type", "location_pk"]]
+       }
+       prepopulated_fields = {'slug': ('name',)}
+
+
+class CalendarAdmin(EntityAdmin):
+       prepopulated_fields = {'slug': ('name',)}
+       filter_horizontal = ['events']
+       fieldsets = (
+               (None, {
+                       'fields': ('name', 'description', 'events')
+               }),
+               ('Advanced', {
+                       'fields': ('slug', 'site', 'language',),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+
+
+class CalendarViewAdmin(EntityAdmin):
+       fieldsets = (
+               (None, {
+                       'fields': ('calendar',)
+               }),
+               ('Pages', {
+                       'fields': ('index_page', 'event_detail_page')
+               }),
+               ('General Settings', {
+                       'fields': ('tag_permalink_base', 'owner_permalink_base', 'location_permalink_base', 'events_per_page')
+               }),
+               ('Event List Pages', {
+                       'fields': ('timespan_page', 'tag_page', 'location_page', 'owner_page'),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Archive Pages', {
+                       'fields': ('location_archive_page', 'tag_archive_page', 'owner_archive_page'),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Feed Settings', {
+                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       raw_id_fields = ('index_page', 'event_detail_page', 'timespan_page', 'tag_page', 'location_page', 'owner_page', 'location_archive_page', 'tag_archive_page', 'owner_archive_page', 'item_title_template', 'item_description_template',)
+       related_lookup_fields = {'fk': raw_id_fields}
+
+
+admin.site.register(Location, LocationAdmin)
+admin.site.register(Event, EventAdmin)
+admin.site.register(Calendar, CalendarAdmin)
+admin.site.register(CalendarView, CalendarViewAdmin)
\ No newline at end of file
diff --git a/contrib/julian/feedgenerator.py b/contrib/julian/feedgenerator.py
new file mode 100644 (file)
index 0000000..819a273
--- /dev/null
@@ -0,0 +1,77 @@
+from django.http import HttpResponse
+from django.utils.feedgenerator import SyndicationFeed
+import vobject
+
+
+# Map the keys in the ICalendarFeed internal dictionary to the names of iCalendar attributes.
+FEED_ICAL_MAP = {
+       'title': 'x-wr-calname',
+       'description': 'x-wr-caldesc',
+       #'link': ???,
+       #'language': ???,
+       #author_email
+       #author_name
+       #author_link
+       #subtitle
+       #categories
+       #feed_url
+       #feed_copyright
+       'id': 'prodid',
+       'ttl': 'x-published-ttl'
+}
+
+
+ITEM_ICAL_MAP = {
+       'title': 'summary',
+       'description': 'description',
+       'link': 'url',
+       # author_email, author_name, and author_link need special handling. Consider them the
+       # 'organizer' of the event <http://tools.ietf.org/html/rfc5545#section-3.8.4.3> and
+       # construct something based on that.
+       'pubdate': 'created',
+       'last_modified': 'last-modified',
+       #'comments' require special handling as well <http://tools.ietf.org/html/rfc5545#section-3.8.1.4>
+       'unique_id': 'uid',
+       'enclosure': 'attach', # does this need special handling?
+       'categories': 'categories', # does this need special handling?
+       # ttl is ignored.
+       'start': 'dtstart',
+       'end': 'dtend',
+}
+
+
+class ICalendarFeed(SyndicationFeed):
+       mime_type = 'text/calendar'
+       
+       def add_item(self, *args, **kwargs):
+               for kwarg in ['start', 'end', 'last_modified', 'location']:
+                       kwargs.setdefault(kwarg, None)
+               super(ICalendarFeed, self).add_item(*args, **kwargs)
+       
+       def write(self, outfile, encoding):
+               # TODO: Use encoding... how? Just convert all values when setting them should work...
+               cal = vobject.iCalendar()
+               
+               # IE/Outlook needs this. See
+               # <http://blog.thescoop.org/archives/2007/07/31/django-ical-and-vobject/>
+               cal.add('method').value = 'PUBLISH'
+               
+               for key, val in self.feed.items():
+                       if key in FEED_ICAL_MAP and val:
+                               cal.add(FEED_ICAL_MAP[key]).value = val
+               
+               for item in self.items:
+                       # TODO: handle multiple types of events.
+                       event = cal.add('vevent')
+                       for key, val in item.items():
+                               #TODO: handle the non-standard items like comments and author.
+                               if key in ITEM_ICAL_MAP and val:
+                                       event.add(ITEM_ICAL_MAP[key]).value = val
+               
+               cal.serialize(outfile)
+               
+               # Some special handling for HttpResponses. See link above.
+               if isinstance(outfile, HttpResponse):
+                       filename = self.feed.get('filename', 'filename.ics')
+                       outfile['Filename'] = filename
+                       outfile['Content-Disposition'] = 'attachment; filename=%s' % filename
\ No newline at end of file
diff --git a/contrib/julian/migrations/0001_initial.py b/contrib/julian/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..3236095
--- /dev/null
@@ -0,0 +1,286 @@
+# 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 model 'Location'
+        db.create_table('julian_location', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=255, db_index=True)),
+        ))
+        db.send_create_signal('julian', ['Location'])
+
+        # Adding model 'Event'
+        db.create_table('julian_event', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('start_date', self.gf('django.db.models.fields.DateField')()),
+            ('start_time', self.gf('django.db.models.fields.TimeField')(null=True, blank=True)),
+            ('end_date', self.gf('django.db.models.fields.DateField')()),
+            ('end_time', self.gf('django.db.models.fields.TimeField')(null=True, blank=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255, db_index=True)),
+            ('location_content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'], null=True, blank=True)),
+            ('location_pk', self.gf('django.db.models.fields.TextField')(blank=True)),
+            ('description', self.gf('philo.models.fields.TemplateField')()),
+            ('parent_event', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['julian.Event'], null=True, blank=True)),
+            ('owner', self.gf('django.db.models.fields.related.ForeignKey')(related_name='owned_events', to=orm['auth.User'])),
+            ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
+            ('last_modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
+            ('site', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['sites.Site'])),
+        ))
+        db.send_create_signal('julian', ['Event'])
+
+        # Adding unique constraint on 'Event', fields ['site', 'created']
+        db.create_unique('julian_event', ['site_id', 'created'])
+
+        # Adding M2M table for field tags on 'Event'
+        db.create_table('julian_event_tags', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('event', models.ForeignKey(orm['julian.event'], null=False)),
+            ('tag', models.ForeignKey(orm['philo.tag'], null=False))
+        ))
+        db.create_unique('julian_event_tags', ['event_id', 'tag_id'])
+
+        # Adding model 'Calendar'
+        db.create_table('julian_calendar', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=100)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=100, db_index=True)),
+            ('description', self.gf('django.db.models.fields.TextField')(blank=True)),
+            ('site', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['sites.Site'])),
+            ('language', self.gf('django.db.models.fields.CharField')(default='en', max_length=5)),
+        ))
+        db.send_create_signal('julian', ['Calendar'])
+
+        # Adding unique constraint on 'Calendar', fields ['name', 'site', 'language']
+        db.create_unique('julian_calendar', ['name', 'site_id', 'language'])
+
+        # Adding M2M table for field events on 'Calendar'
+        db.create_table('julian_calendar_events', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('calendar', models.ForeignKey(orm['julian.calendar'], null=False)),
+            ('event', models.ForeignKey(orm['julian.event'], null=False))
+        ))
+        db.create_unique('julian_calendar_events', ['calendar_id', 'event_id'])
+
+        # Adding model 'CalendarView'
+        db.create_table('julian_calendarview', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('feed_type', self.gf('django.db.models.fields.CharField')(default='text/calendar', max_length=50)),
+            ('feed_suffix', self.gf('django.db.models.fields.CharField')(default='feed', max_length=255)),
+            ('feeds_enabled', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True)),
+            ('item_title_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='julian_calendarview_title_related', null=True, to=orm['philo.Template'])),
+            ('item_description_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='julian_calendarview_description_related', null=True, to=orm['philo.Template'])),
+            ('calendar', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['julian.Calendar'])),
+            ('index_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='calendar_index_related', to=orm['philo.Page'])),
+            ('event_detail_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='calendar_detail_related', to=orm['philo.Page'])),
+            ('timespan_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_timespan_related', null=True, to=orm['philo.Page'])),
+            ('tag_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_tag_related', null=True, to=orm['philo.Page'])),
+            ('location_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_location_related', null=True, to=orm['philo.Page'])),
+            ('owner_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_owner_related', null=True, to=orm['philo.Page'])),
+            ('tag_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_tag_archive_related', null=True, to=orm['philo.Page'])),
+            ('location_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_location_archive_related', null=True, to=orm['philo.Page'])),
+            ('owner_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_owner_archive_related', null=True, to=orm['philo.Page'])),
+            ('tag_permalink_base', self.gf('django.db.models.fields.CharField')(default='tags', max_length=30)),
+            ('owner_permalink_base', self.gf('django.db.models.fields.CharField')(default='owners', max_length=30)),
+            ('location_permalink_base', self.gf('django.db.models.fields.CharField')(default='locations', max_length=30)),
+            ('events_per_page', self.gf('django.db.models.fields.PositiveIntegerField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('julian', ['CalendarView'])
+
+
+    def backwards(self, orm):
+        
+        # Removing unique constraint on 'Calendar', fields ['name', 'site', 'language']
+        db.delete_unique('julian_calendar', ['name', 'site_id', 'language'])
+
+        # Removing unique constraint on 'Event', fields ['site', 'created']
+        db.delete_unique('julian_event', ['site_id', 'created'])
+
+        # Deleting model 'Location'
+        db.delete_table('julian_location')
+
+        # Deleting model 'Event'
+        db.delete_table('julian_event')
+
+        # Removing M2M table for field tags on 'Event'
+        db.delete_table('julian_event_tags')
+
+        # Deleting model 'Calendar'
+        db.delete_table('julian_calendar')
+
+        # Removing M2M table for field events on 'Calendar'
+        db.delete_table('julian_calendar_events')
+
+        # Deleting model 'CalendarView'
+        db.delete_table('julian_calendarview')
+
+
+    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']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", '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']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            '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']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", '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'})
+        },
+        'julian.calendar': {
+            'Meta': {'unique_together': "(('name', 'site', 'language'),)", 'object_name': 'Calendar'},
+            'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'events': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'calendars'", 'blank': 'True', 'to': "orm['julian.Event']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'language': ('django.db.models.fields.CharField', [], {'default': "'en'", 'max_length': '5'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'db_index': 'True'})
+        },
+        'julian.calendarview': {
+            'Meta': {'object_name': 'CalendarView'},
+            'calendar': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['julian.Calendar']"}),
+            'event_detail_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'calendar_detail_related'", 'to': "orm['philo.Page']"}),
+            'events_per_page': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}),
+            'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}),
+            'feed_type': ('django.db.models.fields.CharField', [], {'default': "'text/calendar'", 'max_length': '50'}),
+            'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'calendar_index_related'", 'to': "orm['philo.Page']"}),
+            'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'julian_calendarview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'julian_calendarview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'location_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_location_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'location_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_location_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'location_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'locations'", 'max_length': '30'}),
+            'owner_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_owner_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'owner_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_owner_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'owner_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'owners'", 'max_length': '30'}),
+            'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_tag_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '30'}),
+            'timespan_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_timespan_related'", 'null': 'True', 'to': "orm['philo.Page']"})
+        },
+        'julian.event': {
+            'Meta': {'unique_together': "(('site', 'created'),)", 'object_name': 'Event'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'description': ('philo.models.fields.TemplateField', [], {}),
+            'end_date': ('django.db.models.fields.DateField', [], {}),
+            'end_time': ('django.db.models.fields.TimeField', [], {'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'}),
+            'location_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'location_pk': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'owned_events'", 'to': "orm['auth.User']"}),
+            'parent_event': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['julian.Event']", 'null': 'True', 'blank': 'True'}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'start_date': ('django.db.models.fields.DateField', [], {}),
+            'start_time': ('django.db.models.fields.TimeField', [], {'null': 'True', 'blank': 'True'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'events'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"})
+        },
+        'julian.location': {
+            'Meta': {'object_name': 'Location'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+        },
+        'oberlin.locationcoordinates': {
+            'Meta': {'unique_together': "(('location_ct', 'location_pk'),)", 'object_name': 'LocationCoordinates'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'latitude': ('django.db.models.fields.FloatField', [], {}),
+            'location_ct': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'location_pk': ('django.db.models.fields.TextField', [], {}),
+            'longitude': ('django.db.models.fields.FloatField', [], {})
+        },
+        'philo.attribute': {
+            'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'},
+            'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}),
+            'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'philo.node': {
+            'Meta': {'object_name': 'Node'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.page': {
+            'Meta': {'object_name': 'Page'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.tag': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+        },
+        'philo.template': {
+            'Meta': {'object_name': 'Template'},
+            'code': ('philo.models.fields.TemplateField', [], {}),
+            'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+        },
+        'sites.site': {
+            'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"},
+            'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'root_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'sites'", 'null': 'True', 'to': "orm['philo.Node']"})
+        }
+    }
+
+    complete_apps = ['julian']
diff --git a/contrib/julian/migrations/__init__.py b/contrib/julian/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/contrib/julian/models.py b/contrib/julian/models.py
new file mode 100644 (file)
index 0000000..5dea7a3
--- /dev/null
@@ -0,0 +1,461 @@
+from django.conf import settings
+from django.conf.urls.defaults import url, patterns, include
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.generic import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.sites.models import Site
+from django.core.exceptions import ValidationError, ObjectDoesNotExist
+from django.core.validators import RegexValidator
+from django.db import models
+from django.db.models.query import QuerySet
+from django.http import HttpResponse, Http404
+from django.utils.encoding import force_unicode
+from philo.contrib.julian.feedgenerator import ICalendarFeed
+from philo.contrib.penfield.models import FeedView, FEEDS
+from philo.exceptions import ViewCanNotProvideSubpath
+from philo.models import Tag, Entity, Page, TemplateField
+from philo.utils import ContentTypeRegistryLimiter
+import datetime, calendar
+
+
+__all__ = ('register_location_model', 'unregister_location_model', 'Location', 'TimedModel', 'Event', 'Calendar', 'CalendarView',)
+
+
+ICALENDAR = ICalendarFeed.mime_type
+FEEDS[ICALENDAR] = ICalendarFeed
+try:
+       DEFAULT_SITE = Site.objects.get_current()
+except:
+       DEFAULT_SITE = None
+_languages = dict(settings.LANGUAGES)
+try:
+       _languages[settings.LANGUAGE_CODE]
+       DEFAULT_LANGUAGE = settings.LANGUAGE_CODE
+except KeyError:
+       try:
+               lang = settings.LANGUAGE_CODE.split('-')[0]
+               _languages[lang]
+               DEFAULT_LANGUAGE = lang
+       except KeyError:
+               DEFAULT_LANGUAGE = None
+
+
+location_content_type_limiter = ContentTypeRegistryLimiter()
+
+
+def register_location_model(model):
+       location_content_type_limiter.register_class(model)
+
+
+def unregister_location_model(model):
+       location_content_type_limiter.unregister_class(model)
+
+
+class Location(Entity):
+       name = models.CharField(max_length=255)
+       slug = models.SlugField(max_length=255, unique=True)
+       
+       def __unicode__(self):
+               return self.name
+
+
+register_location_model(Location)
+
+
+class TimedModel(models.Model):
+       start_date = models.DateField(help_text="YYYY-MM-DD")
+       start_time = models.TimeField(blank=True, null=True, help_text="HH:MM:SS - 24 hour clock")
+       end_date = models.DateField()
+       end_time = models.TimeField(blank=True, null=True)
+       
+       def is_all_day(self):
+               return self.start_time is None and self.end_time is None
+       
+       def clean(self):
+               if bool(self.start_time) != bool(self.end_time):
+                       raise ValidationError("A %s must have either a start time and an end time or neither.")
+               
+               if self.start_date > self.end_date or self.start_date == self.end_date and self.start_time > self.end_time:
+                       raise ValidationError("A %s cannot end before it starts." % self.__class__.__name__)
+       
+       def get_start(self):
+               return self.start_date
+       
+       def get_end(self):
+               return self.end_date
+       
+       class Meta:
+               abstract = True
+
+
+class EventManager(models.Manager):
+       def get_query_set(self):
+               return EventQuerySet(self.model)
+
+class EventQuerySet(QuerySet):
+       def upcoming(self):
+               return self.filter(start_date__gte=datetime.date.today())
+       def current(self):
+               return self.filter(start_date__lte=datetime.date.today(), end_date__gte=datetime.date.today())
+       def single_day(self):
+               return self.filter(start_date__exact=models.F('end_date'))
+       def multiday(self):
+               return self.exclude(start_date__exact=models.F('end_date'))
+
+class Event(Entity, TimedModel):
+       name = models.CharField(max_length=255)
+       slug = models.SlugField(max_length=255, unique_for_date='start_date')
+       
+       location_content_type = models.ForeignKey(ContentType, limit_choices_to=location_content_type_limiter, blank=True, null=True)
+       location_pk = models.TextField(blank=True)
+       location = GenericForeignKey('location_content_type', 'location_pk')
+       
+       description = TemplateField()
+       
+       tags = models.ManyToManyField(Tag, related_name='events', blank=True, null=True)
+       
+       parent_event = models.ForeignKey('self', blank=True, null=True)
+       
+       # TODO: "User module"
+       owner = models.ForeignKey(User, related_name='owned_events')
+       
+       created = models.DateTimeField(auto_now_add=True)
+       last_modified = models.DateTimeField(auto_now=True)
+       
+       site = models.ForeignKey(Site, default=DEFAULT_SITE)
+       
+       @property
+       def uuid(self):
+               return "%s@%s" % (self.created.isoformat(), getattr(self.site, 'domain', 'None'))
+       
+       objects = EventManager()
+       
+       def __unicode__(self):
+               return self.name
+       
+       class Meta:
+               unique_together = ('site', 'created')
+
+
+class Calendar(Entity):
+       name = models.CharField(max_length=100)
+       slug = models.SlugField(max_length=100)
+       description = models.TextField(blank=True)
+       events = models.ManyToManyField(Event, related_name='calendars', blank=True)
+       
+       site = models.ForeignKey(Site, default=DEFAULT_SITE)
+       language = models.CharField(max_length=5, choices=settings.LANGUAGES, default=DEFAULT_LANGUAGE)
+       
+       def __unicode__(self):
+               return self.name
+       
+       @property
+       def fpi(self):
+               # See http://xml.coverpages.org/tauber-fpi.html or ISO 9070:1991 for format information.
+               return "-//%s//%s//%s" % (self.site.name, self.name, self.language.split('-')[0].upper())
+       
+       class Meta:
+               unique_together = ('name', 'site', 'language')
+
+
+class CalendarView(FeedView):
+       calendar = models.ForeignKey(Calendar)
+       index_page = models.ForeignKey(Page, related_name="calendar_index_related")
+       event_detail_page = models.ForeignKey(Page, related_name="calendar_detail_related")
+       
+       timespan_page = models.ForeignKey(Page, related_name="calendar_timespan_related", blank=True, null=True)
+       tag_page = models.ForeignKey(Page, related_name="calendar_tag_related", blank=True, null=True)
+       location_page = models.ForeignKey(Page, related_name="calendar_location_related", blank=True, null=True)
+       owner_page = models.ForeignKey(Page, related_name="calendar_owner_related", blank=True, null=True)
+       
+       tag_archive_page = models.ForeignKey(Page, related_name="calendar_tag_archive_related", blank=True, null=True)
+       location_archive_page = models.ForeignKey(Page, related_name="calendar_location_archive_related", blank=True, null=True)
+       owner_archive_page = models.ForeignKey(Page, related_name="calendar_owner_archive_related", blank=True, null=True)
+       
+       tag_permalink_base = models.CharField(max_length=30, default='tags')
+       owner_permalink_base = models.CharField(max_length=30, default='owners')
+       location_permalink_base = models.CharField(max_length=30, default='locations')
+       events_per_page = models.PositiveIntegerField(blank=True, null=True)
+       
+       item_context_var = "events"
+       object_attr = "calendar"
+       
+       def get_reverse_params(self, obj):
+               if isinstance(obj, User):
+                       return 'events_for_user', [], {'username': obj.username}
+               elif isinstance(obj, Event):
+                       return 'event_detail', [], {
+                               'year': str(obj.start_date.year).zfill(4),
+                               'month': str(obj.start_date.month).zfill(2),
+                               'day': str(obj.start_date.day).zfill(2),
+                               'slug': obj.slug
+                       }
+               elif isinstance(obj, Tag) or isinstance(obj, models.query.QuerySet) and obj.model == Tag:
+                       if isinstance(obj, Tag):
+                               obj = [obj]
+                       return 'entries_by_tag', [], {'tag_slugs': '/'.join(obj)}
+               raise ViewCanNotProvideSubpath
+       
+       def timespan_patterns(self, pattern, timespan_name):
+               return self.feed_patterns(pattern, 'get_events_by_timespan', 'timespan_page', "events_by_%s" % timespan_name)
+       
+       @property
+       def urlpatterns(self):
+               # Perhaps timespans should be done with GET parameters? Or two /-separated
+               # date slugs? (e.g. 2010-02-1/2010-02-2) or a start and duration?
+               # (e.g. 2010-02-01/week/ or ?d=2010-02-01&l=week)
+               urlpatterns = self.feed_patterns(r'^', 'get_all_events', 'index_page', 'index') + \
+                       self.timespan_patterns(r'^(?P<year>\d{4})', 'year') + \
+                       self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'month') + \
+                       self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'day') + \
+                       self.feed_patterns(r'^%s/(?P<username>[^/]+)' % self.owner_permalink_base, 'get_events_by_owner', 'owner_page', 'events_by_user') + \
+                       self.feed_patterns(r'^%s/(?P<app_label>\w+)/(?P<model>\w+)/(?P<pk>[^/]+)' % self.location_permalink_base, 'get_events_by_location', 'location_page', 'events_by_location') + \
+                       self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_events_by_tag', 'tag_page', 'events_by_tag') + \
+                       patterns('',
+                               url(r'(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)$', self.event_detail_view, name="event_detail"),
+                       )
+                       
+                       # Some sort of shortcut for a location would be useful. This could be on a per-calendar
+                       # or per-calendar-view basis.
+                       #url(r'^%s/(?P<slug>[\w-]+)' % self.location_permalink_base, ...)
+               
+               if self.tag_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^%s$' % self.tag_permalink_base, self.tag_archive_view, name='tag_archive')
+                       )
+               
+               if self.owner_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^%s$' % self.owner_permalink_base, self.owner_archive_view, name='owner_archive')
+                       )
+               
+               if self.location_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^%s$' % self.location_permalink_base, self.location_archive_view, name='location_archive')
+                       )
+               return urlpatterns
+       
+       # Basic QuerySet fetchers.
+       def get_event_queryset(self):
+               return self.calendar.events.all()
+       
+       def get_timespan_queryset(self, year, month=None, day=None):
+               qs = self.get_event_queryset()
+               # See python documentation for the min/max values.
+               if year and month and day:
+                       year, month, day = int(year), int(month), int(day)
+                       start_datetime = datetime.datetime(year, month, day, 0, 0)
+                       end_datetime = datetime.datetime(year, month, day, 23, 59)
+               elif year and month:
+                       year, month = int(year), int(month)
+                       start_datetime = datetime.datetime(year, month, 1, 0, 0)
+                       end_datetime = datetime.datetime(year, month, calendar.monthrange(year, month)[1], 23, 59)
+               else:
+                       year = int(year)
+                       start_datetime = datetime.datetime(year, 1, 1, 0, 0)
+                       end_datetime = datetime.datetime(year, 12, 31, 23, 59)
+               
+               return qs.exclude(end_date__lt=start_datetime, end_time__lt=start_datetime).exclude(start_date__gt=end_datetime, start_time__gt=end_datetime, start_time__isnull=False).exclude(start_time__isnull=True, start_date__gt=end_datetime)
+       
+       def get_tag_queryset(self):
+               return Tag.objects.filter(events__calendars=self.calendar).distinct()
+       
+       def get_location_querysets(self):
+               # Potential bottleneck?
+               location_map = {}
+               locations = Event.objects.values_list('location_content_type', 'location_pk')
+               
+               for ct, pk in locations:
+                       location_map.setdefault(ct, []).append(pk)
+               
+               location_cts = ContentType.objects.in_bulk(location_map.keys())
+               location_querysets = {}
+               
+               for ct_pk, pks in location_map.items():
+                       ct = location_cts[ct_pk]
+                       location_querysets[ct] = ct.model_class()._default_manager.filter(pk__in=pks)
+               
+               return location_querysets
+       
+       def get_owner_queryset(self):
+               return User.objects.filter(owned_events__calendars=self.calendar).distinct()
+       
+       # Event QuerySet parsers for a request/args/kwargs
+       def get_all_events(self, request, extra_context=None):
+               return self.get_event_queryset(), extra_context
+       
+       def get_events_by_timespan(self, request, year, month=None, day=None, extra_context=None):
+               context = extra_context or {}
+               context.update({
+                       'year': year,
+                       'month': month,
+                       'day': day
+               })
+               return self.get_timespan_queryset(year, month, day), context
+       
+       def get_events_by_owner(self, request, username, extra_context=None):
+               try:
+                       owner = self.get_owner_queryset().get(username=username)
+               except User.DoesNotExist:
+                       raise Http404
+               
+               qs = self.get_event_queryset().filter(owner=owner)
+               context = extra_context or {}
+               context.update({
+                       'owner': owner
+               })
+               return qs, context
+       
+       def get_events_by_tag(self, request, tag_slugs, extra_context=None):
+               tag_slugs = tag_slugs.replace('+', '/').split('/')
+               tags = self.get_tag_queryset().filter(slug__in=tag_slugs)
+               
+               if not tags:
+                       raise Http404
+               
+               # Raise a 404 on an incorrect slug.
+               found_slugs = [tag.slug for tag in tags]
+               for slug in tag_slugs:
+                       if slug and slug not in found_slugs:
+                               raise Http404
+
+               events = self.get_event_queryset()
+               for tag in tags:
+                       events = events.filter(tags=tag)
+               
+               context = extra_context or {}
+               context.update({'tags': tags})
+               
+               return events, context
+       
+       def get_events_by_location(self, request, app_label, model, pk, extra_context=None):
+               try:
+                       ct = ContentType.objects.get(app_label=app_label, model=model)
+                       location = ct.model_class()._default_manager.get(pk=pk)
+               except ObjectDoesNotExist:
+                       raise Http404
+               
+               events = self.get_event_queryset().filter(location_content_type=ct, location_pk=location.pk)
+               
+               context = extra_context or {}
+               context.update({
+                       'location': location
+               })
+               return events, context
+       
+       # Detail View.
+       def event_detail_view(self, request, year, month, day, slug, extra_context=None):
+               try:
+                       event = Event.objects.select_related('parent_event').get(start_date__year=year, start_date__month=month, start_date__day=day, slug=slug)
+               except Event.DoesNotExist:
+                       raise Http404
+               
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'event': event
+               })
+               return self.event_detail_page.render_to_response(request, extra_context=context)
+       
+       # Archive Views.
+       def tag_archive_view(self, request, extra_context=None):
+               tags = self.get_tag_queryset()
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'tags': tags
+               })
+               return self.tag_archive_page.render_to_response(request, extra_context=context)
+       
+       def location_archive_view(self, request, extra_context=None):
+               # What datastructure should locations be?
+               locations = self.get_location_querysets()
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'locations': locations
+               })
+               return self.location_archive_page.render_to_response(request, extra_context=context)
+       
+       def owner_archive_view(self, request, extra_context=None):
+               owners = self.get_owner_queryset()
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'owners': owners
+               })
+               return self.owner_archive_page.render_to_response(request, extra_context=context)
+       
+       # Process page items
+       def process_page_items(self, request, items):
+               if self.events_per_page:
+                       page_num = request.GET.get('page', 1)
+                       paginator, paginated_page, items = paginate(items, self.events_per_page, page_num)
+                       item_context = {
+                               'paginator': paginator,
+                               'paginated_page': paginated_page,
+                               self.item_context_var: items
+                       }
+               else:
+                       item_context = {
+                               self.item_context_var: items
+                       }
+               return items, item_context
+       
+       # Feed information hooks
+       def title(self, obj):
+               return obj.name
+       
+       def link(self, obj):
+               # Link is ignored anyway...
+               return ""
+       
+       def feed_guid(self, obj):
+               return obj.fpi
+       
+       def description(self, obj):
+               return obj.description
+       
+       def feed_extra_kwargs(self, obj):
+               return {'filename': "%s.ics" % obj.slug}
+       
+       def item_title(self, item):
+               return item.name
+       
+       def item_description(self, item):
+               return item.description
+       
+       def item_link(self, item):
+               return self.reverse(item)
+       
+       def item_guid(self, item):
+               return item.uuid
+       
+       def item_author_name(self, item):
+               if item.owner:
+                       return item.owner.get_full_name()
+       
+       def item_author_email(self, item):
+               return getattr(item.owner, 'email', None) or None
+       
+       def item_pubdate(self, item):
+               return item.created
+       
+       def item_categories(self, item):
+               return [tag.name for tag in item.tags.all()]
+       
+       def item_extra_kwargs(self, item):
+               return {
+                       'start': item.get_start(),
+                       'end': item.get_end(),
+                       'last_modified': item.last_modified,
+                       # Is forcing unicode enough, or should we look for a "custom method"?
+                       'location': force_unicode(item.location),
+               }
+       
+       def __unicode__(self):
+               return u"%s for %s" % (self.__class__.__name__, self.calendar)
+
+field = CalendarView._meta.get_field('feed_type')
+field._choices += ((ICALENDAR, 'iCalendar'),)
+field.default = ICALENDAR
\ No newline at end of file
index 5aee6f8..c70cf46 100644 (file)
@@ -62,7 +62,7 @@ class BlogViewAdmin(EntityAdmin):
                        'classes': COLLAPSE_CLASSES
                }),
                ('Feed Settings', {
-                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',),
+                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'feed_length', 'item_title_template', 'item_description_template',),
                        'classes': COLLAPSE_CLASSES
                })
        )
index 57fb020..1413bdf 100644 (file)
@@ -167,6 +167,4 @@ def navigation_host(node, key):
        try:
                return Navigation.objects.filter(node__in=node.get_ancestors(include_self=True), key=key).order_by('-node__level')[0].node
        except:
-               if settings.TEMPLATE_DEBUG:
-                       raise
                return node
\ No newline at end of file
diff --git a/contrib/sobol/__init__.py b/contrib/sobol/__init__.py
new file mode 100644 (file)
index 0000000..90eaf18
--- /dev/null
@@ -0,0 +1 @@
+from philo.contrib.sobol.search import *
\ No newline at end of file
diff --git a/contrib/sobol/admin.py b/contrib/sobol/admin.py
new file mode 100644 (file)
index 0000000..87dd39a
--- /dev/null
@@ -0,0 +1,104 @@
+from django.conf import settings
+from django.conf.urls.defaults import patterns, url
+from django.contrib import admin
+from django.core.urlresolvers import reverse
+from django.db.models import Count
+from django.http import HttpResponseRedirect, Http404
+from django.shortcuts import render_to_response
+from django.template import RequestContext
+from django.utils.translation import ugettext_lazy as _
+from philo.admin import EntityAdmin
+from philo.contrib.sobol.models import Search, ResultURL, SearchView
+from functools import update_wrapper
+
+
+class ResultURLInline(admin.TabularInline):
+       model = ResultURL
+       readonly_fields = ('url',)
+       can_delete = False
+       extra = 0
+       max_num = 0
+
+
+class SearchAdmin(admin.ModelAdmin):
+       readonly_fields = ('string',)
+       inlines = [ResultURLInline]
+       list_display = ['string', 'unique_urls', 'total_clicks']
+       search_fields = ['string', 'result_urls__url']
+       actions = ['results_action']
+       if 'grappelli' in settings.INSTALLED_APPS:
+               results_template = 'admin/sobol/search/grappelli_results.html'
+       else:
+               results_template = 'admin/sobol/search/results.html'
+       
+       def get_urls(self):
+               urlpatterns = super(SearchAdmin, self).get_urls()
+               
+               def wrap(view):
+                       def wrapper(*args, **kwargs):
+                               return self.admin_site.admin_view(view)(*args, **kwargs)
+                       return update_wrapper(wrapper, view)
+               
+               info = self.model._meta.app_label, self.model._meta.module_name
+               
+               urlpatterns = patterns('',
+                       url(r'^results/$', wrap(self.results_view), name="%s_%s_selected_results" % info),
+                       url(r'^(.+)/results/$', wrap(self.results_view), name="%s_%s_results" % info)
+               ) + urlpatterns
+               return urlpatterns
+       
+       def unique_urls(self, obj):
+               return obj.unique_urls
+       unique_urls.admin_order_field = 'unique_urls'
+       
+       def total_clicks(self, obj):
+               return obj.total_clicks
+       total_clicks.admin_order_field = 'total_clicks'
+       
+       def queryset(self, request):
+               qs = super(SearchAdmin, self).queryset(request)
+               return qs.annotate(total_clicks=Count('result_urls__clicks', distinct=True), unique_urls=Count('result_urls', distinct=True))
+       
+       def results_action(self, request, queryset):
+               info = self.model._meta.app_label, self.model._meta.module_name
+               if len(queryset) == 1:
+                       return HttpResponseRedirect(reverse("admin:%s_%s_results" % info, args=(queryset[0].pk,)))
+               else:
+                       url = reverse("admin:%s_%s_selected_results" % info)
+                       return HttpResponseRedirect("%s?ids=%s" % (url, ','.join([str(item.pk) for item in queryset])))
+       results_action.short_description = "View results for selected %(verbose_name_plural)s"
+       
+       def results_view(self, request, object_id=None, extra_context=None):
+               if object_id is not None:
+                       object_ids = [object_id]
+               else:
+                       object_ids = request.GET.get('ids').split(',')
+                       
+                       if object_ids is None:
+                               raise Http404
+               
+               qs = self.queryset(request).filter(pk__in=object_ids)
+               opts = self.model._meta
+               
+               if len(object_ids) == 1:
+                       title = _(u"Search results for %s" % qs[0])
+               else:
+                       title = _(u"Search results for multiple objects")
+               
+               context = {
+                       'title': title,
+                       'queryset': qs,
+                       'opts': opts,
+                       'root_path': self.admin_site.root_path,
+                       'app_label': opts.app_label
+               }
+               return render_to_response(self.results_template, context, context_instance=RequestContext(request))
+
+
+class SearchViewAdmin(EntityAdmin):
+       raw_id_fields = ('results_page',)
+       related_lookup_fields = {'fk': raw_id_fields}
+
+
+admin.site.register(Search, SearchAdmin)
+admin.site.register(SearchView, SearchViewAdmin)
\ No newline at end of file
diff --git a/contrib/sobol/forms.py b/contrib/sobol/forms.py
new file mode 100644 (file)
index 0000000..e79d9e7
--- /dev/null
@@ -0,0 +1,12 @@
+from django import forms
+from philo.contrib.sobol.utils import SEARCH_ARG_GET_KEY
+
+
+class BaseSearchForm(forms.BaseForm):
+       base_fields = {
+               SEARCH_ARG_GET_KEY: forms.CharField()
+       }
+
+
+class SearchForm(forms.Form, BaseSearchForm):
+       pass
\ No newline at end of file
diff --git a/contrib/sobol/models.py b/contrib/sobol/models.py
new file mode 100644 (file)
index 0000000..b653c09
--- /dev/null
@@ -0,0 +1,224 @@
+from django.conf.urls.defaults import patterns, url
+from django.contrib import messages
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.http import HttpResponseRedirect, Http404, HttpResponse
+from django.utils import simplejson as json
+from django.utils.datastructures import SortedDict
+from philo.contrib.sobol import registry
+from philo.contrib.sobol.forms import SearchForm
+from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash
+from philo.exceptions import ViewCanNotProvideSubpath
+from philo.models import MultiView, Page
+from philo.models.fields import SlugMultipleChoiceField
+from philo.validators import RedirectValidator
+import datetime
+try:
+       import eventlet
+except:
+       eventlet = False
+
+
+class Search(models.Model):
+       string = models.TextField()
+       
+       def __unicode__(self):
+               return self.string
+       
+       def get_weighted_results(self, threshhold=None):
+               "Returns this search's results ordered by decreasing weight."
+               if not hasattr(self, '_weighted_results'):
+                       result_qs = self.result_urls.all()
+                       
+                       if threshhold is not None:
+                               result_qs = result_qs.filter(counts__datetime__gte=threshhold)
+                       
+                       results = [result for result in result_qs]
+                       
+                       results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
+                       
+                       self._weighted_results = results
+               
+               return self._weighted_results
+       
+       def get_favored_results(self, error=5, threshhold=None):
+               """
+               Calculate the set of most-favored results. A higher error
+               will cause this method to be more reticent about adding new
+               items.
+               
+               The thought is to see whether there are any results which
+               vastly outstrip the other options. As such, evenly-weighted
+               results should be grouped together and either added or
+               excluded as a group.
+               """
+               if not hasattr(self, '_favored_results'):
+                       results = self.get_weighted_results(threshhold)
+                       
+                       grouped_results = SortedDict()
+                       
+                       for result in results:
+                               grouped_results.setdefault(result.weight, []).append(result)
+                       
+                       self._favored_results = []
+                       
+                       for value, subresults in grouped_results.items():
+                               cost = error * sum([(value - result.weight)**2 for result in self._favored_results])
+                               if value > cost:
+                                       self._favored_results += subresults
+                               else:
+                                       break
+               return self._favored_results
+       
+       class Meta:
+               ordering = ['string']
+               verbose_name_plural = 'searches'
+
+
+class ResultURL(models.Model):
+       search = models.ForeignKey(Search, related_name='result_urls')
+       url = models.TextField(validators=[RedirectValidator()])
+       
+       def __unicode__(self):
+               return self.url
+       
+       def get_weight(self, threshhold=None):
+               if not hasattr(self, '_weight'):
+                       clicks = self.clicks.all()
+                       
+                       if threshhold is not None:
+                               clicks = clicks.filter(datetime__gte=threshhold)
+                       
+                       self._weight = sum([click.weight for click in clicks])
+               
+               return self._weight
+       weight = property(get_weight)
+       
+       class Meta:
+               ordering = ['url']
+
+
+class Click(models.Model):
+       result = models.ForeignKey(ResultURL, related_name='clicks')
+       datetime = models.DateTimeField()
+       
+       def __unicode__(self):
+               return self.datetime.strftime('%B %d, %Y %H:%M:%S')
+       
+       def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
+               if not hasattr(self, '_weight'):
+                       days = (datetime.datetime.now() - self.datetime).days
+                       if days < 0:
+                               raise ValueError("Click dates must be in the past.")
+                       default = float(default)
+                       if days == 0:
+                               self._weight = float(default)
+                       else:
+                               self._weight = weighted(default, days)
+               return self._weight
+       weight = property(get_weight)
+       
+       def clean(self):
+               if self.datetime > datetime.datetime.now():
+                       raise ValidationError("Click dates must be in the past.")
+       
+       class Meta:
+               ordering = ['datetime']
+               get_latest_by = 'datetime'
+
+
+class SearchView(MultiView):
+       results_page = models.ForeignKey(Page, related_name='search_results_related')
+       searches = SlugMultipleChoiceField(choices=registry.iterchoices())
+       enable_ajax_api = models.BooleanField("Enable AJAX API", default=True)
+       placeholder_text = models.CharField(max_length=75, default="Search")
+       
+       search_form = SearchForm
+       
+       def __unicode__(self):
+               return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices()]))
+       
+       def get_reverse_params(self, obj):
+               raise ViewCanNotProvideSubpath
+       
+       @property
+       def urlpatterns(self):
+               urlpatterns = patterns('',
+                       url(r'^$', self.results_view, name='results'),
+               )
+               if self.enable_ajax_api:
+                       urlpatterns += patterns('',
+                               url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
+                       )
+               return urlpatterns
+       
+       def get_search_instance(self, slug, search_string):
+               return registry[slug](search_string.lower())
+       
+       def results_view(self, request, extra_context=None):
+               results = None
+               
+               context = self.get_context()
+               context.update(extra_context or {})
+               
+               if SEARCH_ARG_GET_KEY in request.GET:
+                       form = self.search_form(request.GET)
+                       
+                       if form.is_valid():
+                               search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
+                               url = request.GET.get(URL_REDIRECT_GET_KEY)
+                               hash = request.GET.get(HASH_REDIRECT_GET_KEY)
+                               
+                               if url and hash:
+                                       if check_redirect_hash(hash, search_string, url):
+                                               # Create the necessary models
+                                               search = Search.objects.get_or_create(string=search_string)[0]
+                                               result_url = search.result_urls.get_or_create(url=url)[0]
+                                               result_url.clicks.create(datetime=datetime.datetime.now())
+                                               return HttpResponseRedirect(url)
+                                       else:
+                                               messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
+                                               # TODO: Should search_string be escaped here?
+                                               return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
+                               if not self.enable_ajax_api:
+                                       search_instances = []
+                                       if eventlet:
+                                               pool = eventlet.GreenPool()
+                                       for slug in self.searches:
+                                               search_instance = self.get_search_instance(slug, search_string)
+                                               search_instances.append(search_instance)
+                                               if eventlet:
+                                                       pool.spawn_n(self.make_result_cache, search_instance)
+                                               else:
+                                                       self.make_result_cache(search_instance)
+                                       if eventlet:
+                                               pool.waitall()
+                                       context.update({
+                                               'searches': search_instances
+                                       })
+                               else:
+                                       context.update({
+                                               'searches': [{'verbose_name': verbose_name, 'url': self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node)} for slug, verbose_name in registry.iterchoices()]
+                                       })
+               else:
+                       form = SearchForm()
+               
+               context.update({
+                       'form': form
+               })
+               return self.results_page.render_to_response(request, extra_context=context)
+       
+       def make_result_cache(self, search_instance):
+               search_instance.results
+       
+       def ajax_api_view(self, request, slug, extra_context=None):
+               search_string = request.GET.get(SEARCH_ARG_GET_KEY)
+               
+               if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
+                       raise Http404
+               
+               search_instance = self.get_search_instance(slug, search_string)
+               response = HttpResponse(json.dumps({
+                       'results': [result.get_context() for result in search_instance.results],
+               }))
+               return response
\ No newline at end of file
diff --git a/contrib/sobol/search.py b/contrib/sobol/search.py
new file mode 100644 (file)
index 0000000..36c2b5d
--- /dev/null
@@ -0,0 +1,382 @@
+#encoding: utf-8
+
+from django.conf import settings
+from django.contrib.sites.models import Site
+from django.core.cache import cache
+from django.db.models.options import get_verbose_name as convert_camelcase
+from django.utils import simplejson as json
+from django.utils.http import urlquote_plus
+from django.utils.safestring import mark_safe
+from django.utils.text import capfirst
+from django.template import loader, Context, Template
+import datetime
+from philo.contrib.sobol.utils import make_tracking_querydict
+
+try:
+       from eventlet.green import urllib2
+except:
+       import urllib2
+
+
+__all__ = (
+       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry'
+)
+
+
+SEARCH_CACHE_KEY = 'philo_sobol_search_results'
+DEFAULT_RESULT_TEMPLATE_STRING = "{% if url %}<a href='{{ url }}'>{% endif %}{{ title }}{% if url %}</a>{% endif %}"
+
+# Determines the timeout on the entire result cache.
+MAX_CACHE_TIMEOUT = 60*60*24*7
+
+
+class RegistrationError(Exception):
+       pass
+
+
+class SearchRegistry(object):
+       # Holds a registry of search types by slug.
+       def __init__(self):
+               self._registry = {}
+       
+       def register(self, search, slug=None):
+               slug = slug or search.slug
+               if slug in self._registry:
+                       if self._registry[slug] != search:
+                               raise RegistrationError("A different search is already registered as `%s`")
+               else:
+                       self._registry[slug] = search
+       
+       def unregister(self, search, slug=None):
+               if slug is not None:
+                       if slug in self._registry and self._registry[slug] == search:
+                               del self._registry[slug]
+                       raise RegistrationError("`%s` is not registered as `%s`" % (search, slug))
+               else:
+                       for slug, search in self._registry.items():
+                               if search == search:
+                                       del self._registry[slug]
+       
+       def items(self):
+               return self._registry.items()
+       
+       def iteritems(self):
+               return self._registry.iteritems()
+       
+       def iterchoices(self):
+               for slug, search in self.iteritems():
+                       yield slug, search.verbose_name
+       
+       def __getitem__(self, key):
+               return self._registry[key]
+       
+       def __iter__(self):
+               return self._registry.__iter__()
+
+
+registry = SearchRegistry()
+
+
+class Result(object):
+       """
+       A result is instantiated with a configuration dictionary, a search,
+       and a template name. The configuration dictionary is expected to
+       define a `title` and optionally a `url`. Any other variables may be
+       defined; they will be made available through the result object in
+       the template, if one is defined.
+       """
+       def __init__(self, search, result):
+               self.search = search
+               self.result = result
+       
+       def get_title(self):
+               return self.search.get_result_title(self.result)
+       
+       def get_url(self):
+               return "?%s" % self.search.get_result_querydict(self.result).urlencode()
+       
+       def get_template(self):
+               return self.search.get_result_template(self.result)
+       
+       def get_extra_context(self):
+               return self.search.get_result_extra_context(self.result)
+       
+       def get_context(self):
+               context = self.get_extra_context()
+               context.update({
+                       'title': self.get_title(),
+                       'url': self.get_url()
+               })
+               return context
+       
+       def render(self):
+               t = self.get_template()
+               c = Context(self.get_context())
+               return t.render(c)
+       
+       def __unicode__(self):
+               return self.render()
+
+
+class BaseSearchMetaclass(type):
+       def __new__(cls, name, bases, attrs):
+               if 'verbose_name' not in attrs:
+                       attrs['verbose_name'] = capfirst(convert_camelcase(name))
+               if 'slug' not in attrs:
+                       attrs['slug'] = name.lower()
+               return super(BaseSearchMetaclass, cls).__new__(cls, name, bases, attrs)
+
+
+class BaseSearch(object):
+       """
+       Defines a generic search interface. Accessing self.results will
+       attempt to retrieve cached results and, if that fails, will
+       initiate a new search and store the results in the cache.
+       """
+       __metaclass__ = BaseSearchMetaclass
+       result_limit = 10
+       _cache_timeout = 60*48
+       
+       def __init__(self, search_arg):
+               self.search_arg = search_arg
+       
+       def _get_cached_results(self):
+               """Return the cached results if the results haven't timed out. Otherwise return None."""
+               result_cache = cache.get(SEARCH_CACHE_KEY)
+               if result_cache and self.__class__ in result_cache and self.search_arg.lower() in result_cache[self.__class__]:
+                       cached = result_cache[self.__class__][self.search_arg.lower()]
+                       if cached['timeout'] >= datetime.datetime.now():
+                               return cached['results']
+               return None
+       
+       def _set_cached_results(self, results, timeout):
+               """Sets the results to the cache for <timeout> minutes."""
+               result_cache = cache.get(SEARCH_CACHE_KEY) or {}
+               cached = result_cache.setdefault(self.__class__, {}).setdefault(self.search_arg.lower(), {})
+               cached.update({
+                       'results': results,
+                       'timeout': datetime.datetime.now() + datetime.timedelta(minutes=timeout)
+               })
+               cache.set(SEARCH_CACHE_KEY, result_cache, MAX_CACHE_TIMEOUT)
+       
+       @property
+       def results(self):
+               if not hasattr(self, '_results'):
+                       results = self._get_cached_results()
+                       if results is None:
+                               try:
+                                       # Cache one extra result so we can see if there are
+                                       # more results to be had.
+                                       limit = self.result_limit
+                                       if limit is not None:
+                                               limit += 1
+                                       results = self.get_results(self.result_limit)
+                               except:
+                                       if settings.DEBUG:
+                                               raise
+                                       #  On exceptions, don't set any cache; just return.
+                                       return []
+                       
+                               self._set_cached_results(results, self._cache_timeout)
+                       self._results = results
+               
+               return self._results
+       
+       def get_results(self, limit=None, result_class=Result):
+               """
+               Calls self.search() and parses the return value into Result objects.
+               """
+               results = self.search(limit)
+               return [result_class(self, result) for result in results]
+       
+       def search(self, limit=None):
+               """
+               Returns an iterable of up to <limit> results. The
+               get_result_title, get_result_url, get_result_template, and
+               get_result_extra_context methods will be used to interpret the
+               individual items that this function returns, so the result can
+               be an object with attributes as easily as a dictionary
+               with keys. The only restriction is that the objects be
+               pickleable so that they can be used with django's cache system.
+               """
+               raise NotImplementedError
+       
+       def get_result_title(self, result):
+               raise NotImplementedError
+       
+       def get_result_url(self, result):
+               "Subclasses override this to provide the actual URL for the result."
+               raise NotImplementedError
+       
+       def get_result_querydict(self, result):
+               return make_tracking_querydict(self.search_arg, self.get_result_url(result))
+       
+       def get_result_template(self, result):
+               if hasattr(self, 'result_template'):
+                       return loader.get_template(self.result_template)
+               if not hasattr(self, '_result_template'):
+                       self._result_template = Template(DEFAULT_RESULT_TEMPLATE_STRING)
+               return self._result_template
+       
+       def get_result_extra_context(self, result):
+               return {}
+       
+       def has_more_results(self):
+               """Useful to determine whether to display a `view more results` link."""
+               return len(self.results) > self.result_limit
+       
+       @property
+       def more_results_url(self):
+               """
+               Returns the actual url for more results. This will be encoded
+               into a querystring for tracking purposes.
+               """
+               raise NotImplementedError
+       
+       @property
+       def more_results_querydict(self):
+               return make_tracking_querydict(self.search_arg, self.more_results_url)
+       
+       def __unicode__(self):
+               return ' '.join(self.__class__.verbose_name.rsplit(' ', 1)[:-1]) + ' results'
+
+
+class DatabaseSearch(BaseSearch):
+       model = None
+       
+       def has_more_results(self):
+               return self.get_queryset().count() > self.result_limit
+       
+       def search(self, limit=None):
+               if not hasattr(self, '_qs'):
+                       self._qs = self.get_queryset()
+                       if limit is not None:
+                               self._qs = self._qs[:limit]
+               
+               return self._qs
+       
+       def get_queryset(self):
+               return self.model._default_manager.all()
+
+
+class URLSearch(BaseSearch):
+       """
+       Defines a generic interface for searches that require accessing a
+       certain url to get search results.
+       """
+       search_url = ''
+       query_format_str = "%s"
+
+       @property
+       def url(self):
+               "The URL where the search gets its results."
+               return self.search_url + self.query_format_str % urlquote_plus(self.search_arg)
+
+       @property
+       def more_results_url(self):
+               "The URL where the users would go to get more results."
+               return self.url
+       
+       def parse_response(self, response, limit=None):
+               raise NotImplementedError
+       
+       def search(self, limit=None):
+               return self.parse_response(urllib2.urlopen(self.url), limit=limit)
+
+
+class JSONSearch(URLSearch):
+       """
+       Makes a GET request and parses the results as JSON. The default
+       behavior assumes that the return value is a list of results.
+       """
+       def parse_response(self, response, limit=None):
+               return json.loads(response.read())[:limit]
+
+
+class GoogleSearch(JSONSearch):
+       search_url = "http://ajax.googleapis.com/ajax/services/search/web"
+       query_format_str = "?v=1.0&q=%s"
+       # TODO: Change this template to reflect the app's actual name.
+       result_template = 'search/googlesearch.html'
+       timeout = 60
+       
+       def parse_response(self, response, limit=None):
+               responseData = json.loads(response.read())['responseData']
+               results, cursor = responseData['results'], responseData['cursor']
+               
+               if results:
+                       self._more_results_url = cursor['moreResultsUrl']
+                       self._estimated_result_count = cursor['estimatedResultCount']
+               
+               return results[:limit]
+       
+       @property
+       def url(self):
+               # Google requires that an ajax request have a proper Referer header.
+               return urllib2.Request(
+                       super(GoogleSearch, self).url,
+                       None,
+                       {'Referer': "http://%s" % Site.objects.get_current().domain}
+               )
+       
+       @property
+       def has_more_results(self):
+               if self.results and len(self.results) < self._estimated_result_count:
+                       return True
+               return False
+       
+       @property
+       def more_results_url(self):
+               return self._more_results_url
+       
+       def get_result_title(self, result):
+               return result['titleNoFormatting']
+       
+       def get_result_url(self, result):
+               return result['unescapedUrl']
+       
+       def get_result_extra_context(self, result):
+               return result
+
+
+registry.register(GoogleSearch)
+
+
+try:
+       from BeautifulSoup import BeautifulSoup, SoupStrainer, BeautifulStoneSoup
+except:
+       pass
+else:
+       __all__ += ('ScrapeSearch', 'XMLSearch',)
+       class ScrapeSearch(URLSearch):
+               _strainer_args = []
+               _strainer_kwargs = {}
+               
+               @property
+               def strainer(self):
+                       if not hasattr(self, '_strainer'):
+                               self._strainer = SoupStrainer(*self._strainer_args, **self._strainer_kwargs)
+                       return self._strainer
+               
+               def parse_response(self, response, limit=None):
+                       strainer = self.strainer
+                       soup = BeautifulSoup(response, parseOnlyThese=strainer)
+                       return self.parse_results(soup[:limit])
+               
+               def parse_results(self, results):
+                       """
+                       Provides a hook for parsing the results of straining. This
+                       has no default behavior because the results absolutely
+                       must be parsed to properly extract the information.
+                       For more information, see http://www.crummy.com/software/BeautifulSoup/documentation.html#Improving%20Memory%20Usage%20with%20extract
+                       """
+                       raise NotImplementedError
+       
+       
+       class XMLSearch(ScrapeSearch):
+               _self_closing_tags = []
+               
+               def parse_response(self, response, limit=None):
+                       strainer = self.strainer
+                       soup = BeautifulStoneSoup(page, selfClosingTags=self._self_closing_tags, parseOnlyThese=strainer)
+                       return self.parse_results(soup[:limit])
\ No newline at end of file
diff --git a/contrib/sobol/templates/admin/sobol/search/grappelli_results.html b/contrib/sobol/templates/admin/sobol/search/grappelli_results.html
new file mode 100644 (file)
index 0000000..45135ff
--- /dev/null
@@ -0,0 +1,53 @@
+{% extends "admin/base_site.html" %}
+
+<!-- LOADING -->
+{% load i18n %}
+
+<!-- EXTRASTYLES -->
+{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
+
+<!-- BREADCRUMBS -->
+{% block breadcrumbs %}
+       <div id="breadcrumbs">
+               {% if queryset|length > 1 %}
+               <a href="../../">{% trans "Home" %}</a> &rsaquo;
+               <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
+               <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+               {% trans 'Search results for multiple objects' %}
+               {% else %}
+               <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
+               <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo;
+               <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+               <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
+               {% trans 'Results' %}
+               {% endif %}
+       </div>
+{% endblock %}
+
+<!-- CONTENT -->
+{% block content %}
+       <div class="container-grid delete-confirmation">
+               {% for search in queryset %}
+               {% if not forloop.first and not forloop.last %}<h1>{{ search.string }}</h1>{% endif %}
+               <div class="group tabular">
+                       <h2>{% blocktrans %}Results{% endblocktrans %}</h2>{% comment %}For the favored results, add a class?{% endcomment %}
+                       <div class="module table">
+                               <div class="module thead">
+                                       <div class="tr">
+                                               <div class="th">Weight</div>
+                                               <div class="th">URL</div>
+                                       </div>
+                               </div>
+                               <div class="module tbody">
+                                       {% for result in search.get_weighted_results %}
+                                       <div class="tr{% if result in search.get_favored_results %} favored{% endif %}">
+                                               <div class="td">{{ result.weight }}</div>
+                                               <div class="td">{{ result.url }}</div>
+                                       </div>
+                                       {% endfor %}
+                               </div>
+                       </div>
+               </div>
+               {% endfor %}
+       </div>
+{% endblock %}
\ No newline at end of file
diff --git a/contrib/sobol/templates/admin/sobol/search/results.html b/contrib/sobol/templates/admin/sobol/search/results.html
new file mode 100644 (file)
index 0000000..44d4e7c
--- /dev/null
@@ -0,0 +1,47 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+       {% if queryset|length > 1 %}
+       <a href="../../">{% trans "Home" %}</a> &rsaquo;
+       <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
+       <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+       {% trans 'Search results for multiple objects' %}
+       {% else %}
+       <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
+       <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo; 
+       <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+       <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
+       {% trans 'Results' %}
+       {% endif %}
+</div>
+{% endblock %}
+
+
+{% block content %}
+               {% for search in queryset %}
+               {% if not forloop.first and not forloop.last %}<h1>{{ search.string }}</h1>{% endif %}
+                       <fieldset class="module">
+                               <h2>{% blocktrans %}Results{% endblocktrans %}</h2>{% comment %}For the favored results, add a class?{% endcomment %}
+                               <table>
+                                       <thead>
+                                               <tr>
+                                                       <th>Weight</th>
+                                                       <th>URL</th>
+                                               </tr>
+                                       </thead>
+                                       <tbody>
+                                               {% for result in search.get_weighted_results %}
+                                               <tr{% if result in search.favored_results %} class="favored"{% endif %}>
+                                                       <td>{{ result.weight }}</td>
+                                                       <td>{{ result.url }}</td>
+                                               </tr>
+                                               {% endfor %}
+                                       </tbody>
+                               </table>
+                       </fieldset>
+               {% endfor %}
+{% endblock %}
\ No newline at end of file
diff --git a/contrib/sobol/templates/search/googlesearch.html b/contrib/sobol/templates/search/googlesearch.html
new file mode 100644 (file)
index 0000000..1b22388
--- /dev/null
@@ -0,0 +1,4 @@
+<article>
+       <h1><a href="{{ url }}">{{ title|safe }}</a></h1>
+       <p>{{ content|safe }}</p>
+</article>
\ No newline at end of file
diff --git a/contrib/sobol/utils.py b/contrib/sobol/utils.py
new file mode 100644 (file)
index 0000000..3c5e537
--- /dev/null
@@ -0,0 +1,32 @@
+from django.conf import settings
+from django.http import QueryDict
+from django.utils.encoding import smart_str
+from django.utils.http import urlquote_plus, urlquote
+from hashlib import sha1
+
+
+SEARCH_ARG_GET_KEY = 'q'
+URL_REDIRECT_GET_KEY = 'url'
+HASH_REDIRECT_GET_KEY = 's'
+
+
+def make_redirect_hash(search_arg, url):
+       return sha1(smart_str(search_arg + url + settings.SECRET_KEY)).hexdigest()[::2]
+
+
+def check_redirect_hash(hash, search_arg, url):
+       return hash == make_redirect_hash(search_arg, url)
+
+
+def make_tracking_querydict(search_arg, url):
+       """
+       Returns a QueryDict instance containing the information necessary
+       for tracking clicks of this url.
+       
+       NOTE: will this kind of initialization handle quoting correctly?
+       """
+       return QueryDict("%s=%s&%s=%s&%s=%s" % (
+               SEARCH_ARG_GET_KEY, urlquote_plus(search_arg),
+               URL_REDIRECT_GET_KEY, urlquote(url),
+               HASH_REDIRECT_GET_KEY, make_redirect_hash(search_arg, url))
+       )
\ No newline at end of file
index 95ce0c0..80f0b11 100644 (file)
@@ -7,6 +7,7 @@ from datetime import date
 from django.conf import settings
 from django.utils.http import int_to_base36, base36_to_int
 from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from hashlib import sha1
 
 
 REGISTRATION_TIMEOUT_DAYS = getattr(settings, 'WALDO_REGISTRATION_TIMEOUT_DAYS', 1)
@@ -52,8 +53,7 @@ class RegistrationTokenGenerator(PasswordResetTokenGenerator):
                # By hashing on the internal state of the user and using state that is
                # sure to change, we produce a hash that will be invalid as soon as it
                # is used.
-               from django.utils.hashcompat import sha_constructor
-               hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) + unicode(user.is_active) + user.last_login.strftime('%Y-%m-%d %H:%M:%S') + unicode(timestamp)).hexdigest()[::2]
+               hash = sha1(settings.SECRET_KEY + unicode(user.id) + unicode(user.is_active) + user.last_login.strftime('%Y-%m-%d %H:%M:%S') + unicode(timestamp)).hexdigest()[::2]
                return '%s-%s' % (ts_b36, hash)
 
 
@@ -98,8 +98,7 @@ class EmailTokenGenerator(PasswordResetTokenGenerator):
        def _make_token_with_timestamp(self, user, email, timestamp):
                ts_b36 = int_to_base36(timestamp)
                
-               from django.utils.hashcompat import sha_constructor
-               hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) + user.email + email + unicode(timestamp)).hexdigest()[::2]
+               hash = sha1(settings.SECRET_KEY + unicode(user.id) + user.email + email + unicode(timestamp)).hexdigest()[::2]
                return '%s-%s' % (ts_b36, hash)
 
 
index b6259a3..e781128 100644 (file)
@@ -1,4 +1,4 @@
-from django.forms.models import ModelFormMetaclass, ModelForm
+from django.forms.models import ModelFormMetaclass, ModelForm, ModelFormOptions
 from django.utils.datastructures import SortedDict
 from philo.utils import fattr
 
@@ -6,7 +6,7 @@ from philo.utils import fattr
 __all__ = ('EntityForm',)
 
 
-def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widgets=None, formfield_callback=lambda f, **kwargs: f.formfield(**kwargs)):
+def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widgets=None, formfield_callback=None):
        field_list = []
        ignored = []
        opts = entity_model._entity_meta
@@ -21,7 +21,14 @@ def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widge
                        kwargs = {'widget': widgets[f.name]}
                else:
                        kwargs = {}
-               formfield = formfield_callback(f, **kwargs)
+               
+               if formfield_callback is None:
+                       formfield = f.formfield(**kwargs)
+               elif not callable(formfield_callback):
+                       raise TypeError('formfield_callback must be a function or callable')
+               else:
+                       formfield = formfield_callback(f, **kwargs)
+               
                if formfield:
                        field_list.append((f.name, formfield))
                else:
@@ -35,31 +42,59 @@ def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widge
        return field_dict
 
 
-# BEGIN HACK - This will not be required after http://code.djangoproject.com/ticket/14082 has been resolved
-
-class EntityFormBase(ModelForm):
-       pass
+# HACK until http://code.djangoproject.com/ticket/14082 is resolved.
+_old = ModelFormMetaclass.__new__
+def _new(cls, name, bases, attrs):
+       if cls == ModelFormMetaclass:
+               m = attrs.get('__metaclass__', None)
+               if m is None:
+                       parents = [b for b in bases if issubclass(b, ModelForm)]
+                       for c in parents:
+                               if c.__metaclass__ != ModelFormMetaclass:
+                                       m = c.__metaclass__
+                                       break
+       
+               if m is not None:
+                       return m(name, bases, attrs)
+       
+       return _old(cls, name, bases, attrs)
+ModelFormMetaclass.__new__ = staticmethod(_new)
+# END HACK
 
-_old_metaclass_new = ModelFormMetaclass.__new__
 
-def _new_metaclass_new(cls, name, bases, attrs):
-       formfield_callback = attrs.get('formfield_callback', lambda f, **kwargs: f.formfield(**kwargs))
-       new_class = _old_metaclass_new(cls, name, bases, attrs)
-       opts = new_class._meta
-       if issubclass(new_class, EntityFormBase) and opts.model:
-               # "override" proxy fields with declared fields by excluding them if there's a name conflict.
-               exclude = (list(opts.exclude or []) + new_class.declared_fields.keys()) or None
-               proxy_fields = proxy_fields_for_entity_model(opts.model, opts.fields, exclude, opts.widgets, formfield_callback) # don't pass in formfield_callback
+class EntityFormMetaclass(ModelFormMetaclass):
+       def __new__(cls, name, bases, attrs):
+               try:
+                       parents = [b for b in bases if issubclass(b, EntityForm)]
+               except NameError:
+                       # We are defining EntityForm itself
+                       parents = None
+               sup = super(EntityFormMetaclass, cls)
+               
+               if not parents:
+                       # Then there's no business trying to use proxy fields.
+                       return sup.__new__(cls, name, bases, attrs)
+               
+               # Fake a declaration of all proxy fields so they'll be handled correctly.
+               opts = ModelFormOptions(attrs.get('Meta', None))
+               
+               if opts.model:
+                       formfield_callback = attrs.get('formfield_callback', None)
+                       proxy_fields = proxy_fields_for_entity_model(opts.model, opts.fields, opts.exclude, opts.widgets, formfield_callback)
+               else:
+                       proxy_fields = {}
+               
+               new_attrs = proxy_fields.copy()
+               new_attrs.update(attrs)
+               
+               new_class = sup.__new__(cls, name, bases, new_attrs)
                new_class.proxy_fields = proxy_fields
-               new_class.base_fields.update(proxy_fields)
-       return new_class
+               return new_class
 
-ModelFormMetaclass.__new__ = staticmethod(_new_metaclass_new)
 
-# END HACK
-
-
-class EntityForm(EntityFormBase): # Would inherit from ModelForm directly if it weren't for the above HACK
+class EntityForm(ModelForm):
+       __metaclass__ = EntityFormMetaclass
+       
        def __init__(self, *args, **kwargs):
                initial = kwargs.pop('initial', None)
                instance = kwargs.get('instance', None)
index c0b1e9e..5ec3e77 100644 (file)
@@ -24,16 +24,18 @@ class LazyNode(object):
                                node, subpath = Node.objects.get_with_path(path, root=getattr(current_site, 'root_node', None), absolute_result=False)
                        except Node.DoesNotExist:
                                node = None
-                       
-                       if node:
+                       else:
                                if subpath is None:
                                        subpath = ""
                                subpath = "/" + subpath
                                
-                               if trailing_slash and subpath[-1] != "/":
-                                       subpath += "/"
-                               
-                               node.subpath = subpath
+                               if not node.handles_subpath(subpath):
+                                       node = None
+                               else:
+                                       if trailing_slash and subpath[-1] != "/":
+                                               subpath += "/"
+                                       
+                                       node.subpath = subpath
                        
                        request._found_node = node
                
@@ -46,7 +48,10 @@ class RequestNodeMiddleware(object):
                request.__class__.node = LazyNode()
        
        def process_view(self, request, view_func, view_args, view_kwargs):
-               request._cached_node_path = view_kwargs.get('path', '/')
+               try:
+                       request._cached_node_path = view_kwargs['path']
+               except KeyError:
+                       pass
        
        def process_exception(self, request, exception):
                if settings.DEBUG or not hasattr(request, 'node') or not request.node:
index faac89b..af1e880 100644 (file)
@@ -271,9 +271,9 @@ class EntityOptions(object):
 
 class EntityBase(models.base.ModelBase):
        def __new__(cls, name, bases, attrs):
+               entity_meta = attrs.pop('EntityMeta', None)
                new = super(EntityBase, cls).__new__(cls, name, bases, attrs)
-               entity_options = attrs.pop('EntityMeta', None)
-               setattr(new, '_entity_meta', EntityOptions(entity_options))
+               new.add_to_class('_entity_meta', EntityOptions(entity_meta))
                entity_class_prepared.send(sender=new)
                return new
 
index d8ed839..1f9603e 100644 (file)
@@ -1,6 +1,10 @@
 from django import forms
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_slug
 from django.db import models
 from django.utils import simplejson as json
+from django.utils.text import capfirst
+from django.utils.translation import ugettext_lazy as _
 from philo.forms.fields import JSONFormField
 from philo.validators import TemplateValidator, json_validator
 #from philo.models.fields.entities import *
@@ -47,18 +51,85 @@ class JSONField(models.TextField):
                models.signals.pre_init.connect(self.fix_init_kwarg, sender=cls)
        
        def fix_init_kwarg(self, sender, args, kwargs, **signal_kwargs):
+               # Anything passed in as self.name is assumed to come from a serializer and
+               # will be treated as a json string.
                if self.name in kwargs:
-                       kwargs[self.attname] = json.dumps(kwargs.pop(self.name))
+                       value = kwargs.pop(self.name)
+                       
+                       # Hack to handle the xml serializer's handling of "null"
+                       if value is None:
+                               value = 'null'
+                       
+                       kwargs[self.attname] = value
        
        def formfield(self, *args, **kwargs):
                kwargs["form_class"] = JSONFormField
                return super(JSONField, self).formfield(*args, **kwargs)
 
 
+class SlugMultipleChoiceField(models.Field):
+       __metaclass__ = models.SubfieldBase
+       description = _("Comma-separated slug field")
+       
+       def get_internal_type(self):
+               return "TextField"
+       
+       def to_python(self, value):
+               if not value:
+                       return []
+               
+               if isinstance(value, list):
+                       return value
+               
+               return value.split(',')
+       
+       def get_prep_value(self, value):
+               return ','.join(value)
+       
+       def formfield(self, **kwargs):
+               # This is necessary because django hard-codes TypedChoiceField for things with choices.
+               defaults = {
+                       'widget': forms.CheckboxSelectMultiple,
+                       'choices': self.get_choices(include_blank=False),
+                       'label': capfirst(self.verbose_name),
+                       'required': not self.blank,
+                       'help_text': self.help_text
+               }
+               if self.has_default():
+                       if callable(self.default):
+                               defaults['initial'] = self.default
+                               defaults['show_hidden_initial'] = True
+                       else:
+                               defaults['initial'] = self.get_default()
+               
+               for k in kwargs.keys():
+                       if k not in ('coerce', 'empty_value', 'choices', 'required',
+                                                'widget', 'label', 'initial', 'help_text',
+                                                'error_messages', 'show_hidden_initial'):
+                               del kwargs[k]
+               
+               defaults.update(kwargs)
+               form_class = forms.TypedMultipleChoiceField
+               return form_class(**defaults)
+       
+       def validate(self, value, model_instance):
+               invalid_values = []
+               for val in value:
+                       try:
+                               validate_slug(val)
+                       except ValidationError:
+                               invalid_values.append(val)
+               
+               if invalid_values:
+                       # should really make a custom message.
+                       raise ValidationError(self.error_messages['invalid_choice'] % invalid_values)
+
+
 try:
        from south.modelsinspector import add_introspection_rules
 except ImportError:
        pass
 else:
+       add_introspection_rules([], ["^philo\.models\.fields\.SlugMultipleChoiceField"])
        add_introspection_rules([], ["^philo\.models\.fields\.TemplateField"])
        add_introspection_rules([], ["^philo\.models\.fields\.JSONField"])
\ No newline at end of file
index 39125ef..86db88f 100644 (file)
@@ -18,16 +18,24 @@ from philo.signals import page_about_to_render_to_string, page_finished_renderin
 
 
 class LazyContainerFinder(object):
-       def __init__(self, nodes):
+       def __init__(self, nodes, extends=False):
                self.nodes = nodes
                self.initialized = False
                self.contentlet_specs = set()
                self.contentreference_specs = SortedDict()
                self.blocks = {}
                self.block_super = False
+               self.extends = extends
        
        def process(self, nodelist):
                for node in nodelist:
+                       if self.extends:
+                               if isinstance(node, BlockNode):
+                                       self.blocks[node.name] = block = LazyContainerFinder(node.nodelist)
+                                       block.initialize()
+                                       self.blocks.update(block.blocks)
+                               continue
+                       
                        if isinstance(node, ContainerNode):
                                if not node.references:
                                        self.contentlet_specs.add(node.name)
@@ -36,15 +44,6 @@ class LazyContainerFinder(object):
                                                self.contentreference_specs[node.name] = node.references
                                continue
                        
-                       if isinstance(node, BlockNode):
-                               self.blocks[node.name] = block = LazyContainerFinder(node.nodelist)
-                               block.initialize()
-                               self.blocks.update(block.blocks)
-                               continue
-                       
-                       if isinstance(node, ExtendsNode):
-                               continue
-                       
                        if isinstance(node, VariableNode):
                                if node.filter_expression.var.lookups == (u'block', u'super'):
                                        self.block_super = True
@@ -97,7 +96,7 @@ class Template(TreeModel):
                        
                        if extends:
                                if extends.nodelist:
-                                       nodelists.append(LazyContainerFinder(extends.nodelist))
+                                       nodelists.append(LazyContainerFinder(extends.nodelist, extends=True))
                                loaded_template = getattr(extends, LOADED_TEMPLATE_ATTR)
                                nodelists.extend(build_extension_tree(loaded_template.nodelist))
                        else:
@@ -131,7 +130,7 @@ class Template(TreeModel):
                return contentlet_specs, contentreference_specs
        
        def __unicode__(self):
-               return self.get_path(pathsep=u' › ', field='name')
+               return self.name
        
        class Meta:
                app_label = 'philo'
diff --git a/urls.py b/urls.py
index 47be7da..0363224 100644 (file)
--- a/urls.py
+++ b/urls.py
@@ -3,6 +3,6 @@ from philo.views import node_view
 
 
 urlpatterns = patterns('',
-       url(r'^$', node_view, name='philo-root'),
+       url(r'^$', node_view, kwargs={'path': '/'}, name='philo-root'),
        url(r'^(?P<path>.*)$', node_view, name='philo-node-by-path')
 )
index 8b39abd..5ae9409 100644 (file)
@@ -3,6 +3,7 @@ from django.core.validators import RegexValidator
 from django.core.exceptions import ValidationError
 from django.template import Template, Parser, Lexer, TOKEN_BLOCK, TOKEN_VAR, TemplateSyntaxError
 from django.utils import simplejson as json
+from django.utils.html import escape, mark_safe
 import re
 from philo.utils import LOADED_TEMPLATE_ATTR
 
@@ -116,6 +117,16 @@ class TemplateValidationParser(Parser):
                raise ValidationError('Tag "%s" is not permitted here.' % command)
 
 
+def linebreak_iter(template_source):
+       # Cribbed from django/views/debug.py
+       yield 0
+       p = template_source.find('\n')
+       while p >= 0:
+               yield p+1
+               p = template_source.find('\n', p+1)
+       yield len(template_source) + 1
+
+
 class TemplateValidator(object): 
        def __init__(self, allow=None, disallow=None, secure=True):
                self.allow = allow
@@ -128,6 +139,14 @@ class TemplateValidator(object):
                except ValidationError:
                        raise
                except Exception, e:
+                       if hasattr(e, 'source') and isinstance(e, TemplateSyntaxError):
+                               origin, (start, end) = e.source
+                               template_source = origin.reload()
+                               upto = 0
+                               for num, next in enumerate(linebreak_iter(template_source)):
+                                       if start >= upto and end <= next:
+                                               raise ValidationError(mark_safe("Template code invalid: \"%s\" (%s:%d).<br />%s" % (escape(template_source[start:end]), origin.loadname, num, e)))
+                                       upto = next
                        raise ValidationError("Template code invalid. Error was: %s: %s" % (e.__class__.__name__, e))
        
        def validate_template(self, template_string):
index f5a2c7f..598be36 100644 (file)
--- a/views.py
+++ b/views.py
@@ -28,7 +28,7 @@ def node_view(request, path=None, **kwargs):
        subpath = request.node.subpath
        
        # Explicitly disallow trailing slashes if we are otherwise at a node's url.
-       if request.path and request.path != "/" and request.path[-1] == "/" and subpath == "/":
+       if request._cached_node_path != "/" and request._cached_node_path[-1] == "/" and subpath == "/":
                return HttpResponseRedirect(node.get_absolute_url())
        
        if not node.handles_subpath(subpath):