Merge branch 'master' into search
authorStephen Burrows <stephen.r.burrows@gmail.com>
Thu, 24 Feb 2011 19:07:40 +0000 (14:07 -0500)
committerStephen Burrows <stephen.r.burrows@gmail.com>
Thu, 24 Feb 2011 19:07:40 +0000 (14:07 -0500)
43 files changed:
admin/base.py
admin/forms/containers.py
admin/nodes.py
contrib/cowell/__init__.py [deleted file]
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 [new file with mode: 0644]
contrib/penfield/models.py
contrib/penfield/templates/penfield/feeds/blog_entry/description.html [deleted file]
contrib/penfield/templates/penfield/feeds/blog_entry/title.html [deleted file]
contrib/penfield/templates/penfield/feeds/newsletter_article/description.html [deleted file]
contrib/penfield/templates/penfield/feeds/newsletter_article/title.html [deleted file]
contrib/penfield/utils.py [deleted file]
contrib/shipherd/admin.py
contrib/shipherd/migrations/0001_initial.py [new file with mode: 0644]
contrib/shipherd/migrations/0002_auto.py [new file with mode: 0644]
contrib/shipherd/migrations/__init__.py [new file with mode: 0644]
contrib/shipherd/models.py
contrib/shipherd/templatetags/shipherd.py
contrib/waldo/forms.py
contrib/waldo/models.py
exceptions.py
forms/__init__.py
forms/entities.py [moved from contrib/cowell/forms.py with 71% similarity]
media/admin/js/TagCreation.js
middleware.py
migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py [new file with mode: 0644]
migrations/0011_move_target_url.py [new file with mode: 0644]
migrations/0012_auto__del_field_redirect_target.py [new file with mode: 0644]
migrations/0013_auto.py [new file with mode: 0644]
migrations/0014_auto.py [new file with mode: 0644]
models/__init__.py
models/base.py
models/fields/__init__.py [moved from models/fields.py with 98% similarity]
models/fields/entities.py [moved from contrib/cowell/fields.py with 79% similarity]
models/nodes.py
models/pages.py
templates/admin/philo/edit_inline/grappelli_tabular_container.html
templates/admin/philo/edit_inline/tabular_container.html
templatetags/nodes.py
utils.py
views.py

index acba9c3..8151461 100644 (file)
@@ -5,8 +5,10 @@ from django.http import HttpResponse
 from django.utils import simplejson as json
 from django.utils.html import escape
 from philo.models import Tag, Attribute
+from philo.models.fields.entities import ForeignKeyAttribute, ManyToManyAttribute
 from philo.admin.forms.attributes import AttributeForm, AttributeInlineFormSet
 from philo.admin.widgets import TagFilteredSelectMultiple
+from philo.forms.entities import EntityForm, proxy_fields_for_entity_model
 from mptt.admin import MPTTModelAdmin
 
 
@@ -29,16 +31,119 @@ class AttributeInline(generic.GenericTabularInline):
                template = 'admin/philo/edit_inline/tabular_attribute.html'
 
 
+def hide_proxy_fields(cls, attname, proxy_field_set):
+       val_set = set(getattr(cls, attname))
+       if proxy_field_set & val_set:
+               cls._hidden_attributes[attname] = list(val_set)
+               setattr(cls, attname, list(val_set - proxy_field_set))
+
+
+class EntityAdminMetaclass(admin.ModelAdmin.__metaclass__):
+       def __new__(cls, name, bases, attrs):
+               # HACK to bypass model validation for proxy fields by masking them as readonly fields
+               new_class = super(EntityAdminMetaclass, cls).__new__(cls, name, bases, attrs)
+               form = getattr(new_class, 'form', None)
+               if form:
+                       opts = form._meta
+                       if issubclass(form, EntityForm) and opts.model:
+                               proxy_fields = proxy_fields_for_entity_model(opts.model).keys()
+                               
+                               # Store readonly fields iff they have been declared.
+                               if 'readonly_fields' in attrs or not hasattr(new_class, '_real_readonly_fields'):
+                                       new_class._real_readonly_fields = new_class.readonly_fields
+                               
+                               readonly_fields = new_class.readonly_fields
+                               new_class.readonly_fields = list(set(readonly_fields) | set(proxy_fields))
+                               
+                               # Additional HACKS to handle raw_id_fields and other attributes that the admin
+                               # uses model._meta.get_field to validate.
+                               new_class._hidden_attributes = {}
+                               proxy_fields = set(proxy_fields)
+                               hide_proxy_fields(new_class, 'raw_id_fields', proxy_fields)
+               #END HACK
+               return new_class
+
+
 class EntityAdmin(admin.ModelAdmin):
+       __metaclass__ = EntityAdminMetaclass
+       form = EntityForm
        inlines = [AttributeInline]
        save_on_top = True
+       
+       def __init__(self, *args, **kwargs):
+               # HACK PART 2 restores the actual readonly fields etc. on __init__.
+               if hasattr(self, '_real_readonly_fields'):
+                       self.readonly_fields = self.__class__._real_readonly_fields
+               if hasattr(self, '_hidden_attributes'):
+                       for name, value in self._hidden_attributes.items():
+                               setattr(self, name, value)
+               # END HACK
+               super(EntityAdmin, self).__init__(*args, **kwargs)
+       
+       def formfield_for_dbfield(self, db_field, **kwargs):
+               """
+               Override the default behavior to provide special formfields for EntityEntitys.
+               Essentially clones the ForeignKey/ManyToManyField special behavior for the Attribute versions.
+               """
+               if not db_field.choices and isinstance(db_field, (ForeignKeyAttribute, ManyToManyAttribute)):
+                       request = kwargs.pop("request", None)
+                       # Combine the field kwargs with any options for formfield_overrides.
+                       # Make sure the passed in **kwargs override anything in
+                       # formfield_overrides because **kwargs is more specific, and should
+                       # always win.
+                       if db_field.__class__ in self.formfield_overrides:
+                               kwargs = dict(self.formfield_overrides[db_field.__class__], **kwargs)
+                       
+                       # Get the correct formfield.
+                       if isinstance(db_field, ManyToManyAttribute):
+                               formfield = self.formfield_for_manytomanyattribute(db_field, request, **kwargs)
+                       elif isinstance(db_field, ForeignKeyAttribute):
+                               formfield = self.formfield_for_foreignkeyattribute(db_field, request, **kwargs)
+                       
+                       # For non-raw_id fields, wrap the widget with a wrapper that adds
+                       # extra HTML -- the "add other" interface -- to the end of the
+                       # rendered output. formfield can be None if it came from a
+                       # OneToOneField with parent_link=True or a M2M intermediary.
+                       # TODO: Implement this.
+                       #if formfield and db_field.name not in self.raw_id_fields:
+                       #       formfield.widget = admin.widgets.RelatedFieldWidgetWrapper(formfield.widget, db_field, self.admin_site)
+                       
+                       return formfield
+               return super(EntityAdmin, self).formfield_for_dbfield(db_field, **kwargs)
+       
+       def formfield_for_foreignkeyattribute(self, db_field, request=None, **kwargs):
+               """Get a form field for a ForeignKeyAttribute field."""
+               db = kwargs.get('using')
+               if db_field.name in self.raw_id_fields:
+                       kwargs['widget'] = admin.widgets.ForeignKeyRawIdWidget(db_field, db)
+               #TODO: Add support for radio fields
+               #elif db_field.name in self.radio_fields:
+               #       kwargs['widget'] = widgets.AdminRadioSelect(attrs={
+               #               'class': get_ul_class(self.radio_fields[db_field.name]),
+               #       })
+               #       kwargs['empty_label'] = db_field.blank and _('None') or None
+               
+               return db_field.formfield(**kwargs)
+       
+       def formfield_for_manytomanyattribute(self, db_field, request=None, **kwargs):
+               """Get a form field for a ManyToManyAttribute field."""
+               db = kwargs.get('using')
+               
+               if db_field.name in self.raw_id_fields:
+                       kwargs['widget'] = admin.widgets.ManyToManyRawIdWidget(db_field, using=db)
+                       kwargs['help_text'] = ''
+               #TODO: Add support for filtered fields.
+               #elif db_field.name in (list(self.filter_vertical) + list(self.filter_horizontal)):
+               #       kwargs['widget'] = widgets.FilteredSelectMultiple(db_field.verbose_name, (db_field.name in self.filter_vertical))
+               
+               return db_field.formfield(**kwargs)
 
 
 class TreeAdmin(MPTTModelAdmin):
        pass
 
 
-class TreeEntityAdmin(TreeAdmin, EntityAdmin):
+class TreeEntityAdmin(EntityAdmin, TreeAdmin):
        pass
 
 
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 a576d44..66be107 100644 (file)
@@ -1,10 +1,15 @@
 from django.contrib import admin
-from philo.admin.base import EntityAdmin, TreeEntityAdmin
+from philo.admin.base import EntityAdmin, TreeEntityAdmin, COLLAPSE_CLASSES
 from philo.models import Node, Redirect, File
 
 
 class NodeAdmin(TreeEntityAdmin):
        list_display = ('slug', 'view', 'accepts_subpath')
+       related_lookup_fields = {
+               'fk': [],
+               'm2m': [],
+               'generic': [['view_content_type', 'view_object_id']]
+       }
        
        def accepts_subpath(self, obj):
                return obj.accepts_subpath
@@ -18,11 +23,19 @@ class ViewAdmin(EntityAdmin):
 class RedirectAdmin(ViewAdmin):
        fieldsets = (
                (None, {
-                       'fields': ('target', 'status_code')
+                       'fields': ('target_node', 'url_or_subpath', 'status_code')
                }),
+               ('Advanced', {
+                       'fields': ('reversing_parameters',),
+                       'classes': COLLAPSE_CLASSES
+               })
        )
-       list_display = ('target', 'status_code')
+       list_display = ('target_url', 'status_code', 'target_node', 'url_or_subpath')
        list_filter = ('status_code',)
+       raw_id_fields = ['target_node']
+       related_lookup_fields = {
+               'fk': raw_id_fields
+       }
 
 
 class FileAdmin(ViewAdmin):
diff --git a/contrib/cowell/__init__.py b/contrib/cowell/__init__.py
deleted file mode 100644 (file)
index 710d164..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-"""
-Cowell handles the code necessary for creating AttributeFields on Entities. This can be used
-to give the appearance of fields added to models without the needing to change the schema or
-to define multiple models due to minor differences in requirements.
-"""
\ No newline at end of file
index d8fcd90..328394a 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
 
@@ -41,6 +43,7 @@ class BlogEntryAdmin(TitledAdmin, AddTagAdmin):
                        'classes': COLLAPSE_CLASSES
                })
        )
+       related_lookup_fields = {'fk': raw_id_fields}
 
 
 class BlogViewAdmin(EntityAdmin):
@@ -54,16 +57,17 @@ class BlogViewAdmin(EntityAdmin):
                ('Archive Pages', {
                        'fields': ('entry_archive_page', 'tag_archive_page')
                }),
