Merge branch 'master' into gilbert
authorJoseph Spiros <joseph.spiros@ithinksw.com>
Mon, 21 Mar 2011 15:37:44 +0000 (11:37 -0400)
committerJoseph Spiros <joseph.spiros@ithinksw.com>
Mon, 21 Mar 2011 15:37:44 +0000 (11:37 -0400)
* master: (25 commits)
  Added feed length limit to FeedView. Implements feature #111.
  Corrected LazyNavigationRecurser to mark its return value as safe.
  Switched back to setting a {{ children }} variable, but set it to a lazy recurser instead of a rendered result. Switched item and children to be set in the context directly for easy access. Improved/updated recursenavigation docstring.
  Refactored the RecurseNavigationNode to have less repetition. Switched from {{ children }} to {% recurse %} because it makes more sense to collect recursion only if needed.
  Added a number of counting variables to the context when rendering shipherd navigation. Designed to mirror the variables generated when using a for loop.
  Minor corrections to shipherd recursenavigation docstring.
  Added CSRF cookie js to TagCreation.js... apparently it isn't in the admin by default. Resolves issue 83.
  Added an admin action to NewsletterArticleAdmin to handle creating a NewsletterIssue from a selection of articles. Resolves issue 82.
  Minor LazyContainerFinder cleanup. It's not really that lazy in practice...
  Removed nodelist_crawl since nothing is using it and I'm starting to question its usefulness in general.
  Built a clearer algorithm for finding a template's containers; added support for template block overrides cancelling out containers in that block, which resolves issue #90.
  Corrected FeedView handling of incorrect Accept headers. Will now actually return 406 errors.
  Genericized nodelist_crawl to just pass each node to a callback function. Overloaded the Template.containers callback to create a blockcontext for handling containers in overridden blocks as per issue 90 and to handle contentreference/contentlet generation in a single sweep.
  Refactored ContainerForms to reflect a more suitable structure by storing the containers internally as a SortedDict. By handling containers more consistently, this commit resolves issue #89.
  Removed apparently-vestigial Entity.attribute property.
  Corrected JSONValue.__unicode__ to return unicode instead of bytestrings... not sure what it was being modeled on. Tweaked TargetURLModel to use smart_str instead of str to construct the Python < 2.6.5 kwargs to get a more consistent byte string.
  Corrected WaldoAuthenticationForm.clean to handle lack of password/username. Moved password-related forms into a class attribute for customizability. Standardized confirmation email context and allowed for a secure confirmation link.
  Tweaked TargetURLModel.get_reverse_params() to convert kwargs keys to bytestrings to support Python versions prior to 2.6.5
  Minor correction to BlogView.urlpatterns.
  Switched feed_patterns to return urlpatterns suitable for addition to urlpatterns instead of inclusion in urlpatterns. Adjusted penfield urlpatterns accordingly. Added default behavior for FeedView.get_feed in cases where a known feed_type is not supplied for whatever reason. Added RegistrationMultiView.registration_form attribute.
  ...

18 files changed:
admin/forms/containers.py
contrib/penfield/admin.py
contrib/penfield/exceptions.py [new file with mode: 0644]
contrib/penfield/middleware.py [new file with mode: 0644]
contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py
contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py [new file with mode: 0644]
contrib/penfield/models.py
contrib/shipherd/models.py
contrib/shipherd/templatetags/shipherd.py
contrib/waldo/forms.py
contrib/waldo/models.py
media/admin/js/TagCreation.js
models/base.py
models/nodes.py
models/pages.py
templates/admin/philo/edit_inline/grappelli_tabular_container.html
templates/admin/philo/edit_inline/tabular_container.html
utils.py

index 5991dfa..420ba17 100644 (file)
@@ -2,8 +2,9 @@ from django import forms
 from django.contrib.admin.widgets import AdminTextareaWidget
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
-from django.forms.models import ModelForm, BaseInlineFormSet
+from django.forms.models import ModelForm, BaseInlineFormSet, BaseModelFormSet
 from django.forms.formsets import TOTAL_FORM_COUNT
+from django.utils.datastructures import SortedDict
 from philo.admin.widgets import ModelLookupWidget
 from philo.models import Contentlet, ContentReference
 
@@ -20,17 +21,19 @@ class ContainerForm(ModelForm):
        def __init__(self, *args, **kwargs):
                super(ContainerForm, self).__init__(*args, **kwargs)
                self.verbose_name = self.instance.name.replace('_', ' ')
+               self.prefix = self.instance.name
 
 
 class ContentletForm(ContainerForm):
        content = forms.CharField(required=False, widget=AdminTextareaWidget, label='Content')
        
        def should_delete(self):
-               return not bool(self.cleaned_data['content'])
+               # Delete iff: the data has changed and is now empty.
+               return self.has_changed() and not bool(self.cleaned_data['content'])
        
        class Meta:
                model = Contentlet
-               fields = ['name', 'content']
+               fields = ['content']
 
 
 class ContentReferenceForm(ContainerForm):
@@ -43,72 +46,92 @@ class ContentReferenceForm(ContainerForm):
                        pass
        
        def should_delete(self):
-               return (self.cleaned_data['content_id'] is None)
+               return self.has_changed() and (self.cleaned_data['content_id'] is None)
        
        class Meta:
                model = ContentReference
-               fields = ['name', 'content_id']
+               fields = ['content_id']
 
 
 class ContainerInlineFormSet(BaseInlineFormSet):
-       def __init__(self, containers, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None):
-               # Unfortunately, I need to add some things to BaseInline between its __init__ and its
-               # super call, so a lot of this is repetition.
+       @property
+       def containers(self):
+               if not hasattr(self, '_containers'):
+                       self._containers = self.get_containers()
+               return self._containers
+       
+       def total_form_count(self):
+               # This ignores the posted management form data... but that doesn't
+               # seem to have any ill side effects.
+               return len(self.containers.keys())
+       
+       def _get_initial_forms(self):
+               return [form for form in self.forms if form.instance.pk is not None]
+       initial_forms = property(_get_initial_forms)
+       
+       def _get_extra_forms(self):
+               return [form for form in self.forms if form.instance.pk is None]
+       extra_forms = property(_get_extra_forms)
+       
+       def _construct_form(self, i, **kwargs):
+               if 'instance' not in kwargs:
+                       kwargs['instance'] = self.containers.values()[i]
                
-               # Start cribbed from BaseInline
-               from django.db.models.fields.related import RelatedObject
-               self.save_as_new = save_as_new
-               # is there a better way to get the object descriptor?
-               self.rel_name = RelatedObject(self.fk.rel.to, self.model, self.fk).get_accessor_name()
-               if self.fk.rel.field_name == self.fk.rel.to._meta.pk.name:
-                       backlink_value = self.instance
-               else:
-                       backlink_value = getattr(self.instance, self.fk.rel.field_name)
-               if queryset is None:
-                       queryset = self.model._default_manager
-               qs = queryset.filter(**{self.fk.name: backlink_value})
-               # End cribbed from BaseInline
+               # Skip over the BaseModelFormSet. We have our own way of doing things!
+               form = super(BaseModelFormSet, self)._construct_form(i, **kwargs)
                
-               self.container_instances, qs = self.get_container_instances(containers, qs)
-               self.extra_containers = containers
-               self.extra = len(self.extra_containers)
-               super(BaseInlineFormSet, self).__init__(data, files, prefix=prefix, queryset=qs)
-       
-       def get_container_instances(self, containers, qs):
-               raise NotImplementedError
+               # Since we skipped over BaseModelFormSet, we need to duplicate what BaseInlineFormSet would do.
+               if self.save_as_new:
+                       # Remove the primary key from the form's data, we are only
+                       # creating new instances
+                       form.data[form.add_prefix(self._pk_field.name)] = None
+                       
+                       # Remove the foreign key from the form's data
+                       form.data[form.add_prefix(self.fk.name)] = None
+               
+               # Set the fk value here so that the form can do it's validation.
+               setattr(form.instance, self.fk.get_attname(), self.instance.pk)
+               return form
        
-       def total_form_count(self):
-               if self.data or self.files:
-                       return self.management_form.cleaned_data[TOTAL_FORM_COUNT]
+       def add_fields(self, form, index):
+               """Override the pk field's initial value with a real one."""
+               super(ContainerInlineFormSet, self).add_fields(form, index)
+               if index is not None:
+                       pk_value = self.containers.values()[index].pk
                else:
-                       return self.initial_form_count() + self.extra
+                       pk_value = None
+               form.fields[self._pk_field.name].initial = pk_value
        
        def save_existing_objects(self, commit=True):
                self.changed_objects = []
                self.deleted_objects = []
                if not self.get_queryset():
                        return []
-
+               
                saved_instances = []
                for form in self.initial_forms:
                        pk_name = self._pk_field.name
                        raw_pk_value = form._raw_value(pk_name)
-
+                       
                        # clean() for different types of PK fields can sometimes return
                        # the model instance, and sometimes the PK. Handle either.
                        pk_value = form.fields[pk_name].clean(raw_pk_value)
                        pk_value = getattr(pk_value, 'pk', pk_value)
-
-                       obj = self._existing_object(pk_value)
-                       if form.should_delete():
-                               self.deleted_objects.append(obj)
-                               obj.delete()
-                               continue
-                       if form.has_changed():
-                               self.changed_objects.append((obj, form.changed_data))
-                               saved_instances.append(self.save_existing(form, obj, commit=commit))
-                               if not commit:
-                                       self.saved_forms.append(form)
+                       
+                       # if the pk_value is None, they have just switched to a
+                       # template which didn't contain data about this container.
+                       # Skip!
+                       if pk_value is not None:
+                               obj = self._existing_object(pk_value)
+                               if form.should_delete():
+                                       self.deleted_objects.append(obj)
+                                       obj.delete()
+                                       continue
+                               if form.has_changed():
+                                       self.changed_objects.append((obj, form.changed_data))
+                                       saved_instances.append(self.save_existing(form, obj, commit=commit))
+                                       if not commit:
+                                               self.saved_forms.append(form)
                return saved_instances
 
        def save_new_objects(self, commit=True):
