Merge branch 'docs' into release
authorStephen Burrows <stephen.r.burrows@gmail.com>
Wed, 27 Apr 2011 21:07:41 +0000 (17:07 -0400)
committerStephen Burrows <stephen.r.burrows@gmail.com>
Wed, 27 Apr 2011 21:07:41 +0000 (17:07 -0400)
109 files changed:
README
README.markdown
__init__.py [deleted file]
admin/forms/containers.py [deleted file]
contrib/shipherd/templatetags/shipherd.py [deleted file]
models/fields/__init__.py [deleted file]
philo/LICENSE [moved from LICENSE with 100% similarity]
philo/__init__.py [new file with mode: 0644]
philo/admin/__init__.py [moved from admin/__init__.py with 100% similarity]
philo/admin/base.py [moved from admin/base.py with 78% similarity]
philo/admin/collections.py [moved from admin/collections.py with 86% similarity]
philo/admin/forms/__init__.py [moved from admin/forms/__init__.py with 100% similarity]
philo/admin/forms/attributes.py [moved from admin/forms/attributes.py with 100% similarity]
philo/admin/forms/containers.py [new file with mode: 0644]
philo/admin/nodes.py [moved from admin/nodes.py with 79% similarity]
philo/admin/pages.py [moved from admin/pages.py with 84% similarity]
philo/admin/widgets.py [moved from admin/widgets.py with 59% similarity]
philo/contrib/__init__.py [moved from contrib/__init__.py with 100% similarity]
philo/contrib/julian/__init__.py [moved from contrib/penfield/__init__.py with 100% similarity]
philo/contrib/julian/admin.py [new file with mode: 0644]
philo/contrib/julian/feedgenerator.py [new file with mode: 0644]
philo/contrib/julian/migrations/0001_initial.py [new file with mode: 0644]
philo/contrib/julian/migrations/__init__.py [moved from contrib/penfield/migrations/__init__.py with 100% similarity]
philo/contrib/julian/models.py [new file with mode: 0644]
philo/contrib/penfield/__init__.py [moved from contrib/penfield/templatetags/__init__.py with 100% similarity]
philo/contrib/penfield/admin.py [moved from contrib/penfield/admin.py with 82% similarity]
philo/contrib/penfield/exceptions.py [new file with mode: 0644]
philo/contrib/penfield/middleware.py [new file with mode: 0644]
philo/contrib/penfield/migrations/0001_initial.py [moved from contrib/penfield/migrations/0001_initial.py with 100% similarity]
philo/contrib/penfield/migrations/0002_auto.py [moved from contrib/penfield/migrations/0002_auto.py with 100% similarity]
philo/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py [moved from contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py with 100% similarity]
philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py [new file with mode: 0644]
philo/contrib/penfield/migrations/__init__.py [moved from contrib/shipherd/__init__.py with 100% similarity]
philo/contrib/penfield/models.py [moved from contrib/penfield/models.py with 97% similarity]
philo/contrib/penfield/templatetags/__init__.py [moved from contrib/shipherd/migrations/__init__.py with 100% similarity]
philo/contrib/penfield/templatetags/penfield.py [moved from contrib/penfield/templatetags/penfield.py with 100% similarity]
philo/contrib/penfield/validators.py [moved from contrib/penfield/validators.py with 100% similarity]
philo/contrib/shipherd/__init__.py [moved from contrib/shipherd/templatetags/__init__.py with 100% similarity]
philo/contrib/shipherd/admin.py [moved from contrib/shipherd/admin.py with 100% similarity]
philo/contrib/shipherd/migrations/0001_initial.py [moved from contrib/shipherd/migrations/0001_initial.py with 100% similarity]
philo/contrib/shipherd/migrations/0002_auto.py [moved from contrib/shipherd/migrations/0002_auto.py with 100% similarity]
philo/contrib/shipherd/migrations/__init__.py [moved from contrib/waldo/__init__.py with 100% similarity]
philo/contrib/shipherd/models.py [moved from contrib/shipherd/models.py with 100% similarity]
philo/contrib/shipherd/templatetags/__init__.py [moved from loaders/__init__.py with 100% similarity]
philo/contrib/shipherd/templatetags/shipherd.py [new file with mode: 0644]
philo/contrib/sobol/__init__.py [new file with mode: 0644]
philo/contrib/sobol/admin.py [new file with mode: 0644]
philo/contrib/sobol/forms.py [new file with mode: 0644]
philo/contrib/sobol/models.py [new file with mode: 0644]
philo/contrib/sobol/search.py [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/results.html [new file with mode: 0644]
philo/contrib/sobol/templates/search/googlesearch.html [new file with mode: 0644]
philo/contrib/sobol/utils.py [new file with mode: 0644]
philo/contrib/waldo/__init__.py [moved from templatetags/__init__.py with 100% similarity]
philo/contrib/waldo/forms.py [moved from contrib/waldo/forms.py with 100% similarity]
philo/contrib/waldo/models.py [moved from contrib/waldo/models.py with 100% similarity]
philo/contrib/waldo/tokens.py [moved from contrib/waldo/tokens.py with 85% similarity]
philo/exceptions.py [moved from exceptions.py with 100% similarity]
philo/fixtures/test_fixtures.json [moved from fixtures/test_fixtures.json with 100% similarity]
philo/forms/__init__.py [moved from forms/__init__.py with 100% similarity]
philo/forms/entities.py [moved from forms/entities.py with 51% similarity]
philo/forms/fields.py [moved from forms/fields.py with 100% similarity]
philo/loaders/__init__.py [new file with mode: 0644]
philo/loaders/database.py [moved from loaders/database.py with 100% similarity]
philo/middleware.py [moved from middleware.py with 83% similarity]
philo/migrations/0001_initial.py [moved from migrations/0001_initial.py with 100% similarity]
philo/migrations/0002_auto__add_field_attribute_value.py [moved from migrations/0002_auto__add_field_attribute_value.py with 100% similarity]
philo/migrations/0003_move_json.py [moved from migrations/0003_move_json.py with 100% similarity]
philo/migrations/0004_auto__del_field_attribute_json_value.py [moved from migrations/0004_auto__del_field_attribute_json_value.py with 100% similarity]
philo/migrations/0005_add_attribute_values.py [moved from migrations/0005_add_attribute_values.py with 100% similarity]
philo/migrations/0006_move_attribute_and_relationship_values.py [moved from migrations/0006_move_attribute_and_relationship_values.py with 100% similarity]
philo/migrations/0007_auto__del_relationship__del_field_attribute_value.py [moved from migrations/0007_auto__del_relationship__del_field_attribute_value.py with 100% similarity]
philo/migrations/0008_auto__del_field_manytomanyvalue_object_ids.py [moved from migrations/0008_auto__del_field_manytomanyvalue_object_ids.py with 100% similarity]
philo/migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py [moved from migrations/0009_auto__add_field_node_lft__add_field_node_rght__add_field_node_tree_id_.py with 100% similarity]
philo/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py [moved from migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py with 100% similarity]
philo/migrations/0011_move_target_url.py [moved from migrations/0011_move_target_url.py with 100% similarity]
philo/migrations/0012_auto__del_field_redirect_target.py [moved from migrations/0012_auto__del_field_redirect_target.py with 100% similarity]
philo/migrations/0013_auto.py [moved from migrations/0013_auto.py with 100% similarity]
philo/migrations/0014_auto.py [moved from migrations/0014_auto.py with 100% similarity]
philo/migrations/__init__.py [moved from migrations/__init__.py with 100% similarity]
philo/models/__init__.py [moved from models/__init__.py with 100% similarity]
philo/models/base.py [moved from models/base.py with 98% similarity]
philo/models/collections.py [moved from models/collections.py with 100% similarity]
philo/models/fields/__init__.py [new file with mode: 0644]
philo/models/fields/entities.py [moved from models/fields/entities.py with 100% similarity]
philo/models/nodes.py [moved from models/nodes.py with 100% similarity]
philo/models/pages.py [moved from models/pages.py with 56% similarity]
philo/signals.py [moved from signals.py with 100% similarity]
philo/static/admin/js/TagCreation.js [moved from media/admin/js/TagCreation.js with 75% similarity]
philo/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html [moved from templates/admin/philo/edit_inline/grappelli_tabular_attribute.html with 56% similarity]
philo/templates/admin/philo/edit_inline/grappelli_tabular_container.html [new file with mode: 0644]
philo/templates/admin/philo/edit_inline/tabular_attribute.html [moved from templates/admin/philo/edit_inline/tabular_attribute.html with 100% similarity]
philo/templates/admin/philo/edit_inline/tabular_container.html [moved from templates/admin/philo/edit_inline/tabular_container.html with 72% similarity]
philo/templates/admin/philo/page/add_form.html [new file with mode: 0644]
philo/templatetags/__init__.py [new file with mode: 0644]
philo/templatetags/collections.py [moved from templatetags/collections.py with 100% similarity]
philo/templatetags/containers.py [moved from templatetags/containers.py with 100% similarity]
philo/templatetags/embed.py [moved from templatetags/embed.py with 100% similarity]
philo/templatetags/include_string.py [moved from templatetags/include_string.py with 100% similarity]
philo/templatetags/nodes.py [moved from templatetags/nodes.py with 100% similarity]
philo/tests.py [moved from tests.py with 94% similarity]
philo/urls.py [moved from urls.py with 73% similarity]
philo/utils.py [moved from utils.py with 73% similarity]
philo/validators.py [moved from validators.py with 85% similarity]
philo/views.py [moved from views.py with 91% similarity]
setup.py [new file with mode: 0644]
templates/admin/philo/edit_inline/grappelli_tabular_container.html [deleted file]
templates/admin/philo/page/add_form.html [deleted file]

diff --git a/README b/README
index 4b1a6f7..6e47860 100644 (file)
--- a/README
+++ b/README
@@ -2,7 +2,7 @@ Philo is a foundation for developing web content management systems.
 
 Prerequisites:
        * Python 2.5.4+ <http://www.python.org/>
-       * Django 1.2+ <http://www.djangoproject.com/>
+       * Django 1.3+ <http://www.djangoproject.com/>
        * django-mptt e734079+ <https://github.com/django-mptt/django-mptt/> 
        * (Optional) django-grappelli 2.0+ <http://code.google.com/p/django-grappelli/>
        * (Optional) recaptcha-django r6 <http://code.google.com/p/recaptcha-django/>
index 8060db8..349a727 100644 (file)
@@ -3,7 +3,7 @@ Philo is a foundation for developing web content management systems.
 Prerequisites:
 
  * [Python 2.5.4+ &lt;http://www.python.org&gt;](http://www.python.org/)
- * [Django 1.2+ &lt;http://www.djangoproject.com/&gt;](http://www.djangoproject.com/)
+ * [Django 1.3+ &lt;http://www.djangoproject.com/&gt;](http://www.djangoproject.com/)
  * [django-mptt e734079+ &lt;https://github.com/django-mptt/django-mptt/&gt;](https://github.com/django-mptt/django-mptt/)
  * (Optional) [django-grappelli 2.0+ &lt;http://code.google.com/p/django-grappelli/&gt;](http://code.google.com/p/django-grappelli/)
  * (Optional) [south 0.7.2+ &lt;http://south.aeracode.org/)](http://south.aeracode.org/)
diff --git a/__init__.py b/__init__.py
deleted file mode 100644 (file)
index ba78dda..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-from philo.loaders.database import Loader
-
-
-_loader = Loader()
-
-
-def load_template_source(template_name, template_dirs=None):
-    # For backwards compatibility
-    import warnings
-    warnings.warn(
-        "'philo.load_template_source' is deprecated; use 'philo.loaders.database.Loader' instead.",
-        PendingDeprecationWarning
-    )
-    return _loader.load_template_source(template_name, template_dirs)
-load_template_source.is_usable = True
diff --git a/admin/forms/containers.py b/admin/forms/containers.py
deleted file mode 100644 (file)
index 5991dfa..0000000
+++ /dev/null
@@ -1,190 +0,0 @@
-from django import forms
-from django.contrib.admin.widgets import AdminTextareaWidget
-from django.core.exceptions import ObjectDoesNotExist
-from django.db.models import Q
-from django.forms.models import ModelForm, BaseInlineFormSet
-from django.forms.formsets import TOTAL_FORM_COUNT
-from philo.admin.widgets import ModelLookupWidget
-from philo.models import Contentlet, ContentReference
-
-
-__all__ = (
-       'ContentletForm',
-       'ContentletInlineFormSet',
-       'ContentReferenceForm',
-       'ContentReferenceInlineFormSet'
-)
-
-
-class ContainerForm(ModelForm):
-       def __init__(self, *args, **kwargs):
-               super(ContainerForm, self).__init__(*args, **kwargs)
-               self.verbose_name = self.instance.name.replace('_', ' ')
-
-
-class ContentletForm(ContainerForm):
-       content = forms.CharField(required=False, widget=AdminTextareaWidget, label='Content')
-       
-       def should_delete(self):
-               return not bool(self.cleaned_data['content'])
-       
-       class Meta:
-               model = Contentlet
-               fields = ['name', 'content']
-
-
-class ContentReferenceForm(ContainerForm):
-       def __init__(self, *args, **kwargs):
-               super(ContentReferenceForm, self).__init__(*args, **kwargs)
-               try:
-                       self.fields['content_id'].widget = ModelLookupWidget(self.instance.content_type)
-               except ObjectDoesNotExist:
-                       # This will happen when an empty form (which we will never use) gets instantiated.
-                       pass
-       
-       def should_delete(self):
-               return (self.cleaned_data['content_id'] is None)
-       
-       class Meta:
-               model = ContentReference
-               fields = ['name', 'content_id']
-
-
-class ContainerInlineFormSet(BaseInlineFormSet):
-       def __init__(self, containers, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None):
-               # Unfortunately, I need to add some things to BaseInline between its __init__ and its
-               # super call, so a lot of this is repetition.
-               
-               # Start cribbed from BaseInline
-               from django.db.models.fields.related import RelatedObject
-               self.save_as_new = save_as_new
-               # is there a better way to get the object descriptor?
-               self.rel_name = RelatedObject(self.fk.rel.to, self.model, self.fk).get_accessor_name()
-               if self.fk.rel.field_name == self.fk.rel.to._meta.pk.name:
-                       backlink_value = self.instance
-               else:
-                       backlink_value = getattr(self.instance, self.fk.rel.field_name)
-               if queryset is None:
-                       queryset = self.model._default_manager
-               qs = queryset.filter(**{self.fk.name: backlink_value})
-               # End cribbed from BaseInline
-               
-               self.container_instances, qs = self.get_container_instances(containers, qs)
-               self.extra_containers = containers
-               self.extra = len(self.extra_containers)
-               super(BaseInlineFormSet, self).__init__(data, files, prefix=prefix, queryset=qs)
-       
-       def get_container_instances(self, containers, qs):
-               raise NotImplementedError
-       
-       def total_form_count(self):
-               if self.data or self.files:
-                       return self.management_form.cleaned_data[TOTAL_FORM_COUNT]
-               else:
-                       return self.initial_form_count() + self.extra
-       
-       def save_existing_objects(self, commit=True):
-               self.changed_objects = []
-               self.deleted_objects = []
-               if not self.get_queryset():
-                       return []
-
-               saved_instances = []
-               for form in self.initial_forms:
-                       pk_name = self._pk_field.name
-                       raw_pk_value = form._raw_value(pk_name)
-
-                       # clean() for different types of PK fields can sometimes return
-                       # the model instance, and sometimes the PK. Handle either.
-                       pk_value = form.fields[pk_name].clean(raw_pk_value)
-                       pk_value = getattr(pk_value, 'pk', pk_value)
-
-                       obj = self._existing_object(pk_value)
-                       if form.should_delete():
-                               self.deleted_objects.append(obj)
-                               obj.delete()
-                               continue
-                       if form.has_changed():
-                               self.changed_objects.append((obj, form.changed_data))
-                               saved_instances.append(self.save_existing(form, obj, commit=commit))
-                               if not commit:
-                                       self.saved_forms.append(form)
-               return saved_instances
-
-       def save_new_objects(self, commit=True):
-               self.new_objects = []
-               for form in self.extra_forms:
-                       if not form.has_changed():
-                               continue
-                       # If someone has marked an add form for deletion, don't save the
-                       # object.
-                       if form.should_delete():
-                               continue
-                       self.new_objects.append(self.save_new(form, commit=commit))
-                       if not commit:
-                               self.saved_forms.append(form)
-               return self.new_objects
-
-
-class ContentletInlineFormSet(ContainerInlineFormSet):
-       def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None):
-               if instance is None:
-                       self.instance = self.fk.rel.to()
-               else:
-                       self.instance = instance
-               
-               try:
-                       containers = list(self.instance.containers[0])
-               except ObjectDoesNotExist:
-                       containers = []
-       
-               super(ContentletInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset)
-       
-       def get_container_instances(self, containers, qs):
-               qs = qs.filter(name__in=containers)
-               container_instances = []
-               for container in qs:
-                       container_instances.append(container)
-                       containers.remove(container.name)
-               return container_instances, qs
-       
-       def _construct_form(self, i, **kwargs):
-               if i >= self.initial_form_count(): # and not kwargs.get('instance'):
-                       kwargs['instance'] = self.model(name=self.extra_containers[i - self.initial_form_count() - 1])
-               
-               return super(ContentletInlineFormSet, self)._construct_form(i, **kwargs)
-
-
-class ContentReferenceInlineFormSet(ContainerInlineFormSet):
-       def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None):
-               if instance is None:
-                       self.instance = self.fk.rel.to()
-               else:
-                       self.instance = instance
-               
-               try:
-                       containers = list(self.instance.containers[1])
-               except ObjectDoesNotExist:
-                       containers = []
-       
-               super(ContentReferenceInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset)
-       
-       def get_container_instances(self, containers, qs):
-               filter = Q()
-               
-               for name, ct in containers:
-                       filter |= Q(name=name, content_type=ct)
-               
-               qs = qs.filter(filter)
-               container_instances = []
-               for container in qs:
-                       container_instances.append(container)
-                       containers.remove((container.name, container.content_type))
-               return container_instances, qs
-
-       def _construct_form(self, i, **kwargs):
-               if i >= self.initial_form_count(): # and not kwargs.get('instance'):
-                       name, content_type = self.extra_containers[i - self.initial_form_count() - 1]
-                       kwargs['instance'] = self.model(name=name, content_type=content_type)
-
-               return super(ContentReferenceInlineFormSet, self)._construct_form(i, **kwargs)
\ No newline at end of file
diff --git a/contrib/shipherd/templatetags/shipherd.py b/contrib/shipherd/templatetags/shipherd.py
deleted file mode 100644 (file)
index fa4ec3e..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-from django import template
-from django.conf import settings
-from django.utils.safestring import mark_safe
-from philo.contrib.shipherd.models import Navigation
-from philo.models import Node
-from mptt.templatetags.mptt_tags import RecurseTreeNode, cache_tree_children
-from django.utils.translation import ugettext as _
-
-
-register = template.Library()
-
-
-class RecurseNavigationNode(RecurseTreeNode):
-       def __init__(self, template_nodes, instance_var, key):
-               self.template_nodes = template_nodes
-               self.instance_var = instance_var
-               self.key = key
-       
-       def _render_node(self, context, item, request):
-               bits = []
-               context.push()
-               for child in item.get_children():
-                       context['item'] = child
-                       bits.append(self._render_node(context, child, request))
-               context['item'] = item
-               context['children'] = mark_safe(u''.join(bits))
-               context['active'] = item.is_active(request)
-               context['active_descendants'] = item.has_active_descendants(request)
-               rendered = self.template_nodes.render(context)
-               context.pop()
-               return rendered
-       
-       def render(self, context):
-               try:
-                       request = context['request']
-               except KeyError:
-                       return ''
-               
-               instance = self.instance_var.resolve(context)
-               
-               try:
-                       navigation = instance.navigation[self.key]
-               except:
-                       return settings.TEMPLATE_STRING_IF_INVALID
-               
-               bits = [self._render_node(context, item, request) for item in navigation]
-               return ''.join(bits)
-
-
-@register.tag
-def recursenavigation(parser, token):
-       """
-       Based on django-mptt's recursetree templatetag. In addition to {{ item }} and {{ children }},
-       sets {{ active }} and {{ active_descendants }} in the context.
-       
-       Note that the tag takes one variable, which is a Node instance.
-       
-       Usage:
-               <ul>
-                       {% recursenavigation node main %}
-                               <li{% if active %} class='active'{% endif %}>
-                                       {{ navigation.text }}
-                                       {% if navigation.get_children %}
-                                               <ul>
-                                                       {{ children }}
-                                               </ul>
-                                       {% endif %}
-                               </li>
-                       {% endrecursenavigation %}
-               </ul>
-       """
-       bits = token.contents.split()
-       if len(bits) != 3:
-               raise template.TemplateSyntaxError(_('%s tag requires two arguments: a node and a navigation section name') % bits[0])
-       
-       instance_var = parser.compile_filter(bits[1])
-       key = bits[2]
-       
-       template_nodes = parser.parse(('endrecursenavigation',))
-       parser.delete_first_token()
-       
-       return RecurseNavigationNode(template_nodes, instance_var, key)
-
-
-@register.filter
-def has_navigation(node, key=None):
-       try:
-               nav = node.navigation
-               if key is not None:
-                       if key in nav and bool(node.navigation[key]):
-                               return True
-                       elif key not in node.navigation:
-                               return False
-               return bool(node.navigation)
-       except:
-               return False
-
-
-@register.filter
-def navigation_host(node, key):
-       try:
-               return Navigation.objects.filter(node__in=node.get_ancestors(include_self=True), key=key).order_by('-node__level')[0].node
-       except:
-               if settings.TEMPLATE_DEBUG:
-                       raise
-               return node
\ No newline at end of file
diff --git a/models/fields/__init__.py b/models/fields/__init__.py
deleted file mode 100644 (file)
index d8ed839..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-from django import forms
-from django.db import models
-from django.utils import simplejson as json
-from philo.forms.fields import JSONFormField
-from philo.validators import TemplateValidator, json_validator
-#from philo.models.fields.entities import *
-
-
-class TemplateField(models.TextField):
-       def __init__(self, allow=None, disallow=None, secure=True, *args, **kwargs):
-               super(TemplateField, self).__init__(*args, **kwargs)
-               self.validators.append(TemplateValidator(allow, disallow, secure))
-
-
-class JSONDescriptor(object):
-       def __init__(self, field):
-               self.field = field
-       
-       def __get__(self, instance, owner):
-               if instance is None:
-                       raise AttributeError # ?
-               
-               if self.field.name not in instance.__dict__:
-                       json_string = getattr(instance, self.field.attname)
-                       instance.__dict__[self.field.name] = json.loads(json_string)
-               
-               return instance.__dict__[self.field.name]
-       
-       def __set__(self, instance, value):
-               instance.__dict__[self.field.name] = value
-               setattr(instance, self.field.attname, json.dumps(value))
-       
-       def __delete__(self, instance):
-               del(instance.__dict__[self.field.name])
-               setattr(instance, self.field.attname, json.dumps(None))
-
-
-class JSONField(models.TextField):
-       default_validators = [json_validator]
-       
-       def get_attname(self):
-               return "%s_json" % self.name
-       
-       def contribute_to_class(self, cls, name):
-               super(JSONField, self).contribute_to_class(cls, name)
-               setattr(cls, name, JSONDescriptor(self))
-               models.signals.pre_init.connect(self.fix_init_kwarg, sender=cls)
-       
-       def fix_init_kwarg(self, sender, args, kwargs, **signal_kwargs):
-               if self.name in kwargs:
-                       kwargs[self.attname] = json.dumps(kwargs.pop(self.name))
-       
-       def formfield(self, *args, **kwargs):
-               kwargs["form_class"] = JSONFormField
-               return super(JSONField, self).formfield(*args, **kwargs)
-
-
-try:
-       from south.modelsinspector import add_introspection_rules
-except ImportError:
-       pass
-else:
-       add_introspection_rules([], ["^philo\.models\.fields\.TemplateField"])
-       add_introspection_rules([], ["^philo\.models\.fields\.JSONField"])
\ No newline at end of file
similarity index 100%
rename from LICENSE
rename to philo/LICENSE
diff --git a/philo/__init__.py b/philo/__init__.py
new file mode 100644 (file)
index 0000000..32297e0
--- /dev/null
@@ -0,0 +1 @@
+VERSION = (0, 0)
similarity index 100%
rename from admin/__init__.py
rename to philo/admin/__init__.py
similarity index 78%
rename from admin/base.py
rename to philo/admin/base.py
index 8151461..75fa336 100644 (file)
@@ -31,54 +31,50 @@ class AttributeInline(generic.GenericTabularInline):
                template = 'admin/philo/edit_inline/tabular_attribute.html'
 
 
-def hide_proxy_fields(cls, attname, proxy_field_set):
-       val_set = set(getattr(cls, attname))
-       if proxy_field_set & val_set:
-               cls._hidden_attributes[attname] = list(val_set)
-               setattr(cls, attname, list(val_set - proxy_field_set))
+# HACK to bypass model validation for proxy fields
+class SpoofedHiddenFields(object):
+       def __init__(self, proxy_fields, value):
+               self.value = value
+               self.spoofed = list(set(value) - set(proxy_fields))
+       
+       def __get__(self, instance, owner):
+               if instance is None:
+                       return self.spoofed
+               return self.value
+
+
+class SpoofedAddedFields(SpoofedHiddenFields):
+       def __init__(self, proxy_fields, value):
+               self.value = value
+               self.spoofed = list(set(value) | set(proxy_fields))
+
+
+def hide_proxy_fields(cls, attname):
+       val = getattr(cls, attname, [])
+       proxy_fields = getattr(cls, 'proxy_fields')
+       if val:
+               setattr(cls, attname, SpoofedHiddenFields(proxy_fields, val))
+
+def add_proxy_fields(cls, attname):
+       val = getattr(cls, attname, [])
+       proxy_fields = getattr(cls, 'proxy_fields')
+       setattr(cls, attname, SpoofedAddedFields(proxy_fields, val))
 
 
 class EntityAdminMetaclass(admin.ModelAdmin.__metaclass__):
        def __new__(cls, name, bases, attrs):
-               # HACK to bypass model validation for proxy fields by masking them as readonly fields
                new_class = super(EntityAdminMetaclass, cls).__new__(cls, name, bases, attrs)
-               form = getattr(new_class, 'form', None)
-               if form:
-                       opts = form._meta
-                       if issubclass(form, EntityForm) and opts.model:
-                               proxy_fields = proxy_fields_for_entity_model(opts.model).keys()
-                               
-                               # Store readonly fields iff they have been declared.
-                               if 'readonly_fields' in attrs or not hasattr(new_class, '_real_readonly_fields'):
-                                       new_class._real_readonly_fields = new_class.readonly_fields
-                               
-                               readonly_fields = new_class.readonly_fields
-                               new_class.readonly_fields = list(set(readonly_fields) | set(proxy_fields))
-                               
-                               # Additional HACKS to handle raw_id_fields and other attributes that the admin
-                               # uses model._meta.get_field to validate.
-                               new_class._hidden_attributes = {}
-                               proxy_fields = set(proxy_fields)
-                               hide_proxy_fields(new_class, 'raw_id_fields', proxy_fields)
-               #END HACK
+               hide_proxy_fields(new_class, 'raw_id_fields')
+               add_proxy_fields(new_class, 'readonly_fields')
                return new_class
-
+# END HACK
 
 class EntityAdmin(admin.ModelAdmin):
        __metaclass__ = EntityAdminMetaclass
        form = EntityForm
        inlines = [AttributeInline]
        save_on_top = True
-       
-       def __init__(self, *args, **kwargs):
-               # HACK PART 2 restores the actual readonly fields etc. on __init__.
-               if hasattr(self, '_real_readonly_fields'):
-                       self.readonly_fields = self.__class__._real_readonly_fields
-               if hasattr(self, '_hidden_attributes'):
-                       for name, value in self._hidden_attributes.items():
-                               setattr(self, name, value)
-               # END HACK
-               super(EntityAdmin, self).__init__(*args, **kwargs)
+       proxy_fields = []
        
        def formfield_for_dbfield(self, db_field, **kwargs):
                """
similarity index 86%
rename from admin/collections.py
rename to philo/admin/collections.py
index dfc4826..d422b74 100644 (file)
@@ -10,6 +10,7 @@ class CollectionMemberInline(admin.TabularInline):
        classes = COLLAPSE_CLASSES
        allow_add = True
        fields = ('member_content_type', 'member_object_id', 'index')
+       sortable_field_name = 'index'
 
 
 class CollectionAdmin(admin.ModelAdmin):
diff --git a/philo/admin/forms/containers.py b/philo/admin/forms/containers.py
new file mode 100644 (file)
index 0000000..420ba17
--- /dev/null
@@ -0,0 +1,190 @@
+from django import forms
+from django.contrib.admin.widgets import AdminTextareaWidget
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q
+from django.forms.models import ModelForm, BaseInlineFormSet, BaseModelFormSet
+from django.forms.formsets import TOTAL_FORM_COUNT
+from django.utils.datastructures import SortedDict
+from philo.admin.widgets import ModelLookupWidget
+from philo.models import Contentlet, ContentReference
+
+
+__all__ = (
+       'ContentletForm',
+       'ContentletInlineFormSet',
+       'ContentReferenceForm',
+       'ContentReferenceInlineFormSet'
+)
+
+
+class ContainerForm(ModelForm):
+       def __init__(self, *args, **kwargs):
+               super(ContainerForm, self).__init__(*args, **kwargs)
+               self.verbose_name = self.instance.name.replace('_', ' ')
+               self.prefix = self.instance.name
+
+
+class ContentletForm(ContainerForm):
+       content = forms.CharField(required=False, widget=AdminTextareaWidget, label='Content')
+       
+       def should_delete(self):
+               # Delete iff: the data has changed and is now empty.
+               return self.has_changed() and not bool(self.cleaned_data['content'])
+       
+       class Meta:
+               model = Contentlet
+               fields = ['content']
+
+
+class ContentReferenceForm(ContainerForm):
+       def __init__(self, *args, **kwargs):
+               super(ContentReferenceForm, self).__init__(*args, **kwargs)
+               try:
+                       self.fields['content_id'].widget = ModelLookupWidget(self.instance.content_type)
+               except ObjectDoesNotExist:
+                       # This will happen when an empty form (which we will never use) gets instantiated.
+                       pass
+       
+       def should_delete(self):
+               return self.has_changed() and (self.cleaned_data['content_id'] is None)
+       
+       class Meta:
+               model = ContentReference
+               fields = ['content_id']
+
+
+class ContainerInlineFormSet(BaseInlineFormSet):
+       @property
+       def containers(self):
+               if not hasattr(self, '_containers'):
+                       self._containers = self.get_containers()
+               return self._containers
+       
+       def total_form_count(self):
+               # This ignores the posted management form data... but that doesn't
+               # seem to have any ill side effects.
+               return len(self.containers.keys())
+       
+       def _get_initial_forms(self):
+               return [form for form in self.forms if form.instance.pk is not None]
+       initial_forms = property(_get_initial_forms)
+       
+       def _get_extra_forms(self):
+               return [form for form in self.forms if form.instance.pk is None]
+       extra_forms = property(_get_extra_forms)
+       
+       def _construct_form(self, i, **kwargs):
+               if 'instance' not in kwargs:
+                       kwargs['instance'] = self.containers.values()[i]
+               
+               # Skip over the BaseModelFormSet. We have our own way of doing things!
+               form = super(BaseModelFormSet, self)._construct_form(i, **kwargs)
+               
+               # Since we skipped over BaseModelFormSet, we need to duplicate what BaseInlineFormSet would do.
+               if self.save_as_new:
+                       # Remove the primary key from the form's data, we are only
+                       # creating new instances
+                       form.data[form.add_prefix(self._pk_field.name)] = None
+                       
+                       # Remove the foreign key from the form's data
+                       form.data[form.add_prefix(self.fk.name)] = None
+               
+               # Set the fk value here so that the form can do it's validation.
+               setattr(form.instance, self.fk.get_attname(), self.instance.pk)
+               return form
+       
+       def add_fields(self, form, index):
+               """Override the pk field's initial value with a real one."""
+               super(ContainerInlineFormSet, self).add_fields(form, index)
+               if index is not None:
+                       pk_value = self.containers.values()[index].pk
+               else:
+                       pk_value = None
+               form.fields[self._pk_field.name].initial = pk_value
+       
+       def save_existing_objects(self, commit=True):
+               self.changed_objects = []
+               self.deleted_objects = []
+               if not self.get_queryset():
+                       return []
+               
+               saved_instances = []
+               for form in self.initial_forms:
+                       pk_name = self._pk_field.name
+                       raw_pk_value = form._raw_value(pk_name)
+                       
+                       # clean() for different types of PK fields can sometimes return
+                       # the model instance, and sometimes the PK. Handle either.
+                       pk_value = form.fields[pk_name].clean(raw_pk_value)
+                       pk_value = getattr(pk_value, 'pk', pk_value)
+                       
+                       # if the pk_value is None, they have just switched to a
+                       # template which didn't contain data about this container.
+                       # Skip!
+                       if pk_value is not None:
+                               obj = self._existing_object(pk_value)
+                               if form.should_delete():
+                                       self.deleted_objects.append(obj)
+                                       obj.delete()
+                                       continue
+                               if form.has_changed():
+                                       self.changed_objects.append((obj, form.changed_data))
+                                       saved_instances.append(self.save_existing(form, obj, commit=commit))
+                                       if not commit:
+                                               self.saved_forms.append(form)
+               return saved_instances
+
+       def save_new_objects(self, commit=True):
+               self.new_objects = []
+               for form in self.extra_forms:
+                       if not form.has_changed():
+                               continue
+                       # If someone has marked an add form for deletion, don't save the
+                       # object.
+                       if form.should_delete():
+                               continue
+                       self.new_objects.append(self.save_new(form, commit=commit))
+                       if not commit:
+                               self.saved_forms.append(form)
+               return self.new_objects
+
+
+class ContentletInlineFormSet(ContainerInlineFormSet):
+       def get_containers(self):
+               try:
+                       containers = list(self.instance.containers[0])
+               except ObjectDoesNotExist:
+                       containers = []
+               
+               qs = self.get_queryset().filter(name__in=containers)
+               container_dict = SortedDict([(container.name, container) for container in qs])
+               for name in containers:
+                       if name not in container_dict:
+                               container_dict[name] = self.model(name=name)
+               
+               container_dict.keyOrder = containers
+               return container_dict
+
+
+class ContentReferenceInlineFormSet(ContainerInlineFormSet):
+       def get_containers(self):
+               try:
+                       containers = self.instance.containers[1]
+               except ObjectDoesNotExist:
+                       containers = {}
+               
+               filter = Q()
+               for name, ct in containers.items():
+                       filter |= Q(name=name, content_type=ct)
+               qs = self.get_queryset().filter(filter)
+               
+               container_dict = SortedDict([(container.name, container) for container in qs])
+               
+               keyOrder = []
+               for name, ct in containers.items():
+                       keyOrder.append(name)
+                       if name not in container_dict:
+                               container_dict[name] = self.model(name=name, content_type=ct)
+               
+               container_dict.keyOrder = keyOrder
+               return container_dict
\ No newline at end of file
similarity index 79%
rename from admin/nodes.py
rename to philo/admin/nodes.py
index 66be107..e2a9c9d 100644 (file)
@@ -1,12 +1,14 @@
 from django.contrib import admin
 from philo.admin.base import EntityAdmin, TreeEntityAdmin, COLLAPSE_CLASSES
 from philo.models import Node, Redirect, File
+from mptt.admin import MPTTModelAdmin
 
 
 class NodeAdmin(TreeEntityAdmin):
        list_display = ('slug', 'view', 'accepts_subpath')
+       raw_id_fields = ('parent',)
        related_lookup_fields = {
-               'fk': [],
+               'fk': raw_id_fields,
                'm2m': [],
                'generic': [['view_content_type', 'view_object_id']]
        }
@@ -14,6 +16,9 @@ class NodeAdmin(TreeEntityAdmin):
        def accepts_subpath(self, obj):
                return obj.accepts_subpath
        accepts_subpath.boolean = True
+       
+       def formfield_for_foreignkey(self, db_field, request, **kwargs):
+               return super(MPTTModelAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
 
 
 class ViewAdmin(EntityAdmin):
similarity index 84%
rename from admin/pages.py
rename to philo/admin/pages.py
index 13d4098..f9e96c0 100644 (file)
@@ -46,6 +46,12 @@ class PageAdmin(ViewAdmin):
        list_filter = ('template',)
        search_fields = ['title', 'contentlets__content']
        inlines = [ContentletInline, ContentReferenceInline] + ViewAdmin.inlines
+       
+       def response_add(self, request, obj, post_url_continue='../%s/'):
+               # Shamelessly cribbed from django/contrib/auth/admin.py:143
+               if '_addanother' not in request.POST and '_popup' not in request.POST:
+                       request.POST['_continue'] = 1
+               return super(PageAdmin, self).response_add(request, obj, post_url_continue)
 
 
 class TemplateAdmin(TreeAdmin):
similarity index 59%
rename from admin/widgets.py
rename to philo/admin/widgets.py
index 7a47c63..fb13ac7 100644 (file)
@@ -1,6 +1,6 @@
 from django import forms
 from django.conf import settings
-from django.contrib.admin.widgets import FilteredSelectMultiple
+from django.contrib.admin.widgets import FilteredSelectMultiple, url_params_from_lookup_dict
 from django.utils.translation import ugettext as _
 from django.utils.safestring import mark_safe
 from django.utils.text import truncate_words
@@ -10,28 +10,34 @@ from django.utils.html import escape
 class ModelLookupWidget(forms.TextInput):
        # is_hidden = False
        
-       def __init__(self, content_type, attrs=None):
+       def __init__(self, content_type, attrs=None, limit_choices_to=None):
                self.content_type = content_type
+               self.limit_choices_to = limit_choices_to
                super(ModelLookupWidget, self).__init__(attrs)
        
        def render(self, name, value, attrs=None):
                related_url = '../../../%s/%s/' % (self.content_type.app_label, self.content_type.model)
+               params = url_params_from_lookup_dict(self.limit_choices_to)
+               if params:
+                       url = u'?' + u'&amp;'.join([u'%s=%s' % (k, v) for k, v in params.items()])
+               else:
+                       url = u''
                if attrs is None:
                        attrs = {}
-               if not attrs.has_key('class'):
+               if "class" not in attrs:
                        attrs['class'] = 'vForeignKeyRawIdAdminField'
-               output = super(ModelLookupWidget, self).render(name, value, attrs)
-               output += '<a href="%s" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);">' % (related_url, name)
-               output += '<img src="%simg/admin/selector-search.gif" width="16" height="16" alt="%s" />' % (settings.ADMIN_MEDIA_PREFIX, _('Lookup'))
-               output += '</a>'
+               output = [super(ModelLookupWidget, self).render(name, value, attrs)]
+               output.append('<a href="%s%s" class="related-lookup" id="lookup_id_%s" onclick="return showRelatedObjectLookupPopup(this);">' % (related_url, url, name))
+               output.append('<img src="%simg/admin/selector-search.gif" width="16" height="16" alt="%s" />' % (settings.ADMIN_MEDIA_PREFIX, _('Lookup')))
+               output.append('</a>')
                if value:
                        value_class = self.content_type.model_class()
                        try:
                                value_object = value_class.objects.get(pk=value)
-                               output += '&nbsp;<strong>%s</strong>' % escape(truncate_words(value_object, 14))
+                               output.append('&nbsp;<strong>%s</strong>' % escape(truncate_words(value_object, 14)))
                        except value_class.DoesNotExist:
                                pass
-               return mark_safe(output)
+               return mark_safe(u''.join(output))
 
 
 class TagFilteredSelectMultiple(FilteredSelectMultiple):
@@ -42,15 +48,12 @@ class TagFilteredSelectMultiple(FilteredSelectMultiple):
        catalog has been loaded in the page
        """
        class Media:
-               js = (settings.ADMIN_MEDIA_PREFIX + "js/core.js",
-                         settings.ADMIN_MEDIA_PREFIX + "js/SelectBox.js",
-                         settings.ADMIN_MEDIA_PREFIX + "js/SelectFilter2.js")
-               
-               if 'staticmedia' in settings.INSTALLED_APPS:
-                       import staticmedia
-                       js += (staticmedia.url('admin/js/TagCreation.js'),)
-               else:
-                       js += (settings.ADMIN_MEDIA_PREFIX + "js/TagCreation.js",)
+               js = (
+                       settings.ADMIN_MEDIA_PREFIX + "js/core.js",
+                       settings.ADMIN_MEDIA_PREFIX + "js/SelectBox.js",
+                       settings.ADMIN_MEDIA_PREFIX + "js/SelectFilter2.js",
+                       settings.ADMIN_MEDIA_PREFIX + "js/TagCreation.js",
+               )
 
        def render(self, name, value, attrs=None, choices=()):
                if attrs is None: attrs = {}
diff --git a/philo/contrib/julian/admin.py b/philo/contrib/julian/admin.py
new file mode 100644 (file)
index 0000000..8f104e2
--- /dev/null
@@ -0,0 +1,80 @@
+from django.contrib import admin
+from philo.admin import EntityAdmin, COLLAPSE_CLASSES
+from philo.contrib.julian.models import Location, Event, Calendar, CalendarView
+
+
+class LocationAdmin(EntityAdmin):
+       pass
+
+
+class EventAdmin(EntityAdmin):
+       fieldsets = (
+               (None, {
+                       'fields': ('name', 'slug', 'description', 'tags', 'owner')
+               }),
+               ('Location', {
+                       'fields': ('location_content_type', 'location_pk')
+               }),
+               ('Time', {
+                       'fields': (('start_date', 'start_time'), ('end_date', 'end_time'),),
+               }),
+               ('Advanced', {
+                       'fields': ('parent_event', 'site',),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       filter_horizontal = ['tags']
+       raw_id_fields = ['parent_event']
+       related_lookup_fields = {
+               'fk': raw_id_fields,
+               'generic': [["location_content_type", "location_pk"]]
+       }
+       prepopulated_fields = {'slug': ('name',)}
+
+
+class CalendarAdmin(EntityAdmin):
+       prepopulated_fields = {'slug': ('name',)}
+       filter_horizontal = ['events']
+       fieldsets = (
+               (None, {
+                       'fields': ('name', 'description', 'events')
+               }),
+               ('Advanced', {
+                       'fields': ('slug', 'site', 'language',),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+
+
+class CalendarViewAdmin(EntityAdmin):
+       fieldsets = (
+               (None, {
+                       'fields': ('calendar',)
+               }),
+               ('Pages', {
+                       'fields': ('index_page', 'event_detail_page')
+               }),
+               ('General Settings', {
+                       'fields': ('tag_permalink_base', 'owner_permalink_base', 'location_permalink_base', 'events_per_page')
+               }),
+               ('Event List Pages', {
+                       'fields': ('timespan_page', 'tag_page', 'location_page', 'owner_page'),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Archive Pages', {
+                       'fields': ('location_archive_page', 'tag_archive_page', 'owner_archive_page'),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Feed Settings', {
+                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       raw_id_fields = ('index_page', 'event_detail_page', 'timespan_page', 'tag_page', 'location_page', 'owner_page', 'location_archive_page', 'tag_archive_page', 'owner_archive_page', 'item_title_template', 'item_description_template',)
+       related_lookup_fields = {'fk': raw_id_fields}
+
+
+admin.site.register(Location, LocationAdmin)
+admin.site.register(Event, EventAdmin)
+admin.site.register(Calendar, CalendarAdmin)
+admin.site.register(CalendarView, CalendarViewAdmin)
\ No newline at end of file
diff --git a/philo/contrib/julian/feedgenerator.py b/philo/contrib/julian/feedgenerator.py
new file mode 100644 (file)
index 0000000..819a273
--- /dev/null
@@ -0,0 +1,77 @@
+from django.http import HttpResponse
+from django.utils.feedgenerator import SyndicationFeed
+import vobject
+
+
+# Map the keys in the ICalendarFeed internal dictionary to the names of iCalendar attributes.
+FEED_ICAL_MAP = {
+       'title': 'x-wr-calname',
+       'description': 'x-wr-caldesc',
+       #'link': ???,
+       #'language': ???,
+       #author_email
+       #author_name
+       #author_link
+       #subtitle
+       #categories
+       #feed_url
+       #feed_copyright
+       'id': 'prodid',
+       'ttl': 'x-published-ttl'
+}
+
+
+ITEM_ICAL_MAP = {
+       'title': 'summary',
+       'description': 'description',
+       'link': 'url',
+       # author_email, author_name, and author_link need special handling. Consider them the
+       # 'organizer' of the event <http://tools.ietf.org/html/rfc5545#section-3.8.4.3> and
+       # construct something based on that.
+       'pubdate': 'created',
+       'last_modified': 'last-modified',
+       #'comments' require special handling as well <http://tools.ietf.org/html/rfc5545#section-3.8.1.4>
+       'unique_id': 'uid',
+       'enclosure': 'attach', # does this need special handling?
+       'categories': 'categories', # does this need special handling?
+       # ttl is ignored.
+       'start': 'dtstart',
+       'end': 'dtend',
+}
+
+
+class ICalendarFeed(SyndicationFeed):
+       mime_type = 'text/calendar'
+       
+       def add_item(self, *args, **kwargs):
+               for kwarg in ['start', 'end', 'last_modified', 'location']:
+                       kwargs.setdefault(kwarg, None)
+               super(ICalendarFeed, self).add_item(*args, **kwargs)
+       
+       def write(self, outfile, encoding):
+               # TODO: Use encoding... how? Just convert all values when setting them should work...
+               cal = vobject.iCalendar()
+               
+               # IE/Outlook needs this. See
+               # <http://blog.thescoop.org/archives/2007/07/31/django-ical-and-vobject/>
+               cal.add('method').value = 'PUBLISH'
+               
+               for key, val in self.feed.items():
+                       if key in FEED_ICAL_MAP and val:
+                               cal.add(FEED_ICAL_MAP[key]).value = val
+               
+               for item in self.items:
+                       # TODO: handle multiple types of events.
+                       event = cal.add('vevent')
+                       for key, val in item.items():
+                               #TODO: handle the non-standard items like comments and author.
+                               if key in ITEM_ICAL_MAP and val:
+                                       event.add(ITEM_ICAL_MAP[key]).value = val
+               
+               cal.serialize(outfile)
+               
+               # Some special handling for HttpResponses. See link above.
+               if isinstance(outfile, HttpResponse):
+                       filename = self.feed.get('filename', 'filename.ics')
+                       outfile['Filename'] = filename
+                       outfile['Content-Disposition'] = 'attachment; filename=%s' % filename
\ No newline at end of file
diff --git a/philo/contrib/julian/migrations/0001_initial.py b/philo/contrib/julian/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..3236095
--- /dev/null
@@ -0,0 +1,286 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding model 'Location'
+        db.create_table('julian_location', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=255, db_index=True)),
+        ))
+        db.send_create_signal('julian', ['Location'])
+
+        # Adding model 'Event'
+        db.create_table('julian_event', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('start_date', self.gf('django.db.models.fields.DateField')()),
+            ('start_time', self.gf('django.db.models.fields.TimeField')(null=True, blank=True)),
+            ('end_date', self.gf('django.db.models.fields.DateField')()),
+            ('end_time', self.gf('django.db.models.fields.TimeField')(null=True, blank=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255, db_index=True)),
+            ('location_content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'], null=True, blank=True)),
+            ('location_pk', self.gf('django.db.models.fields.TextField')(blank=True)),
+            ('description', self.gf('philo.models.fields.TemplateField')()),
+            ('parent_event', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['julian.Event'], null=True, blank=True)),
+            ('owner', self.gf('django.db.models.fields.related.ForeignKey')(related_name='owned_events', to=orm['auth.User'])),
+            ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
+            ('last_modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
+            ('site', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['sites.Site'])),
+        ))
+        db.send_create_signal('julian', ['Event'])
+
+        # Adding unique constraint on 'Event', fields ['site', 'created']
+        db.create_unique('julian_event', ['site_id', 'created'])
+
+        # Adding M2M table for field tags on 'Event'
+        db.create_table('julian_event_tags', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('event', models.ForeignKey(orm['julian.event'], null=False)),
+            ('tag', models.ForeignKey(orm['philo.tag'], null=False))
+        ))
+        db.create_unique('julian_event_tags', ['event_id', 'tag_id'])
+
+        # Adding model 'Calendar'
+        db.create_table('julian_calendar', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=100)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=100, db_index=True)),
+            ('description', self.gf('django.db.models.fields.TextField')(blank=True)),
+            ('site', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['sites.Site'])),
+            ('language', self.gf('django.db.models.fields.CharField')(default='en', max_length=5)),
+        ))
+        db.send_create_signal('julian', ['Calendar'])
+
+        # Adding unique constraint on 'Calendar', fields ['name', 'site', 'language']
+        db.create_unique('julian_calendar', ['name', 'site_id', 'language'])
+
+        # Adding M2M table for field events on 'Calendar'
+        db.create_table('julian_calendar_events', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('calendar', models.ForeignKey(orm['julian.calendar'], null=False)),
+            ('event', models.ForeignKey(orm['julian.event'], null=False))
+        ))
+        db.create_unique('julian_calendar_events', ['calendar_id', 'event_id'])
+
+        # Adding model 'CalendarView'
+        db.create_table('julian_calendarview', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('feed_type', self.gf('django.db.models.fields.CharField')(default='text/calendar', max_length=50)),
+            ('feed_suffix', self.gf('django.db.models.fields.CharField')(default='feed', max_length=255)),
+            ('feeds_enabled', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True)),
+            ('item_title_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='julian_calendarview_title_related', null=True, to=orm['philo.Template'])),
+            ('item_description_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='julian_calendarview_description_related', null=True, to=orm['philo.Template'])),
+            ('calendar', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['julian.Calendar'])),
+            ('index_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='calendar_index_related', to=orm['philo.Page'])),
+            ('event_detail_page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='calendar_detail_related', to=orm['philo.Page'])),
+            ('timespan_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_timespan_related', null=True, to=orm['philo.Page'])),
+            ('tag_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_tag_related', null=True, to=orm['philo.Page'])),
+            ('location_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_location_related', null=True, to=orm['philo.Page'])),
+            ('owner_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_owner_related', null=True, to=orm['philo.Page'])),
+            ('tag_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_tag_archive_related', null=True, to=orm['philo.Page'])),
+            ('location_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_location_archive_related', null=True, to=orm['philo.Page'])),
+            ('owner_archive_page', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='calendar_owner_archive_related', null=True, to=orm['philo.Page'])),
+            ('tag_permalink_base', self.gf('django.db.models.fields.CharField')(default='tags', max_length=30)),
+            ('owner_permalink_base', self.gf('django.db.models.fields.CharField')(default='owners', max_length=30)),
+            ('location_permalink_base', self.gf('django.db.models.fields.CharField')(default='locations', max_length=30)),
+            ('events_per_page', self.gf('django.db.models.fields.PositiveIntegerField')(null=True, blank=True)),
+        ))
+        db.send_create_signal('julian', ['CalendarView'])
+
+
+    def backwards(self, orm):
+        
+        # Removing unique constraint on 'Calendar', fields ['name', 'site', 'language']
+        db.delete_unique('julian_calendar', ['name', 'site_id', 'language'])
+
+        # Removing unique constraint on 'Event', fields ['site', 'created']
+        db.delete_unique('julian_event', ['site_id', 'created'])
+
+        # Deleting model 'Location'
+        db.delete_table('julian_location')
+
+        # Deleting model 'Event'
+        db.delete_table('julian_event')
+
+        # Removing M2M table for field tags on 'Event'
+        db.delete_table('julian_event_tags')
+
+        # Deleting model 'Calendar'
+        db.delete_table('julian_calendar')
+
+        # Removing M2M table for field events on 'Calendar'
+        db.delete_table('julian_calendar_events')
+
+        # Deleting model 'CalendarView'
+        db.delete_table('julian_calendarview')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'julian.calendar': {
+            'Meta': {'unique_together': "(('name', 'site', 'language'),)", 'object_name': 'Calendar'},
+            'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'events': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'calendars'", 'blank': 'True', 'to': "orm['julian.Event']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'language': ('django.db.models.fields.CharField', [], {'default': "'en'", 'max_length': '5'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '100', 'db_index': 'True'})
+        },
+        'julian.calendarview': {
+            'Meta': {'object_name': 'CalendarView'},
+            'calendar': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['julian.Calendar']"}),
+            'event_detail_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'calendar_detail_related'", 'to': "orm['philo.Page']"}),
+            'events_per_page': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}),
+            'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}),
+            'feed_type': ('django.db.models.fields.CharField', [], {'default': "'text/calendar'", 'max_length': '50'}),
+            'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'calendar_index_related'", 'to': "orm['philo.Page']"}),
+            'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'julian_calendarview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'julian_calendarview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'location_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_location_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'location_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_location_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'location_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'locations'", 'max_length': '30'}),
+            'owner_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_owner_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'owner_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_owner_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'owner_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'owners'", 'max_length': '30'}),
+            'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_tag_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '30'}),
+            'timespan_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'calendar_timespan_related'", 'null': 'True', 'to': "orm['philo.Page']"})
+        },
+        'julian.event': {
+            'Meta': {'unique_together': "(('site', 'created'),)", 'object_name': 'Event'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'description': ('philo.models.fields.TemplateField', [], {}),
+            'end_date': ('django.db.models.fields.DateField', [], {}),
+            'end_time': ('django.db.models.fields.TimeField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'location_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}),
+            'location_pk': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'owned_events'", 'to': "orm['auth.User']"}),
+            'parent_event': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['julian.Event']", 'null': 'True', 'blank': 'True'}),
+            'site': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['sites.Site']"}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'start_date': ('django.db.models.fields.DateField', [], {}),
+            'start_time': ('django.db.models.fields.TimeField', [], {'null': 'True', 'blank': 'True'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'events'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"})
+        },
+        'julian.location': {
+            'Meta': {'object_name': 'Location'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+        },
+        'oberlin.locationcoordinates': {
+            'Meta': {'unique_together': "(('location_ct', 'location_pk'),)", 'object_name': 'LocationCoordinates'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'latitude': ('django.db.models.fields.FloatField', [], {}),
+            'location_ct': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'location_pk': ('django.db.models.fields.TextField', [], {}),
+            'longitude': ('django.db.models.fields.FloatField', [], {})
+        },
+        'philo.attribute': {
+            'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'},
+            'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}),
+            'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'philo.node': {
+            'Meta': {'object_name': 'Node'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.page': {
+            'Meta': {'object_name': 'Page'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.tag': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+        },
+        'philo.template': {
+            'Meta': {'object_name': 'Template'},
+            'code': ('philo.models.fields.TemplateField', [], {}),
+            'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+        },
+        'sites.site': {
+            'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"},
+            'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'root_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'sites'", 'null': 'True', 'to': "orm['philo.Node']"})
+        }
+    }
+
+    complete_apps = ['julian']
diff --git a/philo/contrib/julian/models.py b/philo/contrib/julian/models.py
new file mode 100644 (file)
index 0000000..5c49c7e
--- /dev/null
@@ -0,0 +1,461 @@
+from django.conf import settings
+from django.conf.urls.defaults import url, patterns, include
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.generic import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.sites.models import Site
+from django.core.exceptions import ValidationError, ObjectDoesNotExist
+from django.core.validators import RegexValidator
+from django.db import models
+from django.db.models.query import QuerySet
+from django.http import HttpResponse, Http404
+from django.utils.encoding import force_unicode
+from philo.contrib.julian.feedgenerator import ICalendarFeed
+from philo.contrib.penfield.models import FeedView, FEEDS
+from philo.exceptions import ViewCanNotProvideSubpath
+from philo.models import Tag, Entity, Page, TemplateField
+from philo.utils import ContentTypeRegistryLimiter
+import datetime, calendar
+
+
+__all__ = ('register_location_model', 'unregister_location_model', 'Location', 'TimedModel', 'Event', 'Calendar', 'CalendarView',)
+
+
+ICALENDAR = ICalendarFeed.mime_type
+FEEDS[ICALENDAR] = ICalendarFeed
+try:
+       DEFAULT_SITE = Site.objects.get_current()
+except:
+       DEFAULT_SITE = None
+_languages = dict(settings.LANGUAGES)
+try:
+       _languages[settings.LANGUAGE_CODE]
+       DEFAULT_LANGUAGE = settings.LANGUAGE_CODE
+except KeyError:
+       try:
+               lang = settings.LANGUAGE_CODE.split('-')[0]
+               _languages[lang]
+               DEFAULT_LANGUAGE = lang
+       except KeyError:
+               DEFAULT_LANGUAGE = None
+
+
+location_content_type_limiter = ContentTypeRegistryLimiter()
+
+
+def register_location_model(model):
+       location_content_type_limiter.register_class(model)
+
+
+def unregister_location_model(model):
+       location_content_type_limiter.unregister_class(model)
+
+
+class Location(Entity):
+       name = models.CharField(max_length=255)
+       slug = models.SlugField(max_length=255, unique=True)
+       
+       def __unicode__(self):
+               return self.name
+
+
+register_location_model(Location)
+
+
+class TimedModel(models.Model):
+       start_date = models.DateField(help_text="YYYY-MM-DD")
+       start_time = models.TimeField(blank=True, null=True, help_text="HH:MM:SS - 24 hour clock")
+       end_date = models.DateField()
+       end_time = models.TimeField(blank=True, null=True)
+       
+       def is_all_day(self):
+               return self.start_time is None and self.end_time is None
+       
+       def clean(self):
+               if bool(self.start_time) != bool(self.end_time):
+                       raise ValidationError("A %s must have either a start time and an end time or neither.")
+               
+               if self.start_date > self.end_date or self.start_date == self.end_date and self.start_time > self.end_time:
+                       raise ValidationError("A %s cannot end before it starts." % self.__class__.__name__)
+       
+       def get_start(self):
+               return datetime.datetime.combine(self.start_date, self.start_time) if self.start_time else self.start_date
+       
+       def get_end(self):
+               return datetime.datetime.combine(self.end_date, self.end_time) if self.end_time else self.end_date
+       
+       class Meta:
+               abstract = True
+
+
+class EventManager(models.Manager):
+       def get_query_set(self):
+               return EventQuerySet(self.model)
+
+class EventQuerySet(QuerySet):
+       def upcoming(self):
+               return self.filter(start_date__gte=datetime.date.today())
+       def current(self):
+               return self.filter(start_date__lte=datetime.date.today(), end_date__gte=datetime.date.today())
+       def single_day(self):
+               return self.filter(start_date__exact=models.F('end_date'))
+       def multiday(self):
+               return self.exclude(start_date__exact=models.F('end_date'))
+
+class Event(Entity, TimedModel):
+       name = models.CharField(max_length=255)
+       slug = models.SlugField(max_length=255, unique_for_date='start_date')
+       
+       location_content_type = models.ForeignKey(ContentType, limit_choices_to=location_content_type_limiter, blank=True, null=True)
+       location_pk = models.TextField(blank=True)
+       location = GenericForeignKey('location_content_type', 'location_pk')
+       
+       description = TemplateField()
+       
+       tags = models.ManyToManyField(Tag, related_name='events', blank=True, null=True)
+       
+       parent_event = models.ForeignKey('self', blank=True, null=True)
+       
+       # TODO: "User module"
+       owner = models.ForeignKey(User, related_name='owned_events')
+       
+       created = models.DateTimeField(auto_now_add=True)
+       last_modified = models.DateTimeField(auto_now=True)
+       
+       site = models.ForeignKey(Site, default=DEFAULT_SITE)
+       
+       @property
+       def uuid(self):
+               return "%s@%s" % (self.created.isoformat(), getattr(self.site, 'domain', 'None'))
+       
+       objects = EventManager()
+       
+       def __unicode__(self):
+               return self.name
+       
+       class Meta:
+               unique_together = ('site', 'created')
+
+
+class Calendar(Entity):
+       name = models.CharField(max_length=100)
+       slug = models.SlugField(max_length=100)
+       description = models.TextField(blank=True)
+       events = models.ManyToManyField(Event, related_name='calendars', blank=True)
+       
+       site = models.ForeignKey(Site, default=DEFAULT_SITE)
+       language = models.CharField(max_length=5, choices=settings.LANGUAGES, default=DEFAULT_LANGUAGE)
+       
+       def __unicode__(self):
+               return self.name
+       
+       @property
+       def fpi(self):
+               # See http://xml.coverpages.org/tauber-fpi.html or ISO 9070:1991 for format information.
+               return "-//%s//%s//%s" % (self.site.name, self.name, self.language.split('-')[0].upper())
+       
+       class Meta:
+               unique_together = ('name', 'site', 'language')
+
+
+class CalendarView(FeedView):
+       calendar = models.ForeignKey(Calendar)
+       index_page = models.ForeignKey(Page, related_name="calendar_index_related")
+       event_detail_page = models.ForeignKey(Page, related_name="calendar_detail_related")
+       
+       timespan_page = models.ForeignKey(Page, related_name="calendar_timespan_related", blank=True, null=True)
+       tag_page = models.ForeignKey(Page, related_name="calendar_tag_related", blank=True, null=True)
+       location_page = models.ForeignKey(Page, related_name="calendar_location_related", blank=True, null=True)
+       owner_page = models.ForeignKey(Page, related_name="calendar_owner_related", blank=True, null=True)
+       
+       tag_archive_page = models.ForeignKey(Page, related_name="calendar_tag_archive_related", blank=True, null=True)
+       location_archive_page = models.ForeignKey(Page, related_name="calendar_location_archive_related", blank=True, null=True)
+       owner_archive_page = models.ForeignKey(Page, related_name="calendar_owner_archive_related", blank=True, null=True)
+       
+       tag_permalink_base = models.CharField(max_length=30, default='tags')
+       owner_permalink_base = models.CharField(max_length=30, default='owners')
+       location_permalink_base = models.CharField(max_length=30, default='locations')
+       events_per_page = models.PositiveIntegerField(blank=True, null=True)
+       
+       item_context_var = "events"
+       object_attr = "calendar"
+       
+       def get_reverse_params(self, obj):
+               if isinstance(obj, User):
+                       return 'events_for_user', [], {'username': obj.username}
+               elif isinstance(obj, Event):
+                       return 'event_detail', [], {
+                               'year': str(obj.start_date.year).zfill(4),
+                               'month': str(obj.start_date.month).zfill(2),
+                               'day': str(obj.start_date.day).zfill(2),
+                               'slug': obj.slug
+                       }
+               elif isinstance(obj, Tag) or isinstance(obj, models.query.QuerySet) and obj.model == Tag:
+                       if isinstance(obj, Tag):
+                               obj = [obj]
+                       return 'entries_by_tag', [], {'tag_slugs': '/'.join(obj)}
+               raise ViewCanNotProvideSubpath
+       
+       def timespan_patterns(self, pattern, timespan_name):
+               return self.feed_patterns(pattern, 'get_events_by_timespan', 'timespan_page', "events_by_%s" % timespan_name)
+       
+       @property
+       def urlpatterns(self):
+               # Perhaps timespans should be done with GET parameters? Or two /-separated
+               # date slugs? (e.g. 2010-02-1/2010-02-2) or a start and duration?
+               # (e.g. 2010-02-01/week/ or ?d=2010-02-01&l=week)
+               urlpatterns = self.feed_patterns(r'^', 'get_all_events', 'index_page', 'index') + \
+                       self.timespan_patterns(r'^(?P<year>\d{4})', 'year') + \
+                       self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})', 'month') + \
+                       self.timespan_patterns(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})', 'day') + \
+                       self.feed_patterns(r'^%s/(?P<username>[^/]+)' % self.owner_permalink_base, 'get_events_by_owner', 'owner_page', 'events_by_user') + \
+                       self.feed_patterns(r'^%s/(?P<app_label>\w+)/(?P<model>\w+)/(?P<pk>[^/]+)' % self.location_permalink_base, 'get_events_by_location', 'location_page', 'events_by_location') + \
+                       self.feed_patterns(r'^%s/(?P<tag_slugs>[-\w]+[-+/\w]*)' % self.tag_permalink_base, 'get_events_by_tag', 'tag_page', 'events_by_tag') + \
+                       patterns('',
+                               url(r'(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<slug>[\w-]+)$', self.event_detail_view, name="event_detail"),
+                       )
+                       
+                       # Some sort of shortcut for a location would be useful. This could be on a per-calendar
+                       # or per-calendar-view basis.
+                       #url(r'^%s/(?P<slug>[\w-]+)' % self.location_permalink_base, ...)
+               
+               if self.tag_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^%s$' % self.tag_permalink_base, self.tag_archive_view, name='tag_archive')
+                       )
+               
+               if self.owner_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^%s$' % self.owner_permalink_base, self.owner_archive_view, name='owner_archive')
+                       )
+               
+               if self.location_archive_page:
+                       urlpatterns += patterns('',
+                               url(r'^%s$' % self.location_permalink_base, self.location_archive_view, name='location_archive')
+                       )
+               return urlpatterns
+       
+       # Basic QuerySet fetchers.
+       def get_event_queryset(self):
+               return self.calendar.events.all()
+       
+       def get_timespan_queryset(self, year, month=None, day=None):
+               qs = self.get_event_queryset()
+               # See python documentation for the min/max values.
+               if year and month and day:
+                       year, month, day = int(year), int(month), int(day)
+                       start_datetime = datetime.datetime(year, month, day, 0, 0)
+                       end_datetime = datetime.datetime(year, month, day, 23, 59)
+               elif year and month:
+                       year, month = int(year), int(month)
+                       start_datetime = datetime.datetime(year, month, 1, 0, 0)
+                       end_datetime = datetime.datetime(year, month, calendar.monthrange(year, month)[1], 23, 59)
+               else:
+                       year = int(year)
+                       start_datetime = datetime.datetime(year, 1, 1, 0, 0)
+                       end_datetime = datetime.datetime(year, 12, 31, 23, 59)
+               
+               return qs.exclude(end_date__lt=start_datetime, end_time__lt=start_datetime).exclude(start_date__gt=end_datetime, start_time__gt=end_datetime, start_time__isnull=False).exclude(start_time__isnull=True, start_date__gt=end_datetime)
+       
+       def get_tag_queryset(self):
+               return Tag.objects.filter(events__calendars=self.calendar).distinct()
+       
+       def get_location_querysets(self):
+               # Potential bottleneck?
+               location_map = {}
+               locations = Event.objects.values_list('location_content_type', 'location_pk')
+               
+               for ct, pk in locations:
+                       location_map.setdefault(ct, []).append(pk)
+               
+               location_cts = ContentType.objects.in_bulk(location_map.keys())
+               location_querysets = {}
+               
+               for ct_pk, pks in location_map.items():
+                       ct = location_cts[ct_pk]
+                       location_querysets[ct] = ct.model_class()._default_manager.filter(pk__in=pks)
+               
+               return location_querysets
+       
+       def get_owner_queryset(self):
+               return User.objects.filter(owned_events__calendars=self.calendar).distinct()
+       
+       # Event QuerySet parsers for a request/args/kwargs
+       def get_all_events(self, request, extra_context=None):
+               return self.get_event_queryset(), extra_context
+       
+       def get_events_by_timespan(self, request, year, month=None, day=None, extra_context=None):
+               context = extra_context or {}
+               context.update({
+                       'year': year,
+                       'month': month,
+                       'day': day
+               })
+               return self.get_timespan_queryset(year, month, day), context
+       
+       def get_events_by_owner(self, request, username, extra_context=None):
+               try:
+                       owner = self.get_owner_queryset().get(username=username)
+               except User.DoesNotExist:
+                       raise Http404
+               
+               qs = self.get_event_queryset().filter(owner=owner)
+               context = extra_context or {}
+               context.update({
+                       'owner': owner
+               })
+               return qs, context
+       
+       def get_events_by_tag(self, request, tag_slugs, extra_context=None):
+               tag_slugs = tag_slugs.replace('+', '/').split('/')
+               tags = self.get_tag_queryset().filter(slug__in=tag_slugs)
+               
+               if not tags:
+                       raise Http404
+               
+               # Raise a 404 on an incorrect slug.
+               found_slugs = [tag.slug for tag in tags]
+               for slug in tag_slugs:
+                       if slug and slug not in found_slugs:
+                               raise Http404
+
+               events = self.get_event_queryset()
+               for tag in tags:
+                       events = events.filter(tags=tag)
+               
+               context = extra_context or {}
+               context.update({'tags': tags})
+               
+               return events, context
+       
+       def get_events_by_location(self, request, app_label, model, pk, extra_context=None):
+               try:
+                       ct = ContentType.objects.get(app_label=app_label, model=model)
+                       location = ct.model_class()._default_manager.get(pk=pk)
+               except ObjectDoesNotExist:
+                       raise Http404
+               
+               events = self.get_event_queryset().filter(location_content_type=ct, location_pk=location.pk)
+               
+               context = extra_context or {}
+               context.update({
+                       'location': location
+               })
+               return events, context
+       
+       # Detail View.
+       def event_detail_view(self, request, year, month, day, slug, extra_context=None):
+               try:
+                       event = Event.objects.select_related('parent_event').get(start_date__year=year, start_date__month=month, start_date__day=day, slug=slug)
+               except Event.DoesNotExist:
+                       raise Http404
+               
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'event': event
+               })
+               return self.event_detail_page.render_to_response(request, extra_context=context)
+       
+       # Archive Views.
+       def tag_archive_view(self, request, extra_context=None):
+               tags = self.get_tag_queryset()
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'tags': tags
+               })
+               return self.tag_archive_page.render_to_response(request, extra_context=context)
+       
+       def location_archive_view(self, request, extra_context=None):
+               # What datastructure should locations be?
+               locations = self.get_location_querysets()
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'locations': locations
+               })
+               return self.location_archive_page.render_to_response(request, extra_context=context)
+       
+       def owner_archive_view(self, request, extra_context=None):
+               owners = self.get_owner_queryset()
+               context = self.get_context()
+               context.update(extra_context or {})
+               context.update({
+                       'owners': owners
+               })
+               return self.owner_archive_page.render_to_response(request, extra_context=context)
+       
+       # Process page items
+       def process_page_items(self, request, items):
+               if self.events_per_page:
+                       page_num = request.GET.get('page', 1)
+                       paginator, paginated_page, items = paginate(items, self.events_per_page, page_num)
+                       item_context = {
+                               'paginator': paginator,
+                               'paginated_page': paginated_page,
+                               self.item_context_var: items
+                       }
+               else:
+                       item_context = {
+                               self.item_context_var: items
+                       }
+               return items, item_context
+       
+       # Feed information hooks
+       def title(self, obj):
+               return obj.name
+       
+       def link(self, obj):
+               # Link is ignored anyway...
+               return ""
+       
+       def feed_guid(self, obj):
+               return obj.fpi
+       
+       def description(self, obj):
+               return obj.description
+       
+       def feed_extra_kwargs(self, obj):
+               return {'filename': "%s.ics" % obj.slug}
+       
+       def item_title(self, item):
+               return item.name
+       
+       def item_description(self, item):
+               return item.description
+       
+       def item_link(self, item):
+               return self.reverse(item)
+       
+       def item_guid(self, item):
+               return item.uuid
+       
+       def item_author_name(self, item):
+               if item.owner:
+                       return item.owner.get_full_name()
+       
+       def item_author_email(self, item):
+               return getattr(item.owner, 'email', None) or None
+       
+       def item_pubdate(self, item):
+               return item.created
+       
+       def item_categories(self, item):
+               return [tag.name for tag in item.tags.all()]
+       
+       def item_extra_kwargs(self, item):
+               return {
+                       'start': item.get_start(),
+                       'end': item.get_end(),
+                       'last_modified': item.last_modified,
+                       # Is forcing unicode enough, or should we look for a "custom method"?
+                       'location': force_unicode(item.location),
+               }
+       
+       def __unicode__(self):
+               return u"%s for %s" % (self.__class__.__name__, self.calendar)
+
+field = CalendarView._meta.get_field('feed_type')
+field._choices += ((ICALENDAR, 'iCalendar'),)
+field.default = ICALENDAR
\ No newline at end of file
similarity index 82%
rename from contrib/penfield/admin.py
rename to philo/contrib/penfield/admin.py
index 950539d..c70cf46 100644 (file)
@@ -1,5 +1,7 @@
-from django.contrib import admin
 from django import forms
+from django.contrib import admin
+from django.core.urlresolvers import reverse
+from django.http import HttpResponseRedirect, QueryDict
 from philo.admin import EntityAdmin, AddTagAdmin, COLLAPSE_CLASSES
 from philo.contrib.penfield.models import BlogEntry, Blog, BlogView, Newsletter, NewsletterArticle, NewsletterIssue, NewsletterView
 
@@ -60,7 +62,7 @@ class BlogViewAdmin(EntityAdmin):
                        'classes': COLLAPSE_CLASSES
                }),
                ('Feed Settings', {
-                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',),
+                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'feed_length', 'item_title_template', 'item_description_template',),
                        'classes': COLLAPSE_CLASSES
                })
        )
@@ -91,10 +93,18 @@ class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin):
                        'classes': COLLAPSE_CLASSES
                })
        )
+       actions = ['make_issue']
        
        def author_names(self, obj):
                return ', '.join([author.get_full_name() for author in obj.authors.all()])
        author_names.short_description = "Authors"
+       
+       def make_issue(self, request, queryset):
+               opts = NewsletterIssue._meta
+               info = opts.app_label, opts.module_name
+               url = reverse("admin:%s_%s_add" % info)
+               return HttpResponseRedirect("%s?articles=%s" % (url, ",".join([str(a.pk) for a in queryset])))
+       make_issue.short_description = u"Create issue from selected %(verbose_name_plural)s"
 
 
 class NewsletterIssueAdmin(TitledAdmin):
@@ -117,7 +127,7 @@ class NewsletterViewAdmin(EntityAdmin):
                        'classes': COLLAPSE_CLASSES
                }),
                ('Feeds', {
-                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',),
+                       'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'feed_length', 'item_title_template', 'item_description_template',),
                        'classes': COLLAPSE_CLASSES
                })
        )
diff --git a/philo/contrib/penfield/exceptions.py b/philo/contrib/penfield/exceptions.py
new file mode 100644 (file)
index 0000000..96b96ed
--- /dev/null
@@ -0,0 +1,3 @@
+class HttpNotAcceptable(Exception):
+       """This will be raised if an Http-Accept header will not accept the feed content types that are available."""
+       pass
\ No newline at end of file
diff --git a/philo/contrib/penfield/middleware.py b/philo/contrib/penfield/middleware.py
new file mode 100644 (file)
index 0000000..b25a28b
--- /dev/null
@@ -0,0 +1,14 @@
+from django.http import HttpResponse
+from django.utils.decorators import decorator_from_middleware
+from philo.contrib.penfield.exceptions import HttpNotAcceptable
+
+
+class HttpNotAcceptableMiddleware(object):
+       """Middleware to catch HttpNotAcceptable errors and return an Http406 response.
+       See RFC 2616."""
+       def process_exception(self, request, exception):
+               if isinstance(exception, HttpNotAcceptable):
+                       return HttpResponse(status=406)
+
+
+http_not_acceptable = decorator_from_middleware(HttpNotAcceptableMiddleware)
\ No newline at end of file
diff --git a/philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py b/philo/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py
new file mode 100644 (file)
index 0000000..9b9ffa7
--- /dev/null
@@ -0,0 +1,204 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding field 'NewsletterView.feed_length'
+        db.add_column('penfield_newsletterview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False)
+
+        # Adding field 'BlogView.feed_length'
+        db.add_column('penfield_blogview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'NewsletterView.feed_length'
+        db.delete_column('penfield_newsletterview', 'feed_length')
+
+        # Deleting field 'BlogView.feed_length'
+        db.delete_column('penfield_blogview', 'feed_length')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'oberlin.person': {
+            'Meta': {'object_name': 'Person'},
+            'bio': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '70', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'penfield.blog': {
+            'Meta': {'object_name': 'Blog'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.blogentry': {
+            'Meta': {'object_name': 'BlogEntry'},
+            'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['oberlin.Person']"}),
+            'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}),
+            'content': ('django.db.models.fields.TextField', [], {}),
+            'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}),
+            'excerpt': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'blogentries'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.blogview': {
+            'Meta': {'object_name': 'BlogView'},
+            'blog': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogviews'", 'to': "orm['penfield.Blog']"}),
+            'entries_per_page': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'entry_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_entry_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'entry_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_entry_related'", 'to': "orm['philo.Page']"}),
+            'entry_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'entries'", 'max_length': '255'}),
+            'entry_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+            'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}),
+            'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}),
+            'feed_type': ('django.db.models.fields.CharField', [], {'default': "'application/atom+xml; charset=utf8'", 'max_length': '50'}),
+            'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_index_related'", 'to': "orm['philo.Page']"}),
+            'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_tag_related'", 'to': "orm['philo.Page']"}),
+            'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '255'})
+        },
+        'penfield.newsletter': {
+            'Meta': {'object_name': 'Newsletter'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.newsletterarticle': {
+            'Meta': {'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'},
+            'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['oberlin.Person']"}),
+            'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}),
+            'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'lede': ('philo.models.fields.TemplateField', [], {'null': 'True', 'blank': 'True'}),
+            'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'articles'", 'to': "orm['penfield.Newsletter']"}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'newsletterarticles'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.newsletterissue': {
+            'Meta': {'unique_together': "(('newsletter', 'numbering'),)", 'object_name': 'NewsletterIssue'},
+            'articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'issues'", 'symmetrical': 'False', 'to': "orm['penfield.NewsletterArticle']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issues'", 'to': "orm['penfield.Newsletter']"}),
+            'numbering': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'penfield.newsletterview': {
+            'Meta': {'object_name': 'NewsletterView'},
+            'article_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_article_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'article_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_article_related'", 'to': "orm['philo.Page']"}),
+            'article_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'articles'", 'max_length': '255'}),
+            'article_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
+            'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}),
+            'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}),
+            'feed_type': ('django.db.models.fields.CharField', [], {'default': "'application/atom+xml; charset=utf8'", 'max_length': '50'}),
+            'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_index_related'", 'to': "orm['philo.Page']"}),
+            'issue_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_issue_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}),
+            'issue_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_issue_related'", 'to': "orm['philo.Page']"}),
+            'issue_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'issues'", 'max_length': '255'}),
+            'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletterviews'", 'to': "orm['penfield.Newsletter']"})
+        },
+        'philo.attribute': {
+            'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'},
+            'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}),
+            'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'philo.node': {
+            'Meta': {'object_name': 'Node'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}),
+            'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
+        },
+        'philo.page': {
+            'Meta': {'object_name': 'Page'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'philo.tag': {
+            'Meta': {'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'})
+        },
+        'philo.template': {
+            'Meta': {'object_name': 'Template'},
+            'code': ('philo.models.fields.TemplateField', [], {}),
+            'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
+        }
+    }
+
+    complete_apps = ['penfield']
similarity index 97%
rename from contrib/penfield/models.py
rename to philo/contrib/penfield/models.py
index 98dcdd5..a03bed8 100644 (file)
@@ -10,6 +10,8 @@ from django.utils.datastructures import SortedDict
 from django.utils.encoding import smart_unicode, force_unicode
 from django.utils.html import escape
 from datetime import date, datetime
+from philo.contrib.penfield.exceptions import HttpNotAcceptable
+from philo.contrib.penfield.middleware import http_not_acceptable
 from philo.contrib.penfield.validators import validate_pagination_count
 from philo.exceptions import ViewCanNotProvideSubpath
 from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template
@@ -44,6 +46,7 @@ class FeedView(MultiView):
        feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM)
        feed_suffix = models.CharField(max_length=255, blank=False, default="feed")
        feeds_enabled = models.BooleanField(default=True)
+       feed_length = models.PositiveIntegerField(blank=True, null=True, default=15, help_text="The maximum number of items to return for this feed. All items will be returned if this field is blank.")
        
        item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related")
        item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related")
@@ -62,7 +65,7 @@ class FeedView(MultiView):
                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_view = http_not_acceptable(self.feed_view(get_items_attr, feed_reverse_name))
                        feed_pattern = r'%s%s%s$' % (base, (base and base[-1] != "^") and "/" or "", self.feed_suffix)
                        urlpatterns += patterns('',
                                url(feed_pattern, feed_view, name=feed_reverse_name),
@@ -139,8 +142,7 @@ class FeedView(MultiView):
                                else:
                                        feed_type = None
                        if not feed_type:
-                               # See RFC 2616
-                               return HttpResponse(status=406)
+                               raise HttpNotAcceptable
                return FEEDS[feed_type]
        
        def get_feed(self, obj, request, reverse_name):
@@ -194,6 +196,9 @@ class FeedView(MultiView):
                except Site.DoesNotExist:
                        current_site = RequestSite(request)
                
+               if self.feed_length is not None:
+                       items = items[:self.feed_length]
+               
                for item in items:
                        if title_template is not None:
                                title = title_template.render(RequestContext(request, {'obj': item}))
diff --git a/philo/contrib/shipherd/templatetags/shipherd.py b/philo/contrib/shipherd/templatetags/shipherd.py
new file mode 100644 (file)
index 0000000..e3019e1
--- /dev/null
@@ -0,0 +1,170 @@
+from django import template, VERSION as django_version
+from django.conf import settings
+from django.utils.safestring import mark_safe
+from philo.contrib.shipherd.models import Navigation
+from philo.models import Node
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext as _
+
+
+register = template.Library()
+
+
+class LazyNavigationRecurser(object):
+       def __init__(self, template_nodes, items, context, request):
+               self.template_nodes = template_nodes
+               self.items = items
+               self.context = context
+               self.request = request
+       
+       def __call__(self):
+               items = self.items
+               context = self.context
+               request = self.request
+               
+               if not items:
+                       return ''
+               
+               if 'navloop' in context:
+                       parentloop = context['navloop']
+               else:
+                       parentloop = {}
+               context.push()
+               
+               depth = items[0].get_level()
+               len_items = len(items)
+               
+               loop_dict = context['navloop'] = {
+                       'parentloop': parentloop,
+                       'depth': depth + 1,
+                       'depth0': depth
+               }
+               
+               bits = []
+               
+               for i, item in enumerate(items):
+                       # First set context variables.
+                       loop_dict['counter0'] = i
+                       loop_dict['counter'] = i + 1
+                       loop_dict['revcounter'] = len_items - i
+                       loop_dict['revcounter0'] = len_items - i - 1
+                       loop_dict['first'] = (i == 0)
+                       loop_dict['last'] = (i == len_items - 1)
+                       
+                       # Set on loop_dict and context for backwards-compatibility.
+                       # Eventually only allow access through the loop_dict.
+                       loop_dict['active'] = context['active'] = item.is_active(request)
+                       loop_dict['active_descendants'] = context['active_descendants'] = item.has_active_descendants(request)
+                       
+                       # Set these directly in the context for easy access.
+                       context['item'] = item
+                       context['children'] = self.__class__(self.template_nodes, item.get_children(), context, request)
+                       
+                       # Then render the nodelist bit by bit.
+                       for node in self.template_nodes:
+                               bits.append(node.render(context))
+               context.pop()
+               return mark_safe(''.join(bits))
+
+
+class RecurseNavigationNode(template.Node):
+       def __init__(self, template_nodes, instance_var, key):
+               self.template_nodes = template_nodes
+               self.instance_var = instance_var
+               self.key = key
+       
+       def render(self, context):
+               try:
+                       request = context['request']
+               except KeyError:
+                       return ''
+               
+               instance = self.instance_var.resolve(context)
+               
+               try:
+                       items = instance.navigation[self.key]
+               except:
+                       return settings.TEMPLATE_STRING_IF_INVALID
+               
+               return LazyNavigationRecurser(self.template_nodes, items, context, request)()
+
+
+@register.tag
+def recursenavigation(parser, token):
+       """
+       The recursenavigation templatetag takes two arguments:
+       - the node for which the navigation should be found
+       - the navigation's key.
+       
+       It will then recursively loop over each item in the navigation and render the template
+       chunk within the block. recursenavigation sets the following variables in the context:
+       
+               ==============================  ================================================
+               Variable                        Description
+               ==============================  ================================================
+               ``navloop.depth``               The current depth of the loop (1 is the top level)
+               ``navloop.depth0``              The current depth of the loop (0 is the top level)
+               ``navloop.counter``             The current iteration of the current level(1-indexed)
+               ``navloop.counter0``            The current iteration of the current level(0-indexed)
+               ``navloop.first``               True if this is the first time through the current level
+               ``navloop.last``                True if this is the last time through the current level
+               ``navloop.parentloop``          This is the loop one level "above" the current one
+               ==============================  ================================================
+               ``item``                        The current item in the loop (a NavigationItem instance)
+               ``children``                    If accessed, performs the next level of recursion.
+               ``navloop.active``              True if the item is active for this request
+               ``navloop.active_descendants``  True if the item has active descendants for this request
+               ==============================  ================================================
+       
+       Example:
+               <ul>
+                       {% recursenavigation node main %}
+                               <li{% if navloop.active %} class='active'{% endif %}>
+                                       {{ navloop.item.text }}
+                                       {% if item.get_children %}
+                                               <ul>
+                                                       {{ children }}
+                                               </ul>
+                                       {% endif %}
+                               </li>
+                       {% endrecursenavigation %}
+               </ul>
+       """
+       bits = token.contents.split()
+       if len(bits) != 3:
+               raise template.TemplateSyntaxError(_('%s tag requires two arguments: a node and a navigation section name') % bits[0])
+       
+       instance_var = parser.compile_filter(bits[1])
+       key = bits[2]
+       
+       template_nodes = parser.parse(('recurse', 'endrecursenavigation',))
+       
+       token = parser.next_token()
+       if token.contents == 'recurse':
+               template_nodes.append(RecurseNavigationMarker())
+               template_nodes.extend(parser.parse(('endrecursenavigation')))
+               parser.delete_first_token()
+       
+       return RecurseNavigationNode(template_nodes, instance_var, key)
+
+
+@register.filter
+def has_navigation(node, key=None):
+       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
+def navigation_host(node, key):
+       try:
+               return Navigation.objects.filter(node__in=node.get_ancestors(include_self=True), key=key).order_by('-node__level')[0].node
+       except:
+               return node
\ No newline at end of file
diff --git a/philo/contrib/sobol/__init__.py b/philo/contrib/sobol/__init__.py
new file mode 100644 (file)
index 0000000..90eaf18
--- /dev/null
@@ -0,0 +1 @@
+from philo.contrib.sobol.search import *
\ No newline at end of file
diff --git a/philo/contrib/sobol/admin.py b/philo/contrib/sobol/admin.py
new file mode 100644 (file)
index 0000000..87dd39a
--- /dev/null
@@ -0,0 +1,104 @@
+from django.conf import settings
+from django.conf.urls.defaults import patterns, url
+from django.contrib import admin
+from django.core.urlresolvers import reverse
+from django.db.models import Count
+from django.http import HttpResponseRedirect, Http404
+from django.shortcuts import render_to_response
+from django.template import RequestContext
+from django.utils.translation import ugettext_lazy as _
+from philo.admin import EntityAdmin
+from philo.contrib.sobol.models import Search, ResultURL, SearchView
+from functools import update_wrapper
+
+
+class ResultURLInline(admin.TabularInline):
+       model = ResultURL
+       readonly_fields = ('url',)
+       can_delete = False
+       extra = 0
+       max_num = 0
+
+
+class SearchAdmin(admin.ModelAdmin):
+       readonly_fields = ('string',)
+       inlines = [ResultURLInline]
+       list_display = ['string', 'unique_urls', 'total_clicks']
+       search_fields = ['string', 'result_urls__url']
+       actions = ['results_action']
+       if 'grappelli' in settings.INSTALLED_APPS:
+               results_template = 'admin/sobol/search/grappelli_results.html'
+       else:
+               results_template = 'admin/sobol/search/results.html'
+       
+       def get_urls(self):
+               urlpatterns = super(SearchAdmin, self).get_urls()
+               
+               def wrap(view):
+                       def wrapper(*args, **kwargs):
+                               return self.admin_site.admin_view(view)(*args, **kwargs)
+                       return update_wrapper(wrapper, view)
+               
+               info = self.model._meta.app_label, self.model._meta.module_name
+               
+               urlpatterns = patterns('',
+                       url(r'^results/$', wrap(self.results_view), name="%s_%s_selected_results" % info),
+                       url(r'^(.+)/results/$', wrap(self.results_view), name="%s_%s_results" % info)
+               ) + urlpatterns
+               return urlpatterns
+       
+       def unique_urls(self, obj):
+               return obj.unique_urls
+       unique_urls.admin_order_field = 'unique_urls'
+       
+       def total_clicks(self, obj):
+               return obj.total_clicks
+       total_clicks.admin_order_field = 'total_clicks'
+       
+       def queryset(self, request):
+               qs = super(SearchAdmin, self).queryset(request)
+               return qs.annotate(total_clicks=Count('result_urls__clicks', distinct=True), unique_urls=Count('result_urls', distinct=True))
+       
+       def results_action(self, request, queryset):
+               info = self.model._meta.app_label, self.model._meta.module_name
+               if len(queryset) == 1:
+                       return HttpResponseRedirect(reverse("admin:%s_%s_results" % info, args=(queryset[0].pk,)))
+               else:
+                       url = reverse("admin:%s_%s_selected_results" % info)
+                       return HttpResponseRedirect("%s?ids=%s" % (url, ','.join([str(item.pk) for item in queryset])))
+       results_action.short_description = "View results for selected %(verbose_name_plural)s"
+       
+       def results_view(self, request, object_id=None, extra_context=None):
+               if object_id is not None:
+                       object_ids = [object_id]
+               else:
+                       object_ids = request.GET.get('ids').split(',')
+                       
+                       if object_ids is None:
+                               raise Http404
+               
+               qs = self.queryset(request).filter(pk__in=object_ids)
+               opts = self.model._meta
+               
+               if len(object_ids) == 1:
+                       title = _(u"Search results for %s" % qs[0])
+               else:
+                       title = _(u"Search results for multiple objects")
+               
+               context = {
+                       'title': title,
+                       'queryset': qs,
+                       'opts': opts,
+                       'root_path': self.admin_site.root_path,
+                       'app_label': opts.app_label
+               }
+               return render_to_response(self.results_template, context, context_instance=RequestContext(request))
+
+
+class SearchViewAdmin(EntityAdmin):
+       raw_id_fields = ('results_page',)
+       related_lookup_fields = {'fk': raw_id_fields}
+
+
+admin.site.register(Search, SearchAdmin)
+admin.site.register(SearchView, SearchViewAdmin)
\ No newline at end of file
diff --git a/philo/contrib/sobol/forms.py b/philo/contrib/sobol/forms.py
new file mode 100644 (file)
index 0000000..e79d9e7
--- /dev/null
@@ -0,0 +1,12 @@
+from django import forms
+from philo.contrib.sobol.utils import SEARCH_ARG_GET_KEY
+
+
+class BaseSearchForm(forms.BaseForm):
+       base_fields = {
+               SEARCH_ARG_GET_KEY: forms.CharField()
+       }
+
+
+class SearchForm(forms.Form, BaseSearchForm):
+       pass
\ No newline at end of file
diff --git a/philo/contrib/sobol/models.py b/philo/contrib/sobol/models.py
new file mode 100644 (file)
index 0000000..ee8187d
--- /dev/null
@@ -0,0 +1,224 @@
+from django.conf.urls.defaults import patterns, url
+from django.contrib import messages
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.http import HttpResponseRedirect, Http404, HttpResponse
+from django.utils import simplejson as json
+from django.utils.datastructures import SortedDict
+from philo.contrib.sobol import registry
+from philo.contrib.sobol.forms import SearchForm
+from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash
+from philo.exceptions import ViewCanNotProvideSubpath
+from philo.models import MultiView, Page
+from philo.models.fields import SlugMultipleChoiceField
+from philo.validators import RedirectValidator
+import datetime
+try:
+       import eventlet
+except:
+       eventlet = False
+
+
+class Search(models.Model):
+       string = models.TextField()
+       
+       def __unicode__(self):
+               return self.string
+       
+       def get_weighted_results(self, threshhold=None):
+               "Returns this search's results ordered by decreasing weight."
+               if not hasattr(self, '_weighted_results'):
+                       result_qs = self.result_urls.all()
+                       
+                       if threshhold is not None:
+                               result_qs = result_qs.filter(counts__datetime__gte=threshhold)
+                       
+                       results = [result for result in result_qs]
+                       
+                       results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
+                       
+                       self._weighted_results = results
+               
+               return self._weighted_results
+       
+       def get_favored_results(self, error=5, threshhold=None):
+               """
+               Calculate the set of most-favored results. A higher error
+               will cause this method to be more reticent about adding new
+               items.
+               
+               The thought is to see whether there are any results which
+               vastly outstrip the other options. As such, evenly-weighted
+               results should be grouped together and either added or
+               excluded as a group.
+               """
+               if not hasattr(self, '_favored_results'):
+                       results = self.get_weighted_results(threshhold)
+                       
+                       grouped_results = SortedDict()
+                       
+                       for result in results:
+                               grouped_results.setdefault(result.weight, []).append(result)
+                       
+                       self._favored_results = []
+                       
+                       for value, subresults in grouped_results.items():
+                               cost = error * sum([(value - result.weight)**2 for result in self._favored_results])
+                               if value > cost:
+                                       self._favored_results += subresults
+                               else:
+                                       break
+               return self._favored_results
+       
+       class Meta:
+               ordering = ['string']
+               verbose_name_plural = 'searches'
+
+
+class ResultURL(models.Model):
+       search = models.ForeignKey(Search, related_name='result_urls')
+       url = models.TextField(validators=[RedirectValidator()])
+       
+       def __unicode__(self):
+               return self.url
+       
+       def get_weight(self, threshhold=None):
+               if not hasattr(self, '_weight'):
+                       clicks = self.clicks.all()
+                       
+                       if threshhold is not None:
+                               clicks = clicks.filter(datetime__gte=threshhold)
+                       
+                       self._weight = sum([click.weight for click in clicks])
+               
+               return self._weight
+       weight = property(get_weight)
+       
+       class Meta:
+               ordering = ['url']
+
+
+class Click(models.Model):
+       result = models.ForeignKey(ResultURL, related_name='clicks')
+       datetime = models.DateTimeField()
+       
+       def __unicode__(self):
+               return self.datetime.strftime('%B %d, %Y %H:%M:%S')
+       
+       def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
+               if not hasattr(self, '_weight'):
+                       days = (datetime.datetime.now() - self.datetime).days
+                       if days < 0:
+                               raise ValueError("Click dates must be in the past.")
+                       default = float(default)
+                       if days == 0:
+                               self._weight = float(default)
+                       else:
+                               self._weight = weighted(default, days)
+               return self._weight
+       weight = property(get_weight)
+       
+       def clean(self):
+               if self.datetime > datetime.datetime.now():
+                       raise ValidationError("Click dates must be in the past.")
+       
+       class Meta:
+               ordering = ['datetime']
+               get_latest_by = 'datetime'
+
+
+class SearchView(MultiView):
+       results_page = models.ForeignKey(Page, related_name='search_results_related')
+       searches = SlugMultipleChoiceField(choices=registry.iterchoices())
+       enable_ajax_api = models.BooleanField("Enable AJAX API", default=True, help_text="Search results will be available <i>only</i> by AJAX, not as template variables.")
+       placeholder_text = models.CharField(max_length=75, default="Search")
+       
+       search_form = SearchForm
+       
+       def __unicode__(self):
+               return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices() if slug in self.searches]))
+       
+       def get_reverse_params(self, obj):
+               raise ViewCanNotProvideSubpath
+       
+       @property
+       def urlpatterns(self):
+               urlpatterns = patterns('',
+                       url(r'^$', self.results_view, name='results'),
+               )
+               if self.enable_ajax_api:
+                       urlpatterns += patterns('',
+                               url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
+                       )
+               return urlpatterns
+       
+       def get_search_instance(self, slug, search_string):
+               return registry[slug](search_string.lower())
+       
+       def results_view(self, request, extra_context=None):
+               results = None
+               
+               context = self.get_context()
+               context.update(extra_context or {})
+               
+               if SEARCH_ARG_GET_KEY in request.GET:
+                       form = self.search_form(request.GET)
+                       
+                       if form.is_valid():
+                               search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
+                               url = request.GET.get(URL_REDIRECT_GET_KEY)
+                               hash = request.GET.get(HASH_REDIRECT_GET_KEY)
+                               
+                               if url and hash:
+                                       if check_redirect_hash(hash, search_string, url):
+                                               # Create the necessary models
+                                               search = Search.objects.get_or_create(string=search_string)[0]
+                                               result_url = search.result_urls.get_or_create(url=url)[0]
+                                               result_url.clicks.create(datetime=datetime.datetime.now())
+                                               return HttpResponseRedirect(url)
+                                       else:
+                                               messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
+                                               # TODO: Should search_string be escaped here?
+                                               return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
+                               if not self.enable_ajax_api:
+                                       search_instances = []
+                                       if eventlet:
+                                               pool = eventlet.GreenPool()
+                                       for slug in self.searches:
+                                               search_instance = self.get_search_instance(slug, search_string)
+                                               search_instances.append(search_instance)
+                                               if eventlet:
+                                                       pool.spawn_n(self.make_result_cache, search_instance)
+                                               else:
+                                                       self.make_result_cache(search_instance)
+                                       if eventlet:
+                                               pool.waitall()
+                                       context.update({
+                                               'searches': search_instances
+                                       })
+                               else:
+                                       context.update({
+                                               'searches': [{'verbose_name': verbose_name, 'slug': slug, 'url': self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), 'result_template': registry[slug].result_template} for slug, verbose_name in registry.iterchoices() if slug in self.searches]
+                                       })
+               else:
+                       form = SearchForm()
+               
+               context.update({
+                       'form': form
+               })
+               return self.results_page.render_to_response(request, extra_context=context)
+       
+       def make_result_cache(self, search_instance):
+               search_instance.results
+       
+       def ajax_api_view(self, request, slug, extra_context=None):
+               search_string = request.GET.get(SEARCH_ARG_GET_KEY)
+               
+               if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
+                       raise Http404
+               
+               search_instance = self.get_search_instance(slug, search_string)
+               response = HttpResponse(json.dumps({
+                       'results': [result.get_context() for result in search_instance.results],
+               }))
+               return response
\ No newline at end of file
diff --git a/philo/contrib/sobol/search.py b/philo/contrib/sobol/search.py
new file mode 100644 (file)
index 0000000..39b93c7
--- /dev/null
@@ -0,0 +1,398 @@
+#encoding: utf-8
+
+from django.conf import settings
+from django.contrib.sites.models import Site
+from django.core.cache import cache
+from django.db.models.options import get_verbose_name as convert_camelcase
+from django.utils import simplejson as json
+from django.utils.http import urlquote_plus
+from django.utils.safestring import mark_safe
+from django.utils.text import capfirst
+from django.template import loader, Context, Template
+import datetime
+from philo.contrib.sobol.utils import make_tracking_querydict
+
+try:
+       from eventlet.green import urllib2
+except:
+       import urllib2
+
+
+__all__ = (
+       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry'
+)
+
+
+SEARCH_CACHE_KEY = 'philo_sobol_search_results'
+DEFAULT_RESULT_TEMPLATE_STRING = "{% if url %}<a href='{{ url }}'>{% endif %}{{ title }}{% if url %}</a>{% endif %}"
+DEFAULT_RESULT_TEMPLATE = Template(DEFAULT_RESULT_TEMPLATE_STRING)
+
+# Determines the timeout on the entire result cache.
+MAX_CACHE_TIMEOUT = 60*24*7
+
+
+class RegistrationError(Exception):
+       pass
+
+
+class SearchRegistry(object):
+       # Holds a registry of search types by slug.
+       def __init__(self):
+               self._registry = {}
+       
+       def register(self, search, slug=None):
+               slug = slug or search.slug
+               if slug in self._registry:
+                       registered = self._registry[slug]
+                       if registered.__module__ != search.__module__:
+                               raise RegistrationError("A different search is already registered as `%s`" % slug)
+               else:
+                       self._registry[slug] = search
+       
+       def unregister(self, search, slug=None):
+               if slug is not None:
+                       if slug in self._registry and self._registry[slug] == search:
+                               del self._registry[slug]
+                       raise RegistrationError("`%s` is not registered as `%s`" % (search, slug))
+               else:
+                       for slug, search in self._registry.items():
+                               if search == search:
+                                       del self._registry[slug]
+       
+       def items(self):
+               return self._registry.items()
+       
+       def iteritems(self):
+               return self._registry.iteritems()
+       
+       def iterchoices(self):
+               for slug, search in self.iteritems():
+                       yield slug, search.verbose_name
+       
+       def __getitem__(self, key):
+               return self._registry[key]
+       
+       def __iter__(self):
+               return self._registry.__iter__()
+
+
+registry = SearchRegistry()
+
+
+class Result(object):
+       """
+       A result is instantiated with a configuration dictionary, a search,
+       and a template name. The configuration dictionary is expected to
+       define a `title` and optionally a `url`. Any other variables may be
+       defined; they will be made available through the result object in
+       the template, if one is defined.
+       """
+       def __init__(self, search, result):
+               self.search = search
+               self.result = result
+       
+       def get_title(self):
+               return self.search.get_result_title(self.result)
+       
+       def get_url(self):
+               qd = self.search.get_result_querydict(self.result)
+               if qd is None:
+                       return ""
+               return "?%s" % qd.urlencode()
+       
+       def get_template(self):
+               return self.search.get_result_template(self.result)
+       
+       def get_extra_context(self):
+               return self.search.get_result_extra_context(self.result)
+       
+       def get_context(self):
+               context = self.get_extra_context()
+               context.update({
+                       'title': self.get_title(),
+                       'url': self.get_url()
+               })
+               return context
+       
+       def render(self):
+               t = self.get_template()
+               c = Context(self.get_context())
+               return t.render(c)
+       
+       def __unicode__(self):
+               return self.render()
+
+
+class BaseSearchMetaclass(type):
+       def __new__(cls, name, bases, attrs):
+               if 'verbose_name' not in attrs:
+                       attrs['verbose_name'] = capfirst(convert_camelcase(name))
+               if 'slug' not in attrs:
+                       attrs['slug'] = name.lower()
+               return super(BaseSearchMetaclass, cls).__new__(cls, name, bases, attrs)
+
+
+class BaseSearch(object):
+       """
+       Defines a generic search interface. Accessing self.results will
+       attempt to retrieve cached results and, if that fails, will
+       initiate a new search and store the results in the cache.
+       """
+       __metaclass__ = BaseSearchMetaclass
+       result_limit = 10
+       _cache_timeout = 60*48
+       
+       def __init__(self, search_arg):
+               self.search_arg = search_arg
+       
+       def _get_cached_results(self):
+               """Return the cached results if the results haven't timed out. Otherwise return None."""
+               result_cache = cache.get(SEARCH_CACHE_KEY)
+               if result_cache and self.__class__ in result_cache and self.search_arg.lower() in result_cache[self.__class__]:
+                       cached = result_cache[self.__class__][self.search_arg.lower()]
+                       if cached['timeout'] >= datetime.datetime.now():
+                               return cached['results']
+               return None
+       
+       def _set_cached_results(self, results, timeout):
+               """Sets the results to the cache for <timeout> minutes."""
+               result_cache = cache.get(SEARCH_CACHE_KEY) or {}
+               cached = result_cache.setdefault(self.__class__, {}).setdefault(self.search_arg.lower(), {})
+               cached.update({
+                       'results': results,
+                       'timeout': datetime.datetime.now() + datetime.timedelta(minutes=timeout)
+               })
+               cache.set(SEARCH_CACHE_KEY, result_cache, MAX_CACHE_TIMEOUT)
+       
+       @property
+       def results(self):
+               if not hasattr(self, '_results'):
+                       results = self._get_cached_results()
+                       if results is None:
+                               try:
+                                       # Cache one extra result so we can see if there are
+                                       # more results to be had.
+                                       limit = self.result_limit
+                                       if limit is not None:
+                                               limit += 1
+                                       results = self.get_results(limit)
+                               except:
+                                       if settings.DEBUG:
+                                               raise
+                                       #  On exceptions, don't set any cache; just return.
+                                       return []
+                       
+                               self._set_cached_results(results, self._cache_timeout)
+                       self._results = results
+               
+               return self._results
+       
+       def get_results(self, limit=None, result_class=Result):
+               """
+               Calls self.search() and parses the return value into Result objects.
+               """
+               results = self.search(limit)
+               return [result_class(self, result) for result in results]
+       
+       def search(self, limit=None):
+               """
+               Returns an iterable of up to <limit> results. The
+               get_result_title, get_result_url, get_result_template, and
+               get_result_extra_context methods will be used to interpret the
+               individual items that this function returns, so the result can
+               be an object with attributes as easily as a dictionary
+               with keys. The only restriction is that the objects be
+               pickleable so that they can be used with django's cache system.
+               """
+               raise NotImplementedError
+       
+       def get_result_title(self, result):
+               raise NotImplementedError
+       
+       def get_result_url(self, result):
+               "Subclasses override this to provide the actual URL for the result."
+               raise NotImplementedError
+       
+       def get_result_querydict(self, result):
+               url = self.get_result_url(result)
+               if url is None:
+                       return None
+               return make_tracking_querydict(self.search_arg, url)
+       
+       def get_result_template(self, result):
+               if hasattr(self, 'result_template'):
+                       return loader.get_template(self.result_template)
+               if not hasattr(self, '_result_template'):
+                       self._result_template = DEFAULT_RESULT_TEMPLATE
+               return self._result_template
+       
+       def get_result_extra_context(self, result):
+               return {}
+       
+       def has_more_results(self):
+               """Useful to determine whether to display a `view more results` link."""
+               return len(self.results) > self.result_limit
+       
+       @property
+       def more_results_url(self):
+               """
+               Returns the actual url for more results. This will be encoded
+               into a querystring for tracking purposes.
+               """
+               raise NotImplementedError
+       
+       @property
+       def more_results_querydict(self):
+               return make_tracking_querydict(self.search_arg, self.more_results_url)
+       
+       def __unicode__(self):
+               return ' '.join(self.__class__.verbose_name.rsplit(' ', 1)[:-1]) + ' results'
+
+
+class DatabaseSearch(BaseSearch):
+       model = None
+       
+       def search(self, limit=None):
+               if not hasattr(self, '_qs'):
+                       self._qs = self.get_queryset()
+                       if limit is not None:
+                               self._qs = self._qs[:limit]
+               
+               return self._qs
+       
+       def get_queryset(self):
+               return self.model._default_manager.all()
+
+
+class URLSearch(BaseSearch):
+       """
+       Defines a generic interface for searches that require accessing a
+       certain url to get search results.
+       """
+       search_url = ''
+       query_format_str = "%s"
+
+       @property
+       def url(self):
+               "The URL where the search gets its results."
+               return self.search_url + self.query_format_str % urlquote_plus(self.search_arg)
+
+       @property
+       def more_results_url(self):
+               "The URL where the users would go to get more results."
+               return self.url
+       
+       def parse_response(self, response, limit=None):
+               raise NotImplementedError
+       
+       def search(self, limit=None):
+               return self.parse_response(urllib2.urlopen(self.url), limit=limit)
+
+
+class JSONSearch(URLSearch):
+       """
+       Makes a GET request and parses the results as JSON. The default
+       behavior assumes that the return value is a list of results.
+       """
+       def parse_response(self, response, limit=None):
+               return json.loads(response.read())[:limit]
+
+
+class GoogleSearch(JSONSearch):
+       search_url = "http://ajax.googleapis.com/ajax/services/search/web"
+       # TODO: Change this template to reflect the app's actual name.
+       result_template = 'search/googlesearch.html'
+       _cache_timeout = 60
+       verbose_name = "Google search (current site)"
+       
+       @property
+       def query_format_str(self):
+               default_args = self.default_args
+               if default_args:
+                       default_args += " "
+               return "?v=1.0&q=%s%%s" % urlquote_plus(default_args).replace('%', '%%')
+       
+       @property
+       def default_args(self):
+               return "site:%s" % Site.objects.get_current().domain
+       
+       def parse_response(self, response, limit=None):
+               responseData = json.loads(response.read())['responseData']
+               results, cursor = responseData['results'], responseData['cursor']
+               
+               if results:
+                       self._more_results_url = cursor['moreResultsUrl']
+                       self._estimated_result_count = cursor['estimatedResultCount']
+               
+               return results[:limit]
+       
+       @property
+       def url(self):
+               # Google requires that an ajax request have a proper Referer header.
+               return urllib2.Request(
+                       super(GoogleSearch, self).url,
+                       None,
+                       {'Referer': "http://%s" % Site.objects.get_current().domain}
+               )
+       
+       @property
+       def has_more_results(self):
+               if self.results and len(self.results) < self._estimated_result_count:
+                       return True
+               return False
+       
+       @property
+       def more_results_url(self):
+               return self._more_results_url
+       
+       def get_result_title(self, result):
+               return result['titleNoFormatting']
+       
+       def get_result_url(self, result):
+               return result['unescapedUrl']
+       
+       def get_result_extra_context(self, result):
+               return result
+
+
+registry.register(GoogleSearch)
+
+
+try:
+       from BeautifulSoup import BeautifulSoup, SoupStrainer, BeautifulStoneSoup
+except:
+       pass
+else:
+       __all__ += ('ScrapeSearch', 'XMLSearch',)
+       class ScrapeSearch(URLSearch):
+               _strainer_args = []
+               _strainer_kwargs = {}
+               
+               @property
+               def strainer(self):
+                       if not hasattr(self, '_strainer'):
+                               self._strainer = SoupStrainer(*self._strainer_args, **self._strainer_kwargs)
+                       return self._strainer
+               
+               def parse_response(self, response, limit=None):
+                       strainer = self.strainer
+                       soup = BeautifulSoup(response, parseOnlyThese=strainer)
+                       return self.parse_results(soup.findAll(recursive=False, limit=limit))
+               
+               def parse_results(self, results):
+                       """
+                       Provides a hook for parsing the results of straining. This
+                       has no default behavior because the results absolutely
+                       must be parsed to properly extract the information.
+                       For more information, see http://www.crummy.com/software/BeautifulSoup/documentation.html#Improving%20Memory%20Usage%20with%20extract
+                       """
+                       raise NotImplementedError
+       
+       
+       class XMLSearch(ScrapeSearch):
+               _self_closing_tags = []
+               
+               def parse_response(self, response, limit=None):
+                       strainer = self.strainer
+                       soup = BeautifulStoneSoup(response, selfClosingTags=self._self_closing_tags, parseOnlyThese=strainer)
+                       return self.parse_results(soup.findAll(recursive=False, limit=limit))
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html
new file mode 100644 (file)
index 0000000..45135ff
--- /dev/null
@@ -0,0 +1,53 @@
+{% extends "admin/base_site.html" %}
+
+<!-- LOADING -->
+{% load i18n %}
+
+<!-- EXTRASTYLES -->
+{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
+
+<!-- BREADCRUMBS -->
+{% block breadcrumbs %}
+       <div id="breadcrumbs">
+               {% if queryset|length > 1 %}
+               <a href="../../">{% trans "Home" %}</a> &rsaquo;
+               <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
+               <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+               {% trans 'Search results for multiple objects' %}
+               {% else %}
+               <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
+               <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo;
+               <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+               <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
+               {% trans 'Results' %}
+               {% endif %}
+       </div>
+{% endblock %}
+
+<!-- CONTENT -->
+{% block content %}
+       <div class="container-grid delete-confirmation">
+               {% for search in queryset %}
+               {% if not forloop.first and not forloop.last %}<h1>{{ search.string }}</h1>{% endif %}
+               <div class="group tabular">
+                       <h2>{% blocktrans %}Results{% endblocktrans %}</h2>{% comment %}For the favored results, add a class?{% endcomment %}
+                       <div class="module table">
+                               <div class="module thead">
+                                       <div class="tr">
+                                               <div class="th">Weight</div>
+                                               <div class="th">URL</div>
+                                       </div>
+                               </div>
+                               <div class="module tbody">
+                                       {% for result in search.get_weighted_results %}
+                                       <div class="tr{% if result in search.get_favored_results %} favored{% endif %}">
+                                               <div class="td">{{ result.weight }}</div>
+                                               <div class="td">{{ result.url }}</div>
+                                       </div>
+                                       {% endfor %}
+                               </div>
+                       </div>
+               </div>
+               {% endfor %}
+       </div>
+{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/results.html b/philo/contrib/sobol/templates/admin/sobol/search/results.html
new file mode 100644 (file)
index 0000000..44d4e7c
--- /dev/null
@@ -0,0 +1,47 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+       {% if queryset|length > 1 %}
+       <a href="../../">{% trans "Home" %}</a> &rsaquo;
+       <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
+       <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+       {% trans 'Search results for multiple objects' %}
+       {% else %}
+       <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
+       <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo; 
+       <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+       <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
+       {% trans 'Results' %}
+       {% endif %}
+</div>
+{% endblock %}
+
+
+{% block content %}
+               {% for search in queryset %}
+               {% if not forloop.first and not forloop.last %}<h1>{{ search.string }}</h1>{% endif %}
+                       <fieldset class="module">
+                               <h2>{% blocktrans %}Results{% endblocktrans %}</h2>{% comment %}For the favored results, add a class?{% endcomment %}
+                               <table>
+                                       <thead>
+                                               <tr>
+                                                       <th>Weight</th>
+                                                       <th>URL</th>
+                                               </tr>
+                                       </thead>
+                                       <tbody>
+                                               {% for result in search.get_weighted_results %}
+                                               <tr{% if result in search.favored_results %} class="favored"{% endif %}>
+                                                       <td>{{ result.weight }}</td>
+                                                       <td>{{ result.url }}</td>
+                                               </tr>
+                                               {% endfor %}
+                                       </tbody>
+                               </table>
+                       </fieldset>
+               {% endfor %}
+{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/search/googlesearch.html b/philo/contrib/sobol/templates/search/googlesearch.html
new file mode 100644 (file)
index 0000000..1b22388
--- /dev/null
@@ -0,0 +1,4 @@
+<article>
+       <h1><a href="{{ url }}">{{ title|safe }}</a></h1>
+       <p>{{ content|safe }}</p>
+</article>
\ No newline at end of file
diff --git a/philo/contrib/sobol/utils.py b/philo/contrib/sobol/utils.py
new file mode 100644 (file)
index 0000000..3c5e537
--- /dev/null
@@ -0,0 +1,32 @@
+from django.conf import settings
+from django.http import QueryDict
+from django.utils.encoding import smart_str
+from django.utils.http import urlquote_plus, urlquote
+from hashlib import sha1
+
+
+SEARCH_ARG_GET_KEY = 'q'
+URL_REDIRECT_GET_KEY = 'url'
+HASH_REDIRECT_GET_KEY = 's'
+
+
+def make_redirect_hash(search_arg, url):
+       return sha1(smart_str(search_arg + url + settings.SECRET_KEY)).hexdigest()[::2]
+
+
+def check_redirect_hash(hash, search_arg, url):
+       return hash == make_redirect_hash(search_arg, url)
+
+
+def make_tracking_querydict(search_arg, url):
+       """
+       Returns a QueryDict instance containing the information necessary
+       for tracking clicks of this url.
+       
+       NOTE: will this kind of initialization handle quoting correctly?
+       """
+       return QueryDict("%s=%s&%s=%s&%s=%s" % (
+               SEARCH_ARG_GET_KEY, urlquote_plus(search_arg),
+               URL_REDIRECT_GET_KEY, urlquote(url),
+               HASH_REDIRECT_GET_KEY, make_redirect_hash(search_arg, url))
+       )
\ No newline at end of file
similarity index 85%
rename from contrib/waldo/tokens.py
rename to philo/contrib/waldo/tokens.py
index 95ce0c0..80f0b11 100644 (file)
@@ -7,6 +7,7 @@ from datetime import date
 from django.conf import settings
 from django.utils.http import int_to_base36, base36_to_int
 from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from hashlib import sha1
 
 
 REGISTRATION_TIMEOUT_DAYS = getattr(settings, 'WALDO_REGISTRATION_TIMEOUT_DAYS', 1)
@@ -52,8 +53,7 @@ class RegistrationTokenGenerator(PasswordResetTokenGenerator):
                # By hashing on the internal state of the user and using state that is
                # sure to change, we produce a hash that will be invalid as soon as it
                # is used.
-               from django.utils.hashcompat import sha_constructor
-               hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) + unicode(user.is_active) + user.last_login.strftime('%Y-%m-%d %H:%M:%S') + unicode(timestamp)).hexdigest()[::2]
+               hash = sha1(settings.SECRET_KEY + unicode(user.id) + unicode(user.is_active) + user.last_login.strftime('%Y-%m-%d %H:%M:%S') + unicode(timestamp)).hexdigest()[::2]
                return '%s-%s' % (ts_b36, hash)
 
 
@@ -98,8 +98,7 @@ class EmailTokenGenerator(PasswordResetTokenGenerator):
        def _make_token_with_timestamp(self, user, email, timestamp):
                ts_b36 = int_to_base36(timestamp)
                
-               from django.utils.hashcompat import sha_constructor
-               hash = sha_constructor(settings.SECRET_KEY + unicode(user.id) + user.email + email + unicode(timestamp)).hexdigest()[::2]
+               hash = sha1(settings.SECRET_KEY + unicode(user.id) + user.email + email + unicode(timestamp)).hexdigest()[::2]
                return '%s-%s' % (ts_b36, hash)
 
 
similarity index 100%
rename from exceptions.py
rename to philo/exceptions.py
similarity index 100%
rename from forms/__init__.py
rename to philo/forms/__init__.py
similarity index 51%
rename from forms/entities.py
rename to philo/forms/entities.py
index b6259a3..e781128 100644 (file)
@@ -1,4 +1,4 @@
-from django.forms.models import ModelFormMetaclass, ModelForm
+from django.forms.models import ModelFormMetaclass, ModelForm, ModelFormOptions
 from django.utils.datastructures import SortedDict
 from philo.utils import fattr
 
@@ -6,7 +6,7 @@ from philo.utils import fattr
 __all__ = ('EntityForm',)
 
 
-def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widgets=None, formfield_callback=lambda f, **kwargs: f.formfield(**kwargs)):
+def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widgets=None, formfield_callback=None):
        field_list = []
        ignored = []
        opts = entity_model._entity_meta
@@ -21,7 +21,14 @@ def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widge
                        kwargs = {'widget': widgets[f.name]}
                else:
                        kwargs = {}
-               formfield = formfield_callback(f, **kwargs)
+               
+               if formfield_callback is None:
+                       formfield = f.formfield(**kwargs)
+               elif not callable(formfield_callback):
+                       raise TypeError('formfield_callback must be a function or callable')
+               else:
+                       formfield = formfield_callback(f, **kwargs)
+               
                if formfield:
                        field_list.append((f.name, formfield))
                else:
@@ -35,31 +42,59 @@ def proxy_fields_for_entity_model(entity_model, fields=None, exclude=None, widge
        return field_dict
 
 
-# BEGIN HACK - This will not be required after http://code.djangoproject.com/ticket/14082 has been resolved
-
-class EntityFormBase(ModelForm):
-       pass
+# HACK until http://code.djangoproject.com/ticket/14082 is resolved.
+_old = ModelFormMetaclass.__new__
+def _new(cls, name, bases, attrs):
+       if cls == ModelFormMetaclass:
+               m = attrs.get('__metaclass__', None)
+               if m is None:
+                       parents = [b for b in bases if issubclass(b, ModelForm)]
+                       for c in parents:
+                               if c.__metaclass__ != ModelFormMetaclass:
+                                       m = c.__metaclass__
+                                       break
+       
+               if m is not None:
+                       return m(name, bases, attrs)
+       
+       return _old(cls, name, bases, attrs)
+ModelFormMetaclass.__new__ = staticmethod(_new)
+# END HACK
 
-_old_metaclass_new = ModelFormMetaclass.__new__
 
-def _new_metaclass_new(cls, name, bases, attrs):
-       formfield_callback = attrs.get('formfield_callback', lambda f, **kwargs: f.formfield(**kwargs))
-       new_class = _old_metaclass_new(cls, name, bases, attrs)
-       opts = new_class._meta
-       if issubclass(new_class, EntityFormBase) and opts.model:
-               # "override" proxy fields with declared fields by excluding them if there's a name conflict.
-               exclude = (list(opts.exclude or []) + new_class.declared_fields.keys()) or None
-               proxy_fields = proxy_fields_for_entity_model(opts.model, opts.fields, exclude, opts.widgets, formfield_callback) # don't pass in formfield_callback
+class EntityFormMetaclass(ModelFormMetaclass):
+       def __new__(cls, name, bases, attrs):
+               try:
+                       parents = [b for b in bases if issubclass(b, EntityForm)]
+               except NameError:
+                       # We are defining EntityForm itself
+                       parents = None
+               sup = super(EntityFormMetaclass, cls)
+               
+               if not parents:
+                       # Then there's no business trying to use proxy fields.
+                       return sup.__new__(cls, name, bases, attrs)
+               
+               # Fake a declaration of all proxy fields so they'll be handled correctly.
+               opts = ModelFormOptions(attrs.get('Meta', None))
+               
+               if opts.model:
+                       formfield_callback = attrs.get('formfield_callback', None)
+                       proxy_fields = proxy_fields_for_entity_model(opts.model, opts.fields, opts.exclude, opts.widgets, formfield_callback)
+               else:
+                       proxy_fields = {}
+               
+               new_attrs = proxy_fields.copy()
+               new_attrs.update(attrs)
+               
+               new_class = sup.__new__(cls, name, bases, new_attrs)
                new_class.proxy_fields = proxy_fields
-               new_class.base_fields.update(proxy_fields)
-       return new_class
+               return new_class
 
-ModelFormMetaclass.__new__ = staticmethod(_new_metaclass_new)
 
-# END HACK
-
-
-class EntityForm(EntityFormBase): # Would inherit from ModelForm directly if it weren't for the above HACK
+class EntityForm(ModelForm):
+       __metaclass__ = EntityFormMetaclass
+       
        def __init__(self, *args, **kwargs):
                initial = kwargs.pop('initial', None)
                instance = kwargs.get('instance', None)
similarity index 100%
rename from forms/fields.py
rename to philo/forms/fields.py
diff --git a/philo/loaders/__init__.py b/philo/loaders/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
similarity index 83%
rename from middleware.py
rename to philo/middleware.py
index c0b1e9e..5ec3e77 100644 (file)
@@ -24,16 +24,18 @@ class LazyNode(object):
                                node, subpath = Node.objects.get_with_path(path, root=getattr(current_site, 'root_node', None), absolute_result=False)
                        except Node.DoesNotExist:
                                node = None
-                       
-                       if node:
+                       else:
                                if subpath is None:
                                        subpath = ""
                                subpath = "/" + subpath
                                
-                               if trailing_slash and subpath[-1] != "/":
-                                       subpath += "/"
-                               
-                               node.subpath = subpath
+                               if not node.handles_subpath(subpath):
+                                       node = None
+                               else:
+                                       if trailing_slash and subpath[-1] != "/":
+                                               subpath += "/"
+                                       
+                                       node.subpath = subpath
                        
                        request._found_node = node
                
@@ -46,7 +48,10 @@ class RequestNodeMiddleware(object):
                request.__class__.node = LazyNode()
        
        def process_view(self, request, view_func, view_args, view_kwargs):
-               request._cached_node_path = view_kwargs.get('path', '/')
+               try:
+                       request._cached_node_path = view_kwargs['path']
+               except KeyError:
+                       pass
        
        def process_exception(self, request, exception):
                if settings.DEBUG or not hasattr(request, 'node') or not request.node:
similarity index 100%
rename from models/__init__.py
rename to philo/models/__init__.py
similarity index 98%
rename from models/base.py
rename to philo/models/base.py
index 836fe4a..af1e880 100644 (file)
@@ -55,10 +55,6 @@ def unregister_value_model(model):
 class AttributeValue(models.Model):
        attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id')
        
-       @property
-       def attribute(self):
-               return self.attribute_set.all()[0]
-       
        def set_value(self, value):
                raise NotImplementedError
        
@@ -275,9 +271,9 @@ class EntityOptions(object):
 
 class EntityBase(models.base.ModelBase):
        def __new__(cls, name, bases, attrs):
+               entity_meta = attrs.pop('EntityMeta', None)
                new = super(EntityBase, cls).__new__(cls, name, bases, attrs)
-               entity_options = attrs.pop('EntityMeta', None)
-               setattr(new, '_entity_meta', EntityOptions(entity_options))
+               new.add_to_class('_entity_meta', EntityOptions(entity_meta))
                entity_class_prepared.send(sender=new)
                return new
 
diff --git a/philo/models/fields/__init__.py b/philo/models/fields/__init__.py
new file mode 100644 (file)
index 0000000..1f9603e
--- /dev/null
@@ -0,0 +1,135 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_slug
+from django.db import models
+from django.utils import simplejson as json
+from django.utils.text import capfirst
+from django.utils.translation import ugettext_lazy as _
+from philo.forms.fields import JSONFormField
+from philo.validators import TemplateValidator, json_validator
+#from philo.models.fields.entities import *
+
+
+class TemplateField(models.TextField):
+       def __init__(self, allow=None, disallow=None, secure=True, *args, **kwargs):
+               super(TemplateField, self).__init__(*args, **kwargs)
+               self.validators.append(TemplateValidator(allow, disallow, secure))
+
+
+class JSONDescriptor(object):
+       def __init__(self, field):
+               self.field = field
+       
+       def __get__(self, instance, owner):
+               if instance is None:
+                       raise AttributeError # ?
+               
+               if self.field.name not in instance.__dict__:
+                       json_string = getattr(instance, self.field.attname)
+                       instance.__dict__[self.field.name] = json.loads(json_string)
+               
+               return instance.__dict__[self.field.name]
+       
+       def __set__(self, instance, value):
+               instance.__dict__[self.field.name] = value
+               setattr(instance, self.field.attname, json.dumps(value))
+       
+       def __delete__(self, instance):
+               del(instance.__dict__[self.field.name])
+               setattr(instance, self.field.attname, json.dumps(None))
+
+
+class JSONField(models.TextField):
+       default_validators = [json_validator]
+       
+       def get_attname(self):
+               return "%s_json" % self.name
+       
+       def contribute_to_class(self, cls, name):
+               super(JSONField, self).contribute_to_class(cls, name)
+               setattr(cls, name, JSONDescriptor(self))
+               models.signals.pre_init.connect(self.fix_init_kwarg, sender=cls)
+       
+       def fix_init_kwarg(self, sender, args, kwargs, **signal_kwargs):
+               # Anything passed in as self.name is assumed to come from a serializer and
+               # will be treated as a json string.
+               if self.name in kwargs:
+                       value = kwargs.pop(self.name)
+                       
+                       # Hack to handle the xml serializer's handling of "null"
+                       if value is None:
+                               value = 'null'
+                       
+                       kwargs[self.attname] = value
+       
+       def formfield(self, *args, **kwargs):
+               kwargs["form_class"] = JSONFormField
+               return super(JSONField, self).formfield(*args, **kwargs)
+
+
+class SlugMultipleChoiceField(models.Field):
+       __metaclass__ = models.SubfieldBase
+       description = _("Comma-separated slug field")
+       
+       def get_internal_type(self):
+               return "TextField"
+       
+       def to_python(self, value):
+               if not value:
+                       return []
+               
+               if isinstance(value, list):
+                       return value
+               
+               return value.split(',')
+       
+       def get_prep_value(self, value):
+               return ','.join(value)
+       
+       def formfield(self, **kwargs):
+               # This is necessary because django hard-codes TypedChoiceField for things with choices.
+               defaults = {
+                       'widget': forms.CheckboxSelectMultiple,
+                       'choices': self.get_choices(include_blank=False),
+                       'label': capfirst(self.verbose_name),
+                       'required': not self.blank,
+                       'help_text': self.help_text
+               }
+               if self.has_default():
+                       if callable(self.default):
+                               defaults['initial'] = self.default
+                               defaults['show_hidden_initial'] = True
+                       else:
+                               defaults['initial'] = self.get_default()
+               
+               for k in kwargs.keys():
+                       if k not in ('coerce', 'empty_value', 'choices', 'required',
+                                                'widget', 'label', 'initial', 'help_text',
+                                                'error_messages', 'show_hidden_initial'):
+                               del kwargs[k]
+               
+               defaults.update(kwargs)
+               form_class = forms.TypedMultipleChoiceField
+               return form_class(**defaults)
+       
+       def validate(self, value, model_instance):
+               invalid_values = []
+               for val in value:
+                       try:
+                               validate_slug(val)
+                       except ValidationError:
+                               invalid_values.append(val)
+               
+               if invalid_values:
+                       # should really make a custom message.
+                       raise ValidationError(self.error_messages['invalid_choice'] % invalid_values)
+
+
+try:
+       from south.modelsinspector import add_introspection_rules
+except ImportError:
+       pass
+else:
+       add_introspection_rules([], ["^philo\.models\.fields\.SlugMultipleChoiceField"])
+       add_introspection_rules([], ["^philo\.models\.fields\.TemplateField"])
+       add_introspection_rules([], ["^philo\.models\.fields\.JSONField"])
\ No newline at end of file
similarity index 100%
rename from models/nodes.py
rename to philo/models/nodes.py
similarity index 56%
rename from models/pages.py
rename to philo/models/pages.py
index ef68b5f..2221ee4 100644 (file)
@@ -5,16 +5,70 @@ from django.contrib.contenttypes import generic
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.http import HttpResponse
-from django.template import TemplateDoesNotExist, Context, RequestContext, Template as DjangoTemplate, add_to_builtins as register_templatetags
+from django.template import TemplateDoesNotExist, Context, RequestContext, Template as DjangoTemplate, add_to_builtins as register_templatetags, TextNode, VariableNode
+from django.template.loader_tags import BlockNode, ExtendsNode, BlockContext
+from django.utils.datastructures import SortedDict
 from philo.models.base import TreeModel, register_value_model
 from philo.models.fields import TemplateField
 from philo.models.nodes import View
 from philo.templatetags.containers import ContainerNode
-from philo.utils import fattr, nodelist_crawl
+from philo.utils import fattr
 from philo.validators import LOADED_TEMPLATE_ATTR
 from philo.signals import page_about_to_render_to_string, page_finished_rendering_to_string
 
 
+class LazyContainerFinder(object):
+       def __init__(self, nodes, extends=False):
+               self.nodes = nodes
+               self.initialized = False
+               self.contentlet_specs = set()
+               self.contentreference_specs = SortedDict()
+               self.blocks = {}
+               self.block_super = False
+               self.extends = extends
+       
+       def process(self, nodelist):
+               for node in nodelist:
+                       if self.extends:
+                               if isinstance(node, BlockNode):
+                                       self.blocks[node.name] = block = LazyContainerFinder(node.nodelist)
+                                       block.initialize()
+                                       self.blocks.update(block.blocks)
+                               continue
+                       
+                       if isinstance(node, ContainerNode):
+                               if not node.references:
+                                       self.contentlet_specs.add(node.name)
+                               else:
+                                       if node.name not in self.contentreference_specs.keys():
+                                               self.contentreference_specs[node.name] = node.references
+                               continue
+                       
+                       if isinstance(node, VariableNode):
+                               if node.filter_expression.var.lookups == (u'block', u'super'):
+                                       self.block_super = True
+                       
+                       if hasattr(node, 'child_nodelists'):
+                               for nodelist_name in node.child_nodelists:
+                                       if hasattr(node, nodelist_name):
+                                               nodelist = getattr(node, nodelist_name)
+                                               self.process(nodelist)
+                       
+                       # LOADED_TEMPLATE_ATTR contains the name of an attribute philo uses to declare a
+                       # node as rendering an additional template. Philo monkeypatches the attribute onto
+                       # the relevant default nodes and declares it on any native nodes.
+                       if hasattr(node, LOADED_TEMPLATE_ATTR):
+                               loaded_template = getattr(node, LOADED_TEMPLATE_ATTR)
+                               if loaded_template:
+                                       nodelist = loaded_template.nodelist
+                                       self.process(nodelist)
+       
+       def initialize(self):
+               if not self.initialized:
+                       self.process(self.nodes)
+                       self.initialized = True
+
+
 class Template(TreeModel):
        name = models.CharField(max_length=255)
        documentation = models.TextField(null=True, blank=True)
@@ -29,22 +83,54 @@ class Template(TreeModel):
                This will break if there is a recursive extends or includes in the template code.
                Due to the use of an empty Context, any extends or include tags with dynamic arguments probably won't work.
                """
-               def process_node(node, nodes):
-                       if isinstance(node, ContainerNode):
-                               nodes.append(node)
+               template = DjangoTemplate(self.code)
+               
+               def build_extension_tree(nodelist):
+                       nodelists = []
+                       extends = None
+                       for node in nodelist:
+                               if not isinstance(node, TextNode):
+                                       if isinstance(node, ExtendsNode):
+                                               extends = node
+                                       break
+                       
+                       if extends:
+                               if extends.nodelist:
+                                       nodelists.append(LazyContainerFinder(extends.nodelist, extends=True))
+                               loaded_template = getattr(extends, LOADED_TEMPLATE_ATTR)
+                               nodelists.extend(build_extension_tree(loaded_template.nodelist))
+                       else:
+                               # Base case: root.
+                               nodelists.append(LazyContainerFinder(nodelist))
+                       return nodelists
                
-               all_nodes = nodelist_crawl(DjangoTemplate(self.code).nodelist, process_node)
-               contentlet_node_names = set([node.name for node in all_nodes if not node.references])
-               contentreference_node_names = []
-               contentreference_node_specs = []
-               for node in all_nodes:
-                       if node.references and node.name not in contentreference_node_names:
-                               contentreference_node_specs.append((node.name, node.references))
-                               contentreference_node_names.append(node.name)
-               return contentlet_node_names, contentreference_node_specs
+               # Build a tree of the templates we're using, placing the root template first.
+               levels = build_extension_tree(template.nodelist)[::-1]
+               
+               contentlet_specs = set()
+               contentreference_specs = SortedDict()
+               blocks = {}
+               
+               for level in levels:
+                       level.initialize()
+                       contentlet_specs |= level.contentlet_specs
+                       contentreference_specs.update(level.contentreference_specs)
+                       for name, block in level.blocks.items():
+                               if block.block_super:
+                                       blocks.setdefault(name, []).append(block)
+                               else:
+                                       blocks[name] = [block]
+               
+               for block_list in blocks.values():
+                       for block in block_list:
+                               block.initialize()
+                               contentlet_specs |= block.contentlet_specs
+                               contentreference_specs.update(block.contentreference_specs)
+               
+               return contentlet_specs, contentreference_specs
        
        def __unicode__(self):
-               return self.get_path(pathsep=u' â€º ', field='name')
+               return self.name
        
        class Meta:
                app_label = 'philo'
@@ -85,6 +171,9 @@ class Page(View):
                return self.title
        
        def clean_fields(self, exclude=None):
+               if exclude is None:
+                       exclude = []
+               
                try:
                        super(Page, self).clean_fields(exclude)
                except ValidationError, e:
similarity index 100%
rename from signals.py
rename to philo/signals.py
similarity index 75%
rename from media/admin/js/TagCreation.js
rename to philo/static/admin/js/TagCreation.js
index 31f2910..d08d41e 100644 (file)
@@ -1,6 +1,29 @@
 var tagCreation = window.tagCreation;
 
 (function($) {
+       location_re = new RegExp("^https?:\/\/" + window.location.host + "/")
+       
+       $('html').ajaxSend(function(event, xhr, settings) {
+               function getCookie(name) {
+                       var cookieValue = null;
+                       if (document.cookie && document.cookie != '') {
+                               var cookies = document.cookie.split(';');
+                               for (var i = 0; i < cookies.length; i++) {
+                                       var cookie = $.trim(cookies[i]);
+                                       // Does this cookie string begin with the name we want?
+                                       if (cookie.substring(0, name.length + 1) == (name + '=')) {
+                                               cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+                                               break;
+                                       }
+                               }
+                       }
+                       return cookieValue;
+               }
+               if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url)) || location_re.test(settings.url)) {
+                       // Only send the token to relative URLs i.e. locally.
+                       xhr.setRequestHeader("X-CSRFToken", getCookie('csrftoken'));
+               }
+       });
        tagCreation = {
                'cache': {},
                'addTagFromSlug': function(triggeringLink) {
@@ -1,4 +1,4 @@
-{% load i18n adminmedia %}
+{% load i18n adminmedia grp_tags %}
 
 <!-- group -->
 <div class="group tabular{% if inline_admin_formset.opts.classes %} {{ inline_admin_formset.opts.classes|join:" " }}{% endif %}"
 
 <script type="text/javascript">
 (function($) {
-    $(document).ready(function($) {
-        
-        $("#{{ inline_admin_formset.formset.prefix }}-group").grp_inline({
-            prefix: "{{ inline_admin_formset.formset.prefix }}",
-            onBeforeAdded: function(inline) {},
-            onAfterAdded: function(form) {
-                grappelli.reinitDateTimeFields(form);
-                grappelli.updateSelectFilter(form);
-                form.find("input.vForeignKeyRawIdAdminField").grp_related_fk({lookup_url:"{% url grp_related_lookup %}"});
-                form.find("input.vManyToManyRawIdAdminField").grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"});
-                form.find("input[name*='object_id'][name$='id']").grp_related_generic({lookup_url:"{% url grp_related_lookup %}"});
-            },
-        });
-        
-        {% if inline_admin_formset.opts.sortable_field_name %}
-        $("#{{ inline_admin_formset.formset.prefix }}-group > div.table").sortable({
-            handle: "a.drag-handler",
-            items: "div.dynamic-form",
-            axis: "y",
-            appendTo: 'body',
-            forceHelperSize: true,
-            containment: '#{{ inline_admin_formset.formset.prefix }}-group > div.table',
-            tolerance: 'pointer',
-        });
-        $("#{{ opts.module_name }}_form").bind("submit", function(){
-            var sortable_field_name = "{{ inline_admin_formset.opts.sortable_field_name }}";
-            var i = 0;
-            $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.dynamic-form").each(function(){
-                var fields = $(this).find("div.td :input[value]");
-                if (fields.serialize()) {
-                    $(this).find("input[name$='"+sortable_field_name+"']").val(i);
-                    i++;
-                }
-            });
-        });
-        {% endif %}
-        
-    });
+       $(document).ready(function($) {
+               
+               var prefix = "{{ inline_admin_formset.formset.prefix }}";
+               var related_lookup_fields_fk = {% get_related_lookup_fields_fk inline_admin_formset.opts %};
+               var related_lookup_fields_m2m = {% get_related_lookup_fields_m2m inline_admin_formset.opts %};
+               var related_lookup_fields_generic = {% get_related_lookup_fields_generic inline_admin_formset.opts %};
+               $.each(related_lookup_fields_fk, function() {
+                       $("#{{ inline_admin_formset.formset.prefix }}-group > div.table")
+                       .find("input[name^='" + prefix + "'][name$='" + this + "']")
+                       .grp_related_fk({lookup_url:"{% url grp_related_lookup %}"});
+               });
+               $.each(related_lookup_fields_m2m, function() {
+                       $("#{{ inline_admin_formset.formset.prefix }}-group > div.table")
+                       .find("input[name^='" + prefix + "'][name$='" + this + "']")
+                       .grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"});
+               });
+               $.each(related_lookup_fields_generic, function() {
+                       var content_type = this[0],
+                               object_id = this[1];
+                       $("#{{ inline_admin_formset.formset.prefix }}-group > div.table")
+                       .find("input[name^='" + prefix + "'][name$='" + this[1] + "']")
+                       .each(function() {
+                               var i = $(this).attr("id").match(/-\d+-/);
+                               if (i) {
+                                       var ct_id = "#id_" + prefix + i[0] + content_type,
+                                               obj_id = "#id_" + prefix + i[0] + object_id;
+                                       $(this).grp_related_generic({content_type:ct_id, object_id:obj_id, lookup_url:"{% url grp_related_lookup %}"});
+                               }
+                       });
+               });
+               
+               $("#{{ inline_admin_formset.formset.prefix }}-group").grp_inline({
+                       prefix: "{{ inline_admin_formset.formset.prefix }}",
+                       onBeforeAdded: function(inline) {},
+                       onAfterAdded: function(form) {
+                               grappelli.reinitDateTimeFields(form);
+                               grappelli.updateSelectFilter(form);
+                               $.each(related_lookup_fields_fk, function() {
+                                       form.find("input[name^='" + prefix + "'][name$='" + this + "']")
+                                       .grp_related_fk({lookup_url:"{% url grp_related_lookup %}"});
+                               });
+                               $.each(related_lookup_fields_m2m, function() {
+                                       form.find("input[name^='" + prefix + "'][name$='" + this + "']")
+                                       .grp_related_m2m({lookup_url:"{% url grp_m2m_lookup %}"});
+                               });
+                               $.each(related_lookup_fields_generic, function() {
+                                       var content_type = this[0],
+                                               object_id = this[1];
+                                       form.find("input[name^='" + prefix + "'][name$='" + this[1] + "']")
+                                       .each(function() {
+                                               var i = $(this).attr("id").match(/-\d+-/);
+                                               if (i) {
+                                                       var ct_id = "#id_" + prefix + i[0] + content_type,
+                                                               obj_id = "#id_" + prefix + i[0] + object_id;
+                                                       $(this).grp_related_generic({content_type:ct_id, object_id:obj_id, lookup_url:"{% url grp_related_lookup %}"});
+                                               }
+                                       });
+                               });
+                       },
+               });
+               
+               {% if inline_admin_formset.opts.sortable_field_name %}
+               $("#{{ inline_admin_formset.formset.prefix }}-group > div.table").sortable({
+                       handle: "a.drag-handler",
+                       items: "div.dynamic-form",
+                       axis: "y",
+                       appendTo: 'body',
+                       forceHelperSize: true,
+                       containment: '#{{ inline_admin_formset.formset.prefix }}-group > div.table',
+                       tolerance: 'pointer',
+               });
+               $("#{{ opts.module_name }}_form").bind("submit", function(){
+                       var sortable_field_name = "{{ inline_admin_formset.opts.sortable_field_name }}";
+                       var i = 0;
+                       $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.dynamic-form").each(function(){
+                               var fields = $(this).find("div.td :input[value]");
+                               if (fields.serialize()) {
+                                       $(this).find("input[name$='"+sortable_field_name+"']").val(i);
+                                       i++;
+                               }
+                       });
+               });
+               {% endif %}
+               
+       });
 })(django.jQuery);
 </script>
diff --git a/philo/templates/admin/philo/edit_inline/grappelli_tabular_container.html b/philo/templates/admin/philo/edit_inline/grappelli_tabular_container.html
new file mode 100644 (file)
index 0000000..621fea6
--- /dev/null
@@ -0,0 +1,43 @@
+{% load i18n adminmedia %}
+
+<!-- group -->
+{{ inline_admin_formset.formset.management_form }}
+{% comment %}Don't render the formset at all if there aren't any forms.{% endcomment %}
+{% if inline_admin_formset.formset.forms %}
+       <fieldset class="module{% if inline_admin_formset.opts.classes %} {{ inline_admin_formset.opts.classes|join:" " }}{% endif %}">
+               <h2{% if "collapse" in inline_admin_formset.opts.classes %} class="collapse-handler"{% endif %}>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
+               {{ inline_admin_formset.formset.non_form_errors }}
+               {% for inline_admin_form in inline_admin_formset %}
+                       {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
+                       {{ inline_admin_form.fk_field.field }}
+                       {% spaceless %}
+                       {% for fieldset in inline_admin_form %}
+                       {% for line in fieldset %}
+                               {% for field in line %}
+                                       {% if field.is_hidden %} {{ field.field }} {% endif %}
+                               {% endfor %}
+                       {% endfor %}
+                       {% endfor %}{% endspaceless %}
+               {% endfor %}
+               {% for form in inline_admin_formset.formset.forms %}
+                       <div class="row cells-{{ form.fields|length }} cells{% if form.errors %} errors{% endif %}{% for field in form %} {{ field.field.name }}{% endfor %}">
+                               {{ form.non_field_errors }}
+                               <div>
+                               {% for field in form %}
+                                       {% if not field.is_hidden %}
+                                       {% comment %}This will be true for one field: the content/content reference{% endcomment %}
+                                       <div class="column span-4"><label class='required' for="{{ form.content.auto_id }}{{ form.content_id.auto_id }}">{{ form.verbose_name|capfirst }}:</label></div>
+                                       <div class="column span-flexible">
+                                               {{ field }}
+                                               {{ field.errors }}
+                                               {% if field.field.help_text %}
+                                                       <p class="help">{{ field.field.help_text|safe }}</p>
+                                               {% endif %}
+                                       </div>
+                                       {% endif %}
+                               {% endfor %}
+                               </div>
+                       </div>
+               {% endfor %}
+       </fieldset>
+{% endif %}
@@ -7,15 +7,6 @@
    <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
    {{ inline_admin_formset.formset.non_form_errors }}
    <table>
-        <thead><tr>
-        {% for field in inline_admin_formset.fields %}
-          {% if not field.widget.is_hidden %}
-                <th{% if field.required %} class="required"{% endif %}>{{ field.label|capfirst }}</th>
-          {% endif %}
-        {% endfor %}
-        {% if inline_admin_formset.formset.can_delete %}<th>{% trans "Delete?" %}</th>{% endif %}
-        </tr></thead>
-
         <tbody>
         {% for inline_admin_form in inline_admin_formset %}
                {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
                  {% endfor %}
                {% endfor %}
                {% endfor %}
-               {{ inline_admin_form.form.name.as_hidden }}
                {% endspaceless %}
-               {% if inline_admin_form.form.non_field_errors %}
-               <tr><td colspan="{{ inline_admin_form.field_count }}">{{ inline_admin_form.form.non_field_errors }}</td></tr>
+       {% endfor %}
+       {% for form in inline_admin_formset.formset.forms %}
+               {% if form.non_field_errors %}
+               <tr><td colspan="2">{{ form.non_field_errors }}</td></tr>
                {% endif %}
-               <tr class="{% cycle "row1" "row2" %} {% if forloop.last %} empty-form{% endif %}"
-                        id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
-                       <th>{{ inline_admin_form.form.verbose_name|capfirst }}:</th>
-               {% for fieldset in inline_admin_form %}
-                 {% for line in fieldset %}
-                       {% for field in line %}
-                         {% if field.field.name != 'name' %}
+               <tr class="{% cycle "row1" "row2" %}{% comment %} {% if forloop.last %} empty-form{% endif %}{% endcomment %}"
+                        id="{{ formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
+                       <th>{{ form.verbose_name|capfirst }}:</th>
+                       {% for field in form %}
+                         {% if not field.is_hidden %}
                          <td class="{{ field.field.name }}">
-                         {% if field.is_readonly %}
-                                 <p>{{ field.contents }}</p>
-                         {% else %}
                                  {{ field.field.errors.as_ul }}
-                                 {{ field.field }}
-                         {% endif %}
+                                 {{ field }}
+                                 {% if field.field.help_text %}
+                                 <p class="help">{{ field.field.help_text|safe }}</p>
+                                 {% endif %}
                          </td>
                          {% endif %}
                        {% endfor %}
-                 {% endfor %}
-               {% endfor %}
-               {% if inline_admin_formset.formset.can_delete %}
-                 <td class="delete">{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}</td>
-               {% endif %}
                </tr>
         {% endfor %}
         </tbody>
diff --git a/philo/templates/admin/philo/page/add_form.html b/philo/templates/admin/philo/page/add_form.html
new file mode 100644 (file)
index 0000000..b2a6358
--- /dev/null
@@ -0,0 +1,14 @@
+{% extends "admin/change_form.html" %}
+{% load i18n %}
+
+{% block form_top %}
+       {% if not is_popup %}
+               <p>{% trans "First, choose a template. After saving, you'll be able to provide additional content for containers." %}</p>
+       {% else %}
+               <p>{% trans "Choose a template" %}</p>
+       {% endif %}
+{% endblock %}
+
+{% block after_field_sets %}
+<script type="text/javascript">document.getElementById("id_name").focus();</script>
+{% endblock %}
\ No newline at end of file
diff --git a/philo/templatetags/__init__.py b/philo/templatetags/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
similarity index 94%
rename from tests.py
rename to philo/tests.py
index 96ac7b6..a0e0184 100644 (file)
--- a/tests.py
@@ -1,13 +1,17 @@
-from django.test import TestCase
+import sys
+import traceback
+
 from django import template
 from django.conf import settings
 from django.db import connection
 from django.template import loader
 from django.template.loaders import cached
+from django.test import TestCase
+from django.test.utils import setup_test_template_loader
+
+from philo.contrib.penfield.models import Blog, BlogView, BlogEntry
 from philo.exceptions import AncestorDoesNotExist
 from philo.models import Node, Page, Template
-from philo.contrib.penfield.models import Blog, BlogView, BlogEntry
-import sys, traceback
 
 
 class TemplateTestCase(TestCase):
@@ -17,19 +21,15 @@ class TemplateTestCase(TestCase):
                "Tests to make sure that embed behaves with complex includes and extends"
                template_tests = self.get_template_tests()
                
-               # Register our custom template loader. Shamelessly cribbed from django core regressiontests.
-               def test_template_loader(template_name, template_dirs=None):
-                       "A custom template loader that loads the unit-test templates."
-                       try:
-                               return (template_tests[template_name][0] , "test:%s" % template_name)
-                       except KeyError:
-                               raise template.TemplateDoesNotExist, template_name
-               
-               cache_loader = cached.Loader(('test_template_loader',))
-               cache_loader._cached_loaders = (test_template_loader,)
+               # Register our custom template loader. Shamelessly cribbed from django/tests/regressiontests/templates/tests.py:384.
+               cache_loader = setup_test_template_loader(
+                       dict([(name, t[0]) for name, t in template_tests.iteritems()]),
+                       use_cached_loader=True,
+               )
                
-               old_template_loaders = loader.template_source_loaders
-               loader.template_source_loaders = [cache_loader]
+               failures = []
+               tests = template_tests.items()
+               tests.sort()
                
                # Turn TEMPLATE_DEBUG off, because tests assume that.
                old_td, settings.TEMPLATE_DEBUG = settings.TEMPLATE_DEBUG, False
@@ -38,9 +38,6 @@ class TemplateTestCase(TestCase):
                old_invalid = settings.TEMPLATE_STRING_IF_INVALID
                expected_invalid_str = 'INVALID'
                
-               failures = []
-               tests = template_tests.items()
-               tests.sort()
                # Run tests
                for name, vals in tests:
                        xx, context, result = vals
similarity index 73%
rename from urls.py
rename to philo/urls.py
index 47be7da..0363224 100644 (file)
--- a/urls.py
@@ -3,6 +3,6 @@ from philo.views import node_view
 
 
 urlpatterns = patterns('',
-       url(r'^$', node_view, name='philo-root'),
+       url(r'^$', node_view, kwargs={'path': '/'}, name='philo-root'),
        url(r'^(?P<path>.*)$', node_view, name='philo-node-by-path')
 )
similarity index 73%
rename from utils.py
rename to philo/utils.py
index deb009c..57f949e 100644 (file)
--- a/utils.py
@@ -121,30 +121,4 @@ def get_included(self):
 
 # We ignore the IncludeNode because it will never work in a blank context.
 setattr(ExtendsNode, LOADED_TEMPLATE_ATTR, property(get_extended))
-setattr(ConstantIncludeNode, LOADED_TEMPLATE_ATTR, property(get_included))
-
-
-def nodelist_crawl(nodelist, callback):
-       """This function crawls through a template's nodelist and the nodelists of any included or extended
-       templates, as determined by the presence and value of <LOADED_TEMPLATE_ATTR> on a node. Each node
-       will also be passed to a callback function for additional processing."""
-       results = []
-       for node in nodelist:
-               try:
-                       if hasattr(node, 'child_nodelists'):
-                               for nodelist_name in node.child_nodelists:
-                                       if hasattr(node, nodelist_name):
-                                               results.extend(nodelist_crawl(getattr(node, nodelist_name), callback))
-                       
-                       # LOADED_TEMPLATE_ATTR contains the name of an attribute philo uses to declare a
-                       # node as rendering an additional template. Philo monkeypatches the attribute onto
-                       # the relevant default nodes and declares it on any native nodes.
-                       if hasattr(node, LOADED_TEMPLATE_ATTR):
-                               loaded_template = getattr(node, LOADED_TEMPLATE_ATTR)
-                               if loaded_template:
-                                       results.extend(nodelist_crawl(loaded_template.nodelist, callback))
-                       
-                       callback(node, results)
-               except:
-                       raise # fail for this node
-       return results
\ No newline at end of file
+setattr(ConstantIncludeNode, LOADED_TEMPLATE_ATTR, property(get_included))
\ No newline at end of file
similarity index 85%
rename from validators.py
rename to philo/validators.py
index 8b39abd..c8e5dc9 100644 (file)
@@ -3,6 +3,7 @@ from django.core.validators import RegexValidator
 from django.core.exceptions import ValidationError
 from django.template import Template, Parser, Lexer, TOKEN_BLOCK, TOKEN_VAR, TemplateSyntaxError
 from django.utils import simplejson as json
+from django.utils.html import escape, mark_safe
 import re
 from philo.utils import LOADED_TEMPLATE_ATTR
 
@@ -116,6 +117,16 @@ class TemplateValidationParser(Parser):
                raise ValidationError('Tag "%s" is not permitted here.' % command)
 
 
+def linebreak_iter(template_source):
+       # Cribbed from django/views/debug.py:18
+       yield 0
+       p = template_source.find('\n')
+       while p >= 0:
+               yield p+1
+               p = template_source.find('\n', p+1)
+       yield len(template_source) + 1
+
+
 class TemplateValidator(object): 
        def __init__(self, allow=None, disallow=None, secure=True):
                self.allow = allow
@@ -128,6 +139,14 @@ class TemplateValidator(object):
                except ValidationError:
                        raise
                except Exception, e:
+                       if hasattr(e, 'source') and isinstance(e, TemplateSyntaxError):
+                               origin, (start, end) = e.source
+                               template_source = origin.reload()
+                               upto = 0
+                               for num, next in enumerate(linebreak_iter(template_source)):
+                                       if start >= upto and end <= next:
+                                               raise ValidationError(mark_safe("Template code invalid: \"%s\" (%s:%d).<br />%s" % (escape(template_source[start:end]), origin.loadname, num, e)))
+                                       upto = next
                        raise ValidationError("Template code invalid. Error was: %s: %s" % (e.__class__.__name__, e))
        
        def validate_template(self, template_string):
similarity index 91%
rename from views.py
rename to philo/views.py
index f5a2c7f..598be36 100644 (file)
--- a/views.py
@@ -28,7 +28,7 @@ def node_view(request, path=None, **kwargs):
        subpath = request.node.subpath
        
        # Explicitly disallow trailing slashes if we are otherwise at a node's url.
-       if request.path and request.path != "/" and request.path[-1] == "/" and subpath == "/":
+       if request._cached_node_path != "/" and request._cached_node_path[-1] == "/" and subpath == "/":
                return HttpResponseRedirect(node.get_absolute_url())
        
        if not node.handles_subpath(subpath):
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
index 0000000..3c18b16
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python
+
+from distutils.core import setup
+import os
+
+
+# Shamelessly cribbed from django's setup.py file.
+def fullsplit(path, result=None):
+       """
+       Split a pathname into components (the opposite of os.path.join) in a
+       platform-neutral way.
+       """
+       if result is None:
+               result = []
+       head, tail = os.path.split(path)
+       if head == '':
+               return [tail] + result
+       if head == path:
+               return result
+       return fullsplit(head, [tail] + result)
+
+# Compile the list of packages available, because distutils doesn't have
+# an easy way to do this. Shamelessly cribbed from django's setup.py file.
+packages, data_files = [], []
+root_dir = os.path.dirname(__file__)
+if root_dir != '':
+    os.chdir(root_dir)
+philo_dir = 'philo'
+
+for dirpath, dirnames, filenames in os.walk(philo_dir):
+       # Ignore dirnames that start with '.'
+       for i, dirname in enumerate(dirnames):
+               if dirname.startswith('.'): del dirnames[i]
+       if '__init__.py' in filenames:
+               packages.append('.'.join(fullsplit(dirpath)))
+       elif filenames:
+               data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]])
+
+
+version = __import__('philo').VERSION
+
+setup(
+       name = 'Philo',
+       version = '%s.%s' % (version[0], version[1]),
+       packages = packages,
+       data_files = data_files,
+)
\ No newline at end of file
diff --git a/templates/admin/philo/edit_inline/grappelli_tabular_container.html b/templates/admin/philo/edit_inline/grappelli_tabular_container.html
deleted file mode 100644 (file)
index 5602a38..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-{% load i18n adminmedia %}
-
-<!-- group -->
-{{ inline_admin_formset.formset.management_form }}
-{% comment %}Don't render the formset at all if there aren't any forms.{% endcomment %}
-{% if inline_admin_formset.formset.forms %}
-       <fieldset class="module{% if inline_admin_formset.opts.classes %} {{ inline_admin_formset.opts.classes|join:" " }}{% endif %}">
-               <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
-               {{ inline_admin_formset.formset.non_form_errors }}
-               {% for inline_admin_form in inline_admin_formset %}
-                       {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
-                       {{ inline_admin_form.fk_field.field }}
-                       {% spaceless %}
-                       {% for fieldset in inline_admin_form %}
-                       {% for line in fieldset %}
-                               {% for field in line %}
-                                       {% if field.is_hidden %} {{ field.field }} {% endif %}
-                               {% endfor %}
-                       {% endfor %}
-                       {% endfor %}{% endspaceless %}
-                       <div class="row cells-{{ inline_admin_form.fields|length }}{% if not inline_admin_form.fields|length_is:"2" %} cells{% endif %}{% if inline_admin_form.errors %} errors{% endif %} {% for field in inline_admin_form %}{{ field.field.name }} {% endfor %}{% if forloop.last %} empty-form{% endif %}">
-                               <div{% if not inline_admin_form.fields|length_is:"2" %} class="cell"{% endif %}>
-                                       <div class="column span-4"><label class='required' for="{{ inline_admin_form.form.content.auto_id }}{{ inline_admin_form.form.content_id.auto_id }}">{{ inline_admin_form.form.verbose_name|capfirst }}:</label>{{ inline_admin_form.form.name.as_hidden }}</div>
-                               {% for fieldset in inline_admin_form %}{% for line in fieldset %}{% for field in line %}
-                                       {% if field.field.name != 'name' %}
-                                       <div class="column span-flexible">
-                                               {% if field.is_readonly %}
-                                                       <p class="readonly">{{ field.contents }}</p>
-                                               {% else %}
-                                                       {{ field.field }}
-                                               {% endif %}
-                                               {{ inline_admin_form.errors }}
-                                               {% if field.field.field.help_text %}
-                                                       <p class="help">{{ field.field.field.help_text|safe }}</p>
-                                               {% endif %}
-                                       </div>
-                                       {% endif %}
-                               {% endfor %}{% endfor %}{% endfor %}
-                               </div>
-                       </div>
-               {% endfor %}
-       </fieldset>
-{% endif %}
diff --git a/templates/admin/philo/page/add_form.html b/templates/admin/philo/page/add_form.html
deleted file mode 100644 (file)
index 67f6ec4..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-{% extends "admin/change_form.html" %}
-{% load i18n %}
-
-{% block extrahead %}{{ block.super }}
-<!-- This will break if anything ever changes and may not work in all browsers. Sad face. -->
-<script type='text/javascript'>
-(function($){
-       $(function(){
-               $('#page_form input[type=submit]').click(function(e){
-                       if (e.target.name == '_addanother') {
-                               hidden = document.getElementById('page_form')._continue[0]
-                               hidden.parentNode.removeChild(hidden)
-                       }
-               })
-       })
-}(django.jQuery));
-</script>
-{% endblock %}
-
-{% block form_top %}
-       <p>{% trans "First, choose a template. After saving, you'll be able to provide additional content for containers." %}</p>
-       <input type="hidden" name="_continue" value="1" />
-{% endblock %}
-
-{% block content %}
-{% with 0 as save_on_top %}
-{{ block.super }}
-{% endwith %}
-{% endblock %}
\ No newline at end of file