-               ('Permalinks', {
-                       'fields': ('entry_permalink_style', 'entry_permalink_base', 'tag_permalink_base'),
+               ('General Settings', {
+                       'fields': ('entry_permalink_style', 'entry_permalink_base', 'tag_permalink_base', 'entries_per_page'),
                        'classes': COLLAPSE_CLASSES
                }),
-               ('Feeds', {
-                       'fields': ('feed_suffix', 'feeds_enabled'),
+               ('Feed Settings', {
+                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',),
                        'classes': COLLAPSE_CLASSES
                })
        )
-       raw_id_fields = ('index_page', 'entry_page', 'tag_page', 'entry_archive_page', 'tag_archive_page',)
+       raw_id_fields = ('index_page', 'entry_page', 'tag_page', 'entry_archive_page', 'tag_archive_page', 'item_title_template', 'item_description_template',)
+       related_lookup_fields = {'fk': raw_id_fields}
 
 
 class NewsletterAdmin(TitledAdmin):
@@ -89,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):
@@ -115,11 +127,12 @@ class NewsletterViewAdmin(EntityAdmin):
                        'classes': COLLAPSE_CLASSES
                }),
                ('Feeds', {
-                       'fields': ('feed_suffix', 'feeds_enabled'),
+                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',),
                        'classes': COLLAPSE_CLASSES
                })
        )
-       raw_id_fields = ('index_page', 'article_page', 'issue_page', 'article_archive_page', 'issue_archive_page',)
+       raw_id_fields = ('index_page', 'article_page', 'issue_page', 'article_archive_page', 'issue_archive_page', 'item_title_template', 'item_description_template',)
+       related_lookup_fields = {'fk': raw_id_fields}
 
 
 admin.site.register(Blog, BlogAdmin)
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
diff --git a/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py b/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py
new file mode 100644 (file)
index 0000000..eae496e
--- /dev/null
@@ -0,0 +1,226 @@
+# 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_type'
+        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)
+
+        # Adding field 'NewsletterView.item_description_template'
+        db.add_column('penfield_newsletterview', 'item_description_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_newsletterview_description_related', null=True, to=orm['philo.Template']), keep_default=False)
+
+        # Adding field 'BlogView.feed_type'
+        db.add_column('penfield_blogview', 'feed_type', self.gf('django.db.models.fields.CharField')(default='atom', max_length=50), keep_default=False)
+
+        # Adding field 'BlogView.item_title_template'
+        db.add_column('penfield_blogview', 'item_title_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_blogview_title_related', null=True, to=orm['philo.Template']), keep_default=False)
+
+        # Adding field 'BlogView.item_description_template'
+        db.add_column('penfield_blogview', 'item_description_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_blogview_description_related', null=True, to=orm['philo.Template']), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'NewsletterView.feed_type'
+        db.delete_column('penfield_newsletterview', 'feed_type')
+
+        # Deleting field 'NewsletterView.item_title_template'
+        db.delete_column('penfield_newsletterview', 'item_title_template_id')
+
+        # Deleting field 'NewsletterView.item_description_template'
+        db.delete_column('penfield_newsletterview', 'item_description_template_id')
+
+        # Deleting field 'BlogView.feed_type'
+        db.delete_column('penfield_blogview', 'feed_type')
+
+        # Deleting field 'BlogView.item_title_template'
+        db.delete_column('penfield_blogview', 'item_title_template_id')
+
+        # Deleting field 'BlogView.item_description_template'
+        db.delete_column('penfield_blogview', 'item_description_template_id')
+
+
+    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_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}),
+            'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", '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_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}),
+            'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", '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', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            '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', [], {'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 7ca879d..b970643 100644 (file)
 from django.conf import settings
 from django.conf.urls.defaults import url, patterns, include
+from django.contrib.sites.models import Site, RequestSite
+from django.contrib.syndication.views import add_domain
 from django.db import models
-from django.http import Http404
-from django.template import loader, Context
-from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed
+from django.http import Http404, HttpResponse
+from django.template import RequestContext, Template as DjangoTemplate
+from django.utils import feedgenerator, tzinfo
+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.utils import FeedMultiViewMixin
+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
+from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template
 from philo.utils import paginate
+try:
+       import mimeparse
+except:
+       mimeparse = None
+
+
+ATOM = feedgenerator.Atom1Feed.mime_type
+RSS = feedgenerator.Rss201rev2Feed.mime_type
+FEEDS = SortedDict([
+       (ATOM, feedgenerator.Atom1Feed),
+       (RSS, feedgenerator.Rss201rev2Feed),
+])
+FEED_CHOICES = (
+       (ATOM, "Atom"),
+       (RSS, "RSS"),
+)
+
+
+class FeedView(MultiView):
+       """
+       The FeedView expects to handle a number of different feeds for the
+       same object - i.e. patterns for a blog to handle all entries or
+       just entries for a certain year/month/day.
+       
+       This class would subclass django.contrib.syndication.views.Feed, but
+       that would make it callable, which causes problems.
+       """
+       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)
+       
+       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")
+       
+       item_context_var = 'items'
+       object_attr = 'object'
+       
+       description = ""
+       
+       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('')
+               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(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):
+               return getattr(self, self.object_attr)
+       
+       def feed_view(self, get_items_attr, reverse_name):
+               """
+               Returns a view function that renders a list of items as a feed.
+               """
+               get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
+               
+               def inner(request, extra_context=None, *args, **kwargs):
+                       obj = self.get_object(request, *args, **kwargs)
+                       feed = self.get_feed(obj, request, reverse_name)
+                       items, xxx = get_items(request, extra_context=extra_context, *args, **kwargs)
+                       self.populate_feed(feed, items, request)
+                       
+                       response = HttpResponse(mimetype=feed.mime_type)
+                       feed.write(response, 'utf-8')
+                       return response
+               
+               return inner
+       
+       def page_view(self, get_items_attr, page_attr):
+               """
+               Returns a view function that renders a list of items as a page.
+               """
+               get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
+               page = isinstance(page_attr, Page) and page_attr or getattr(self, page_attr)
+               
+               def inner(request, extra_context=None, *args, **kwargs):
+                       items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs)
+                       items, item_context = self.process_page_items(request, items)
+                       
+                       context = self.get_context()
+                       context.update(extra_context or {})
+                       context.update(item_context or {})
+                       
+                       return page.render_to_response(request, extra_context=context)
+               return inner
+       
+       def process_page_items(self, request, items):
+               """
+               Hook for handling any extra processing of items based on a
+               request, such as pagination or searching. This method is
+               expected to return a list of items and a dictionary to be
+               added to the page context.
+               """
+               item_context = {
+                       self.item_context_var: items
+               }
+               return items, item_context
+       
+       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?
+                       if mimeparse:
+                               feed_type = mimeparse.best_match(FEEDS.keys(), accept)
+                       else:
+                               for feed_type in FEEDS.keys():
+                                       if feed_type in accept or "%s/*" % feed_type.split("/")[0] in accept:
+                                               break
+                               else:
+                                       feed_type = None
+                       if not feed_type:
+                               raise HttpNotAcceptable
+               return FEEDS[feed_type]
+       
+       def get_feed(self, obj, request, reverse_name):
+               """
+               Returns an unpopulated feedgenerator.DefaultFeed object for this object.
+               """
+               try:
+                       current_site = Site.objects.get_current()
+               except Site.DoesNotExist:
+                       current_site = RequestSite(request)
+               
+               feed_type = self.get_feed_type(request)
+               node = request.node
+               link = node.get_absolute_url(with_domain=True, request=request, secure=request.is_secure())
+               
+               feed = feed_type(
+                       title = self.__get_dynamic_attr('title', obj),
+                       subtitle = self.__get_dynamic_attr('subtitle', obj),
+                       link = link,
+                       description = self.__get_dynamic_attr('description', obj),
+                       language = settings.LANGUAGE_CODE.decode(),
+                       feed_url = add_domain(
+                               current_site.domain,
+                               self.__get_dynamic_attr('feed_url', obj) or node.construct_url(node.subpath, with_domain=True, request=request, secure=request.is_secure()),
+                               request.is_secure()
+                       ),
+                       author_name = self.__get_dynamic_attr('author_name', obj),
+                       author_link = self.__get_dynamic_attr('author_link', obj),
+                       author_email = self.__get_dynamic_attr('author_email', obj),
+                       categories = self.__get_dynamic_attr('categories', obj),
+                       feed_copyright = self.__get_dynamic_attr('feed_copyright', obj),
+                       feed_guid = self.__get_dynamic_attr('feed_guid', obj),
+                       ttl = self.__get_dynamic_attr('ttl', obj),
+                       **self.feed_extra_kwargs(obj)
+               )
+               return feed
+       
+       def populate_feed(self, feed, items, request):
+               if self.item_title_template:
+                       title_template = DjangoTemplate(self.item_title_template.code)
+               else:
+                       title_template = None
+               if self.item_description_template:
+                       description_template = DjangoTemplate(self.item_description_template.code)
+               else:
+                       description_template = None
+               
+               node = request.node
+               try:
+                       current_site = Site.objects.get_current()
+               except Site.DoesNotExist:
+                       current_site = RequestSite(request)
+               
+               for item in items:
+                       if title_template is not None:
+                               title = title_template.render(RequestContext(request, {'obj': item}))
+                       else:
+                               title = self.__get_dynamic_attr('item_title', item)
+                       if description_template is not None:
+                               description = description_template.render(RequestContext(request, {'obj': item}))
+                       else:
+                               description = self.__get_dynamic_attr('item_description', item)
+                       
+                       link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure())
+                       
+                       enc = None
+                       enc_url = self.__get_dynamic_attr('item_enclosure_url', item)
+                       if enc_url:
+                               enc = feedgenerator.Enclosure(
+                                       url = smart_unicode(add_domain(
+                                                       current_site.domain,
+                                                       enc_url,
+                                                       request.is_secure()
+                                       )),
+                                       length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)),
+                                       mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item))
+                               )
+                       author_name = self.__get_dynamic_attr('item_author_name', item)
+                       if author_name is not None:
+                               author_email = self.__get_dynamic_attr('item_author_email', item)
+                               author_link = self.__get_dynamic_attr('item_author_link', item)
+                       else:
+                               author_email = author_link = None
+                       
+                       pubdate = self.__get_dynamic_attr('item_pubdate', item)
+                       if pubdate and not pubdate.tzinfo:
+                               ltz = tzinfo.LocalTimezone(pubdate)
+                               pubdate = pubdate.replace(tzinfo=ltz)
+                       
+                       feed.add_item(
+                               title = title,
+                               link = link,
+                               description = description,
+                               unique_id = self.__get_dynamic_attr('item_guid', item, link),
+                               enclosure = enc,
+                               pubdate = pubdate,
+                               author_name = author_name,
+                               author_email = author_email,
+                               author_link = author_link,
+                               categories = self.__get_dynamic_attr('item_categories', item),
+                               item_copyright = self.__get_dynamic_attr('item_copyright', item),
+                               **self.item_extra_kwargs(item)
+                       )
+       
+       def __get_dynamic_attr(self, attname, obj, default=None):
+               try:
+                       attr = getattr(self, attname)
+               except AttributeError:
+                       return default
+               if callable(attr):
+                       # Check func_code.co_argcount rather than try/excepting the
+                       # function and catching the TypeError, because something inside
+                       # the function may raise the TypeError. This technique is more
+                       # accurate.
+                       if hasattr(attr, 'func_code'):
+                               argcount = attr.func_code.co_argcount
+                       else:
+                               argcount = attr.__call__.func_code.co_argcount
+                       if argcount == 2: # one argument is 'self'
+                               return attr(obj)
+                       else:
+                               return attr()
+               return attr
+       
+       def feed_extra_kwargs(self, obj):
+               """
+               Returns an extra keyword arguments dictionary that is used when
+               initializing the feed generator.
+               """
+               return {}
+       
+       def item_extra_kwargs(self, item):
+               """
+               Returns an extra keyword arguments dictionary that is used with
+               the `add_item` call of the feed generator.
+               """
+               return {}
+       
+       def item_title(self, item):
+               return escape(force_unicode(item))
+       
+       def item_description(self, item):
+               return force_unicode(item)
+       
+       class Meta:
+               abstract=True
 
 
 class Blog(Entity, Titled):