@@ -127,64 +150,41 @@ class ContainerInlineFormSet(BaseInlineFormSet):
 
 
 class ContentletInlineFormSet(ContainerInlineFormSet):
-       def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None):
-               if instance is None:
-                       self.instance = self.fk.rel.to()
-               else:
-                       self.instance = instance
-               
+       def get_containers(self):
                try:
                        containers = list(self.instance.containers[0])
                except ObjectDoesNotExist:
                        containers = []
-       
-               super(ContentletInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset)
-       
-       def get_container_instances(self, containers, qs):
-               qs = qs.filter(name__in=containers)
-               container_instances = []
-               for container in qs:
-                       container_instances.append(container)
-                       containers.remove(container.name)
-               return container_instances, qs
-       
-       def _construct_form(self, i, **kwargs):
-               if i >= self.initial_form_count(): # and not kwargs.get('instance'):
-                       kwargs['instance'] = self.model(name=self.extra_containers[i - self.initial_form_count() - 1])
                
-               return super(ContentletInlineFormSet, self)._construct_form(i, **kwargs)
+               qs = self.get_queryset().filter(name__in=containers)
+               container_dict = SortedDict([(container.name, container) for container in qs])
+               for name in containers:
+                       if name not in container_dict:
+                               container_dict[name] = self.model(name=name)
+               
+               container_dict.keyOrder = containers
+               return container_dict
 
 
 class ContentReferenceInlineFormSet(ContainerInlineFormSet):
-       def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None):
-               if instance is None:
-                       self.instance = self.fk.rel.to()
-               else:
-                       self.instance = instance
-               
+       def get_containers(self):
                try:
-                       containers = list(self.instance.containers[1])
+                       containers = self.instance.containers[1]
                except ObjectDoesNotExist:
-                       containers = []
-       
-               super(ContentReferenceInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset)
-       
-       def get_container_instances(self, containers, qs):
-               filter = Q()
+                       containers = {}
                
-               for name, ct in containers:
+               filter = Q()
+               for name, ct in containers.items():
                        filter |= Q(name=name, content_type=ct)
+               qs = self.get_queryset().filter(filter)
                
-               qs = qs.filter(filter)
-               container_instances = []
-               for container in qs:
-                       container_instances.append(container)
-                       containers.remove((container.name, container.content_type))
-               return container_instances, qs
-
-       def _construct_form(self, i, **kwargs):
-               if i >= self.initial_form_count(): # and not kwargs.get('instance'):
-                       name, content_type = self.extra_containers[i - self.initial_form_count() - 1]
-                       kwargs['instance'] = self.model(name=name, content_type=content_type)
-
-               return super(ContentReferenceInlineFormSet, self)._construct_form(i, **kwargs)
\ No newline at end of file
+               container_dict = SortedDict([(container.name, container) for container in qs])
+               
+               keyOrder = []
+               for name, ct in containers.items():
+                       keyOrder.append(name)
+                       if name not in container_dict:
+                               container_dict[name] = self.model(name=name, content_type=ct)
+               
+               container_dict.keyOrder = keyOrder
+               return container_dict
\ No newline at end of file
index 950539d..5aee6f8 100644 (file)
@@ -1,5 +1,7 @@
-from django.contrib import admin
 from django import forms
+from django.contrib import admin
+from django.core.urlresolvers import reverse
+from django.http import HttpResponseRedirect, QueryDict
 from philo.admin import EntityAdmin, AddTagAdmin, COLLAPSE_CLASSES
 from philo.contrib.penfield.models import BlogEntry, Blog, BlogView, Newsletter, NewsletterArticle, NewsletterIssue, NewsletterView
 
@@ -91,10 +93,18 @@ class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin):
                        'classes': COLLAPSE_CLASSES
                })
        )
+       actions = ['make_issue']
        
        def author_names(self, obj):
                return ', '.join([author.get_full_name() for author in obj.authors.all()])
        author_names.short_description = "Authors"
+       
+       def make_issue(self, request, queryset):
+               opts = NewsletterIssue._meta
+               info = opts.app_label, opts.module_name
+               url = reverse("admin:%s_%s_add" % info)
+               return HttpResponseRedirect("%s?articles=%s" % (url, ",".join([str(a.pk) for a in queryset])))
+       make_issue.short_description = u"Create issue from selected %(verbose_name_plural)s"
 
 
 class NewsletterIssueAdmin(TitledAdmin):
@@ -117,7 +127,7 @@ class NewsletterViewAdmin(EntityAdmin):
                        'classes': COLLAPSE_CLASSES
                }),
                ('Feeds', {
-                       '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
                })
        )
diff --git a/contrib/penfield/exceptions.py b/contrib/penfield/exceptions.py
new file mode 100644 (file)
index 0000000..96b96ed
--- /dev/null
@@ -0,0 +1,3 @@
+class HttpNotAcceptable(Exception):
+       """This will be raised if an Http-Accept header will not accept the feed content types that are available."""
+       pass
\ No newline at end of file
diff --git a/contrib/penfield/middleware.py b/contrib/penfield/middleware.py
new file mode 100644 (file)
index 0000000..b25a28b
--- /dev/null
@@ -0,0 +1,14 @@
+from django.http import HttpResponse
+from django.utils.decorators import decorator_from_middleware
+from philo.contrib.penfield.exceptions import HttpNotAcceptable
+
+
+class HttpNotAcceptableMiddleware(object):
+       """Middleware to catch HttpNotAcceptable errors and return an Http406 response.
+       See RFC 2616."""
+       def process_exception(self, request, exception):
+               if isinstance(exception, HttpNotAcceptable):
+                       return HttpResponse(status=406)
+
+
+http_not_acceptable = decorator_from_middleware(HttpNotAcceptableMiddleware)
\ No newline at end of file
index 1f6d829..eae496e 100644 (file)
@@ -9,7 +9,7 @@ class Migration(SchemaMigration):
     def forwards(self, orm):
         
         # Adding field 'NewsletterView.feed_type'
-        db.add_column('penfield_newsletterview', 'feed_type', self.gf('django.db.models.fields.CharField')(default='atom', max_length=50), keep_default=False)
+        db.add_column('penfield_newsletterview', 'feed_type', self.gf('django.db.models.fields.CharField')(default='application/atom+xml', max_length=50), keep_default=False)
 
         # Adding field 'NewsletterView.item_title_template'
         db.add_column('penfield_newsletterview', 'item_title_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_newsletterview_title_related', null=True, to=orm['philo.Template']), keep_default=False)
