Merge branch 'master' into docs
authorStephen Burrows <stephen.r.burrows@gmail.com>
Mon, 21 Feb 2011 15:03:42 +0000 (10:03 -0500)
committerStephen Burrows <stephen.r.burrows@gmail.com>
Mon, 21 Feb 2011 15:03:42 +0000 (10:03 -0500)
30 files changed:
admin/base.py
admin/nodes.py
contrib/penfield/admin.py
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
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/base.py
models/nodes.py
models/pages.py
templatetags/nodes.py
views.py

index 81ff8f8..8151461 100644 (file)
@@ -47,9 +47,13 @@ class EntityAdminMetaclass(admin.ModelAdmin.__metaclass__):
                        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._real_readonly_fields = readonly_fields
-                               new_class.readonly_fields = list(readonly_fields) + proxy_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.
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):
index d8fcd90..950539d 100644 (file)
@@ -41,6 +41,7 @@ class BlogEntryAdmin(TitledAdmin, AddTagAdmin):
                        'classes': COLLAPSE_CLASSES
                })
        )
+       related_lookup_fields = {'fk': raw_id_fields}
 
 
 class BlogViewAdmin(EntityAdmin):
@@ -54,16 +55,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):
@@ -115,11 +117,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/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..98dcdd5 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.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 = 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:
+                               # See RFC 2616
+                               return HttpResponse(status=406)
+               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 +326,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 +339,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 +348,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 +366,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 +383,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 +497,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 +596,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 +616,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 +647,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 +740,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 98e3e6b..fa4ec3e 100644 (file)
@@ -84,13 +84,16 @@ def recursenavigation(parser, token):
 
 @register.filter
 def has_navigation(node, key=None):
-       nav = node.navigation
-       if key is not None:
-               if key in nav and bool(node.navigation[key]):
-                       return True
-               elif key not in node.navigation:
-                       return False
-       return bool(node.navigation)
+       try:
+               nav = node.navigation
+               if key is not None:
+                       if key in nav and bool(node.navigation[key]):
+                               return True
+                       elif key not in node.navigation:
+                               return False
+               return bool(node.navigation)
+       except:
+               return False
 
 
 @register.filter
index 615d302..2ee64d0 100644 (file)
@@ -1,6 +1,7 @@
 from datetime import date
 from django import forms
 from django.conf import settings
+from django.contrib.auth import authenticate
 from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
@@ -8,12 +9,6 @@ from django.utils.translation import ugettext_lazy as _
 from philo.contrib.waldo.tokens import REGISTRATION_TIMEOUT_DAYS
 
 
-LOGIN_FORM_KEY = 'this_is_the_login_form'
-LoginForm = type('LoginForm', (AuthenticationForm,), {
-       LOGIN_FORM_KEY: forms.BooleanField(widget=forms.HiddenInput, initial=True)
-})
-
-
 class EmailInput(forms.TextInput):
        input_type = 'email'
 
@@ -70,4 +65,38 @@ class UserAccountForm(forms.ModelForm):
        
        class Meta:
                model = User
-               fields = ('first_name', 'last_name', 'email')
\ No newline at end of file
+               fields = ('first_name', 'last_name', 'email')
+
+
+class WaldoAuthenticationForm(AuthenticationForm):
+       ERROR_MESSAGE = _("Please enter a correct username and password. Note that both fields are case-sensitive.")
+       
+       def clean(self):
+               username = self.cleaned_data.get('username')
+               password = self.cleaned_data.get('password')
+               message = self.ERROR_MESSAGE
+               
+               if username and password:
+                       self.user_cache = authenticate(username=username, password=password)
+                       if self.user_cache is None:
+                               if u'@' in username:
+                                       # Maybe they entered their email? Look it up, but still raise a ValidationError.
+                                       try:
+                                               user = User.objects.get(email=username)
+                                       except (User.DoesNotExist, User.MultipleObjectsReturned):
+                                               pass
+                                       else:
+                                               if user.check_password(password):
+                                                       message = _("Your e-mail address is not your username. Try '%s' instead.") % user.username
+                               raise ValidationError(message)
+                       elif not self.user_cache.is_active:
+                               raise ValidationError(message)
+               self.check_for_test_cookie()
+               return self.cleaned_data
+       
+       def check_for_test_cookie(self):
+               # This method duplicates the Django 1.3 AuthenticationForm method.
+               if self.request and not self.request.session.test_cookie_worked():
+                       raise forms.ValidationError(
+                               _("Your Web browser doesn't appear to have cookies enabled. "
+                                 "Cookies are required for logging in."))
\ No newline at end of file
index 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 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 290e8b8..8370bb7 100644 (file)
@@ -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)
 
@@ -78,7 +81,7 @@ 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)
@@ -101,7 +104,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 +220,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 +315,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 +346,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 +366,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 +379,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 +387,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))
index 2bfb4fd..3f40205 100644 (file)
@@ -1,15 +1,16 @@
 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 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 +31,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 +92,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 +139,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 +152,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 +175,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 +204,74 @@ 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):
+               # 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 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 = {}
+                       for key, val in params.items():
+                               if isinstance(key, unicode):
+                                       key = str(key)
+                               kwargs[key] = val
+               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(View, TargetURLModel):
        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 +279,6 @@ class Redirect(View):
                app_label = 'philo'
 
 
-# Why does this exist?
 class File(View):
        """ For storing arbitrary files """
        
index 81b84c9..ef68b5f 100644 (file)
@@ -108,7 +108,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 +120,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 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 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