@@ -49,7 +327,7 @@ class BlogEntry(Entity, Titled):
 register_value_model(BlogEntry)
 
 
-class BlogView(MultiView, FeedMultiViewMixin):
+class BlogView(FeedView):
        ENTRY_PERMALINK_STYLE_CHOICES = (
                ('D', 'Year, month, and day'),
                ('M', 'Year and month'),
@@ -62,6 +340,7 @@ class BlogView(MultiView, FeedMultiViewMixin):
        
        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)
@@ -70,17 +349,13 @@ class BlogView(MultiView, FeedMultiViewMixin):
        entry_permalink_style = models.CharField(max_length=1, choices=ENTRY_PERMALINK_STYLE_CHOICES)
        entry_permalink_base = models.CharField(max_length=255, blank=False, default='entries')
        tag_permalink_base = models.CharField(max_length=255, blank=False, default='tags')
-       feed_suffix = models.CharField(max_length=255, blank=False, default=FeedMultiViewMixin.feed_suffix)
-       feeds_enabled = models.BooleanField()
-       list_var = 'entries'
+       
+       item_context_var = 'entries'
+       object_attr = 'blog'
        
        def __unicode__(self):
                return u'BlogView for %s' % self.blog.title
        
-       @property
-       def per_page(self):
-               return self.entries_per_page
-       
        def get_reverse_params(self, obj):
                if isinstance(obj, BlogEntry):
                        if obj.blog == self.blog:
@@ -92,9 +367,12 @@ class BlogView(MultiView, FeedMultiViewMixin):
                                                if self.entry_permalink_style == 'D':
                                                        kwargs.update({'day': str(obj.date.day).zfill(2)})
                                return self.entry_view, [], kwargs