diff --git a/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py b/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py
new file mode 100644 (file)
index 0000000..9b9ffa7
--- /dev/null
@@ -0,0 +1,204 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding field 'NewsletterView.feed_length'
+        db.add_column('penfield_newsletterview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False)
+
+        # Adding field 'BlogView.feed_length'
+        db.add_column('penfield_blogview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'NewsletterView.feed_length'
+        db.delete_column('penfield_newsletterview', 'feed_length')
+
+        # Deleting field 'BlogView.feed_length'
+        db.delete_column('penfield_blogview', 'feed_length')
+
+
+    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': {'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', 'blank': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'oberlin.person': {
+            'Meta': {'object_name': 'Person'},
+            'bio': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '70', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'penfield.blog': {
+            'Meta': {'object_name': 'Blog'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.blogentry': {
+            'Meta': {'object_name': 'BlogEntry'},
+            'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['oberlin.Person']"}),
+            'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}),
+            'content': ('django.db.models.fields.TextField', [], {}),
+            'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}),
+            'excerpt': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'blogentries'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.blogview': {
+            'Meta': {'object_name': 'BlogView'},
+            'blog': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogviews'", 'to': "orm['penfield.Blog']"}),
+            'entries_per_page': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'entry_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_entry_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'entry_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_entry_related'", 'to': "orm['philo.Page']"}),
+            'entry_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'entries'", 'max_length': '255'}),
+            'entry_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+            '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': "'application/atom+xml; charset=utf8'", 'max_length': '50'}),
+            'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_index_related'", 'to': "orm['philo.Page']"}),
+            'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_tag_related'", 'to': "orm['philo.Page']"}),
+            'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '255'})
+        },
+        'penfield.newsletter': {
+            'Meta': {'object_name': 'Newsletter'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.newsletterarticle': {
+            'Meta': {'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'},
+            'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['oberlin.Person']"}),
+            'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}),
+            'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'lede': ('philo.models.fields.TemplateField', [], {'null': 'True', 'blank': 'True'}),
+            'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'articles'", 'to': "orm['penfield.Newsletter']"}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'newsletterarticles'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.newsletterissue': {
+            'Meta': {'unique_together': "(('newsletter', 'numbering'),)", 'object_name': 'NewsletterIssue'},
+            'articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'issues'", 'symmetrical': 'False', 'to': "orm['penfield.NewsletterArticle']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issues'", 'to': "orm['penfield.Newsletter']"}),
+            'numbering': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.newsletterview': {
+            'Meta': {'object_name': 'NewsletterView'},
+            'article_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_article_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'article_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_article_related'", 'to': "orm['philo.Page']"}),
+            'article_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'articles'", 'max_length': '255'}),
+            'article_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+            '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': "'application/atom+xml; charset=utf8'", 'max_length': '50'}),
+            'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_index_related'", 'to': "orm['philo.Page']"}),
+            'issue_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_issue_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'issue_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_issue_related'", 'to': "orm['philo.Page']"}),
+            'issue_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'issues'", 'max_length': '255'}),
+            'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletterviews'", 'to': "orm['penfield.Newsletter']"})
+        },
+        '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': {'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'})
+        }
+    }
+
+    complete_apps = ['penfield']
index bb71ba2..a03bed8 100644 (file)
@@ -10,6 +10,8 @@ from django.utils.datastructures import SortedDict
 from django.utils.encoding import smart_unicode, force_unicode
 from django.utils.html import escape
 from datetime import date, datetime
+from philo.contrib.penfield.exceptions import HttpNotAcceptable
+from philo.contrib.penfield.middleware import http_not_acceptable
 from philo.contrib.penfield.validators import validate_pagination_count
 from philo.exceptions import ViewCanNotProvideSubpath
 from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template
@@ -19,6 +21,7 @@ try:
 except:
        mimeparse = None
 
+
 ATOM = feedgenerator.Atom1Feed.mime_type
 RSS = feedgenerator.Rss201rev2Feed.mime_type
 FEEDS = SortedDict([
@@ -43,6 +46,7 @@ class FeedView(MultiView):
        feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM)
        feed_suffix = models.CharField(max_length=255, blank=False, default="feed")
        feeds_enabled = models.BooleanField(default=True)
+       feed_length = models.PositiveIntegerField(blank=True, null=True, default=15, help_text="The maximum number of items to return for this feed. All items will be returned if this field is blank.")
        
        item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related")
        item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related")
@@ -52,20 +56,23 @@ class FeedView(MultiView):
        
        description = ""
        
-       def feed_patterns(self, get_items_attr, page_attr, reverse_name):
+       def feed_patterns(self, base, get_items_attr, page_attr, reverse_name):
                """
                Given the name to be used to reverse this view and the names of
                the attributes for the function that fetches the objects, returns
                patterns suitable for inclusion in urlpatterns.
                """
-               urlpatterns = patterns('',
-                       url(r'^$', self.page_view(get_items_attr, page_attr), name=reverse_name)
-               )
+               urlpatterns = patterns('')
                if self.feeds_enabled:
                        feed_reverse_name = "%s_feed" % reverse_name
+                       feed_view = http_not_acceptable(self.feed_view(get_items_attr, feed_reverse_name))
+                       feed_pattern = r'%s%s%s$' % (base, (base and base[-1] != "^") and "/" or "", self.feed_suffix)
                        urlpatterns += patterns('',
-                               url(r'^%s$' % self.feed_suffix, self.feed_view(get_items_attr, feed_reverse_name), name=feed_reverse_name),
+                               url(feed_pattern, feed_view, name=feed_reverse_name),
                        )
+               urlpatterns += patterns('',
+                       url(r"%s$" % base, self.page_view(get_items_attr, page_attr), name=reverse_name)
+               )
                return urlpatterns
        
        def get_object(self, request, **kwargs):
@@ -121,6 +128,8 @@ class FeedView(MultiView):
        
        def get_feed_type(self, request):
                feed_type = self.feed_type
+               if feed_type not in FEEDS:
+                       feed_type = FEEDS.keys()[0]
                accept = request.META.get('HTTP_ACCEPT')
                if accept and feed_type not in accept and "*/*" not in accept and "%s/*" % feed_type.split("/")[0] not in accept:
                        # Wups! They aren't accepting the chosen format. Is there another format we can use?
@@ -133,8 +142,7 @@ class FeedView(MultiView):
                                else:
                                        feed_type = None
                        if not feed_type:
-                               # See RFC 2616
-                               return HttpResponse(status=406)
+                               raise HttpNotAcceptable
                return FEEDS[feed_type]
        
        def get_feed(self, obj, request, reverse_name):
@@ -188,6 +196,9 @@ class FeedView(MultiView):
                except Site.DoesNotExist:
                        current_site = RequestSite(request)
                
+               if self.feed_length is not None:
+                       items = items[:self.feed_length]
+               
                for item in items:
                        if title_template is not None:
                                title = title_template.render(RequestContext(request, {'obj': item}))
@@ -333,6 +344,7 @@ class BlogView(FeedView):
        
        index_page = models.ForeignKey(Page, related_name='blog_index_related')
        entry_page = models.ForeignKey(Page, related_name='blog_entry_related')
+       # TODO: entry_archive is misleading. Rename to ymd_page or timespan_page.
        entry_archive_page = models.ForeignKey(Page, related_name='blog_entry_archive_related', null=True, blank=True)
        tag_page = models.ForeignKey(Page, related_name='blog_tag_related')
        tag_archive_page = models.ForeignKey(Page, related_name='blog_tag_archive_related', null=True, blank=True)
@@ -359,7 +371,7 @@ class BlogView(FeedView):
                                                if self.entry_permalink_style == 'D':
                                                        kwargs.update({'day': str(obj.date.day).zfill(2)})
                                return self.entry_view, [], kwargs
-               elif isinstance(obj, Tag) or (isinstance(obj, models.QuerySet) and obj.model == Tag and obj):
+               elif isinstance(obj, Tag) or (isinstance(obj, models.query.QuerySet) and obj.model == Tag and obj):
                        if isinstance(obj, Tag):
                                obj = [obj]
                        slugs = [tag.slug for tag in obj if tag in self.get_tag_queryset()]
@@ -376,16 +388,9 @@ class BlogView(FeedView):
        
        @property
        def urlpatterns(self):
-               urlpatterns = patterns('',
-                       url(r'^', include(self.feed_patterns('get_all_entries', 'index_page', 'index'))),
-               )
-               if self.feeds_enabled:
-                       urlpatterns += patterns('',
-                               url(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)/%s$' % (self.tag_permalink_base, self.feed_suffix), self.feed_view('get_entries_by_tag', 'entries_by_tag_feed'), name='entries_by_tag_feed'),
-                       )
-               urlpatterns += patterns('',
-                       url(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)$' % self.tag_permalink_base, self.page_view('get_entries_by_tag', 'tag_page'), name='entries_by_tag')
-               )
+               urlpatterns = self.feed_patterns(r'^', 'get_all_entries', 'index_page', 'index') +\
+                       self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)$' % self.tag_permalink_base, 'get_entries_by_tag', 'tag_page', 'entries_by_tag')
+               
                if self.tag_archive_page:
                        urlpatterns += patterns('',
                                url((r'^%s$' % self.tag_permalink_base), self.tag_archive_view, name='tag_archive')
@@ -393,17 +398,11 @@ class BlogView(FeedView):
                
                if self.entry_archive_page:
                        if self.entry_permalink_style in 'DMY':
-                               urlpatterns += patterns('',
-                                       url(r'^(?P<year>\d{4})', include(self.feed_patterns('get_entries_by_ymd', 'entry_archive_page', 'entries_by_year')))
-                               )
+                               urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_year')
                                if self.entry_permalink_style in 'DM':
-                                       urlpatterns += patterns('',
-                                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})$', include(self.feed_patterns('get_entries_by_ymd', 'entry_archive_page', 'entries_by_month'))),
-                                       )
+                                       urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_month')
                                        if self.entry_permalink_style == 'D':
-                                               urlpatterns += patterns('',
-                                                       url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})$', include(self.feed_patterns('get_entries_by_ymd', 'entry_archive_page', 'entries_by_day')))
-                                               )
+                                               urlpatterns += self.feed_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'get_entries_by_ymd', 'entry_archive_page', 'entries_by_day')
                
                if self.entry_permalink_style == 'D':
                        urlpatterns += patterns('',
@@ -514,7 +513,7 @@ class BlogView(FeedView):
                        
                        if 'tags' in extra_context:
                                tags = extra_context['tags']
-                               feed.feed['link'] = request.node.construct_url(self.reverse(tags), with_domain=True, request=request, secure=request.is_secure())
+                               feed.feed['link'] = request.node.construct_url(self.reverse(obj=tags), with_domain=True, request=request, secure=request.is_secure())
                        else:
                                tags = obj.entry_tags
                        
@@ -653,9 +652,8 @@ class NewsletterView(FeedView):
        
        @property
        def urlpatterns(self):
-               urlpatterns = patterns('',
-                       url(r'^', include(self.feed_patterns('get_all_articles', 'index_page', 'index'))),
-                       url(r'^%s/(?P<numbering>.+)' % self.issue_permalink_base, include(self.feed_patterns('get_articles_by_issue', 'issue_page', 'issue')))
+               urlpatterns = self.feed_patterns(r'^', 'get_all_articles', 'index_page', 'index') + patterns('',
+                       url(r'^%s/(?P<numbering>.+)$' % self.issue_permalink_base, self.page_view('get_articles_by_issue', 'issue_page'), name='issue')
                )
                if self.issue_archive_page:
                        urlpatterns += patterns('',
@@ -666,17 +664,11 @@ class NewsletterView(FeedView):
                                url(r'^%s' % self.article_permalink_base, include(self.feed_patterns('get_all_articles', 'article_archive_page', 'articles')))
                        )
                        if self.article_permalink_style in 'DMY':
-                               urlpatterns += patterns('',
-                                       url(r'^%s/(?P<year>\d{4})' % self.article_permalink_base, include(self.feed_patterns('get_articles_by_ymd', 'article_archive_page', 'articles_by_year')))
-                               )
+                               urlpatterns += self.feed_patterns(r'^%s/(?P<year>\d{4})' % self.article_permalink_base, 'get_articles_by_ymd', 'article_archive_page', 'articles_by_year')
                                if self.article_permalink_style in 'DM':
-                                       urlpatterns += patterns('',
-                                               url(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})' % self.article_permalink_base, include(self.feed_patterns('get_articles_by_ymd', 'article_archive_page', 'articles_by_month')))
-                                       )
+                                       urlpatterns += self.feed_patterns(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})' % self.article_permalink_base, 'get_articles_by_ymd', 'article_archive_page', 'articles_by_month')
                                        if self.article_permalink_style == 'D':