-               elif isinstance(obj, Tag):
-                       if obj in self.get_tag_queryset():
-                               return 'entries_by_tag', [], {'tag_slugs': obj.slug}
+               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()]
+                       if slugs:
+                               return 'entries_by_tag', [], {'tag_slugs': "/".join(slugs)}
                elif isinstance(obj, (date, datetime)):
                        kwargs = {
                                'year': str(obj.year).zfill(4),
@@ -106,54 +384,41 @@ class BlogView(MultiView, FeedMultiViewMixin):
        
        @property
        def urlpatterns(self):
-               urlpatterns = patterns('',
-                       url(r'^', include(self.feed_patterns(self.get_all_entries, self.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(self.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(self.get_entries_by_tag, self.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, 'tag_archive')
+                               url((r'^%s$' % self.tag_permalink_base), self.tag_archive_view, name='tag_archive')
                        )
                
                if self.entry_archive_page:
                        if self.entry_permalink_style in 'DMY':
-                               urlpatterns += patterns('',
-                                       url(r'^(?P<year>\d{4})/', include(self.feed_patterns(self.get_entries_by_ymd, self.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(self.get_entries_by_ymd, self.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(self.get_entries_by_ymd, self.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('',
-                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[-\w]+)/?$', self.entry_view)
+                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[-\w]+)$', self.entry_view)
                        )
                elif self.entry_permalink_style == 'M':
                        urlpatterns += patterns('',
-                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[-\w]+)/?$', self.entry_view)
+                               url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[-\w]+)$', self.entry_view)
                        )
                elif self.entry_permalink_style == 'Y':
                        urlpatterns += patterns('',
-                               url(r'^(?P<year>\d{4})/(?P<slug>[-\w]+)/?$', self.entry_view)
+                               url(r'^(?P<year>\d{4})/(?P<slug>[-\w]+)$', self.entry_view)
                        )
                elif self.entry_permalink_style == 'B':
                        urlpatterns += patterns('',
-                               url((r'^(?:%s)/(?P<slug>[-\w]+)/?$' % self.entry_permalink_base), self.entry_view)
+                               url((r'^%s/(?P<slug>[-\w]+)$' % self.entry_permalink_base), self.entry_view)
                        )
                else:
                        urlpatterns = patterns('',
-                               url(r'^(?P<slug>[-\w]+)/?$', self.entry_view)
+                               url(r'^(?P<slug>[-\w]+)$', self.entry_view)
                        )
                return urlpatterns
        
@@ -233,30 +498,61 @@ class BlogView(MultiView, FeedMultiViewMixin):
                })
                return self.tag_archive_page.render_to_response(request, extra_context=context)
        
-       def add_item(self, feed, obj, kwargs=None):
-               title = loader.get_template("penfield/feeds/blog_entry/title.html")
-               description = loader.get_template("penfield/feeds/blog_entry/description.html")
-               defaults = {
-                       'title': title.render(Context({'entry': obj})),
-                       'description': description.render(Context({'entry': obj})),
-                       'author_name': obj.author.get_full_name(),
-                       'pubdate': obj.date
-               }
-               defaults.update(kwargs or {})
-               super(BlogView, self).add_item(feed, obj, defaults)
-       
-       def get_feed(self, feed_type, extra_context, kwargs=None):
-               tags = (extra_context or {}).get('tags', None)
-               title = self.blog.title
+       def feed_view(self, get_items_attr, reverse_name):
+               get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr)
                
-               if tags is not None:
-                       title += " - %s" % ', '.join([tag.name for tag in tags])
+               def inner(request, extra_context=None, *args, **kwargs):
+                       obj = self.get_object(request, *args, **kwargs)
+                       feed = self.get_feed(obj, request, reverse_name)
+                       items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs)
+                       self.populate_feed(feed, items, request)
+                       
+                       if 'tags' in extra_context:
+                               tags = extra_context['tags']
+                               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
+                       
+                       feed.feed['categories'] = [tag.name for tag in tags]
+                       
+                       response = HttpResponse(mimetype=feed.mime_type)
+                       feed.write(response, 'utf-8')
+                       return response
                
-               defaults = {
-                       'title': title
-               }
-               defaults.update(kwargs or {})
-               return super(BlogView, self).get_feed(feed_type, extra_context, defaults)
+               return inner
+       
+       def process_page_items(self, request, items):
+               if self.entries_per_page:
+                       page_num = request.GET.get('page', 1)
+                       paginator, paginated_page, items = paginate(items, self.entries_per_page, page_num)
+                       item_context = {
+                               'paginator': paginator,
+                               'paginated_page': paginated_page,
+                               self.item_context_var: items
+                       }
+               else:
+                       item_context = {
+                               self.item_context_var: items
+                       }
+               return items, item_context
+       
+       def title(self, obj):
+               return obj.title
+       
+       def item_title(self, item):
+               return item.title
+       
+       def item_description(self, item):
+               return item.content
+       
+       def item_author_name(self, item):
+               return item.author.get_full_name()
+       
+       def item_pubdate(self, item):
+               return item.date
+       
+       def item_categories(self, item):
+               return [tag.name for tag in item.tags.all()]
 
 
 class Newsletter(Entity, Titled):
@@ -301,7 +597,7 @@ class NewsletterIssue(Entity, Titled):
 register_value_model(NewsletterIssue)
 
 
-class NewsletterView(MultiView, FeedMultiViewMixin):
+class NewsletterView(FeedView):
        ARTICLE_PERMALINK_STYLE_CHOICES = (
                ('D', 'Year, month, and day'),
                ('M', 'Year and month'),
@@ -321,12 +617,11 @@ class NewsletterView(MultiView, FeedMultiViewMixin):
        article_permalink_base = models.CharField(max_length=255, blank=False, default='articles')
        issue_permalink_base = models.CharField(max_length=255, blank=False, default='issues')
        
-       feed_suffix = models.CharField(max_length=255, blank=False, default=FeedMultiViewMixin.feed_suffix)
-       feeds_enabled = models.BooleanField()
-       list_var = 'articles'
+       item_context_var = 'articles'
+       object_attr = 'newsletter'
        
        def __unicode__(self):
-               return self.newsletter.__unicode__()
+               return "NewsletterView for %s" % self.newsletter.__unicode__()
        
        def get_reverse_params(self, obj):
                if isinstance(obj, NewsletterArticle):
@@ -353,46 +648,39 @@ class NewsletterView(MultiView, FeedMultiViewMixin):
        
        @property
        def urlpatterns(self):
-               urlpatterns = patterns('',
-                       url(r'^', include(self.feed_patterns(self.get_all_articles, self.index_page, 'index'))),
-                       url(r'^(?:%s)/(?P<numbering>.+)/' % self.issue_permalink_base, include(self.feed_patterns(self.get_articles_by_issue, self.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('',
-                               url(r'^(?:%s)/$' % self.issue_permalink_base, self.issue_archive_view, 'issue_archive')
+                               url(r'^%s$' % self.issue_permalink_base, self.issue_archive_view, 'issue_archive')
                        )
                if self.article_archive_page:
                        urlpatterns += patterns('',
-                               url(r'^(?:%s)/' % self.article_permalink_base, include(self.feed_patterns(self.get_all_articles, self.article_archive_page, 'articles')))
+                               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(self.get_articles_by_ymd, self.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(self.get_articles_by_ymd, self.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(self.get_articles_by_ymd, self.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('',
-                               url(r'^(?:%s)/(?P<year>\d{4})/(?P<slug>[\w-]+)/$' % self.article_permalink_base, self.article_view)
+                               url(r'^%s/(?P<year>\d{4})/(?P<slug>[\w-]+)$' % self.article_permalink_base, self.article_view)
                        )
                elif self.article_permalink_style == 'M':
                        urlpatterns += patterns('',
-                               url(r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[\w-]+)/$' % self.article_permalink_base, self.article_view)
+                               url(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})/(?P<slug>[\w-]+)$' % self.article_permalink_base, self.article_view)
                        )
                elif self.article_permalink_style == 'D':
                        urlpatterns += patterns('',
-                               url(r'^(?:%s)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)/$' % self.article_permalink_base, self.article_view)
+                               url(r'^%s/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)$' % self.article_permalink_base, self.article_view)
                        )
                else:   
                        urlpatterns += patterns('',
-                               url(r'^(?:%s)/(?P<slug>[-\w]+)/?$' % self.article_permalink_base, self.article_view)
+                               url(r'^%s/(?P<slug>[-\w]+)$' % self.article_permalink_base, self.article_view)
                        )
                
                return urlpatterns
@@ -453,24 +741,26 @@ class NewsletterView(MultiView, FeedMultiViewMixin):
                })
                return self.issue_archive_page.render_to_response(request, extra_context=context)
        
-       def add_item(self, feed, obj, kwargs=None):
-               title = loader.get_template("penfield/feeds/newsletter_article/title.html")
-               description = loader.get_template("penfield/feeds/newsletter_article/description.html")
-               defaults = {
-                       'title': title.render(Context({'article': obj})),
-                       'author_name': ', '.join([author.get_full_name() for author in obj.authors.all()]),
-                       'pubdate': obj.date,
-                       'description': description.render(Context({'article': obj})),
-                       'categories': [tag.name for tag in obj.tags.all()]
-               }
-               defaults.update(kwargs or {})
-               super(NewsletterView, self).add_item(feed, obj, defaults)
+       def title(self, obj):
+               return obj.title
        
-       def get_feed(self, feed_type, extra_context, kwargs=None):
-               title = self.newsletter.title
-               
-               defaults = {
-                       'title': title
-               }
-               defaults.update(kwargs or {})
-               return super(NewsletterView, self).get_feed(feed_type, extra_context, defaults)
+       def item_title(self, item):
+               return item.title
+       
+       def item_description(self, item):
+               return item.full_text
+       
+       def item_author_name(self, item):
+               authors = list(item.authors.all())
+               if len(authors) > 1:
+                       return "%s and %s" % (", ".join([author.get_full_name() for author in authors[:-1]]), authors[-1].get_full_name())
+               elif authors:
+                       return authors[0].get_full_name()
+               else:
+                       return ''
+       
+       def item_pubdate(self, item):
+               return item.date
+       
+       def item_categories(self, item):
+               return [tag.name for tag in item.tags.all()]
\ No newline at end of file
diff --git a/contrib/penfield/templates/penfield/feeds/blog_entry/description.html b/contrib/penfield/templates/penfield/feeds/blog_entry/description.html
deleted file mode 100644 (file)
index 61060d5..0000000
+++ /dev/null
@@ -1 +0,0 @@
-{{ entry.content }}
\ No newline at end of file
diff --git a/contrib/penfield/templates/penfield/feeds/blog_entry/title.html b/contrib/penfield/templates/penfield/feeds/blog_entry/title.html
deleted file mode 100644 (file)
index f7167dd..0000000
+++ /dev/null
@@ -1 +0,0 @@
-{{ entry.title }}
\ No newline at end of file
diff --git a/contrib/penfield/templates/penfield/feeds/newsletter_article/description.html b/contrib/penfield/templates/penfield/feeds/newsletter_article/description.html
deleted file mode 100644 (file)
index 78e19ce..0000000
+++ /dev/null
@@ -1 +0,0 @@
-{{ article.full_text }}
\ No newline at end of file
diff --git a/contrib/penfield/templates/penfield/feeds/newsletter_article/title.html b/contrib/penfield/templates/penfield/feeds/newsletter_article/title.html
deleted file mode 100644 (file)
index 1f96b1f..0000000
+++ /dev/null
@@ -1 +0,0 @@
-{{ article.title }}
\ No newline at end of file
diff --git a/contrib/penfield/utils.py b/contrib/penfield/utils.py
deleted file mode 100644 (file)
index 43c7c91..0000000
+++ /dev/null
@@ -1,102 +0,0 @@
-from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed
-from django.conf.urls.defaults import url, patterns
-from django.contrib.sites.models import Site
-from django.core.urlresolvers import reverse
-from django.http import HttpResponse
-from philo.utils import paginate
-
-
-class FeedMultiViewMixin(object):
-       """
-       This mixin provides common methods for adding feeds to multiviews. In order to use this mixin,
-       the multiview must define feed_title (probably as properties that return values
-       on related objects.) feed_description may also be defined; it defaults to an empty string.
-       """
-       feed_suffix = 'feed'
-       feeds_enabled = True
-       atom_feed = Atom1Feed
-       rss_feed = Rss201rev2Feed
-       feed_title = None
-       feed_description = None
-       list_var = 'objects'
-       
-       def page_view(self, func, page):
-               """
-               Wraps an object-fetching function and renders the results as a page.
-               """
-               def inner(request, extra_context=None, **kwargs):
-                       objects, extra_context = func(request=request, extra_context=extra_context, **kwargs)
-
-                       context = self.get_context()
-                       context.update(extra_context or {})
-
-                       if 'page' in kwargs or 'page' in request.GET or (hasattr(self, 'per_page') and self.per_page):
-                               page_num = kwargs.get('page', request.GET.get('page', 1))
-                               paginator, paginated_page, objects = paginate(objects, self.per_page, page_num)
-                               context.update({'paginator': paginator, 'paginated_page': paginated_page, self.list_var: objects})
-                       else:
-                               context.update({self.list_var: objects})
-
-                       return page.render_to_response(request, extra_context=context)
-
-               return inner
-       
-       def feed_view(self, func, reverse_name):
-               """
-               Wraps an object-fetching function and renders the results as a rss or atom feed.
-               """
-               def inner(request, extra_context=None, **kwargs):
-                       objects, extra_context = func(request=request, extra_context=extra_context, **kwargs)
-       
-                       if 'HTTP_ACCEPT' in request.META and 'rss' in request.META['HTTP_ACCEPT'] and 'atom' not in request.META['HTTP_ACCEPT']:
-                               feed_type = 'rss'
-                       else:
-                               feed_type = 'atom'
-                       
-                       current_site = Site.objects.get_current()
-                       #Could this be done with request.path instead somehow?
-                       feed_kwargs = {
-                               'link': 'http://%s/%s/%s/' % (current_site.domain, request.node.get_absolute_url().strip('/'), reverse(reverse_name, urlconf=self, kwargs=kwargs).strip('/'))
-                       }
-                       feed = self.get_feed(feed_type, extra_context, feed_kwargs)
-                       
-                       for obj in objects:
-                               kwargs = {
-                                       'link': 'http://%s/%s/%s/' % (current_site.domain, request.node.get_absolute_url().strip('/'), self.get_subpath(obj).strip('/'))
-                               }
-                               self.add_item(feed, obj, kwargs=kwargs)
-       
-                       response = HttpResponse(mimetype=feed.mime_type)
-                       feed.write(response, 'utf-8')
-                       return response
-
-               return inner
-       
-       def get_feed(self, feed_type, extra_context, kwargs=None):
-               defaults = {
-                       'description': ''
-               }
-               defaults.update(kwargs or {})
-               
-               if feed_type == 'rss':
-                       return self.rss_feed(**defaults)
-               
-               if 'description' in defaults and defaults['description'] and 'subtitle' not in defaults:
-                       defaults['subtitle'] = defaults['description']
-               
-               return self.atom_feed(**defaults)
-       
-       def feed_patterns(self, object_fetcher, page, base_name):
-               urlpatterns = patterns('',
-                       url(r'^$', self.page_view(object_fetcher, page), name=base_name)
-               )
-               if self.feeds_enabled:
-                       feed_name = '%s_feed' % base_name
-                       urlpatterns = patterns('',
-                               url(r'^%s/$' % self.feed_suffix, self.feed_view(object_fetcher, feed_name), name=feed_name),
-                       ) + urlpatterns
-               return urlpatterns
-       
-       def add_item(self, feed, obj, kwargs=None):
-               defaults = kwargs or {}
-               feed.add_item(**defaults)
\ No newline at end of file
index fb6cffe..93d21e5 100644 (file)
@@ -12,6 +12,7 @@ class NavigationItemInline(admin.StackedInline):
        model = NavigationItem
        extra = 1
        sortable_field_name = 'order'
+       related_lookup_fields = {'fk': raw_id_fields}
 
 
 class NavigationItemChildInline(NavigationItemInline):
@@ -92,12 +93,14 @@ class NavigationItemAdmin(TreeEntityAdmin):
                })
        )
        raw_id_fields = NAVIGATION_RAW_ID_FIELDS
+       related_lookup_fields = {'fk': raw_id_fields}
        inlines = [NavigationItemChildInline] + TreeEntityAdmin.inlines
 
 
 class NavigationAdmin(EntityAdmin):
        inlines = [NavigationNavigationItemInline]
        raw_id_fields = ['node']
+       related_lookup_fields = {'fk': raw_id_fields}
 
 
 admin.site.unregister(Node)
diff --git a/contrib/shipherd/migrations/0001_initial.py b/contrib/shipherd/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..c33d64a
--- /dev/null
@@ -0,0 +1,108 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding model 'Navigation'
+        db.create_table('shipherd_navigation', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('node', self.gf('django.db.models.fields.related.ForeignKey')(related_name='navigation_set', to=orm['philo.Node'])),
+            ('key', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('depth', self.gf('django.db.models.fields.PositiveSmallIntegerField')(default=3)),
+        ))
+        db.send_create_signal('shipherd', ['Navigation'])
+
+        # Adding unique constraint on 'Navigation', fields ['node', 'key']
+        db.create_unique('shipherd_navigation', ['node_id', 'key'])
+
+        # Adding model 'NavigationItem'
+        db.create_table('shipherd_navigationitem', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('parent', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='children', null=True, to=orm['shipherd.NavigationItem'])),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255, db_index=True)),
+            ('lft', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+            ('rght', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+            ('tree_id', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+            ('level', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
+            ('navigation', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='roots', null=True, to=orm['shipherd.Navigation'])),
+            ('text', self.gf('django.db.models.fields.CharField')(max_length=50)),
+            ('target_node', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='navigation_items', null=True, to=orm['philo.Node'])),
+            ('url_or_subpath', self.gf('django.db.models.fields.CharField')(max_length=200, blank=True)),
+            ('reversing_parameters', self.gf('philo.models.fields.JSONField')(blank=True)),
+            ('order', self.gf('django.db.models.fields.PositiveSmallIntegerField')(default=0)),
+        ))
+        db.send_create_signal('shipherd', ['NavigationItem'])
+
+
+    def backwards(self, orm):
+        
+        # Deleting model 'Navigation'
+        db.delete_table('shipherd_navigation')
+
+        # Removing unique constraint on 'Navigation', fields ['node', 'key']
+        db.delete_unique('shipherd_navigation', ['node_id', 'key'])
+
+        # Deleting model 'NavigationItem'
+        db.delete_table('shipherd_navigationitem')
+
+
+    models = {
+        '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'})
+        },
+        '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', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            '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', [], {'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', [], {})
+        },
+        'shipherd.navigation': {
+            'Meta': {'unique_together': "(('node', 'key'),)", 'object_name': 'Navigation'},
+            'depth': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '3'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'node': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'navigation_set'", 'to': "orm['philo.Node']"})
+        },
+        'shipherd.navigationitem': {
+            'Meta': {'object_name': 'NavigationItem'},
+            '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'}),
+            'navigation': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'roots'", 'null': 'True', 'to': "orm['shipherd.Navigation']"}),
+            'order': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['shipherd.NavigationItem']"}),
+            'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'navigation_items'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'text': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+        }
+    }
+
+    complete_apps = ['shipherd']
diff --git a/contrib/shipherd/migrations/0002_auto.py b/contrib/shipherd/migrations/0002_auto.py
new file mode 100644 (file)
index 0000000..00d095f
--- /dev/null
@@ -0,0 +1,75 @@
+# 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 index on 'Navigation', fields ['key']
+        db.create_index('shipherd_navigation', ['key'])
+
+
+    def backwards(self, orm):
+        
+        # Removing index on 'Navigation', fields ['key']
+        db.delete_index('shipherd_navigation', ['key'])
+
+
+    models = {
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        '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', [], {})
+        },
+        'shipherd.navigation': {
+            'Meta': {'unique_together': "(('node', 'key'),)", 'object_name': 'Navigation'},
+            'depth': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '3'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'node': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'navigation_set'", 'to': "orm['philo.Node']"})
+        },
+        'shipherd.navigationitem': {
+            'Meta': {'object_name': 'NavigationItem'},
+            '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'}),
+            'navigation': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'roots'", 'null': 'True', 'to': "orm['shipherd.Navigation']"}),
+            'order': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['shipherd.NavigationItem']"}),
+            'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'shipherd_navigationitem_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'text': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+        }
+    }
+
+    complete_apps = ['shipherd']
diff --git a/contrib/shipherd/migrations/__init__.py b/contrib/shipherd/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
index dee16e9..654f5f8 100644 (file)
@@ -4,7 +4,7 @@ from django.core.urlresolvers import NoReverseMatch
 from django.core.validators import RegexValidator, MinValueValidator
 from django.db import models
 from django.forms.models import model_to_dict