-                                               urlpatterns += patterns('',
-                                                       url(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})' % self.article_permalink_base, include(self.feed_patterns('get_articles_by_ymd', 'article_archive_page', 'articles_by_day')))
-                                               )
+                                               urlpatterns += self.feed_patterns(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})' % self.article_permalink_base, 'get_articles_by_ymd', 'article_archive_page', 'articles_by_day')
                
                if self.article_permalink_style == 'Y':
                        urlpatterns += patterns('',
index 8efc57a..654f5f8 100644 (file)
@@ -148,18 +148,19 @@ class NavigationManager(models.Manager):
                # about that. TODO: Benchmark it.
                caches = self.__class__._cache[self.db][node].values()
                
-               items = []
+               target_pks = set()
                
                for cache in caches:
-                       items += cache['items']
+                       target_pks |= set([item.target_node_id for item in cache['items']])
                
                # A distinct query is not strictly necessary. TODO: benchmark the efficiency
                # with/without distinct.
-               targets = list(Node.objects.filter(shipherd_navigationitem_related__in=items).distinct())
+               targets = list(Node.objects.filter(pk__in=target_pks).distinct())
                
                for cache in caches:
                        for item in cache['items']:
-                               item.target_node = targets[targets.index(item.target_node)]
+                               if item.target_node_id:
+                                       item.target_node = targets[targets.index(item.target_node)]
        
        def clear_cache(self):
                self.__class__._cache.pop(self.db, None)
index 98e3e6b..57fb020 100644 (file)
@@ -3,32 +3,75 @@ from django.conf import settings
 from django.utils.safestring import mark_safe
 from philo.contrib.shipherd.models import Navigation
 from philo.models import Node
-from mptt.templatetags.mptt_tags import RecurseTreeNode, cache_tree_children
+from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext as _
 
 
 register = template.Library()
 
 
-class RecurseNavigationNode(RecurseTreeNode):
-       def __init__(self, template_nodes, instance_var, key):
+class LazyNavigationRecurser(object):
+       def __init__(self, template_nodes, items, context, request):
                self.template_nodes = template_nodes
-               self.instance_var = instance_var
-               self.key = key
+               self.items = items
+               self.context = context
+               self.request = request
        
-       def _render_node(self, context, item, request):
-               bits = []
+       def __call__(self):
+               items = self.items
+               context = self.context
+               request = self.request
+               
+               if not items:
+                       return ''
+               
+               if 'navloop' in context:
+                       parentloop = context['navloop']
+               else:
+                       parentloop = {}
                context.push()
-               for child in item.get_children():
-                       context['item'] = child
-                       bits.append(self._render_node(context, child, request))
-               context['item'] = item
-               context['children'] = mark_safe(u''.join(bits))
-               context['active'] = item.is_active(request)
-               context['active_descendants'] = item.has_active_descendants(request)
-               rendered = self.template_nodes.render(context)
+               
+               depth = items[0].get_level()
+               len_items = len(items)
+               
+               loop_dict = context['navloop'] = {
+                       'parentloop': parentloop,
+                       'depth': depth + 1,
+                       'depth0': depth
+               }
+               
+               bits = []
+               
+               for i, item in enumerate(items):
+                       # First set context variables.
+                       loop_dict['counter0'] = i
+                       loop_dict['counter'] = i + 1
+                       loop_dict['revcounter'] = len_items - i
+                       loop_dict['revcounter0'] = len_items - i - 1
+                       loop_dict['first'] = (i == 0)
+                       loop_dict['last'] = (i == len_items - 1)
+                       
+                       # Set on loop_dict and context for backwards-compatibility.
+                       # Eventually only allow access through the loop_dict.
+                       loop_dict['active'] = context['active'] = item.is_active(request)
+                       loop_dict['active_descendants'] = context['active_descendants'] = item.has_active_descendants(request)
+                       
+                       # Set these directly in the context for easy access.
+                       context['item'] = item
+                       context['children'] = self.__class__(self.template_nodes, item.get_children(), context, request)
+                       
+                       # Then render the nodelist bit by bit.
+                       for node in self.template_nodes:
+                               bits.append(node.render(context))
                context.pop()
-               return rendered
+               return mark_safe(''.join(bits))
+
+
+class RecurseNavigationNode(template.Node):
+       def __init__(self, template_nodes, instance_var, key):
+               self.template_nodes = template_nodes
+               self.instance_var = instance_var
+               self.key = key
        
        def render(self, context):
                try:
@@ -39,28 +82,46 @@ class RecurseNavigationNode(RecurseTreeNode):
                instance = self.instance_var.resolve(context)
                
                try:
-                       navigation = instance.navigation[self.key]
+                       items = instance.navigation[self.key]
                except:
                        return settings.TEMPLATE_STRING_IF_INVALID
                
-               bits = [self._render_node(context, item, request) for item in navigation]
-               return ''.join(bits)
+               return LazyNavigationRecurser(self.template_nodes, items, context, request)()
 
 
 @register.tag
 def recursenavigation(parser, token):
        """
-       Based on django-mptt's recursetree templatetag. In addition to {{ item }} and {{ children }},
-       sets {{ active }} and {{ active_descendants }} in the context.
+       The recursenavigation templatetag takes two arguments:
+       - the node for which the navigation should be found
+       - the navigation's key.
+       
+       It will then recursively loop over each item in the navigation and render the template
+       chunk within the block. recursenavigation sets the following variables in the context:
        
-       Note that the tag takes one variable, which is a Node instance.
+               ==============================  ================================================
+               Variable                        Description
+               ==============================  ================================================
+               ``navloop.depth``               The current depth of the loop (1 is the top level)
+               ``navloop.depth0``              The current depth of the loop (0 is the top level)
+               ``navloop.counter``             The current iteration of the current level(1-indexed)
+               ``navloop.counter0``            The current iteration of the current level(0-indexed)
+               ``navloop.first``               True if this is the first time through the current level
+               ``navloop.last``                True if this is the last time through the current level
+               ``navloop.parentloop``          This is the loop one level "above" the current one
+               ==============================  ================================================
+               ``item``                        The current item in the loop (a NavigationItem instance)
+               ``children``                    If accessed, performs the next level of recursion.
+               ``navloop.active``              True if the item is active for this request
+               ``navloop.active_descendants``  True if the item has active descendants for this request
+               ==============================  ================================================
        
-       Usage:
+       Example:
                <ul>
                        {% recursenavigation node main %}
-                               <li{% if active %} class='active'{% endif %}>
-                                       {{ navigation.text }}
-                                       {% if navigation.get_children %}
+                               <li{% if navloop.active %} class='active'{% endif %}>
+                                       {{ navloop.item.text }}
+                                       {% if item.get_children %}
                                                <ul>
                                                        {{ children }}
                                                </ul>
@@ -76,21 +137,29 @@ def recursenavigation(parser, token):
        instance_var = parser.compile_filter(bits[1])
        key = bits[2]
        
-       template_nodes = parser.parse(('endrecursenavigation',))
-       parser.delete_first_token()
+       template_nodes = parser.parse(('recurse', 'endrecursenavigation',))
+       
+       token = parser.next_token()
+       if token.contents == 'recurse':
+               template_nodes.append(RecurseNavigationMarker())
+               template_nodes.extend(parser.parse(('endrecursenavigation')))
+               parser.delete_first_token()
        
        return RecurseNavigationNode(template_nodes, instance_var, key)
 
 
 @register.filter
 def has_navigation(node, key=None):
-       nav = node.navigation
-       if key is not None:
-               if key in nav and bool(node.navigation[key]):
-                       return True
-               elif key not in node.navigation:
-                       return False
-       return bool(node.navigation)
+       try:
+               nav = node.navigation
+               if key is not None:
+                       if key in nav and bool(node.navigation[key]):
+                               return True
+                       elif key not in node.navigation:
+                               return False
+               return bool(node.navigation)
+       except:
+               return False
 
 
 @register.filter
index 615d302..2ee64d0 100644 (file)
@@ -1,6 +1,7 @@
 from datetime import date
 from django import forms
 from django.conf import settings
+from django.contrib.auth import authenticate
 from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
@@ -8,12 +9,6 @@ from django.utils.translation import ugettext_lazy as _
 from philo.contrib.waldo.tokens import REGISTRATION_TIMEOUT_DAYS
 
 
-LOGIN_FORM_KEY = 'this_is_the_login_form'
-LoginForm = type('LoginForm', (AuthenticationForm,), {
-       LOGIN_FORM_KEY: forms.BooleanField(widget=forms.HiddenInput, initial=True)
-})
-
-
 class EmailInput(forms.TextInput):
        input_type = 'email'
 
@@ -70,4 +65,38 @@ class UserAccountForm(forms.ModelForm):
        
        class Meta:
                model = User
-               fields = ('first_name', 'last_name', 'email')
\ No newline at end of file
+               fields = ('first_name', 'last_name', 'email')
+
+
+class WaldoAuthenticationForm(AuthenticationForm):
+       ERROR_MESSAGE = _("Please enter a correct username and password. Note that both fields are case-sensitive.")
+       
+       def clean(self):
+               username = self.cleaned_data.get('username')
+               password = self.cleaned_data.get('password')
+               message = self.ERROR_MESSAGE
+               
+               if username and password:
+                       self.user_cache = authenticate(username=username, password=password)
+                       if self.user_cache is None:
+                               if u'@' in username:
+                                       # Maybe they entered their email? Look it up, but still raise a ValidationError.
+                                       try:
+                                               user = User.objects.get(email=username)
+                                       except (User.DoesNotExist, User.MultipleObjectsReturned):
+                                               pass
+                                       else:
+                                               if user.check_password(password):
+                                                       message = _("Your e-mail address is not your username. Try '%s' instead.") % user.username
+                               raise ValidationError(message)
+                       elif not self.user_cache.is_active:
+                               raise ValidationError(message)
+               self.check_for_test_cookie()
+               return self.cleaned_data
+       
+       def check_for_test_cookie(self):
+               # This method duplicates the Django 1.3 AuthenticationForm method.
+               if self.request and not self.request.session.test_cookie_worked():
+                       raise forms.ValidationError(
+                               _("Your Web browser doesn't appear to have cookies enabled. "
+                                 "Cookies are required for logging in."))
\ No newline at end of file
index 3286aa0..f63cdb1 100644 (file)
@@ -12,166 +12,156 @@ from django.http import Http404, HttpResponseRedirect
 from django.shortcuts import render_to_response, get_object_or_404
 from django.template.defaultfilters import striptags
 from django.utils.http import int_to_base36, base36_to_int
-from django.utils.translation import ugettext_lazy, ugettext as _
+from django.utils.translation import ugettext as _
 from django.views.decorators.cache import never_cache
 from django.views.decorators.csrf import csrf_protect
 from philo.models import MultiView, Page
-from philo.contrib.waldo.forms import LOGIN_FORM_KEY, LoginForm, RegistrationForm, UserAccountForm
+from philo.contrib.waldo.forms import WaldoAuthenticationForm, RegistrationForm, UserAccountForm
 from philo.contrib.waldo.tokens import registration_token_generator, email_token_generator
 import urlparse
 
 
-ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.")
-
-
 class LoginMultiView(MultiView):
        """
-       Handles login, registration, and forgotten passwords. In other words, this
-       multiview provides exclusively view and methods related to usernames and
-       passwords.
+       Handles exclusively methods and views related to logging users in and out.
        """
        login_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_login_related')
-       password_reset_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_related')
-       password_reset_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_confirmation_email_related')
-       password_set_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_set_related')
-       password_change_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_change_related', blank=True, null=True)
-       register_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_related')
-       register_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_confirmation_email_related')
+       login_form = WaldoAuthenticationForm
        
        @property
        def urlpatterns(self):
-               urlpatterns = patterns('',
+               return patterns('',
                        url(r'^login$', self.login, name='login'),
                        url(r'^logout$', self.logout, name='logout'),
-                       
-                       url(r'^password/reset$', csrf_protect(self.password_reset), name='password_reset'),
-                       url(r'^password/reset/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.password_reset_confirm, name='password_reset_confirm'),
-                       
-                       url(r'^register$', csrf_protect(self.register), name='register'),
-                       url(r'^register/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.register_confirm, name='register_confirm')
                )
-               
-               if self.password_change_page:
-                       urlpatterns += patterns('',
-                               url(r'^password/change$', csrf_protect(self.login_required(self.password_change)), name='password_change'),
-                       )
-               
-               return urlpatterns
        
-       def make_confirmation_link(self, confirmation_view, token_generator, user, node, token_args=None, reverse_kwargs=None):
-               token = token_generator.make_token(user, *(token_args or []))
-               kwargs = {
-                       'uidb36': int_to_base36(user.id),
-                       'token': token
-               }
-               kwargs.update(reverse_kwargs or {})
-               return node.construct_url(subpath=self.reverse(confirmation_view, kwargs=kwargs), with_domain=True)
-       
-       def display_login_page(self, request, message, extra_context=None):
-               request.session.set_test_cookie()
-               
-               referrer = request.META.get('HTTP_REFERER', None)
-               
-               if referrer is not None:
-                       referrer = urlparse.urlparse(referrer)
-                       host = referrer[1]
-                       if host != request.get_host():
-                               referrer = None
-                       else:
-                               redirect = '%s?%s' % (referrer[2], referrer[4])
+       def set_requirement_redirect(self, request, redirect=None):
+               "Figure out where someone should end up after landing on a `requirement` page like the login page."
+               if redirect is not None:
+                       pass
+               elif 'requirement_redirect' in request.session:
+                       return
+               else:
+                       referrer = request.META.get('HTTP_REFERER', None)
                
-               if referrer is None:
-                       redirect = request.node.get_absolute_url()
+                       if referrer is not None:
+                               referrer = urlparse.urlparse(referrer)
+                               host = referrer[1]
+                               if host != request.get_host():
+                                       referrer = None
+                               else:
+                                       redirect = '%s?%s' % (referrer[2], referrer[4])
                
-               path = request.get_full_path()
-               if redirect != path:
-                       if redirect is None:
-                               redirect = '/'.join(path.split('/')[:-2])
-                       request.session['redirect'] = redirect
+                       path = request.get_full_path()
+                       if referrer is None or redirect == path:
+                               # Default to the index page if we can't find a referrer or
+                               # if we'd otherwise redirect to where we already are.
+                               redirect = request.node.get_absolute_url()
                
-               if request.POST:
-                       form = LoginForm(request.POST)
+               request.session['requirement_redirect'] = redirect
+       
+       def get_requirement_redirect(self, request, default=None):
+               redirect = request.session.pop('requirement_redirect', None)
+               # Security checks a la django.contrib.auth.views.login
+               if not redirect or ' ' in redirect:
+                       redirect = default
                else:
-                       form = LoginForm()
-               context = self.get_context()
-               context.update(extra_context or {})
-               context.update({
-                       'message': message,
-                       'form': form
-               })
-               return self.login_page.render_to_response(request, extra_context=context)
+                       netloc = urlparse.urlparse(redirect)[1]
+                       if netloc and netloc != request.get_host():
+                               redirect = default
+               if redirect is None:
+                       redirect = request.node.get_absolute_url()
+               return redirect
        
+       @never_cache
        def login(self, request, extra_context=None):
                """
                Displays the login form for the given HttpRequest.
                """
-               if request.user.is_authenticated():
-                       return HttpResponseRedirect(request.node.get_absolute_url())
+               self.set_requirement_redirect(request)
                
-               context = self.get_context()
-               context.update(extra_context or {})
-               
-               from django.contrib.auth.models import User
+               # Redirect already-authenticated users to the index page.
+               if request.user.is_authenticated():
+                       messages.add_message(request, messages.INFO, "You are already authenticated. Please log out if you wish to log in as a different user.")
+                       return HttpResponseRedirect(self.get_requirement_redirect(request))
                
-               # If this isn't already the login page, display it.
-               if not request.POST.has_key(LOGIN_FORM_KEY):
-                       if request.POST:
-                               message = _("Please log in again, because your session has expired.")
-                       else:
-                               message = ""
-                       return self.display_login_page(request, message, context)
-
-               # Check that the user accepts cookies.
-               if not request.session.test_cookie_worked():
-                       message = _("Looks like your browser isn't configured to accept cookies. Please enable cookies, reload this page, and try again.")
-                       return self.display_login_page(request, message, context)
+               if request.method == 'POST':
+                       form = self.login_form(request=request, data=request.POST)
+                       if form.is_valid():
+                               redirect = self.get_requirement_redirect(request)
+                               login(request, form.get_user())
+                               
+                               if request.session.test_cookie_worked():
+                                       request.session.delete_test_cookie()
+                               
+                               return HttpResponseRedirect(redirect)
                else:
-                       request.session.delete_test_cookie()
+                       form = self.login_form()
                
-               # Check the password.
-               username = request.POST.get('username', None)
-               password = request.POST.get('password', None)
-               user = authenticate(username=username, password=password)
-               if user is None:
-                       message = ERROR_MESSAGE
-                       if username is not None and u'@' in username:
-                               # Mistakenly entered e-mail address instead of username? Look it up.
-                               try:
-                                       user = User.objects.get(email=username)
-                               except (User.DoesNotExist, User.MultipleObjectsReturned):
-                                       message = _("Usernames cannot contain the '@' character.")
-                               else:
-                                       if user.check_password(password):
-                                               message = _("Your e-mail address is not your username."
-                                                                       " Try '%s' instead.") % user.username
-                                       else:
-                                               message = _("Usernames cannot contain the '@' character.")
-                       return self.display_login_page(request, message, context)
-
-               # The user data is correct; log in the user in and continue.
-               else:
-                       if user.is_active:
-                               login(request, user)
-                               try:
-                                       redirect = request.session.pop('redirect')
-                               except KeyError:
-                                       redirect = request.node.get_absolute_url()
-                               return HttpResponseRedirect(redirect)
-                       else:
-                               return self.display_login_page(request, ERROR_MESSAGE, context)
-       login = never_cache(login)
+               request.session.set_test_cookie()
+               
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'form': form
+               })
+               return self.login_page.render_to_response(request, extra_context=context)
        
-       def logout(self, request):
+       @never_cache
+       def logout(self, request, extra_context=None):
                return auth_views.logout(request, request.META.get('HTTP_REFERER', request.node.get_absolute_url()))
        
        def login_required(self, view):
                def inner(request, *args, **kwargs):
                        if not request.user.is_authenticated():
+                               self.set_requirement_redirect(request, redirect=request.path)
+                               if request.POST:
+                                       messages.add_message(request, messages.ERROR, "Please log in again, because your session has expired.")
                                return HttpResponseRedirect(self.reverse('login', node=request.node))
                        return view(request, *args, **kwargs)
                
                return inner
        
+       class Meta:
+               abstract = True
+
+
+class PasswordMultiView(LoginMultiView):
+       "Adds on views for password-related functions."
+       password_reset_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_related', blank=True, null=True)
+       password_reset_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_reset_confirmation_email_related', blank=True, null=True)
+       password_set_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_set_related', blank=True, null=True)
+       password_change_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_password_change_related', blank=True, null=True)
+       
+       password_change_form = PasswordChangeForm
+       password_set_form = SetPasswordForm
+       password_reset_form = PasswordResetForm
+       
+       @property
+       def urlpatterns(self):
+               urlpatterns = super(PasswordMultiView, self).urlpatterns
+               
+               if self.password_reset_page and self.password_reset_confirmation_email and self.password_set_page:
+                       urlpatterns += patterns('',
+                               url(r'^password/reset$', csrf_protect(self.password_reset), name='password_reset'),
+                               url(r'^password/reset/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.password_reset_confirm, name='password_reset_confirm'),
+                       )
+               
+               if self.password_change_page:
+                       urlpatterns += patterns('',
+                               url(r'^password/change$', csrf_protect(self.login_required(self.password_change)), name='password_change'),
+                       )
+               return urlpatterns
+       
+       def make_confirmation_link(self, confirmation_view, token_generator, user, node, token_args=None, reverse_kwargs=None, secure=False):
+               token = token_generator.make_token(user, *(token_args or []))
+               kwargs = {
+                       'uidb36': int_to_base36(user.id),
+                       'token': token
+               }
+               kwargs.update(reverse_kwargs or {})
+               return node.construct_url(subpath=self.reverse(confirmation_view, kwargs=kwargs), with_domain=True, secure=secure)
+       
        def send_confirmation_email(self, subject, email, page, extra_context):
                text_content = page.render_to_string(extra_context=extra_context)
                from_email = 'noreply@%s' % Site.objects.get_current().domain
@@ -188,19 +178,24 @@ class LoginMultiView(MultiView):
                        return HttpResponseRedirect(request.node.get_absolute_url())
                
                if request.method == 'POST':
-                       form = PasswordResetForm(request.POST)
+                       form = self.password_reset_form(request.POST)
                        if form.is_valid():
                                current_site = Site.objects.get_current()
                                for user in form.users_cache:
                                        context = {
-                                               'link': self.make_confirmation_link('password_reset_confirm', token_generator, user, request.node),
+                                               'link': self.make_confirmation_link('password_reset_confirm', token_generator, user, request.node, secure=request.is_secure()),
+                                               'user': user,
+                                               'site': current_site,
+                                               'request': request,
+                                               
+                                               # Deprecated... leave in for backwards-compatibility
                                                'username': user.username
                                        }
                                        self.send_confirmation_email('Confirm password reset for account at %s' % current_site.domain, user.email, self.password_reset_confirmation_email, context)
                                        messages.add_message(request, messages.SUCCESS, "An email has been sent to the address you provided with details on resetting your password.", fail_silently=True)
                                return HttpResponseRedirect('')
                else:
-                       form = PasswordResetForm()
+                       form = self.password_reset_form()
                
                context = self.get_context()
                context.update(extra_context or {})
@@ -224,14 +219,14 @@ class LoginMultiView(MultiView):
                
                if token_generator.check_token(user, token):
                        if request.method == 'POST':
-                               form = SetPasswordForm(user, request.POST)
+                               form = self.password_set_form(user, request.POST)
                                
                                if form.is_valid():
                                        form.save()
                                        messages.add_message(request, messages.SUCCESS, "Password reset successful.")
                                        return HttpResponseRedirect(self.reverse('login', node=request.node))
                        else:
-                               form = SetPasswordForm(user)
+                               form = self.password_set_form(user)
                        
                        context = self.get_context()
                        context.update(extra_context or {})
@@ -244,13 +239,13 @@ class LoginMultiView(MultiView):
        
        def password_change(self, request, extra_context=None):
                if request.method == 'POST':
-                       form = PasswordChangeForm(request.user, request.POST)
+                       form = self.password_change_form(request.user, request.POST)
                        if form.is_valid():
                                form.save()
                                messages.add_message(request, messages.SUCCESS, 'Password changed successfully.', fail_silently=True)
                                return HttpResponseRedirect('')
                else:
-                       form = PasswordChangeForm(request.user)
+                       form = self.password_change_form(request.user)
                
                context = self.get_context()
                context.update(extra_context or {})
@@ -259,23 +254,46 @@ class LoginMultiView(MultiView):
                })
                return self.password_change_page.render_to_response(request, extra_context=context)
        
+       class Meta:
+               abstract = True
+
+
+class RegistrationMultiView(PasswordMultiView):
+       """Adds on the pages necessary for letting new users register."""
+       register_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_related', blank=True, null=True)
+       register_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_register_confirmation_email_related', blank=True, null=True)
+       registration_form = RegistrationForm
+       
+       @property
+       def urlpatterns(self):
+               urlpatterns = super(RegistrationMultiView, self).urlpatterns
+               if self.register_page and self.register_confirmation_email:
+                       urlpatterns += patterns('',
+                               url(r'^register$', csrf_protect(self.register), name='register'),
+                               url(r'^register/(?P<uidb36>\w+)/(?P<token>[^/]+)$', self.register_confirm, name='register_confirm')
+                       )
+               return urlpatterns
+       
        def register(self, request, extra_context=None, token_generator=registration_token_generator):
                if request.user.is_authenticated():
                        return HttpResponseRedirect(request.node.get_absolute_url())
                
                if request.method == 'POST':
-                       form = RegistrationForm(request.POST)
+                       form = self.registration_form(request.POST)
                        if form.is_valid():
                                user = form.save()
+                               current_site = Site.objects.get_current()
                                context = {
-                                       'link': self.make_confirmation_link('register_confirm', token_generator, user, request.node)
+                                       'link': self.make_confirmation_link('register_confirm', token_generator, user, request.node, secure=request.is_secure()),
+                                       'user': user,
+                                       'site': current_site,
+                                       'request': request
                                }
-                               current_site = Site.objects.get_current()
                                self.send_confirmation_email('Confirm account creation at %s' % current_site.name, user.email, self.register_confirmation_email, context)
                                messages.add_message(request, messages.SUCCESS, 'An email has been sent to %s with details on activating your account.' % user.email, fail_silently=True)
                                return HttpResponseRedirect(request.node.get_absolute_url())
                else:
-                       form = RegistrationForm()
+                       form = self.registration_form()
                
                context = self.get_context()
                context.update(extra_context or {})
@@ -307,7 +325,7 @@ class LoginMultiView(MultiView):
                                authenticated_user = authenticate(username=user.username, password=temp_password)
                                login(request, authenticated_user)
                        finally:
-                               # if anything goes wrong, ABSOLUTELY make sure that the true password is restored.
+                               # if anything goes wrong, do our best make sure that the true password is restored.
                                user.password = true_password
                                user.save()
                        return self.post_register_confirm_redirect(request)
@@ -321,23 +339,28 @@ class LoginMultiView(MultiView):
                abstract = True
 
 
-class AccountMultiView(LoginMultiView):
+class AccountMultiView(RegistrationMultiView):
        """
        By default, the `account` consists of the first_name, last_name, and email fields
        of the User model. Using a different account model is as simple as writing a form that
        accepts a User instance as the first argument.
        """
-       manage_account_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_manage_account_related')
-       email_change_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_email_change_confirmation_email_related')
+       manage_account_page = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_manage_account_related', blank=True, null=True)
+       email_change_confirmation_email = models.ForeignKey(Page, related_name='%(app_label)s_%(class)s_email_change_confirmation_email_related', blank=True, null=True, help_text="If this is left blank, email changes will be performed without confirmation.")
+       
        account_form = UserAccountForm
        
        @property
        def urlpatterns(self):
                urlpatterns = super(AccountMultiView, self).urlpatterns
-               urlpatterns += patterns('',
-                       url(r'^account$', self.login_required(self.account_view), name='account'),
-                       url(r'^account/email/(?P<uidb36>\w+)/(?P<email>[\w.]+[+][\w.]+)/(?P<token>[^/]+)$', self.email_change_confirm, name='email_change_confirm')
-               )
+               if self.manage_account_page:
+                       urlpatterns += patterns('',
+                               url(r'^account$', self.login_required(self.account_view), name='account'),
+                       )
+               if self.email_change_confirmation_email:
+                       urlpatterns += patterns('',
+                               url(r'^account/email/(?P<uidb36>\w+)/(?P<email>[\w.]+[+][\w.]+)/(?P<token>[^/]+)$', self.email_change_confirm, name='email_change_confirm')
+                       )
                return urlpatterns
        
        def account_view(self, request, extra_context=None, token_generator=email_token_generator, *args, **kwargs):
@@ -345,24 +368,40 @@ class AccountMultiView(LoginMultiView):
                        form = self.account_form(request.user, request.POST, request.FILES)
                        
                        if form.is_valid():
-                               if 'email' in form.changed_data:
-                                       # ModelForms modify their instances in-place during validation,
-                                       # so reset the instance's email to its previous value here,
-                                       # then remove the new value from cleaned_data.
+                               message = "Account information saved."
+                               redirect = self.get_requirement_redirect(request, default='')
+                               if 'email' in form.changed_data and self.email_change_confirmation_email:
+                                       # ModelForms modify their instances in-place during
+                                       # validation, so reset the instance's email to its
+                                       # previous value here, then remove the new value
+                                       # from cleaned_data. We only do this if an email
+                                       # change confirmation email is available.
                                        request.user.email = form.initial['email']
                                        
                                        email = form.cleaned_data.pop('email')
                                        
+                                       current_site = Site.objects.get_current()
+                                       
                                        context = {
-                                               'link': self.make_confirmation_link('email_change_confirm', token_generator, request.user, request.node, token_args=[email], reverse_kwargs={'email': email.replace('@', '+')})
+                                               'link': self.make_confirmation_link('email_change_confirm', token_generator, request.user, request.node, token_args=[email], reverse_kwargs={'email': email.replace('@', '+')}, secure=request.is_secure()),
+                                               'user': request.user,
+                                               'site': current_site,
+                                               'request': request
                                        }
-                                       current_site = Site.objects.get_current()
                                        self.send_confirmation_email('Confirm account email change at %s' % current_site.domain, email, self.email_change_confirmation_email, context)
-                                       messages.add_message(request, messages.SUCCESS, "An email has be sent to %s to confirm the email change." % email)
+                                       
+                                       message = "An email has be sent to %s to confirm the email%s." % (email, bool(request.user.email) and " change" or "")
+                                       if not request.user.email:
+                                               message += " You will need to confirm the email before accessing pages that require a valid account."
+                                               redirect = ''
                                
                                form.save()
-                               messages.add_message(request, messages.SUCCESS, "Account information saved.", fail_silently=True)
-                               return HttpResponseRedirect('')
+                               
+                               if redirect != '':
+                                       message += " Here you go!"
+                               
+                               messages.add_message(request, messages.SUCCESS, message, fail_silently=True)
+                               return HttpResponseRedirect(redirect)
                else:
                        form = self.account_form(request.user)
                
@@ -381,17 +420,23 @@ class AccountMultiView(LoginMultiView):
        def account_required(self, view):
                def inner(request, *args, **kwargs):
                        if not self.has_valid_account(request.user):
-                               if not request.method == "POST":
-                                       messages.add_message(request, messages.ERROR, "You need to add some account information before you can access this page.", fail_silently=True)
-                               return self.account_view(request, *args, **kwargs)
+                               messages.add_message(request, messages.ERROR, "You need to add some account information before you can access that page.", fail_silently=True)
+                               if self.manage_account_page:
+                                       self.set_requirement_redirect(request, redirect=request.path)
+                                       redirect = self.reverse('account', node=request.node)
+                               else:
+                                       redirect = node.get_absolute_url()
+                               return HttpResponseRedirect(redirect)
                        return view(request, *args, **kwargs)
                
                inner = self.login_required(inner)
                return inner
        
        def post_register_confirm_redirect(self, request):
-               messages.add_message(request, messages.INFO, 'Welcome! Please fill in some more information.', fail_silently=True)
-               return HttpResponseRedirect(self.reverse('account', node=request.node))
+               if self.manage_account_page:
+                       messages.add_message(request, messages.INFO, 'Welcome! Please fill in some more information.', fail_silently=True)
+                       return HttpResponseRedirect(self.reverse('account', node=request.node))
+               return super(AccountMultiView, self).post_register_confirm_redirect(request)
        
        def email_change_confirm(self, request, extra_context=None, uidb36=None, token=None, email=None, token_generator=email_token_generator):
                """
@@ -416,7 +461,11 @@ class AccountMultiView(LoginMultiView):
                        user.email = email
                        user.save()
                        messages.add_message(request, messages.SUCCESS, 'Email changed successfully.')
-                       return HttpReponseRedirect(self.reverse('account', node=request.node))
+                       if self.manage_account_page:
+                               redirect = self.reverse('account', node=request.node)
+                       else:
+                               redirect = request.node.get_absolute_url()
+                       return HttpResponseRedirect(redirect)
                
                raise Http404
        
index 31f2910..d08d41e 100644 (file)
@@ -1,6 +1,29 @@
 var tagCreation = window.tagCreation;
 
 (function($) {
+       location_re = new RegExp("^https?:\/\/" + window.location.host + "/")
+       
+       $('html').ajaxSend(function(event, xhr, settings) {
+               function getCookie(name) {
+                       var cookieValue = null;
+                       if (document.cookie && document.cookie != '') {
+                               var cookies = document.cookie.split(';');
+                               for (var i = 0; i < cookies.length; i++) {
+                                       var cookie = $.trim(cookies[i]);
+                                       // Does this cookie string begin with the name we want?
+                                       if (cookie.substring(0, name.length + 1) == (name + '=')) {
+                                               cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+                                               break;
+                                       }
+                               }
+                       }
+                       return cookieValue;
+               }
+               if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url)) || location_re.test(settings.url)) {
+                       // Only send the token to relative URLs i.e. locally.
+                       xhr.setRequestHeader("X-CSRFToken", getCookie('csrftoken'));
+               }
+       });
        tagCreation = {
                'cache': {},
                'addTagFromSlug': function(triggeringLink) {
index 8370bb7..faac89b 100644 (file)
@@ -5,7 +5,7 @@ from django.contrib.contenttypes import generic
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.validators import RegexValidator
 from django.utils import simplejson as json
-from django.utils.encoding import smart_str
+from django.utils.encoding import force_unicode
 from philo.exceptions import AncestorDoesNotExist
 from philo.models.fields import JSONField
 from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
@@ -55,10 +55,6 @@ def unregister_value_model(model):
 class AttributeValue(models.Model):
        attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id')
        
-       @property
-       def attribute(self):
-               return self.attribute_set.all()[0]
-       
        def set_value(self, value):
                raise NotImplementedError
        
@@ -84,7 +80,7 @@ class JSONValue(AttributeValue):
        value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null', db_index=True)
        
        def __unicode__(self):
-               return smart_str(self.value)
+               return force_unicode(self.value)
        
        def value_formfields(self):
                kwargs = {'initial': self.value_json}
index 10c51b4..99be196 100644 (file)
@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
 from django.core.servers.basehttp import FileWrapper
 from django.core.urlresolvers import resolve, clear_url_caches, reverse, NoReverseMatch
 from django.template import add_to_builtins as register_templatetags
+from django.utils.encoding import smart_str
 from inspect import getargspec
 from philo.exceptions import MIDDLEWARE_NOT_CONFIGURED
 from philo.models.base import TreeEntity, Entity, QuerySetMapper, register_value_model
@@ -114,8 +115,8 @@ class View(Entity):
                
                try:
                        subpath = reverse(view_name, urlconf=self, args=args or [], kwargs=kwargs or {})
-               except NoReverseMatch:
-                       raise ViewCanNotProvideSubpath
+               except NoReverseMatch, e:
+                       raise ViewCanNotProvideSubpath(e.message)
                
                if node is not None:
                        return node.construct_url(subpath)
@@ -210,7 +211,6 @@ class TargetURLModel(models.Model):
        reversing_parameters = JSONField(blank=True, help_text="If reversing parameters are defined, url_or_subpath will instead be interpreted as the view name to be reversed.")
        
        def clean(self):
-               # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
                if not self.target_node and not self.url_or_subpath:
                        raise ValidationError("Either a target node or a url must be defined.")
                
@@ -219,15 +219,20 @@ class TargetURLModel(models.Model):
                
                try:
                        self.get_target_url()
-               except NoReverseMatch, e:
+               except (NoReverseMatch, ViewCanNotProvideSubpath), e:
                        raise ValidationError(e.message)
                
                super(TargetURLModel, self).clean()
        
        def get_reverse_params(self):
                params = self.reversing_parameters
-               args = isinstance(params, list) and params or None
-               kwargs = isinstance(params, dict) and params or None
+               args = kwargs = None
+               if isinstance(params, list):
+                       args = params
+               elif isinstance(params, dict):
+                       # Convert unicode keys to strings for Python < 2.6.5. Compare
+                       # http://stackoverflow.com/questions/4598604/how-to-pass-unicode-keywords-to-kwargs
+                       kwargs = dict([(smart_str(k, 'ascii'), v) for k, v in params.items()])
                return self.url_or_subpath, args, kwargs
        
        def get_target_url(self):
@@ -254,7 +259,7 @@ class TargetURLModel(models.Model):
                abstract = True
 
 
-class Redirect(View, TargetURLModel):
+class Redirect(TargetURLModel, View):
        STATUS_CODES = (
                (302, 'Temporary'),
                (301, 'Permanent'),
index ef68b5f..39125ef 100644 (file)
@@ -5,16 +5,71 @@ from django.contrib.contenttypes import generic
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.http import HttpResponse
-from django.template import TemplateDoesNotExist, Context, RequestContext, Template as DjangoTemplate, add_to_builtins as register_templatetags
+from django.template import TemplateDoesNotExist, Context, RequestContext, Template as DjangoTemplate, add_to_builtins as register_templatetags, TextNode, VariableNode
+from django.template.loader_tags import BlockNode, ExtendsNode, BlockContext
+from django.utils.datastructures import SortedDict
 from philo.models.base import TreeModel, register_value_model
 from philo.models.fields import TemplateField
 from philo.models.nodes import View
 from philo.templatetags.containers import ContainerNode
-from philo.utils import fattr, nodelist_crawl
+from philo.utils import fattr
 from philo.validators import LOADED_TEMPLATE_ATTR
 from philo.signals import page_about_to_render_to_string, page_finished_rendering_to_string
 
 
+class LazyContainerFinder(object):
+       def __init__(self, nodes):
+               self.nodes = nodes
+               self.initialized = False
+               self.contentlet_specs = set()
+               self.contentreference_specs = SortedDict()
+               self.blocks = {}
+               self.block_super = False
+       
+       def process(self, nodelist):
+               for node in nodelist:
+                       if isinstance(node, ContainerNode):
+                               if not node.references:
+                                       self.contentlet_specs.add(node.name)
+                               else:
+                                       if node.name not in self.contentreference_specs.keys():
+                                               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
+                       
+                       if hasattr(node, 'child_nodelists'):
+                               for nodelist_name in node.child_nodelists:
+                                       if hasattr(node, nodelist_name):
+                                               nodelist = getattr(node, nodelist_name)
+                                               self.process(nodelist)
+                       
+                       # LOADED_TEMPLATE_ATTR contains the name of an attribute philo uses to declare a
+                       # node as rendering an additional template. Philo monkeypatches the attribute onto
+                       # the relevant default nodes and declares it on any native nodes.
+                       if hasattr(node, LOADED_TEMPLATE_ATTR):
+                               loaded_template = getattr(node, LOADED_TEMPLATE_ATTR)
+                               if loaded_template:
+                                       nodelist = loaded_template.nodelist
+                                       self.process(nodelist)
+       
+       def initialize(self):
+               if not self.initialized:
+                       self.process(self.nodes)
+                       self.initialized = True
+
+
 class Template(TreeModel):
        name = models.CharField(max_length=255)
        documentation = models.TextField(null=True, blank=True)
@@ -29,19 +84,51 @@ class Template(TreeModel):
                This will break if there is a recursive extends or includes in the template code.
                Due to the use of an empty Context, any extends or include tags with dynamic arguments probably won't work.
                """
-               def process_node(node, nodes):
-                       if isinstance(node, ContainerNode):
-                               nodes.append(node)
+               template = DjangoTemplate(self.code)
+               
+               def build_extension_tree(nodelist):
+                       nodelists = []
+                       extends = None
+                       for node in nodelist:
+                               if not isinstance(node, TextNode):
+                                       if isinstance(node, ExtendsNode):
+                                               extends = node
+                                       break
+                       
+                       if extends:
+                               if extends.nodelist:
+                                       nodelists.append(LazyContainerFinder(extends.nodelist))
+                               loaded_template = getattr(extends, LOADED_TEMPLATE_ATTR)
+                               nodelists.extend(build_extension_tree(loaded_template.nodelist))
+                       else:
+                               # Base case: root.
+                               nodelists.append(LazyContainerFinder(nodelist))
+                       return nodelists
+               
+               # Build a tree of the templates we're using, placing the root template first.
+               levels = build_extension_tree(template.nodelist)[::-1]
+               
+               contentlet_specs = set()
+               contentreference_specs = SortedDict()
+               blocks = {}
+               
+               for level in levels:
+                       level.initialize()
+                       contentlet_specs |= level.contentlet_specs
+                       contentreference_specs.update(level.contentreference_specs)
+                       for name, block in level.blocks.items():
+                               if block.block_super:
+                                       blocks.setdefault(name, []).append(block)
+                               else:
+                                       blocks[name] = [block]
+               
+               for block_list in blocks.values():
+                       for block in block_list:
+                               block.initialize()
+                               contentlet_specs |= block.contentlet_specs
+                               contentreference_specs.update(block.contentreference_specs)
                
-               all_nodes = nodelist_crawl(DjangoTemplate(self.code).nodelist, process_node)
-               contentlet_node_names = set([node.name for node in all_nodes if not node.references])
-               contentreference_node_names = []
-               contentreference_node_specs = []
-               for node in all_nodes:
-                       if node.references and node.name not in contentreference_node_names:
-                               contentreference_node_specs.append((node.name, node.references))
-                               contentreference_node_names.append(node.name)
-               return contentlet_node_names, contentreference_node_specs
+               return contentlet_specs, contentreference_specs
        
        def __unicode__(self):
                return self.get_path(pathsep=u' › ', field='name')
index 5602a38..59aba8f 100644 (file)
@@ -5,7 +5,7 @@
 {% comment %}Don't render the formset at all if there aren't any forms.{% endcomment %}
 {% if inline_admin_formset.formset.forms %}
        <fieldset class="module{% if inline_admin_formset.opts.classes %} {{ inline_admin_formset.opts.classes|join:" " }}{% endif %}">
-               <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
+               <h2{% if "collapse" in inline_admin_formset.opts.classes %} class="collapse-handler"{% endif %}>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
                {{ inline_admin_formset.formset.non_form_errors }}
                {% for inline_admin_form in inline_admin_formset %}
                        {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
                                {% endfor %}
                        {% endfor %}
                        {% endfor %}{% endspaceless %}
-                       <div class="row cells-{{ inline_admin_form.fields|length }}{% if not inline_admin_form.fields|length_is:"2" %} cells{% endif %}{% if inline_admin_form.errors %} errors{% endif %} {% for field in inline_admin_form %}{{ field.field.name }} {% endfor %}{% if forloop.last %} empty-form{% endif %}">
-                               <div{% if not inline_admin_form.fields|length_is:"2" %} class="cell"{% endif %}>
-                                       <div class="column span-4"><label class='required' for="{{ inline_admin_form.form.content.auto_id }}{{ inline_admin_form.form.content_id.auto_id }}">{{ inline_admin_form.form.verbose_name|capfirst }}:</label>{{ inline_admin_form.form.name.as_hidden }}</div>
-                               {% for fieldset in inline_admin_form %}{% for line in fieldset %}{% for field in line %}
-                                       {% if field.field.name != 'name' %}
+               {% endfor %}
+               {% for form in inline_admin_formset.formset.forms %}
+                       <div class="row cells-{{ form.fields.keys|length }}{% if not form.fields.keys|length_is:"2" %} cells{% endif %}{% if form.errors %} errors{% endif %} {% for field in form %}{{ field.field.name }} {% endfor %}{% comment %} {% if forloop.last %} empty-form{% endif %}{% endcomment %}">
+                               {{ form.non_field_errors }}
+                               <div{% if not form.fields.keys|length_is:"2" %} class="cell"{% endif %}>
+                                       <div class="column span-4"><label class='required' for="{{ form.content.auto_id }}{{ form.content_id.auto_id }}">{{ form.verbose_name|capfirst }}:</label></div>
+                               {% for field in form %}
+                                       {% if not field.is_hidden %}
                                        <div class="column span-flexible">
-                                               {% if field.is_readonly %}
-                                                       <p class="readonly">{{ field.contents }}</p>
-                                               {% else %}
-                                                       {{ field.field }}
-                                               {% endif %}
-                                               {{ inline_admin_form.errors }}
-                                               {% if field.field.field.help_text %}
-                                                       <p class="help">{{ field.field.field.help_text|safe }}</p>
+                                               {{ field }}
+                                               {{ field.errors }}
+                                               {% if field.field.help_text %}
+                                                       <p class="help">{{ field.field.help_text|safe }}</p>
                                                {% endif %}
                                        </div>
                                        {% endif %}
-                               {% endfor %}{% endfor %}{% endfor %}
+                               {% endfor %}
                                </div>
                        </div>
                {% endfor %}
index f93e52f..77d5e23 100644 (file)
@@ -7,15 +7,6 @@
    <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
    {{ inline_admin_formset.formset.non_form_errors }}
    <table>
-        <thead><tr>
-        {% for field in inline_admin_formset.fields %}
-          {% if not field.widget.is_hidden %}
-                <th{% if field.required %} class="required"{% endif %}>{{ field.label|capfirst }}</th>
-          {% endif %}
-        {% endfor %}
-        {% if inline_admin_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
-        </tr></thead>
-
         <tbody>
         {% for inline_admin_form in inline_admin_formset %}
                {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
                  {% endfor %}
                {% endfor %}
                {% endfor %}
-               {{ inline_admin_form.form.name.as_hidden }}
                {% endspaceless %}
-               {% if inline_admin_form.form.non_field_errors %}
-               <tr><td colspan="{{ inline_admin_form.field_count }}">{{ inline_admin_form.form.non_field_errors }}</td></tr>
+       {% endfor %}
+       {% for form in inline_admin_formset.formset.forms %}
+               {% if form.non_field_errors %}
+               <tr><td colspan="2">{{ form.non_field_errors }}</td></tr>
                {% endif %}
-               <tr class="{% cycle "row1" "row2" %} {% if forloop.last %} empty-form{% endif %}"
-                        id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
-                       <th>{{ inline_admin_form.form.verbose_name|capfirst }}:</th>
-               {% for fieldset in inline_admin_form %}
-                 {% for line in fieldset %}
-                       {% for field in line %}
-                         {% if field.field.name != 'name' %}
+               <tr class="{% cycle "row1" "row2" %}{% comment %} {% if forloop.last %} empty-form{% endif %}{% endcomment %}"
+                        id="{{ formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
+                       <th>{{ form.verbose_name|capfirst }}:</th>
+                       {% for field in form %}
+                         {% if not field.is_hidden %}
                          <td class="{{ field.field.name }}">
-                         {% if field.is_readonly %}
-                                 <p>{{ field.contents }}</p>
-                         {% else %}
                                  {{ field.field.errors.as_ul }}
-                                 {{ field.field }}
-                         {% endif %}
+                                 {{ field }}
+                                 {% if field.field.help_text %}
+                                 <p class="help">{{ field.field.help_text|safe }}</p>
+                                 {% endif %}
                          </td>
                          {% endif %}
                        {% endfor %}
-                 {% endfor %}
-               {% endfor %}
-               {% if inline_admin_formset.formset.can_delete %}
-                 <td class="delete">{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}</td>
-               {% endif %}
                </tr>
         {% endfor %}
         </tbody>
index deb009c..57f949e 100644 (file)
--- a/utils.py
+++ b/utils.py
@@ -121,30 +121,4 @@ def get_included(self):
 
 # We ignore the IncludeNode because it will never work in a blank context.
 setattr(ExtendsNode, LOADED_TEMPLATE_ATTR, property(get_extended))
-setattr(ConstantIncludeNode, LOADED_TEMPLATE_ATTR, property(get_included))
-
-
-def nodelist_crawl(nodelist, callback):
-       """This function crawls through a template's nodelist and the nodelists of any included or extended
-       templates, as determined by the presence and value of <LOADED_TEMPLATE_ATTR> on a node. Each node
-       will also be passed to a callback function for additional processing."""
-       results = []
-       for node in nodelist:
-               try:
-                       if hasattr(node, 'child_nodelists'):
-                               for nodelist_name in node.child_nodelists:
-                                       if hasattr(node, nodelist_name):
-                                               results.extend(nodelist_crawl(getattr(node, nodelist_name), callback))
-                       
-                       # LOADED_TEMPLATE_ATTR contains the name of an attribute philo uses to declare a
-                       # node as rendering an additional template. Philo monkeypatches the attribute onto
-                       # the relevant default nodes and declares it on any native nodes.
-                       if hasattr(node, LOADED_TEMPLATE_ATTR):
-                               loaded_template = getattr(node, LOADED_TEMPLATE_ATTR)
-                               if loaded_template:
-                                       results.extend(nodelist_crawl(loaded_template.nodelist, callback))
-                       
-                       callback(node, results)
-               except:
-                       raise # fail for this node
-       return results
\ No newline at end of file
+setattr(ConstantIncludeNode, LOADED_TEMPLATE_ATTR, property(get_included))
\ No newline at end of file