-from philo.models import TreeEntity, JSONField, Node, TreeManager, Entity
+from philo.models import TreeEntity, Node, TreeManager, Entity, TargetURLModel
 from philo.validators import RedirectValidator
 from UserDict import DictMixin
 
@@ -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(navigation_items__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)
@@ -169,7 +170,7 @@ class Navigation(Entity):
        objects = NavigationManager()
        
        node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
-       key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.")
+       key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
        depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
        
        def __init__(self, *args, **kwargs):
@@ -204,16 +205,12 @@ class NavigationItemManager(TreeManager):
                return NavigationCacheQuerySet(self.model, using=self._db)
 
 
-class NavigationItem(TreeEntity):
+class NavigationItem(TreeEntity, TargetURLModel):
        objects = NavigationItemManager()
        
        navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
        text = models.CharField(max_length=50)
        
-       target_node = models.ForeignKey(Node, blank=True, null=True, related_name='navigation_items', help_text="Point to this node's url.")
-       url_or_subpath = models.CharField(max_length=200, validators=[RedirectValidator()], blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.")
-       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.")
-       
        order = models.PositiveSmallIntegerField(default=0)
        
        def __init__(self, *args, **kwargs):
@@ -225,41 +222,10 @@ class NavigationItem(TreeEntity):
                return self.get_path(field='text', pathsep=u' › ')
        
        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.")
-               
-               if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
-                       raise ValidationError("Reversing parameters require a view name and a target node.")
-               
-               try:
-                       self.get_target_url()
-               except NoReverseMatch, e:
-                       raise ValidationError(e.message)
-               
+               super(NavigationItem, self).clean()
                if bool(self.parent) == bool(self.navigation):
                        raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
        
-       def get_target_url(self):
-               node = self.target_node
-               if node is not None and node.accepts_subpath and self.url_or_subpath:
-                       if self.reversing_parameters is not None:
-                               view_name = self.url_or_subpath
-                               params = self.reversing_parameters
-                               args = isinstance(params, list) and params or None
-                               kwargs = isinstance(params, dict) and params or None
-                               return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
-                       else:
-                               subpath = self.url_or_subpath
-                               while subpath and subpath[0] == '/':
-                                       subpath = subpath[1:]
-                               return '%s%s' % (node.get_absolute_url(), subpath)
-               elif node is not None:
-                       return node.get_absolute_url()
-               else:
-                       return self.url_or_subpath
-       target_url = property(get_target_url)
-       
        def is_active(self, request):
                if self.target_url == request.path:
                        # Handle the `default` case where the target_url and requested path
index 97475fd..fa4ec3e 100644 (file)
@@ -83,8 +83,17 @@ def recursenavigation(parser, token):
 
 
 @register.filter
-def has_navigation(node): # optional arg for a key?
-       return bool(node.navigation)
+def has_navigation(node, key=None):
+       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 2f40da7..f63cdb1 100644 (file)
@@ -12,167 +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('',
-                       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')
+               return patterns('',
+                       url(r'^login$', self.login, name='login'),
+                       url(r'^logout$', self.logout, name='logout'),
                )
-               
-               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):
-               current_site = Site.objects.get_current()
-               token = token_generator.make_token(user, *(token_args or []))
-               kwargs = {
-                       'uidb36': int_to_base36(user.id),
-                       'token': token
-               }
-               kwargs.update(reverse_kwargs or {})
-               return 'http://%s%s' % (current_site.domain, self.reverse(confirmation_view, kwargs=kwargs, node=node))
-       
-       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())
-               
-               context = self.get_context()
-               context.update(extra_context or {})
+               self.set_requirement_redirect(request)
                
-               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
@@ -189,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 {})
@@ -225,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 {})
@@ -245,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 {})
@@ -260,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 {})
@@ -308,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)
@@ -322,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):
@@ -346,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)
                
@@ -382,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):
                """
@@ -417,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 1e4b9d9..f53083d 100644 (file)
@@ -5,12 +5,12 @@ MIDDLEWARE_NOT_CONFIGURED = ImproperlyConfigured("""Philo requires the RequestNo
 
 
 class ViewDoesNotProvideSubpaths(Exception):
-       """ Raised by get_subpath when the View does not provide subpaths (the default). """
+       """ Raised by View.reverse when the View does not provide subpaths (the default). """
        silent_variable_failure = True
 
 
 class ViewCanNotProvideSubpath(Exception):
-       """ Raised by get_subpath when the View can not provide a subpath for the supplied object. """
+       """ Raised by View.reverse when the View can not provide a subpath for the supplied arguments. """
        silent_variable_failure = True
 
 
index 9e60966..7e0b0d9 100644 (file)
@@ -1 +1,2 @@
-from philo.forms.fields import *
\ No newline at end of file
+from philo.forms.fields import *
+from philo.forms.entities import *
\ No newline at end of file
similarity index 71%
rename from contrib/cowell/forms.py
rename to forms/entities.py
index c4b573e..b6259a3 100644 (file)
@@ -43,9 +43,15 @@ class EntityFormBase(ModelForm):
 _old_metaclass_new = ModelFormMetaclass.__new__
 
 def _new_metaclass_new(cls, name, bases, attrs):
+       formfield_callback = attrs.get('formfield_callback', lambda f, **kwargs: f.formfield(**kwargs))
        new_class = _old_metaclass_new(cls, name, bases, attrs)
-       if issubclass(new_class, EntityFormBase) and new_class._meta.model:
-               new_class.base_fields.update(proxy_fields_for_entity_model(new_class._meta.model, new_class._meta.fields, new_class._meta.exclude, new_class._meta.widgets)) # don't pass in formfield_callback
+       opts = new_class._meta
+       if issubclass(new_class, EntityFormBase) and opts.model:
+               # "override" proxy fields with declared fields by excluding them if there's a name conflict.
+               exclude = (list(opts.exclude or []) + new_class.declared_fields.keys()) or None
+               proxy_fields = proxy_fields_for_entity_model(opts.model, opts.fields, exclude, opts.widgets, formfield_callback) # don't pass in formfield_callback
+               new_class.proxy_fields = proxy_fields
+               new_class.base_fields.update(proxy_fields)
        return new_class
 
 ModelFormMetaclass.__new__ = staticmethod(_new_metaclass_new)
@@ -84,30 +90,10 @@ class EntityForm(EntityFormBase): # Would inherit from ModelForm directly if it
                                continue
                        if self._meta.exclude and f.name in self._meta.exclude:
                                continue
-                       setattr(instance, f.attname, cleaned_data[f.name])
+                       setattr(instance, f.attname, f.get_storage_value(cleaned_data[f.name]))
                
                if commit:
                        instance.save()
                        self.save_m2m()
                
-               return instance
-
-       
-       def apply_data(self, cleaned_data):
-               self.value = cleaned_data.get('value', None)
-       
-       def apply_data(self, cleaned_data):
-               if 'value' in cleaned_data and cleaned_data['value'] is not None:
-                       self.value = cleaned_data['value']
-               else:
-                       self.content_type = cleaned_data.get('content_type', None)
-                       # If there is no value set in the cleaned data, clear the stored value.
-                       self.object_id = None
-       
-       def apply_data(self, cleaned_data):
-               if 'value' in cleaned_data and cleaned_data['value'] is not None:
-                       self.value = cleaned_data['value']
-               else:
-                       self.content_type = cleaned_data.get('content_type', None)
-                       # If there is no value set in the cleaned data, clear the stored value.
-                       self.value = []
\ No newline at end of file
+               return instance
\ No newline at end of file
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 ad660ec..c0b1e9e 100644 (file)
@@ -15,12 +15,24 @@ class LazyNode(object):
                        except Site.DoesNotExist:
                                current_site = None
                        
+                       path = request._cached_node_path
+                       trailing_slash = False
+                       if path[-1] == '/':
+                               trailing_slash = True
+                       
                        try:
-                               node, subpath = Node.objects.get_with_path(request._cached_node_path, root=getattr(current_site, 'root_node', None), absolute_result=False)
+                               node, subpath = Node.objects.get_with_path(path, root=getattr(current_site, 'root_node', None), absolute_result=False)
                        except Node.DoesNotExist:
                                node = None
                        
                        if node:
+                               if subpath is None:
+                                       subpath = ""
+                               subpath = "/" + subpath
+                               
+                               if trailing_slash and subpath[-1] != "/":
+                                       subpath += "/"
+                               
                                node.subpath = subpath
                        
                        request._found_node = node
diff --git a/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py b/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py
new file mode 100644 (file)
index 0000000..dcacc79
--- /dev/null
@@ -0,0 +1,151 @@
+# 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 'Redirect.target_node'
+        db.add_column('philo_redirect', 'target_node', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='philo_redirect_related', null=True, to=orm['philo.Node']), keep_default=False)
+
+        # Adding field 'Redirect.url_or_subpath'
+        db.add_column('philo_redirect', 'url_or_subpath', self.gf('django.db.models.fields.CharField')(default='', max_length=200, blank=True), keep_default=False)
+
+        # Adding field 'Redirect.reversing_parameters'
+        db.add_column('philo_redirect', 'reversing_parameters', self.gf('philo.models.fields.JSONField')(default='null', blank=True), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'Redirect.target_node'
+        db.delete_column('philo_redirect', 'target_node_id')
+
+        # Deleting field 'Redirect.url_or_subpath'
+        db.delete_column('philo_redirect', 'url_or_subpath')
+
+        # Deleting field 'Redirect.reversing_parameters'
+        db.delete_column('philo_redirect', 'reversing_parameters_json')
+
+
+    models = {
+        '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'})
+        },
+        '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', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            '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', [], {'null': 'True', 'blank': 'True'})
+        },
+        'philo.collection': {
+            'Meta': {'object_name': 'Collection'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.collectionmember': {
+            'Meta': {'object_name': 'CollectionMember'},
+            'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.contentlet': {
+            'Meta': {'object_name': 'Contentlet'},
+            'content': ('philo.models.fields.TemplateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"})
+        },
+        'philo.contentreference': {
+            'Meta': {'object_name': 'ContentReference'},
+            'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            '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': '255'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"})
+        },
+        'philo.file': {
+            'Meta': {'object_name': 'File'},
+            'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.foreignkeyvalue': {
+            'Meta': {'object_name': 'ForeignKeyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'philo.jsonvalue': {
+            'Meta': {'object_name': 'JSONValue'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'value': ('philo.models.fields.JSONField', [], {'default': "'null'"})
+        },
+        'philo.manytomanyvalue': {
+            'Meta': {'object_name': 'ManyToManyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", '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.redirect': {
+            'Meta': {'object_name': 'Redirect'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+            'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}),
+            'target': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+        },
+        '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 = ['philo']
diff --git a/migrations/0011_move_target_url.py b/migrations/0011_move_target_url.py
new file mode 100644 (file)
index 0000000..4fd4304
--- /dev/null
@@ -0,0 +1,141 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import DataMigration
+from django.db import models
+
+class Migration(DataMigration):
+
+       def forwards(self, orm):
+               "Write your forwards methods here."
+               for redirect in orm.Redirect.objects.all():
+                       redirect.url_or_subpath = redirect.target
+                       redirect.save()
+
+
+       def backwards(self, orm):
+               "This will cause data loss and is not advisable. Blurg!"
+               for redirect in orm.Redirect.objects.all():
+                       redirect.target = redirect.url_or_subpath
+                       redirect.save()
+
+
+       models = {
+               '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'})
+               },
+               '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', [], {}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+                       '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', [], {'null': 'True', 'blank': 'True'})
+               },
+               'philo.collection': {
+                       'Meta': {'object_name': 'Collection'},
+                       'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+               },
+               'philo.collectionmember': {
+                       'Meta': {'object_name': 'CollectionMember'},
+                       'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+                       'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+                       'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+               },
+               'philo.contentlet': {
+                       'Meta': {'object_name': 'Contentlet'},
+                       'content': ('philo.models.fields.TemplateField', [], {}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+                       'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"})
+               },
+               'philo.contentreference': {
+                       'Meta': {'object_name': 'ContentReference'},
+                       'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+                       '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': '255'}),
+                       'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"})
+               },
+               'philo.file': {
+                       'Meta': {'object_name': 'File'},
+                       'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+               },
+               'philo.foreignkeyvalue': {
+                       'Meta': {'object_name': 'ForeignKeyValue'},
+                       'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'})
+               },
+               'philo.jsonvalue': {
+                       'Meta': {'object_name': 'JSONValue'},
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'value': ('philo.models.fields.JSONField', [], {'default': "'null'"})
+               },
+               'philo.manytomanyvalue': {
+                       'Meta': {'object_name': 'ManyToManyValue'},
+                       'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", '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.redirect': {
+                       'Meta': {'object_name': 'Redirect'},
+                       'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+                       'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+                       'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}),
+                       'target': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+                       'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+                       'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+               },
+               '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 = ['philo']
diff --git a/migrations/0012_auto__del_field_redirect_target.py b/migrations/0012_auto__del_field_redirect_target.py
new file mode 100644 (file)
index 0000000..a536ebb
--- /dev/null
@@ -0,0 +1,138 @@
+# 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):
+        
+        # Deleting field 'Redirect.target'
+        db.delete_column('philo_redirect', 'target')
+
+
+    def backwards(self, orm):
+        
+        # Adding field 'Redirect.target'
+        db.add_column('philo_redirect', 'target', self.gf('django.db.models.fields.CharField')(default='', max_length=200), keep_default=False)
+
+
+    models = {
+        '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'})
+        },
+        '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', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            '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', [], {'null': 'True', 'blank': 'True'})
+        },
+        'philo.collection': {
+            'Meta': {'object_name': 'Collection'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.collectionmember': {
+            'Meta': {'object_name': 'CollectionMember'},
+            'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.contentlet': {
+            'Meta': {'object_name': 'Contentlet'},
+            'content': ('philo.models.fields.TemplateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"})
+        },
+        'philo.contentreference': {
+            'Meta': {'object_name': 'ContentReference'},
+            'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            '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': '255'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"})
+        },
+        'philo.file': {
+            'Meta': {'object_name': 'File'},
+            'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.foreignkeyvalue': {
+            'Meta': {'object_name': 'ForeignKeyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'philo.jsonvalue': {
+            'Meta': {'object_name': 'JSONValue'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'value': ('philo.models.fields.JSONField', [], {'default': "'null'"})
+        },
+        'philo.manytomanyvalue': {
+            'Meta': {'object_name': 'ManyToManyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", '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.redirect': {
+            'Meta': {'object_name': 'Redirect'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+            'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}),
+            'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+        },
+        '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 = ['philo']
diff --git a/migrations/0013_auto.py b/migrations/0013_auto.py
new file mode 100644 (file)
index 0000000..c8f7799
--- /dev/null
@@ -0,0 +1,150 @@
+# 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 index on 'Attribute', fields ['entity_object_id']
+        db.create_index('philo_attribute', ['entity_object_id'])
+
+        # Adding index on 'Attribute', fields ['value_object_id']
+        db.create_index('philo_attribute', ['value_object_id'])
+
+        # Adding index on 'Attribute', fields ['key']
+        db.create_index('philo_attribute', ['key'])
+
+
+    def backwards(self, orm):
+        
+        # Removing index on 'Attribute', fields ['entity_object_id']
+        db.delete_index('philo_attribute', ['entity_object_id'])
+
+        # Removing index on 'Attribute', fields ['value_object_id']
+        db.delete_index('philo_attribute', ['value_object_id'])
+
+        # Removing index on 'Attribute', fields ['key']
+        db.delete_index('philo_attribute', ['key'])
+
+
+    models = {
+        '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'})
+        },
+        '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.collection': {
+            'Meta': {'object_name': 'Collection'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.collectionmember': {
+            'Meta': {'object_name': 'CollectionMember'},
+            'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.contentlet': {
+            'Meta': {'object_name': 'Contentlet'},
+            'content': ('philo.models.fields.TemplateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"})
+        },
+        'philo.contentreference': {
+            'Meta': {'object_name': 'ContentReference'},
+            'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            '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': '255'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"})
+        },
+        'philo.file': {
+            'Meta': {'object_name': 'File'},
+            'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.foreignkeyvalue': {
+            'Meta': {'object_name': 'ForeignKeyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'philo.jsonvalue': {
+            'Meta': {'object_name': 'JSONValue'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'value': ('philo.models.fields.JSONField', [], {'default': "'null'"})
+        },
+        'philo.manytomanyvalue': {
+            'Meta': {'object_name': 'ManyToManyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", '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.redirect': {
+            'Meta': {'object_name': 'Redirect'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+            'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}),
+            'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+        },
+        '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 = ['philo']
diff --git a/migrations/0014_auto.py b/migrations/0014_auto.py
new file mode 100644 (file)
index 0000000..6375c69
--- /dev/null
@@ -0,0 +1,156 @@
+# 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 index on 'Contentlet', fields ['name']
+        db.create_index('philo_contentlet', ['name'])
+
+        # Adding index on 'JSONValue', fields ['value']
+        db.create_index('philo_jsonvalue', ['value_json'])
+
+        # Adding index on 'ForeignKeyValue', fields ['object_id']
+        db.create_index('philo_foreignkeyvalue', ['object_id'])
+
+        # Adding index on 'ContentReference', fields ['name']
+        db.create_index('philo_contentreference', ['name'])
+
+
+    def backwards(self, orm):
+        
+        # Removing index on 'ContentReference', fields ['name']
+        db.delete_index('philo_contentreference', ['name'])
+
+        # Removing index on 'ForeignKeyValue', fields ['object_id']
+        db.delete_index('philo_foreignkeyvalue', ['object_id'])
+
+        # Removing index on 'JSONValue', fields ['value']
+        db.delete_index('philo_jsonvalue', ['value_json'])
+
+        # Removing index on 'Contentlet', fields ['name']
+        db.delete_index('philo_contentlet', ['name'])
+
+
+    models = {
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        '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.collection': {
+            'Meta': {'object_name': 'Collection'},
+            'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.collectionmember': {
+            'Meta': {'object_name': 'CollectionMember'},
+            'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.contentlet': {
+            'Meta': {'object_name': 'Contentlet'},
+            'content': ('philo.models.fields.TemplateField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"})
+        },
+        'philo.contentreference': {
+            'Meta': {'object_name': 'ContentReference'},
+            'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            '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': '255', 'db_index': 'True'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"})
+        },
+        'philo.file': {
+            'Meta': {'object_name': 'File'},
+            'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.foreignkeyvalue': {
+            'Meta': {'object_name': 'ForeignKeyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'philo.jsonvalue': {
+            'Meta': {'object_name': 'JSONValue'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'})
+        },
+        'philo.manytomanyvalue': {
+            'Meta': {'object_name': 'ManyToManyValue'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", '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.redirect': {
+            'Meta': {'object_name': 'Redirect'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}),
+            'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}),
+            'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+        },
+        'philo.tag': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+        },
+        'philo.template': {
+            'Meta': {'object_name': 'Template'},
+            'code': ('philo.models.fields.TemplateField', [], {}),
+            'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+        }
+    }
+
+    complete_apps = ['philo']
index 76d7812..523f789 100644 (file)
@@ -2,7 +2,6 @@ from philo.models.base import *
 from philo.models.collections import *
 from philo.models.nodes import *
 from philo.models.pages import *
-from philo.models.fields import *
 from django.contrib.auth.models import User, Group
 from django.contrib.sites.models import Site
 
index 290e8b8..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
@@ -45,6 +45,9 @@ def register_value_model(model):
        value_content_type_limiter.register_class(model)
 
 
+register_value_model(Tag)
+
+
 def unregister_value_model(model):
        value_content_type_limiter.unregister_class(model)
 
@@ -52,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
        
@@ -78,10 +77,10 @@ attribute_value_limiter = ContentTypeSubclassLimiter(AttributeValue)
 
 
 class JSONValue(AttributeValue):
-       value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null')
+       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}
@@ -101,7 +100,7 @@ class JSONValue(AttributeValue):
 
 class ForeignKeyValue(AttributeValue):
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
-       object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True)
+       object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
        value = generic.GenericForeignKey()
        
        def value_formfields(self):
@@ -217,14 +216,14 @@ class ManyToManyValue(AttributeValue):
 
 class Attribute(models.Model):
        entity_content_type = models.ForeignKey(ContentType, related_name='attribute_entity_set', verbose_name='Entity type')
-       entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID')
+       entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID', db_index=True)
        entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id')
        
        value_content_type = models.ForeignKey(ContentType, related_name='attribute_value_set', limit_choices_to=attribute_value_limiter, verbose_name='Value type', null=True, blank=True)
-       value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True)
+       value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
        value = generic.GenericForeignKey('value_content_type', 'value_object_id')
        
-       key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.")
+       key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
        
        def __unicode__(self):
                return u'"%s": %s' % (self.key, self.value)
@@ -312,11 +311,6 @@ class TreeManager(models.Manager):
                # tree structure won't be that deep.
                segments = path.split(pathsep)
                
-               # Check for a trailing pathsep so we can restore it later.
-               trailing_pathsep = False
-               if segments[-1] == '':
-                       trailing_pathsep = True
-               
                # Clean out blank segments. Handles multiple consecutive pathseps.
                while True:
                        try:
@@ -348,12 +342,6 @@ class TreeManager(models.Manager):
                        
                        return kwargs
                
-               def build_path(segments):
-                       path = pathsep.join(segments)
-                       if trailing_pathsep and segments and segments[-1] != '':
-                               path += pathsep
-                       return path
-               
                def find_obj(segments, depth, deepest_found=None):
                        if deepest_found is None:
                                deepest_level = 0
@@ -374,7 +362,7 @@ class TreeManager(models.Manager):
                                if deepest_level == depth:
                                        # This should happen if nothing is found with any part of the given path.
                                        if root is not None and deepest_found is None:
-                                               return root, build_path(segments)
+                                               return root, pathsep.join(segments)
                                        raise
                                
                                return find_obj(segments, depth, deepest_found)
@@ -387,7 +375,7 @@ class TreeManager(models.Manager):
                                
                                # Could there be a deeper one?
                                if obj.is_leaf_node():
-                                       return obj, build_path(segments[deepest_level:]) or None
+                                       return obj, pathsep.join(segments[deepest_level:]) or None
                                
                                depth += (len(segments) - depth)/2 or len(segments) - depth
                                
@@ -395,13 +383,13 @@ class TreeManager(models.Manager):
                                        depth = deepest_level + obj.get_descendant_count()
                                
                                if deepest_level == depth:
-                                       return obj, build_path(segments[deepest_level:]) or None
+                                       return obj, pathsep.join(segments[deepest_level:]) or None
                                
                                try:
                                        return find_obj(segments, depth, obj)
                                except self.model.DoesNotExist:
                                        # Then this was the deepest.
-                                       return obj, build_path(segments[deepest_level:])
+                                       return obj, pathsep.join(segments[deepest_level:])
                
                if absolute_result:
                        return self.get(**make_query_kwargs(segments, root))
similarity index 98%
rename from models/fields.py
rename to models/fields/__init__.py
index 9e78273..25af832 100644 (file)
@@ -7,6 +7,7 @@ from django.utils.text import capfirst
 from django.utils.translation import ugettext_lazy as _
 from philo.forms.fields import JSONFormField
 from philo.validators import TemplateValidator, json_validator
+#from philo.models.fields.entities import *
 
 
 class TemplateField(models.TextField):
similarity index 79%
rename from contrib/cowell/fields.py
rename to models/fields/entities.py
index d9f3c8a..6c407d0 100644 (file)
@@ -1,11 +1,11 @@
 """
-The Attributes defined in this file can be assigned as fields on a proxy of
-a subclass of philo.models.Entity. They act like any other model fields,
-but instead of saving their data to the database, they save it to
-attributes related to a model instance. Additionally, a new attribute will
-be created for an instance if and only if the field's value has been set.
-This is relevant i.e. for passthroughs, where the value of the field may
-be defined by some other instance's attributes.
+The EntityProxyFields defined in this file can be assigned as fields on
+a subclass of philo.models.Entity. They act like any other model
+fields, but instead of saving their data to the database, they save it
+to attributes related to a model instance. Additionally, a new
+attribute will be created for an instance if and only if the field's
+value has been set. This is relevant i.e. for passthroughs, where the
+value of the field may be defined by some other instance's attributes.
 
 Example::
 
@@ -14,10 +14,8 @@ Example::
        
        class ThingProxy(Thing):
                improvised = JSONAttribute(models.BooleanField)
-               
-               class Meta:
-                       proxy = True
 """
+from itertools import tee
 from django import forms
 from django.core.exceptions import FieldError
 from django.db import models
@@ -25,6 +23,7 @@ from django.db.models.fields import NOT_PROVIDED
 from django.utils.text import capfirst
 from philo.signals import entity_class_prepared
 from philo.models import ManyToManyValue, JSONValue, ForeignKeyValue, Attribute, Entity
+import datetime
 
 
 __all__ = ('JSONAttribute', 'ForeignKeyAttribute', 'ManyToManyAttribute')
@@ -34,11 +33,12 @@ ATTRIBUTE_REGISTRY = '_attribute_registry'
 
 
 class EntityProxyField(object):
-       def __init__(self, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, *args, **kwargs):
+       def __init__(self, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, choices=None, *args, **kwargs):
                self.verbose_name = verbose_name
                self.help_text = help_text
                self.default = default
                self.editable = editable
+               self._choices = choices or []
        
        def actually_contribute_to_class(self, sender, **kwargs):
                sender._entity_meta.add_proxy_field(self)
@@ -69,8 +69,21 @@ class EntityProxyField(object):
                this field's initial value."""
                return getattr(obj, self.name)
        
+       def get_storage_value(self, value):
+               """Final conversion of `value` before it gets stored on an Entity instance.
+               This step is performed by the ProxyFieldForm."""
+               return value
+       
        def has_default(self):
                return self.default is not NOT_PROVIDED
+       
+       def _get_choices(self):
+               if hasattr(self._choices, 'next'):
+                       choices, self._choices = tee(self._choices)
+                       return choices
+               else:
+                       return self._choices
+       choices = property(_get_choices)
 
 
 class AttributeFieldDescriptor(object):
@@ -151,7 +164,6 @@ class AttributeField(EntityProxyField):
                if not hasattr(opts, '_has_attribute_fields'):
                        opts._has_attribute_fields = True
                        models.signals.post_save.connect(process_attribute_fields, sender=sender)
-               
        
        def contribute_to_class(self, cls, name):
                if self.attribute_key is None:
@@ -160,7 +172,7 @@ class AttributeField(EntityProxyField):
        
        def validate_value(self, value):
                "Confirm that the value is valid or raise an appropriate error."
-               raise NotImplementedError("validate_value must be implemented by AttributeField subclasses.")
+               pass
        
        @property
        def value_class(self):
@@ -176,9 +188,6 @@ class JSONAttribute(AttributeField):
                        field_template = models.CharField(max_length=255)
                self.field_template = field_template
        
-       def validate_value(self, value):
-               pass
-       
        def formfield(self, **kwargs):
                defaults = {
                        'required': False,
@@ -189,6 +198,19 @@ class JSONAttribute(AttributeField):
                        defaults['initial'] = self.default
                defaults.update(kwargs)
                return self.field_template.formfield(**defaults)
+       
+       def value_from_object(self, obj):
+               value = super(JSONAttribute, self).value_from_object(obj)
+               if isinstance(self.field_template, (models.DateField, models.DateTimeField)):
+                       value = self.field_template.to_python(value)
+               return value
+       
+       def get_storage_value(self, value):
+               if isinstance(value, datetime.datetime):
+                       return value.strftime("%Y-%m-%d %H:%M:%S")
+               if isinstance(value, datetime.date):
+                       return value.strftime("%Y-%m-%d")
+               return value
 
 
 class ForeignKeyAttribute(AttributeField):
@@ -196,18 +218,18 @@ class ForeignKeyAttribute(AttributeField):
        
        def __init__(self, model, limit_choices_to=None, **kwargs):
                super(ForeignKeyAttribute, self).__init__(**kwargs)
-               self.model = model
+               self.to = model
                if limit_choices_to is None:
                        limit_choices_to = {}
                self.limit_choices_to = limit_choices_to
        
        def validate_value(self, value):
-               if value is not None and not isinstance(value, self.model) :
-                       raise TypeError("The '%s' attribute can only be set to an instance of %s or None." % (self.name, self.model.__name__))
+               if value is not None and not isinstance(value, self.to) :
+                       raise TypeError("The '%s' attribute can only be set to an instance of %s or None." % (self.name, self.to.__name__))
        
        def formfield(self, form_class=forms.ModelChoiceField, **kwargs):
                defaults = {
-                       'queryset': self.model._default_manager.complex_filter(self.limit_choices_to)
+                       'queryset': self.to._default_manager.complex_filter(self.limit_choices_to)
                }
                defaults.update(kwargs)
                return super(ForeignKeyAttribute, self).formfield(form_class=form_class, **defaults)
@@ -215,14 +237,18 @@ class ForeignKeyAttribute(AttributeField):
        def value_from_object(self, obj):
                relobj = super(ForeignKeyAttribute, self).value_from_object(obj)
                return getattr(relobj, 'pk', None)
+       
+       def get_related_field(self):
+               """Spoof being a rel from a ForeignKey."""
+               return self.to._meta.pk
 
 
 class ManyToManyAttribute(ForeignKeyAttribute):
        value_class = ManyToManyValue
        
        def validate_value(self, value):
-               if not isinstance(value, models.query.QuerySet) or value.model != self.model:
-                       raise TypeError("The '%s' attribute can only be set to a %s QuerySet." % (self.name, self.model.__name__))
+               if not isinstance(value, models.query.QuerySet) or value.model != self.to:
+                       raise TypeError("The '%s' attribute can only be set to a %s QuerySet." % (self.name, self.to.__name__))
        
        def formfield(self, form_class=forms.ModelMultipleChoiceField, **kwargs):
                return super(ManyToManyAttribute, self).formfield(form_class=form_class, **kwargs)
index 2bfb4fd..99be196 100644 (file)
@@ -1,15 +1,17 @@
 from django.db import models
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes import generic
-from django.contrib.sites.models import Site
-from django.http import HttpResponse, HttpResponseServerError, HttpResponseRedirect
-from django.core.exceptions import ViewDoesNotExist
+from django.contrib.sites.models import Site, RequestSite
+from django.http import HttpResponse, HttpResponseServerError, HttpResponseRedirect, Http404
+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
+from philo.models.fields import JSONField
 from philo.utils import ContentTypeSubclassLimiter
 from philo.validators import RedirectValidator
 from philo.exceptions import ViewCanNotProvideSubpath, ViewDoesNotProvideSubpaths, AncestorDoesNotExist
@@ -30,23 +32,53 @@ class Node(TreeEntity):
                        return self.view.accepts_subpath
                return False
        
+       def handles_subpath(self, subpath):
+               return self.view.handles_subpath(subpath)
+       
        def render_to_response(self, request, extra_context=None):
                return self.view.render_to_response(request, extra_context)
        
-       def get_absolute_url(self):
+       def get_absolute_url(self, request=None, with_domain=False, secure=False):
+               return self.construct_url(request=request, with_domain=with_domain, secure=secure)
+       
+       def construct_url(self, subpath="/", request=None, with_domain=False, secure=False):
+               """
+               This method will construct a URL based on the Node's location.
+               If a request is passed in, that will be used as a backup in case
+               the Site lookup fails. The Site lookup takes precedence because
+               it's what's used to find the root node. This will raise:
+               - NoReverseMatch if philo-root is not reverseable
+               - Site.DoesNotExist if a domain is requested but not buildable.
+               - AncestorDoesNotExist if the root node of the site isn't an
+                 ancestor of this instance.
+               """
+               # Try reversing philo-root first, since we can't do anything if that fails.
+               root_url = reverse('philo-root')
+               
                try:
-                       root = Site.objects.get_current().root_node
+                       current_site = Site.objects.get_current()
                except Site.DoesNotExist:
-                       root = None
+                       if request is not None:
+                               current_site = RequestSite(request)
+                       elif with_domain:
+                               # If they want a domain and we can't figure one out,
+                               # best to reraise the error to let them know.
+                               raise
+                       else:
+                               current_site = None
                
-               try:
-                       path = self.get_path(root=root)
-                       if path:
-                               path += '/'
-                       root_url = reverse('philo-root')
-                       return '%s%s' % (root_url, path)
-               except AncestorDoesNotExist, ViewDoesNotExist:
-                       return None
+               root = getattr(current_site, 'root_node', None)
+               path = self.get_path(root=root)
+               
+               if current_site and with_domain:
+                       domain = "http%s://%s" % (secure and "s" or "", current_site.domain)
+               else:
+                       domain = ""
+               
+               if not path or subpath == "/":
+                       subpath = subpath[1:]
+               
+               return '%s%s%s%s' % (domain, root_url, path, subpath)
        
        class Meta:
                app_label = 'philo'
@@ -61,15 +93,34 @@ class View(Entity):
        
        accepts_subpath = False
        
-       def get_subpath(self, obj):
+       def handles_subpath(self, subpath):
+               if not self.accepts_subpath and subpath != "/":
+                       return False
+               return True
+       
+       def reverse(self, view_name=None, args=None, kwargs=None, node=None, obj=None):
+               """Shortcut method to handle the common pattern of getting the
+               absolute url for a view's subpaths."""
                if not self.accepts_subpath:
                        raise ViewDoesNotProvideSubpaths
                
-               view_name, args, kwargs = self.get_reverse_params(obj)
+               if obj is not None:
+                       # Perhaps just override instead of combining?
+                       obj_view_name, obj_args, obj_kwargs = self.get_reverse_params(obj)
+                       if view_name is None:
+                               view_name = obj_view_name
+                       args = list(obj_args) + list(args or [])
+                       obj_kwargs.update(kwargs or {})
+                       kwargs = obj_kwargs
+               
                try:
-                       return reverse(view_name, args=args, kwargs=kwargs, urlconf=self)
-               except NoReverseMatch:
-                       raise ViewCanNotProvideSubpath
+                       subpath = reverse(view_name, urlconf=self, args=args or [], kwargs=kwargs or {})
+               except NoReverseMatch, e:
+                       raise ViewCanNotProvideSubpath(e.message)
+               
+               if node is not None:
+                       return node.construct_url(subpath)
+               return subpath
        
        def get_reverse_params(self, obj):
                """This method should return a view_name, args, kwargs tuple suitable for reversing a url for the given obj using self as the urlconf."""
@@ -89,7 +140,7 @@ class View(Entity):
                return response
        
        def actually_render_to_response(self, request, extra_context=None):
-               raise NotImplementedError('View subclasses must implement render_to_response.')
+               raise NotImplementedError('View subclasses must implement actually_render_to_response.')
        
        class Meta:
                abstract = True
@@ -102,15 +153,21 @@ class MultiView(View):
        accepts_subpath = True
        
        @property
-       def urlpatterns(self, obj):
+       def urlpatterns(self):
                raise NotImplementedError("MultiView subclasses must implement urlpatterns.")
        
+       def handles_subpath(self, subpath):
+               if not super(MultiView, self).handles_subpath(subpath):
+                       return False
+               try:
+                       resolve(subpath, urlconf=self)
+               except Http404:
+                       return False
+               return True
+       
        def actually_render_to_response(self, request, extra_context=None):
                clear_url_caches()
                subpath = request.node.subpath
-               if not subpath:
-                       subpath = ""
-               subpath = "/" + subpath
                view, args, kwargs = resolve(subpath, urlconf=self)
                view_args = getargspec(view)
                if extra_context is not None and ('extra_context' in view_args[0] or view_args[2] is not None):
@@ -119,25 +176,20 @@ class MultiView(View):
                        kwargs['extra_context'] = extra_context
                return view(request, *args, **kwargs)
        
-       def reverse(self, view_name, args=None, kwargs=None, node=None):
-               """Shortcut method to handle the common pattern of getting the absolute url for a multiview's
-               subpaths."""
-               subpath = reverse(view_name, urlconf=self, args=args or [], kwargs=kwargs or {})
-               if node is not None:
-                       return '/%s/%s/' % (node.get_absolute_url().strip('/'), subpath.strip('/'))
-               return subpath
-       
        def get_context(self):
                """Hook for providing instance-specific context - such as the value of a Field - to all views."""
                return {}
        
-       def basic_view(self, view_name):
+       def basic_view(self, field_name):
                """
-               Wraps a field name and returns a simple view function that will render that view
-               with a basic context. This assumes that the field name is a ForeignKey to a
-               model with a render_to_response method.
+               Given the name of a field on ``self``, accesses the value of
+               that field and treats it as a ``View`` instance. Creates a
+               basic context based on self.get_context() and any extra_context
+               that was passed in, then calls the ``View`` instance's
+               render_to_response() method. This method is meant to be called
+               to return a view function appropriate for urlpatterns.
                """
-               field = self._meta.get_field(view_name)
+               field = self._meta.get_field(field_name)
                view = getattr(self, field.name, None)
                
                def inner(request, extra_context=None, **kwargs):
@@ -153,16 +205,69 @@ class MultiView(View):
                abstract = True
 
 
-class Redirect(View):
+class TargetURLModel(models.Model):
+       target_node = models.ForeignKey(Node, blank=True, null=True, related_name="%(app_label)s_%(class)s_related")
+       url_or_subpath = models.CharField(max_length=200, validators=[RedirectValidator()], blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.")
+       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):
+               if not self.target_node and not self.url_or_subpath:
+                       raise ValidationError("Either a target node or a url must be defined.")
+               
+               if self.reversing_parameters and not (self.url_or_subpath or self.target_node):
+                       raise ValidationError("Reversing parameters require either a view name or a target node.")
+               
+               try:
+                       self.get_target_url()
+               except (NoReverseMatch, ViewCanNotProvideSubpath), e:
+                       raise ValidationError(e.message)
+               
+               super(TargetURLModel, self).clean()
+       
+       def get_reverse_params(self):
+               params = self.reversing_parameters
+               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):
+               node = self.target_node
+               if node is not None and node.accepts_subpath and self.url_or_subpath:
+                       if self.reversing_parameters is not None:
+                               view_name, args, kwargs = self.get_reverse_params()
+                               subpath = node.view.reverse(view_name, args=args, kwargs=kwargs)
+                       else:
+                               subpath = self.url_or_subpath
+                               if subpath[0] != '/':
+                                       subpath = '/' + subpath
+                       return node.construct_url(subpath)
+               elif node is not None:
+                       return node.get_absolute_url()
+               else:
+                       if self.reversing_parameters is not None:
+                               view_name, args, kwargs = self.get_reverse_params()
+                               return reverse(view_name, args=args, kwargs=kwargs)
+                       return self.url_or_subpath
+       target_url = property(get_target_url)
+       
+       class Meta:
+               abstract = True
+
+
+class Redirect(TargetURLModel, View):
        STATUS_CODES = (
                (302, 'Temporary'),
                (301, 'Permanent'),
        )
-       target = models.CharField(max_length=200, validators=[RedirectValidator()])
        status_code = models.IntegerField(choices=STATUS_CODES, default=302, verbose_name='redirect type')
        
        def actually_render_to_response(self, request, extra_context=None):
-               response = HttpResponseRedirect(self.target)
+               response = HttpResponseRedirect(self.target_url)
                response.status_code = self.status_code
                return response
        
@@ -170,7 +275,6 @@ class Redirect(View):
                app_label = 'philo'
 
 
-# Why does this exist?
 class File(View):
        """ For storing arbitrary files """
        
index 81b84c9..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
                
-               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
+               # 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)
+               
+               return contentlet_specs, contentreference_specs
        
        def __unicode__(self):
                return self.get_path(pathsep=u' › ', field='name')
@@ -108,7 +195,7 @@ class Page(View):
 
 class Contentlet(models.Model):
        page = models.ForeignKey(Page, related_name='contentlets')
-       name = models.CharField(max_length=255)
+       name = models.CharField(max_length=255, db_index=True)
        content = TemplateField()
        
        def __unicode__(self):
@@ -120,7 +207,7 @@ class Contentlet(models.Model):
 
 class ContentReference(models.Model):
        page = models.ForeignKey(Page, related_name='contentreferences')
-       name = models.CharField(max_length=255)
+       name = models.CharField(max_length=255, db_index=True)
        content_type = models.ForeignKey(ContentType, verbose_name='Content type')
        content_id = models.PositiveIntegerField(verbose_name='Content ID', blank=True, null=True)
        content = generic.GenericForeignKey('content_type', 'content_id')
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 73492d4..5ae507d 100644 (file)
@@ -55,10 +55,7 @@ class NodeURLNode(template.Node):
                                                raise
                                        return settings.TEMPLATE_STRING_IF_INVALID
                        else:
-                               if subpath[0] == '/':
-                                       subpath = subpath[1:]
-                               
-                               url = node.get_absolute_url() + subpath
+                               url = node.construct_url(subpath)
                
                if self.as_var:
                        context[self.as_var] = url
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
index 255e54e..f5a2c7f 100644 (file)
--- a/views.py
+++ b/views.py
@@ -1,5 +1,6 @@
 from django.conf import settings
-from django.http import Http404
+from django.core.urlresolvers import resolve
+from django.http import Http404, HttpResponseRedirect
 from django.views.decorators.vary import vary_on_headers
 from philo.exceptions import MIDDLEWARE_NOT_CONFIGURED
 
@@ -10,11 +11,50 @@ def node_view(request, path=None, **kwargs):
                raise MIDDLEWARE_NOT_CONFIGURED
        
        if not request.node:
+               if settings.APPEND_SLASH and request.path != "/":
+                       path = request.path
+                       
+                       if path[-1] == "/":
+                               path = path[:-1]
+                       else:
+                               path += "/"
+                       
+                       view, args, kwargs = resolve(path)
+                       if view != node_view:
+                               return HttpResponseRedirect(path)
                raise Http404
        
        node = request.node
        subpath = request.node.subpath
        
-       if subpath and not node.accepts_subpath:
-               raise Http404
+       # Explicitly disallow trailing slashes if we are otherwise at a node's url.
+       if request.path and request.path != "/" and request.path[-1] == "/" and subpath == "/":
+               return HttpResponseRedirect(node.get_absolute_url())
+       
+       if not node.handles_subpath(subpath):
+               # If the subpath isn't handled, check settings.APPEND_SLASH. If
+               # it's True, try to correct the subpath.
+               if not settings.APPEND_SLASH:
+                       raise Http404
+               
+               if subpath[-1] == "/":
+                       subpath = subpath[:-1]
+               else:
+                       subpath += "/"
+               
+               redirect_url = node.construct_url(subpath)
+               
+               if node.handles_subpath(subpath):
+                       return HttpResponseRedirect(redirect_url)
+               
+               # Perhaps there is a non-philo view at this address. Can we
+               # resolve *something* there besides node_view? If not,
+               # raise a 404.
+               view, args, kwargs = resolve(redirect_url)
+               
+               if view == node_view:
+                       raise Http404
+               else:
+                       return HttpResponseRedirect(redirect_url)
+       
        return node.render_to_response(request, kwargs)
\ No newline at end of file