Merge branch 'master' of git://github.com/melinath/philo
authorJoseph Spiros <joseph.spiros@ithinksw.com>
Fri, 28 Jan 2011 12:45:12 +0000 (07:45 -0500)
committerJoseph Spiros <joseph.spiros@ithinksw.com>
Fri, 28 Jan 2011 12:45:12 +0000 (07:45 -0500)
* 'master' of git://github.com/melinath/philo: (31 commits)
  Merged cowell back into core.
  Added support for DateTime information being stored in a JSONAttribute. Switched ForeignKeyAttribute and M2MAttribute to store the related model in self.to instead of overwriting self.model.
  Initial raw_id_fields support for proxy fields. Involves hacks to bypass model validation for things like raw_id_fields, where validation depends on model._meta.get_field(). Renamed EntityForm to ProxyFieldForm to more accurately reflect its purpose. Removed extraneous code from ProxyFieldForm that didn't belong there.
  Ungenericized get_item_queryset and added tag/issue queryset fetching methods to BlogView and NewsletterView, respectively. Re-added the tag_archive_view and issue_archive_view methods and set them up to use these methods.
  Added get_item_queryset method to BlogView and NewsletterView so that subclasses can replace this functionality. Moved get_context hook onto the MultiView base class and added a basic_view method which provides a simple way to add a view method to a multiview's urlpatterns for a related View subclass instance.
  Added missing import to forms/fields.py
  Small tweaks to TreeModel.get_path to take advantage of updates to django-mptt. Updated README accordingly.
  Moved container forms and attribute forms into admin. Moved AttributeFields and EntityForm into contrib/cowell/; this more accurately reflects their status as convenient in certain circumstances. Simplified imports according to these changes. Made further tweaks to ManyToManyValue's methods.
  Split forms into containers, entities, and fields. Split attribute fields out from other model fields. Revamped the interaction between AttributeForm and the AttributeValue subclasses to more clearly define their roles. Moved all code related to AttributeFields into models.fields.attributes. Genericized AttributeFieldDescriptor and removed the other descriptor options.
  Minor correction to NavigationManager caching.
  Reduced number of queries for BlogView.get_entries_by_tag. Corrected waldo's AccountMultiView.has_valid_account() method, which had holdovers from the user_form/profile_form distinction.
  Improved blog and newsletter ModelAdmins. Set BlogEntries and NewsletterArticlesup to set their dates on save if it wasn't set manually - gives more accurate publishing times than a datetime.now() default. Set feed creation to use templates for title and description so that projects can customize the display for markdown etc. Limited Attribute.key to word characters to ensure template usability. Ordered Tags by name.
  Added a Navigation model to mediate between Nodes and multiple sets of navigation. Updated templatetags accordingly. Addresses issue 70.
  Added fix_init_kwarg method to JSONField and connected it to the pre_init signal during contribute_to_class - this effectively mimics what django does for RelatedObject fields in Model.__init__ by allowing a python value to be passed in to the constructor (e.g. JSONValue(value={}) instead of JSONValue(value_json='{}')). This resolves issue 68.
  Split shipherd NodeNavigationInline into inlines for hosted/targeting inlines. Added NodeAdmin reregistration to bring the new inlines into play. Added navigation_host filter.
  Minor correction to Navigation.is_active()
  updated grappelli_tabular_attribute.html to match changes in grappelli 2.3
  Shifted NavigationManager caching to use node instances rather than node pks. Improved (i.e. added) a distinction between having a cache for a node and having a Navigation instance somewhere in the cache. Refactored Navigation.is_active for better clarity. Added cache clearing for previously-uncached, directly-hosted navigation. Adjusted recursenavigation to accept either a node or a queryset/iterable. Added targeting_navigation filter.
  Added shortcut is_cached method to Navigation instances and Navigation.objects; updated other methods to use these shortcuts. Added cache clearing for new and changed Navigation instances that have not been cached but are within the range of a hosted navigation instance.
  Corrected shipherd Navigation cache clearing and made grappelli attribute inline template compatible with grappelli r1399. Tweaked recursenavigation template tag behavior to set {{ navigation }} in the context instead of node and corrected its documentation. Added has_navigation filter.
  ...

31 files changed:
README
README.markdown
admin/base.py
admin/forms/__init__.py [new file with mode: 0644]
admin/forms/attributes.py [new file with mode: 0644]
admin/forms/containers.py [new file with mode: 0644]
admin/pages.py
contrib/penfield/admin.py
contrib/penfield/models.py
contrib/penfield/templates/penfield/feeds/blog_entry/description.html [new file with mode: 0644]
contrib/penfield/templates/penfield/feeds/blog_entry/title.html [new file with mode: 0644]
contrib/penfield/templates/penfield/feeds/newsletter_article/description.html [new file with mode: 0644]
contrib/penfield/templates/penfield/feeds/newsletter_article/title.html [new file with mode: 0644]
contrib/shipherd/__init__.py [new file with mode: 0644]
contrib/shipherd/admin.py [new file with mode: 0644]
contrib/shipherd/models.py [new file with mode: 0644]
contrib/shipherd/templatetags/__init__.py [new file with mode: 0644]
contrib/shipherd/templatetags/shipherd.py [new file with mode: 0644]
contrib/waldo/models.py
forms.py [deleted file]
forms/__init__.py [new file with mode: 0644]
forms/entities.py [new file with mode: 0644]
forms/fields.py [new file with mode: 0644]
models/__init__.py
models/base.py
models/fields.py [deleted file]
models/fields/__init__.py [new file with mode: 0644]
models/fields/entities.py [new file with mode: 0644]
models/nodes.py
templates/admin/philo/edit_inline/grappelli_tabular_attribute.html
templatetags/embed.py

diff --git a/README b/README
index 5ce7b93..4b1a6f7 100644 (file)
--- a/README
+++ b/README
@@ -3,7 +3,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-mptt 0.4+ <https://github.com/django-mptt/django-mptt/> 
+       * 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/>
        * (Optional) south 0.7.2+ <http://south.aeracode.org/>
@@ -20,4 +20,4 @@ After installing philo and mptt on your python path, make sure to complete the f
 3. include 'philo.urls' somewhere in your urls.py file.
 4. Optionally add a root node to your current Site.
 
-Philo should be ready to go!
\ No newline at end of file
+Philo should be ready to go!
index 0e695c5..8060db8 100644 (file)
@@ -4,7 +4,7 @@ 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-mptt 0.4+ &lt;https://github.com/django-mptt/django-mptt/&gt;](https://github.com/django-mptt/django-mptt/)
+ * [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/)
  * (Optional) [recaptcha-django r6 &lt;http://code.google.com/p/recaptcha-django/&gt;](http://code.google.com/p/recaptcha-django/)
@@ -21,4 +21,4 @@ After installing philo and mptt on your python path, make sure to complete the f
 3. include 'philo.urls' somewhere in your urls.py file.
 4. Optionally add a root node to your current Site.
 
-Philo should be ready to go!
\ No newline at end of file
+Philo should be ready to go!
index 0d35cf6..81ff8f8 100644 (file)
@@ -5,8 +5,10 @@ from django.http import HttpResponse
 from django.utils import simplejson as json
 from django.utils.html import escape
 from philo.models import Tag, Attribute
-from philo.forms import AttributeForm, AttributeInlineFormSet
+from philo.models.fields.entities import ForeignKeyAttribute, ManyToManyAttribute
+from philo.admin.forms.attributes import AttributeForm, AttributeInlineFormSet
 from philo.admin.widgets import TagFilteredSelectMultiple
+from philo.forms.entities import EntityForm, proxy_fields_for_entity_model
 from mptt.admin import MPTTModelAdmin
 
 
@@ -29,16 +31,115 @@ class AttributeInline(generic.GenericTabularInline):
                template = 'admin/philo/edit_inline/tabular_attribute.html'
 
 
+def hide_proxy_fields(cls, attname, proxy_field_set):
+       val_set = set(getattr(cls, attname))
+       if proxy_field_set & val_set:
+               cls._hidden_attributes[attname] = list(val_set)
+               setattr(cls, attname, list(val_set - proxy_field_set))
+
+
+class EntityAdminMetaclass(admin.ModelAdmin.__metaclass__):
+       def __new__(cls, name, bases, attrs):
+               # HACK to bypass model validation for proxy fields by masking them as readonly fields
+               new_class = super(EntityAdminMetaclass, cls).__new__(cls, name, bases, attrs)
+               form = getattr(new_class, 'form', None)
+               if form:
+                       opts = form._meta
+                       if issubclass(form, EntityForm) and opts.model:
+                               proxy_fields = proxy_fields_for_entity_model(opts.model).keys()
+                               readonly_fields = new_class.readonly_fields
+                               new_class._real_readonly_fields = readonly_fields
+                               new_class.readonly_fields = list(readonly_fields) + proxy_fields
+                               
+                               # Additional HACKS to handle raw_id_fields and other attributes that the admin
+                               # uses model._meta.get_field to validate.
+                               new_class._hidden_attributes = {}
+                               proxy_fields = set(proxy_fields)
+                               hide_proxy_fields(new_class, 'raw_id_fields', proxy_fields)
+               #END HACK
+               return new_class
+
+
 class EntityAdmin(admin.ModelAdmin):
+       __metaclass__ = EntityAdminMetaclass
+       form = EntityForm
        inlines = [AttributeInline]
        save_on_top = True
+       
+       def __init__(self, *args, **kwargs):
+               # HACK PART 2 restores the actual readonly fields etc. on __init__.
+               if hasattr(self, '_real_readonly_fields'):
+                       self.readonly_fields = self.__class__._real_readonly_fields
+               if hasattr(self, '_hidden_attributes'):
+                       for name, value in self._hidden_attributes.items():
+                               setattr(self, name, value)
+               # END HACK
+               super(EntityAdmin, self).__init__(*args, **kwargs)
+       
+       def formfield_for_dbfield(self, db_field, **kwargs):
+               """
+               Override the default behavior to provide special formfields for EntityEntitys.
+               Essentially clones the ForeignKey/ManyToManyField special behavior for the Attribute versions.
+               """
+               if not db_field.choices and isinstance(db_field, (ForeignKeyAttribute, ManyToManyAttribute)):
+                       request = kwargs.pop("request", None)
+                       # Combine the field kwargs with any options for formfield_overrides.
+                       # Make sure the passed in **kwargs override anything in
+                       # formfield_overrides because **kwargs is more specific, and should
+                       # always win.
+                       if db_field.__class__ in self.formfield_overrides:
+                               kwargs = dict(self.formfield_overrides[db_field.__class__], **kwargs)
+                       
+                       # Get the correct formfield.
+                       if isinstance(db_field, ManyToManyAttribute):
+                               formfield = self.formfield_for_manytomanyattribute(db_field, request, **kwargs)
+                       elif isinstance(db_field, ForeignKeyAttribute):
+                               formfield = self.formfield_for_foreignkeyattribute(db_field, request, **kwargs)
+                       
+                       # For non-raw_id fields, wrap the widget with a wrapper that adds
+                       # extra HTML -- the "add other" interface -- to the end of the
+                       # rendered output. formfield can be None if it came from a
+                       # OneToOneField with parent_link=True or a M2M intermediary.
+                       # TODO: Implement this.
+                       #if formfield and db_field.name not in self.raw_id_fields:
+                       #       formfield.widget = admin.widgets.RelatedFieldWidgetWrapper(formfield.widget, db_field, self.admin_site)
+                       
+                       return formfield
+               return super(EntityAdmin, self).formfield_for_dbfield(db_field, **kwargs)
+       
+       def formfield_for_foreignkeyattribute(self, db_field, request=None, **kwargs):
+               """Get a form field for a ForeignKeyAttribute field."""
+               db = kwargs.get('using')
+               if db_field.name in self.raw_id_fields:
+                       kwargs['widget'] = admin.widgets.ForeignKeyRawIdWidget(db_field, db)
+               #TODO: Add support for radio fields
+               #elif db_field.name in self.radio_fields:
+               #       kwargs['widget'] = widgets.AdminRadioSelect(attrs={
+               #               'class': get_ul_class(self.radio_fields[db_field.name]),
+               #       })
+               #       kwargs['empty_label'] = db_field.blank and _('None') or None
+               
+               return db_field.formfield(**kwargs)
+       
+       def formfield_for_manytomanyattribute(self, db_field, request=None, **kwargs):
+               """Get a form field for a ManyToManyAttribute field."""
+               db = kwargs.get('using')
+               
+               if db_field.name in self.raw_id_fields:
+                       kwargs['widget'] = admin.widgets.ManyToManyRawIdWidget(db_field, using=db)
+                       kwargs['help_text'] = ''
+               #TODO: Add support for filtered fields.
+               #elif db_field.name in (list(self.filter_vertical) + list(self.filter_horizontal)):
+               #       kwargs['widget'] = widgets.FilteredSelectMultiple(db_field.verbose_name, (db_field.name in self.filter_vertical))
+               
+               return db_field.formfield(**kwargs)
 
 
 class TreeAdmin(MPTTModelAdmin):
        pass
 
 
-class TreeEntityAdmin(TreeAdmin, EntityAdmin):
+class TreeEntityAdmin(EntityAdmin, TreeAdmin):
        pass
 
 
diff --git a/admin/forms/__init__.py b/admin/forms/__init__.py
new file mode 100644 (file)
index 0000000..1906380
--- /dev/null
@@ -0,0 +1,2 @@
+from philo.admin.forms.attributes import *
+from philo.admin.forms.containers import *
\ No newline at end of file
diff --git a/admin/forms/attributes.py b/admin/forms/attributes.py
new file mode 100644 (file)
index 0000000..fc77d0f
--- /dev/null
@@ -0,0 +1,66 @@
+from django.contrib.contenttypes.generic import BaseGenericInlineFormSet
+from django.contrib.contenttypes.models import ContentType
+from django.forms.models import ModelForm
+from philo.models import Attribute
+
+
+__all__ = ('AttributeForm', 'AttributeInlineFormSet')
+
+
+class AttributeForm(ModelForm):
+       """
+       This class handles an attribute's fields as well as the fields for its value (if there is one.)
+       The fields defined will vary depending on the value type, but the fields for defining the value
+       (i.e. value_content_type and value_object_id) will always be defined. Except that value_object_id
+       will never be defined. BLARGH!
+       """
+       def __init__(self, *args, **kwargs):
+               super(AttributeForm, self).__init__(*args, **kwargs)
+               
+               # This is necessary because model forms store changes to self.instance in their clean method.
+               # Mutter mutter.
+               value = self.instance.value
+               self._cached_value_ct = self.instance.value_content_type
+               self._cached_value = value
+               
+               # If there is a value, pull in its fields.
+               if value is not None:
+                       self.value_fields = value.value_formfields()
+                       self.fields.update(self.value_fields)
+       
+       def save(self, *args, **kwargs):
+               # At this point, the cleaned_data has already been stored on self.instance.
+               
+               if self.instance.value_content_type != self._cached_value_ct:
+                       # The value content type has changed. Clear the old value, if there was one.
+                       if self._cached_value:
+                               self._cached_value.delete()
+                       
+                       # Clear the submitted value, if any.
+                       self.cleaned_data.pop('value', None)
+                       
+                       # Now create a new value instance so that on next instantiation, the form will
+                       # know what fields to add.
+                       if self.instance.value_content_type is not None:
+                               self.instance.value = self.instance.value_content_type.model_class().objects.create()
+               elif self.instance.value is not None:
+                       # The value content type is the same, but one of the value fields has changed.
+                       
+                       # Use construct_instance to apply the changes from the cleaned_data to the value instance.
+                       fields = self.value_fields.keys()
+                       if set(fields) & set(self.changed_data):
+                               self.instance.value.construct_instance(**dict([(key, self.cleaned_data[key]) for key in fields]))
+                               self.instance.value.save()
+               
+               return super(AttributeForm, self).save(*args, **kwargs)
+       
+       class Meta:
+               model = Attribute
+
+
+class AttributeInlineFormSet(BaseGenericInlineFormSet):
+       "Necessary to force the GenericInlineFormset to use the form's save method for new objects."
+       def save_new(self, form, commit):
+               setattr(form.instance, self.ct_field.get_attname(), ContentType.objects.get_for_model(self.instance).pk)
+               setattr(form.instance, self.ct_fk_field.get_attname(), self.instance.pk)
+               return form.save()
\ No newline at end of file
diff --git a/admin/forms/containers.py b/admin/forms/containers.py
new file mode 100644 (file)
index 0000000..5991dfa
--- /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
+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
index caeee05..13d4098 100644 (file)
@@ -4,7 +4,7 @@ from django import forms
 from philo.admin.base import COLLAPSE_CLASSES, TreeAdmin
 from philo.admin.nodes import ViewAdmin
 from philo.models.pages import Page, Template, Contentlet, ContentReference
-from philo.forms import ContentletInlineFormSet, ContentReferenceInlineFormSet, ContentletForm, ContentReferenceForm
+from philo.admin.forms.containers import *
 
 
 class ContentletInline(admin.StackedInline):
index 5faf4ef..d8fcd90 100644 (file)
@@ -1,8 +1,17 @@
 from django.contrib import admin
-from philo.admin import EntityAdmin, AddTagAdmin
+from django import forms
+from philo.admin import EntityAdmin, AddTagAdmin, COLLAPSE_CLASSES
 from philo.contrib.penfield.models import BlogEntry, Blog, BlogView, Newsletter, NewsletterArticle, NewsletterIssue, NewsletterView
 
 
+class DelayedDateForm(forms.ModelForm):
+       date_field = 'date'
+       
+       def __init__(self, *args, **kwargs):
+               super(DelayedDateForm, self).__init__(*args, **kwargs)
+               self.fields[self.date_field].required = False
+
+
 class TitledAdmin(EntityAdmin):
        prepopulated_fields = {'slug': ('title',)}
        list_display = ('title', 'slug')
@@ -13,11 +22,48 @@ class BlogAdmin(TitledAdmin):
 
 
 class BlogEntryAdmin(TitledAdmin, AddTagAdmin):
+       form = DelayedDateForm
        filter_horizontal = ['tags']
+       list_filter = ['author', 'blog']
+       date_hierarchy = 'date'
+       search_fields = ('content',)
+       list_display = ['title', 'date', 'author']
+       raw_id_fields = ('author',)
+       fieldsets = (
+               (None, {
+                       'fields': ('title', 'author', 'blog')
+               }),
+               ('Content', {
+                       'fields': ('content', 'excerpt', 'tags'),
+               }),
+               ('Advanced', {
+                       'fields': ('slug', 'date'),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
 
 
 class BlogViewAdmin(EntityAdmin):
-       pass
+       fieldsets = (
+               (None, {
+                       'fields': ('blog',)
+               }),
+               ('Pages', {
+                       'fields': ('index_page', 'entry_page', 'tag_page')
+               }),
+               ('Archive Pages', {
+                       'fields': ('entry_archive_page', 'tag_archive_page')
+               }),
+               ('Permalinks', {
+                       'fields': ('entry_permalink_style', 'entry_permalink_base', 'tag_permalink_base'),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Feeds', {
+                       'fields': ('feed_suffix', 'feeds_enabled'),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       raw_id_fields = ('index_page', 'entry_page', 'tag_page', 'entry_archive_page', 'tag_archive_page',)
 
 
 class NewsletterAdmin(TitledAdmin):
@@ -25,7 +71,28 @@ class NewsletterAdmin(TitledAdmin):
 
 
 class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin):
-       filter_horizontal = TitledAdmin.filter_horizontal + ('tags', 'authors')
+       form = DelayedDateForm
+       filter_horizontal = ('tags', 'authors')
+       list_filter = ('newsletter',)
+       date_hierarchy = 'date'
+       search_fields = ('title', 'authors__name',)
+       list_display = ['title', 'date', 'author_names']
+       fieldsets = (
+               (None, {
+                       'fields': ('title', 'authors', 'newsletter')
+               }),
+               ('Content', {
+                       'fields': ('full_text', 'lede', 'tags')
+               }),
+               ('Advanced', {
+                       'fields': ('slug', 'date'),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       
+       def author_names(self, obj):
+               return ', '.join([author.get_full_name() for author in obj.authors.all()])
+       author_names.short_description = "Authors"
 
 
 class NewsletterIssueAdmin(TitledAdmin):
@@ -33,7 +100,26 @@ class NewsletterIssueAdmin(TitledAdmin):
 
 
 class NewsletterViewAdmin(EntityAdmin):
-       pass
+       fieldsets = (
+               (None, {
+                       'fields': ('newsletter',)
+               }),
+               ('Pages', {
+                       'fields': ('index_page', 'article_page', 'issue_page')
+               }),
+               ('Archive Pages', {
+                       'fields': ('article_archive_page', 'issue_archive_page')
+               }),
+               ('Permalinks', {
+                       'fields': ('article_permalink_style', 'article_permalink_base', 'issue_permalink_base'),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Feeds', {
+                       'fields': ('feed_suffix', 'feeds_enabled'),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       raw_id_fields = ('index_page', 'article_page', 'issue_page', 'article_archive_page', 'issue_archive_page',)
 
 
 admin.site.register(Blog, BlogAdmin)
index 9f1b61b..7ca879d 100644 (file)
@@ -1,14 +1,15 @@
-from django.db import models
 from django.conf import settings
-from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField
-from philo.exceptions import ViewCanNotProvideSubpath
 from django.conf.urls.defaults import url, patterns, include
+from django.db import models
 from django.http import Http404
-from datetime import date, datetime
-from philo.utils import paginate
-from philo.contrib.penfield.validators import validate_pagination_count
+from django.template import loader, Context
 from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed
+from datetime import date, datetime
 from philo.contrib.penfield.utils import FeedMultiViewMixin
+from philo.contrib.penfield.validators import validate_pagination_count
+from philo.exceptions import ViewCanNotProvideSubpath
+from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField
+from philo.utils import paginate
 
 
 class Blog(Entity, Titled):
@@ -29,11 +30,16 @@ register_value_model(Blog)
 class BlogEntry(Entity, Titled):
        blog = models.ForeignKey(Blog, related_name='entries', blank=True, null=True)
        author = models.ForeignKey(getattr(settings, 'PHILO_PERSON_MODULE', 'auth.User'), related_name='blogentries')
-       date = models.DateTimeField(default=datetime.now)
+       date = models.DateTimeField(default=None)
        content = models.TextField()
        excerpt = models.TextField(blank=True, null=True)
        tags = models.ManyToManyField(Tag, related_name='blogentries', blank=True, null=True)
        
+       def save(self, *args, **kwargs):
+               if self.date is None:
+                       self.date = datetime.now()
+               super(BlogEntry, self).save(*args, **kwargs)
+       
        class Meta:
                ordering = ['-date']
                verbose_name_plural = "blog entries"
@@ -87,7 +93,7 @@ class BlogView(MultiView, FeedMultiViewMixin):
                                                        kwargs.update({'day': str(obj.date.day).zfill(2)})
                                return self.entry_view, [], kwargs
                elif isinstance(obj, Tag):
-                       if obj in self.blog.entry_tags:
+                       if obj in self.get_tag_queryset():
                                return 'entries_by_tag', [], {'tag_slugs': obj.slug}
                elif isinstance(obj, (date, datetime)):
                        kwargs = {
@@ -98,9 +104,6 @@ class BlogView(MultiView, FeedMultiViewMixin):
                        return 'entries_by_day', [], kwargs
                raise ViewCanNotProvideSubpath
        
-       def get_context(self):
-               return {'blog': self.blog}
-       
        @property
        def urlpatterns(self):
                urlpatterns = patterns('',
@@ -115,7 +118,7 @@ class BlogView(MultiView, FeedMultiViewMixin):
                )
                if self.tag_archive_page:
                        urlpatterns += patterns('',
-                               url((r'^(?:%s)/?$' % self.tag_permalink_base), self.tag_archive_view)
+                               url((r'^(?:%s)/?$' % self.tag_permalink_base), self.tag_archive_view, 'tag_archive')
                        )
                
                if self.entry_archive_page:
@@ -154,13 +157,22 @@ class BlogView(MultiView, FeedMultiViewMixin):
                        )
                return urlpatterns
        
+       def get_context(self):
+               return {'blog': self.blog}
+       
+       def get_entry_queryset(self):
+               return self.blog.entries.all()
+       
+       def get_tag_queryset(self):
+               return self.blog.entry_tags
+       
        def get_all_entries(self, request, extra_context=None):
-               return self.blog.entries.all(), extra_context
+               return self.get_entry_queryset(), extra_context
        
        def get_entries_by_ymd(self, request, year=None, month=None, day=None, extra_context=None):
                if not self.entry_archive_page:
                        raise Http404
-               entries = self.blog.entries.all()
+               entries = self.get_entry_queryset()
                if year:
                        entries = entries.filter(date__year=year)
                if month:
@@ -173,52 +185,29 @@ class BlogView(MultiView, FeedMultiViewMixin):
                return entries, context
        
        def get_entries_by_tag(self, request, tag_slugs, extra_context=None):
-               tags = []
-               for tag_slug in tag_slugs.replace('+', '/').split('/'):
-                       if tag_slug: # ignore blank slugs, handles for multiple consecutive separators (+ or /)
-                               try:
-                                       tag = self.blog.entry_tags.get(slug=tag_slug)
-                               except:
-                                       raise Http404
-                               tags.append(tag)
-               if len(tags) <= 0:
+               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
 
-               entries = self.blog.entries.all()
+               entries = self.get_entry_queryset()
                for tag in tags:
                        entries = entries.filter(tags=tag)
                
-               context = self.get_context()
-               context.update(extra_context or {})
+               context = extra_context or {}
                context.update({'tags': tags})
                
                return entries, context
        
-       def add_item(self, feed, obj, kwargs=None):
-               defaults = {
-                       'title': obj.title,
-                       'description': obj.content,
-                       'author_name': obj.author.get_full_name(),
-                       'pubdate': obj.date
-               }
-               defaults.update(kwargs or {})
-               super(BlogView, self).add_item(feed, obj, defaults)
-       
-       def get_feed(self, feed_type, extra_context, kwargs=None):
-               tags = (extra_context or {}).get('tags', None)
-               title = self.blog.title
-               
-               if tags is not None:
-                       title += " - %s" % ', '.join([tag.name for tag in tags])
-               
-               defaults = {
-                       'title': title
-               }
-               defaults.update(kwargs or {})
-               return super(BlogView, self).get_feed(feed_type, extra_context, defaults)
-       
        def entry_view(self, request, slug, year=None, month=None, day=None, extra_context=None):
-               entries = self.blog.entries.all()
+               entries = self.get_entry_queryset()
                if year:
                        entries = entries.filter(date__year=year)
                if month:
@@ -237,10 +226,37 @@ class BlogView(MultiView, FeedMultiViewMixin):
        def tag_archive_view(self, request, extra_context=None):
                if not self.tag_archive_page:
                        raise Http404
-               context = {}
+               context = self.get_context()
                context.update(extra_context or {})
-               context.update({'blog': self.blog})
+               context.update({
+                       'tags': self.get_tag_queryset()
+               })
                return self.tag_archive_page.render_to_response(request, extra_context=context)
+       
+       def add_item(self, feed, obj, kwargs=None):
+               title = loader.get_template("penfield/feeds/blog_entry/title.html")
+               description = loader.get_template("penfield/feeds/blog_entry/description.html")
+               defaults = {
+                       'title': title.render(Context({'entry': obj})),
+                       'description': description.render(Context({'entry': obj})),
+                       'author_name': obj.author.get_full_name(),
+                       'pubdate': obj.date
+               }
+               defaults.update(kwargs or {})
+               super(BlogView, self).add_item(feed, obj, defaults)
+       
+       def get_feed(self, feed_type, extra_context, kwargs=None):
+               tags = (extra_context or {}).get('tags', None)
+               title = self.blog.title
+               
+               if tags is not None:
+                       title += " - %s" % ', '.join([tag.name for tag in tags])
+               
+               defaults = {
+                       'title': title
+               }
+               defaults.update(kwargs or {})
+               return super(BlogView, self).get_feed(feed_type, extra_context, defaults)
 
 
 class Newsletter(Entity, Titled):
@@ -253,11 +269,16 @@ register_value_model(Newsletter)
 class NewsletterArticle(Entity, Titled):
        newsletter = models.ForeignKey(Newsletter, related_name='articles')
        authors = models.ManyToManyField(getattr(settings, 'PHILO_PERSON_MODULE', 'auth.User'), related_name='newsletterarticles')
-       date = models.DateTimeField(default=datetime.now)
+       date = models.DateTimeField(default=None)
        lede = TemplateField(null=True, blank=True, verbose_name='Summary')
        full_text = TemplateField(db_index=True)
        tags = models.ManyToManyField(Tag, related_name='newsletterarticles', blank=True, null=True)
        
+       def save(self, *args, **kwargs):
+               if self.date is None:
+                       self.date = datetime.now()
+               super(NewsletterArticle, self).save(*args, **kwargs)
+       
        class Meta:
                get_latest_by = 'date'
                ordering = ['-date']
@@ -338,7 +359,7 @@ class NewsletterView(MultiView, FeedMultiViewMixin):
                )
                if self.issue_archive_page:
                        urlpatterns += patterns('',
-                               url(r'^(?:%s)/$' % self.issue_permalink_base, self.issue_archive_view)
+                               url(r'^(?:%s)/$' % self.issue_permalink_base, self.issue_archive_view, 'issue_archive')
                        )
                if self.article_archive_page:
                        urlpatterns += patterns('',
@@ -379,28 +400,34 @@ class NewsletterView(MultiView, FeedMultiViewMixin):
        def get_context(self):
                return {'newsletter': self.newsletter}
        
+       def get_article_queryset(self):
+               return self.newsletter.articles.all()
+       
+       def get_issue_queryset(self):
+               return self.newsletter.issues.all()
+       
        def get_all_articles(self, request, extra_context=None):
-               return self.newsletter.articles.all(), extra_context
+               return self.get_article_queryset(), extra_context
        
        def get_articles_by_ymd(self, request, year, month=None, day=None, extra_context=None):
-               articles = self.newsletter.articles.filter(dat__year=year)
+               articles = self.get_article_queryset().filter(date__year=year)
                if month:
                        articles = articles.filter(date__month=month)
                if day:
                        articles = articles.filter(date__day=day)
-               return articles
+               return articles, extra_context
        
        def get_articles_by_issue(self, request, numbering, extra_context=None):
                try:
-                       issue = self.newsletter.issues.get(numbering=numbering)
+                       issue = self.get_issue_queryset().get(numbering=numbering)
                except NewsletterIssue.DoesNotExist:
                        raise Http404
                context = extra_context or {}
                context.update({'issue': issue})
-               return issue.articles.all(), context
+               return self.get_article_queryset().filter(issues=issue), context
        
        def article_view(self, request, slug, year=None, month=None, day=None, extra_context=None):
-               articles = self.newsletter.articles.all()
+               articles = self.get_article_queryset()
                if year:
                        articles = articles.filter(date__year=year)
                if month:
@@ -416,20 +443,24 @@ class NewsletterView(MultiView, FeedMultiViewMixin):
                context.update({'article': article})
                return self.article_page.render_to_response(request, extra_context=context)
        
-       def issue_archive_view(self, request, extra_context=None):
+       def issue_archive_view(self, request, extra_context):
                if not self.issue_archive_page:
                        raise Http404
-               context = {}
+               context = self.get_context()
                context.update(extra_context or {})
-               context.update({'newsletter': self.newsletter})
+               context.update({
+                       'issues': self.get_issue_queryset()
+               })
                return self.issue_archive_page.render_to_response(request, extra_context=context)
        
        def add_item(self, feed, obj, kwargs=None):
+               title = loader.get_template("penfield/feeds/newsletter_article/title.html")
+               description = loader.get_template("penfield/feeds/newsletter_article/description.html")
                defaults = {
-                       'title': obj.title,
+                       'title': title.render(Context({'article': obj})),
                        'author_name': ', '.join([author.get_full_name() for author in obj.authors.all()]),
                        'pubdate': obj.date,
-                       'description': obj.full_text,
+                       'description': description.render(Context({'article': obj})),
                        'categories': [tag.name for tag in obj.tags.all()]
                }
                defaults.update(kwargs or {})
diff --git a/contrib/penfield/templates/penfield/feeds/blog_entry/description.html b/contrib/penfield/templates/penfield/feeds/blog_entry/description.html
new file mode 100644 (file)
index 0000000..61060d5
--- /dev/null
@@ -0,0 +1 @@
+{{ entry.content }}
\ No newline at end of file
diff --git a/contrib/penfield/templates/penfield/feeds/blog_entry/title.html b/contrib/penfield/templates/penfield/feeds/blog_entry/title.html
new file mode 100644 (file)
index 0000000..f7167dd
--- /dev/null
@@ -0,0 +1 @@
+{{ entry.title }}
\ No newline at end of file
diff --git a/contrib/penfield/templates/penfield/feeds/newsletter_article/description.html b/contrib/penfield/templates/penfield/feeds/newsletter_article/description.html
new file mode 100644 (file)
index 0000000..78e19ce
--- /dev/null
@@ -0,0 +1 @@
+{{ article.full_text }}
\ No newline at end of file
diff --git a/contrib/penfield/templates/penfield/feeds/newsletter_article/title.html b/contrib/penfield/templates/penfield/feeds/newsletter_article/title.html
new file mode 100644 (file)
index 0000000..1f96b1f
--- /dev/null
@@ -0,0 +1 @@
+{{ article.title }}
\ No newline at end of file
diff --git a/contrib/shipherd/__init__.py b/contrib/shipherd/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/contrib/shipherd/admin.py b/contrib/shipherd/admin.py
new file mode 100644 (file)
index 0000000..fb6cffe
--- /dev/null
@@ -0,0 +1,106 @@
+from django.contrib import admin
+from philo.admin import TreeEntityAdmin, COLLAPSE_CLASSES, NodeAdmin, EntityAdmin
+from philo.models import Node
+from philo.contrib.shipherd.models import NavigationItem, Navigation
+
+
+NAVIGATION_RAW_ID_FIELDS = ('navigation', 'parent', 'target_node')
+
+
+class NavigationItemInline(admin.StackedInline):
+       raw_id_fields = NAVIGATION_RAW_ID_FIELDS
+       model = NavigationItem
+       extra = 1
+       sortable_field_name = 'order'
+
+
+class NavigationItemChildInline(NavigationItemInline):
+       verbose_name = "child"
+       verbose_name_plural = "children"
+       fieldsets = (
+               (None, {
+                       'fields': ('text', 'parent')
+               }),
+               ('Target', {
+                       'fields': ('target_node', 'url_or_subpath',)
+               }),
+               ('Advanced', {
+                       'fields': ('reversing_parameters', 'order'),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+
+
+class NavigationNavigationItemInline(NavigationItemInline):
+       fieldsets = (
+               (None, {
+                       'fields': ('text', 'navigation')
+               }),
+               ('Target', {
+                       'fields': ('target_node', 'url_or_subpath',)
+               }),
+               ('Advanced', {
+                       'fields': ('reversing_parameters', 'order'),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+
+
+class NodeNavigationItemInline(NavigationItemInline):
+       verbose_name_plural = 'targeting navigation'
+       fieldsets = (
+               (None, {
+                       'fields': ('text',)
+               }),
+               ('Target', {
+                       'fields': ('target_node', 'url_or_subpath',)
+               }),
+               ('Advanced', {
+                       'fields': ('reversing_parameters', 'order'),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Expert', {
+                       'fields': ('parent', 'navigation')
+               }),
+       )
+
+
+class NodeNavigationInline(admin.TabularInline):
+       model = Navigation
+       extra = 1
+
+
+NodeAdmin.inlines = [NodeNavigationInline, NodeNavigationItemInline] + NodeAdmin.inlines
+
+
+class NavigationItemAdmin(TreeEntityAdmin):
+       list_display = ('__unicode__', 'target_node', 'url_or_subpath', 'reversing_parameters')
+       fieldsets = (
+               (None, {
+                       'fields': ('text', 'navigation',)
+               }),
+               ('Target', {
+                       'fields': ('target_node', 'url_or_subpath',)
+               }),
+               ('Advanced', {
+                       'fields': ('reversing_parameters',),
+                       'classes': COLLAPSE_CLASSES
+               }),
+               ('Expert', {
+                       'fields': ('parent', 'order'),
+                       'classes': COLLAPSE_CLASSES
+               })
+       )
+       raw_id_fields = NAVIGATION_RAW_ID_FIELDS
+       inlines = [NavigationItemChildInline] + TreeEntityAdmin.inlines
+
+
+class NavigationAdmin(EntityAdmin):
+       inlines = [NavigationNavigationItemInline]
+       raw_id_fields = ['node']
+
+
+admin.site.unregister(Node)
+admin.site.register(Node, NodeAdmin)
+admin.site.register(Navigation, NavigationAdmin)
+admin.site.register(NavigationItem, NavigationItemAdmin)
\ No newline at end of file
diff --git a/contrib/shipherd/models.py b/contrib/shipherd/models.py
new file mode 100644 (file)
index 0000000..dee16e9
--- /dev/null
@@ -0,0 +1,316 @@
+#encoding: utf-8
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import NoReverseMatch
+from django.core.validators import RegexValidator, MinValueValidator
+from django.db import models
+from django.forms.models import model_to_dict
+from philo.models import TreeEntity, JSONField, Node, TreeManager, Entity
+from philo.validators import RedirectValidator
+from UserDict import DictMixin
+
+
+DEFAULT_NAVIGATION_DEPTH = 3
+
+
+class NavigationQuerySetMapper(object, DictMixin):
+       """This class exists to prevent setting of items in the navigation cache through node.navigation."""
+       def __init__(self, node):
+               self.node = node
+       
+       def __getitem__(self, key):
+               return Navigation.objects.get_cache_for(self.node)[key]['root_items']
+       
+       def keys(self):
+               return Navigation.objects.get_cache_for(self.node).keys()
+
+
+def navigation(self):
+       if not hasattr(self, '_navigation'):
+               self._navigation = NavigationQuerySetMapper(self)
+       return self._navigation
+
+
+Node.navigation = property(navigation)
+
+
+class NavigationCacheQuerySet(models.query.QuerySet):
+       """
+       This subclass will trigger general cache clearing for Navigation.objects when a mass
+       update or deletion is performed. As there is no convenient way to iterate over the
+       changed or deleted instances, there's no way to be more precise about what gets cleared.
+       """
+       def update(self, *args, **kwargs):
+               super(NavigationCacheQuerySet, self).update(*args, **kwargs)
+               Navigation.objects.clear_cache()
+       
+       def delete(self, *args, **kwargs):
+               super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
+               Navigation.objects.clear_cache()
+
+
+class NavigationManager(models.Manager):
+       # Since navigation is going to be hit frequently and changed
+       # relatively infrequently, cache it. Analogous to contenttypes.
+       use_for_related = True
+       _cache = {}
+       
+       def get_queryset(self):
+               return NavigationCacheQuerySet(self.model, using=self._db)
+       
+       def get_cache_for(self, node, update_targets=True):
+               created = False
+               if not self.has_cache_for(node):
+                       self.create_cache_for(node)
+                       created = True
+               
+               if update_targets and not created:
+                       self.update_targets_for(node)
+               
+               return self.__class__._cache[self.db][node]
+       
+       def has_cache_for(self, node):
+               return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
+       
+       def create_cache_for(self, node):
+               "This method loops through the nodes ancestors and caches all unique navigation keys."
+               ancestors = node.get_ancestors(ascending=True, include_self=True)
+               
+               nodes_to_cache = []
+               
+               for node in ancestors:
+                       if self.has_cache_for(node):
+                               cache = self.get_cache_for(node).copy()
+                               break
+                       else:
+                               nodes_to_cache.insert(0, node)
+               else:
+                       cache = {}
+               
+               for node in nodes_to_cache:
+                       cache = cache.copy()
+                       cache.update(self._build_cache_for(node))
+                       self.__class__._cache.setdefault(self.db, {})[node] = cache
+       
+       def _build_cache_for(self, node):
+               cache = {}
+               tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
+               level_attr = NavigationItem._mptt_meta.level_attr
+               
+               for navigation in node.navigation_set.all():
+                       tree_ids = navigation.roots.values_list(tree_id_attr)
+                       items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft'))
+                       
+                       root_items = []
+                       
+                       for item in items:
+                               item._is_cached = True
+                               
+                               if not hasattr(item, '_cached_children'):
+                                       item._cached_children = []
+                               
+                               if item.parent:
+                                       # alternatively, if I don't want to force it to a list, I could keep track of
+                                       # instances where the parent hasn't yet been met and do this step later for them.
+                                       # delayed action.
+                                       item.parent = items[items.index(item.parent)]
+                                       if not hasattr(item.parent, '_cached_children'):
+                                               item.parent._cached_children = []
+                                       item.parent._cached_children.append(item)
+                               else:
+                                       root_items.append(item)
+                       
+                       cache[navigation.key] = {
+                               'navigation': navigation,
+                               'root_items': root_items,
+                               'items': items
+                       }
+               
+               return cache
+       
+       def clear_cache_for(self, node):
+               # Clear the cache for this node and all its descendants. The
+               # navigation for this node has probably changed, and for now,
+               # it isn't worth it to only clear the descendants actually
+               # affected by this.
+               if not self.has_cache_for(node):
+                       # Already cleared.
+                       return
+               
+               descendants = node.get_descendants(include_self=True)
+               cache = self.__class__._cache[self.db]
+               for node in descendants:
+                       cache.pop(node, None)
+       
+       def update_targets_for(self, node):
+               # Manually update a cache's target nodes in case something's changed there.
+               # This should be a less complex operation than reloading the models each
+               # time. Not as good as selective updates... but not much to be done
+               # about that. TODO: Benchmark it.
+               caches = self.__class__._cache[self.db][node].values()
+               
+               items = []
+               
+               for cache in caches:
+                       items += cache['items']
+               
+               # A distinct query is not strictly necessary. TODO: benchmark the efficiency
+               # with/without distinct.
+               targets = list(Node.objects.filter(navigation_items__in=items).distinct())
+               
+               for cache in caches:
+                       for item in cache['items']:
+                               item.target_node = targets[targets.index(item.target_node)]
+       
+       def clear_cache(self):
+               self.__class__._cache.pop(self.db, None)
+
+
+class Navigation(Entity):
+       objects = NavigationManager()
+       
+       node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
+       key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.")
+       depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
+       
+       def __init__(self, *args, **kwargs):
+               super(Navigation, self).__init__(*args, **kwargs)
+               self._initial_data = model_to_dict(self)
+       
+       def __unicode__(self):
+               return "%s[%s]" % (self.node, self.key)
+       
+       def _has_changed(self):
+               return self._initial_data != model_to_dict(self)
+       
+       def save(self, *args, **kwargs):
+               super(Navigation, self).save(*args, **kwargs)
+               
+               if self._has_changed():
+                       Navigation.objects.clear_cache_for(self.node)
+                       self._initial_data = model_to_dict(self)
+       
+       def delete(self, *args, **kwargs):
+               super(Navigation, self).delete(*args, **kwargs)
+               Navigation.objects.clear_cache_for(self.node)
+       
+       class Meta:
+               unique_together = ('node', 'key')
+
+
+class NavigationItemManager(TreeManager):
+       use_for_related = True
+       
+       def get_queryset(self):
+               return NavigationCacheQuerySet(self.model, using=self._db)
+
+
+class NavigationItem(TreeEntity):
+       objects = NavigationItemManager()
+       
+       navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
+       text = models.CharField(max_length=50)
+       
+       target_node = models.ForeignKey(Node, blank=True, null=True, related_name='navigation_items', help_text="Point to this node's url.")
+       url_or_subpath = models.CharField(max_length=200, validators=[RedirectValidator()], blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.")
+       reversing_parameters = JSONField(blank=True, help_text="If reversing parameters are defined, url_or_subpath will instead be interpreted as the view name to be reversed.")
+       
+       order = models.PositiveSmallIntegerField(default=0)
+       
+       def __init__(self, *args, **kwargs):
+               super(NavigationItem, self).__init__(*args, **kwargs)
+               self._initial_data = model_to_dict(self)
+               self._is_cached = False
+       
+       def __unicode__(self):
+               return self.get_path(field='text', pathsep=u' â€º ')
+       
+       def clean(self):
+               # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
+               if not self.target_node and not self.url_or_subpath:
+                       raise ValidationError("Either a target node or a url must be defined.")
+               
+               if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
+                       raise ValidationError("Reversing parameters require a view name and a target node.")
+               
+               try:
+                       self.get_target_url()
+               except NoReverseMatch, e:
+                       raise ValidationError(e.message)
+               
+               if bool(self.parent) == bool(self.navigation):
+                       raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
+       
+       def get_target_url(self):
+               node = self.target_node
+               if node is not None and node.accepts_subpath and self.url_or_subpath:
+                       if self.reversing_parameters is not None:
+                               view_name = self.url_or_subpath
+                               params = self.reversing_parameters
+                               args = isinstance(params, list) and params or None
+                               kwargs = isinstance(params, dict) and params or None
+                               return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
+                       else:
+                               subpath = self.url_or_subpath
+                               while subpath and subpath[0] == '/':
+                                       subpath = subpath[1:]
+                               return '%s%s' % (node.get_absolute_url(), subpath)
+               elif node is not None:
+                       return node.get_absolute_url()
+               else:
+                       return self.url_or_subpath
+       target_url = property(get_target_url)
+       
+       def is_active(self, request):
+               if self.target_url == request.path:
+                       # Handle the `default` case where the target_url and requested path
+                       # are identical.
+                       return True
+               
+               if self.target_node is None and self.url_or_subpath == "http%s://%s%s" % (request.is_secure() and 's' or '', request.get_host(), request.path):
+                       # If there's no target_node, double-check whether it's a full-url
+                       # match.
+                       return True
+               
+               if self.target_node and not self.url_or_subpath:
+                       # If there is a target node and it's targeted simply, but the target URL is not
+                       # the same as the request path, check whether the target node is an ancestor
+                       # of the requested node. If so, this is active unless the target node
+                       # is the same as the ``host node`` for this navigation structure.
+                       try:
+                               host_node = self.get_root().navigation.node
+                       except AttributeError:
+                               pass
+                       else:
+                               if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
+                                       return True
+               
+               return False
+       
+       def has_active_descendants(self, request):
+               for child in self.get_children():
+                       if child.is_active(request) or child.has_active_descendants(request):
+                               return True
+               return False
+       
+       def _has_changed(self):
+               if model_to_dict(self) == self._initial_data:
+                       return False
+               return True
+       
+       def _clear_cache(self):
+               try:
+                       root = self.get_root()
+                       if self.get_level() < root.navigation.depth:
+                               Navigation.objects.clear_cache_for(self.get_root().navigation.node)
+               except AttributeError:
+                       pass
+       
+       def save(self, *args, **kwargs):
+               super(NavigationItem, self).save(*args, **kwargs)
+               
+               if self._has_changed():
+                       self._clear_cache()
+       
+       def delete(self, *args, **kwargs):
+               super(NavigationItem, self).delete(*args, **kwargs)
+               self._clear_cache()
\ No newline at end of file
diff --git a/contrib/shipherd/templatetags/__init__.py b/contrib/shipherd/templatetags/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/contrib/shipherd/templatetags/shipherd.py b/contrib/shipherd/templatetags/shipherd.py
new file mode 100644 (file)
index 0000000..97475fd
--- /dev/null
@@ -0,0 +1,97 @@
+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): # optional arg for a key?
+       return bool(node.navigation)
+
+
+@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
index e3dd079..2f40da7 100644 (file)
@@ -67,10 +67,6 @@ class LoginMultiView(MultiView):
                }
                kwargs.update(reverse_kwargs or {})
                return 'http://%s%s' % (current_site.domain, self.reverse(confirmation_view, kwargs=kwargs, node=node))
-               
-       def get_context(self):
-               """Hook for providing instance-specific context - such as the value of a Field - to all views."""
-               return {}
        
        def display_login_page(self, request, message, extra_context=None):
                request.session.set_test_cookie()
@@ -379,18 +375,9 @@ class AccountMultiView(LoginMultiView):
                return self.manage_account_page.render_to_response(request, extra_context=context)
        
        def has_valid_account(self, user):
-               user_form, profile_form = self.get_account_forms()
-               forms = []
-               forms.append(user_form(data=get_field_data(user, self.user_fields)))
-               
-               if profile_form is not None:
-                       profile = self.account_profile._default_manager.get_or_create(user=user)[0]
-                       forms.append(profile_form(data=get_field_data(profile, self.account_profile_fields)))
-               
-               for form in forms:
-                       if not form.is_valid():
-                               return False
-               return True
+               form = self.account_form(user, {})
+               form.data = form.initial
+               return form.is_valid()
        
        def account_required(self, view):
                def inner(request, *args, **kwargs):
diff --git a/forms.py b/forms.py
deleted file mode 100644 (file)
index a1785fb..0000000
--- a/forms.py
+++ /dev/null
@@ -1,340 +0,0 @@
-from django import forms
-from django.contrib.admin.widgets import AdminTextareaWidget
-from django.contrib.contenttypes.generic import BaseGenericInlineFormSet
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError, ObjectDoesNotExist
-from django.db.models import Q
-from django.forms.models import model_to_dict, fields_for_model, ModelFormMetaclass, ModelForm, BaseInlineFormSet
-from django.forms.formsets import TOTAL_FORM_COUNT
-from django.template import loader, loader_tags, TemplateDoesNotExist, Context, Template as DjangoTemplate
-from django.utils.datastructures import SortedDict
-from philo.admin.widgets import ModelLookupWidget
-from philo.models import Entity, Template, Contentlet, ContentReference, Attribute
-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)):
-       field_list = []
-       ignored = []
-       opts = entity_model._entity_meta
-       for f in opts.proxy_fields:
-               if not f.editable:
-                       continue
-               if fields and not f.name in fields:
-                       continue
-               if exclude and f.name in exclude:
-                       continue
-               if widgets and f.name in widgets:
-                       kwargs = {'widget': widgets[f.name]}
-               else:
-                       kwargs = {}
-               formfield = formfield_callback(f, **kwargs)
-               if formfield:
-                       field_list.append((f.name, formfield))
-               else:
-                       ignored.append(f.name)
-       field_dict = SortedDict(field_list)
-       if fields:
-               field_dict = SortedDict(
-                       [(f, field_dict.get(f)) for f in fields
-                               if ((not exclude) or (exclude and f not in exclude)) and (f not in ignored) and (f in field_dict)]
-               )
-       return field_dict
-
-
-# BEGIN HACK - This will not be required after http://code.djangoproject.com/ticket/14082 has been resolved
-
-class EntityFormBase(ModelForm):
-       pass
-
-_old_metaclass_new = ModelFormMetaclass.__new__
-
-def _new_metaclass_new(cls, name, bases, attrs):
-       new_class = _old_metaclass_new(cls, name, bases, attrs)
-       if issubclass(new_class, EntityFormBase) and new_class._meta.model:
-               new_class.base_fields.update(proxy_fields_for_entity_model(new_class._meta.model, new_class._meta.fields, new_class._meta.exclude, new_class._meta.widgets)) # don't pass in formfield_callback
-       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
-       def __init__(self, *args, **kwargs):
-               initial = kwargs.pop('initial', None)
-               instance = kwargs.get('instance', None)
-               if instance is not None:
-                       new_initial = {}
-                       for f in instance._entity_meta.proxy_fields:
-                               if self._meta.fields and not f.name in self._meta.fields:
-                                       continue
-                               if self._meta.exclude and f.name in self._meta.exclude:
-                                       continue
-                               new_initial[f.name] = f.value_from_object(instance)
-               else:
-                       new_initial = {}
-               if initial is not None:
-                       new_initial.update(initial)
-               kwargs['initial'] = new_initial
-               super(EntityForm, self).__init__(*args, **kwargs)
-       
-       @fattr(alters_data=True)
-       def save(self, commit=True):
-               cleaned_data = self.cleaned_data
-               instance = super(EntityForm, self).save(commit=False)
-               
-               for f in instance._entity_meta.proxy_fields:
-                       if not f.editable or not f.name in cleaned_data:
-                               continue
-                       if self._meta.fields and f.name not in self._meta.fields:
-                               continue
-                       if self._meta.exclude and f.name in self._meta.exclude:
-                               continue
-                       setattr(instance, f.attname, cleaned_data[f.name])
-               
-               if commit:
-                       instance.save()
-                       self.save_m2m()
-               
-               return instance
-
-
-class AttributeForm(ModelForm):
-       def __init__(self, *args, **kwargs):
-               super(AttributeForm, self).__init__(*args, **kwargs)
-               
-               # This is necessary because model forms store changes to self.instance in their clean method.
-               # Mutter mutter.
-               self._cached_value_ct = self.instance.value_content_type
-               self._cached_value = self.instance.value
-               
-               if self.instance.value is not None:
-                       value_field = self.instance.value.value_formfield()
-                       if value_field:
-                               self.fields['value'] = value_field
-                       if hasattr(self.instance.value, 'content_type'):
-                               self.fields['content_type'] = self.instance.value._meta.get_field('content_type').formfield(initial=getattr(self.instance.value.content_type, 'pk', None))
-       
-       def save(self, *args, **kwargs):
-               # At this point, the cleaned_data has already been stored on self.instance.
-               if self.instance.value_content_type != self._cached_value_ct:
-                       if self.instance.value is not None:
-                               self._cached_value.delete()
-                               if 'value' in self.cleaned_data:
-                                       del(self.cleaned_data['value'])
-                       
-                       if self.instance.value_content_type is not None:
-                               # Make a blank value of the new type! Run special code for content_type attributes.
-                               if hasattr(self.instance.value_content_type.model_class(), 'content_type'):
-                                       if self._cached_value and hasattr(self._cached_value, 'content_type'):
-                                               new_ct = self._cached_value.content_type
-                                       else:
-                                               new_ct = None
-                                       new_value = self.instance.value_content_type.model_class().objects.create(content_type=new_ct)
-                               else:
-                                       new_value = self.instance.value_content_type.model_class().objects.create()
-                               
-                               new_value.apply_data(self.cleaned_data)
-                               new_value.save()
-                               self.instance.value = new_value
-               else:
-                       # The value type is the same, but one of the fields has changed.
-                       # Check to see if the changed value was the content type. We have to check the
-                       # cleaned_data because self.instance.value.content_type was overridden.
-                       if hasattr(self.instance.value, 'content_type') and 'content_type' in self.cleaned_data and 'value' in self.cleaned_data and (not hasattr(self._cached_value, 'content_type') or self._cached_value.content_type != self.cleaned_data['content_type']):
-                               self.cleaned_data['value'] = None
-                       
-                       self.instance.value.apply_data(self.cleaned_data)
-                       self.instance.value.save()
-               
-               super(AttributeForm, self).save(*args, **kwargs)
-               return self.instance
-       
-       class Meta:
-               model = Attribute
-
-
-class AttributeInlineFormSet(BaseGenericInlineFormSet):
-       "Necessary to force the GenericInlineFormset to use the form's save method for new objects."
-       def save_new(self, form, commit):
-               setattr(form.instance, self.ct_field.get_attname(), ContentType.objects.get_for_model(self.instance).pk)
-               setattr(form.instance, self.ct_fk_field.get_attname(), self.instance.pk)
-               return form.save()
-
-
-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/forms/__init__.py b/forms/__init__.py
new file mode 100644 (file)
index 0000000..7e0b0d9
--- /dev/null
@@ -0,0 +1,2 @@
+from philo.forms.fields import *
+from philo.forms.entities import *
\ No newline at end of file
diff --git a/forms/entities.py b/forms/entities.py
new file mode 100644 (file)
index 0000000..b6259a3
--- /dev/null
@@ -0,0 +1,99 @@
+from django.forms.models import ModelFormMetaclass, ModelForm
+from django.utils.datastructures import SortedDict
+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)):
+       field_list = []
+       ignored = []
+       opts = entity_model._entity_meta
+       for f in opts.proxy_fields:
+               if not f.editable:
+                       continue
+               if fields and not f.name in fields:
+                       continue
+               if exclude and f.name in exclude:
+                       continue
+               if widgets and f.name in widgets:
+                       kwargs = {'widget': widgets[f.name]}
+               else:
+                       kwargs = {}
+               formfield = formfield_callback(f, **kwargs)
+               if formfield:
+                       field_list.append((f.name, formfield))
+               else:
+                       ignored.append(f.name)
+       field_dict = SortedDict(field_list)
+       if fields:
+               field_dict = SortedDict(
+                       [(f, field_dict.get(f)) for f in fields
+                               if ((not exclude) or (exclude and f not in exclude)) and (f not in ignored) and (f in field_dict)]
+               )
+       return field_dict
+
+
+# BEGIN HACK - This will not be required after http://code.djangoproject.com/ticket/14082 has been resolved
+
+class EntityFormBase(ModelForm):
+       pass
+
+_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
+               new_class.proxy_fields = proxy_fields
+               new_class.base_fields.update(proxy_fields)
+       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
+       def __init__(self, *args, **kwargs):
+               initial = kwargs.pop('initial', None)
+               instance = kwargs.get('instance', None)
+               if instance is not None:
+                       new_initial = {}
+                       for f in instance._entity_meta.proxy_fields:
+                               if self._meta.fields and not f.name in self._meta.fields:
+                                       continue
+                               if self._meta.exclude and f.name in self._meta.exclude:
+                                       continue
+                               new_initial[f.name] = f.value_from_object(instance)
+               else:
+                       new_initial = {}
+               if initial is not None:
+                       new_initial.update(initial)
+               kwargs['initial'] = new_initial
+               super(EntityForm, self).__init__(*args, **kwargs)
+       
+       @fattr(alters_data=True)
+       def save(self, commit=True):
+               cleaned_data = self.cleaned_data
+               instance = super(EntityForm, self).save(commit=False)
+               
+               for f in instance._entity_meta.proxy_fields:
+                       if not f.editable or not f.name in cleaned_data:
+                               continue
+                       if self._meta.fields and f.name not in self._meta.fields:
+                               continue
+                       if self._meta.exclude and f.name in self._meta.exclude:
+                               continue
+                       setattr(instance, f.attname, f.get_storage_value(cleaned_data[f.name]))
+               
+               if commit:
+                       instance.save()
+                       self.save_m2m()
+               
+               return instance
\ No newline at end of file
diff --git a/forms/fields.py b/forms/fields.py
new file mode 100644 (file)
index 0000000..b148947
--- /dev/null
@@ -0,0 +1,19 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils import simplejson as json
+from philo.validators import json_validator
+
+
+__all__ = ('JSONFormField',)
+
+
+class JSONFormField(forms.Field):
+       default_validators = [json_validator]
+       
+       def clean(self, value):
+               if value == '' and not self.required:
+                       return None
+               try:
+                       return json.loads(value)
+               except Exception, e:
+                       raise ValidationError(u'JSON decode error: %s' % e)
\ No newline at end of file
index 76d7812..523f789 100644 (file)
@@ -2,7 +2,6 @@ from philo.models.base import *
 from philo.models.collections import *
 from philo.models.nodes import *
 from philo.models.pages import *
-from philo.models.fields import *
 from django.contrib.auth.models import User, Group
 from django.contrib.sites.models import Site
 
index c7b1c26..290e8b8 100644 (file)
@@ -2,8 +2,10 @@ from django import forms
 from django.db import models
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes import generic
-from django.utils import simplejson as json
 from django.core.exceptions import ObjectDoesNotExist
+from django.core.validators import RegexValidator
+from django.utils import simplejson as json
+from django.utils.encoding import smart_str
 from philo.exceptions import AncestorDoesNotExist
 from philo.models.fields import JSONField
 from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
@@ -22,6 +24,7 @@ class Tag(models.Model):
        
        class Meta:
                app_label = 'philo'
+               ordering = ('name',)
 
 
 class Titled(models.Model):
@@ -53,10 +56,15 @@ class AttributeValue(models.Model):
        def attribute(self):
                return self.attribute_set.all()[0]
        
-       def apply_data(self, data):
+       def set_value(self, value):
+               raise NotImplementedError
+       
+       def value_formfields(self, **kwargs):
+               """Define any formfields that would be used to construct an instance of this value."""
                raise NotImplementedError
        
-       def value_formfield(self, **kwargs):
+       def construct_instance(self, **kwargs):
+               """Apply cleaned data from the formfields generated by valid_formfields to oneself."""
                raise NotImplementedError
        
        def __unicode__(self):
@@ -70,17 +78,22 @@ attribute_value_limiter = ContentTypeSubclassLimiter(AttributeValue)
 
 
 class JSONValue(AttributeValue):
-       value = JSONField() #verbose_name='Value (JSON)', help_text='This value must be valid JSON.')
+       value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null')
        
        def __unicode__(self):
-               return self.value_json
+               return smart_str(self.value)
        
-       def value_formfield(self, **kwargs):
-               kwargs['initial'] = self.value_json
-               return self._meta.get_field('value').formfield(**kwargs)
+       def value_formfields(self):
+               kwargs = {'initial': self.value_json}
+               field = self._meta.get_field('value')
+               return {field.name: field.formfield(**kwargs)}
        
-       def apply_data(self, cleaned_data):
-               self.value = cleaned_data.get('value', None)
+       def construct_instance(self, **kwargs):
+               field_name = self._meta.get_field('value').name
+               self.set_value(kwargs.pop(field_name, None))
+       
+       def set_value(self, value):
+               self.value = value
        
        class Meta:
                app_label = 'philo'
@@ -91,19 +104,33 @@ class ForeignKeyValue(AttributeValue):
        object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True)
        value = generic.GenericForeignKey()
        
-       def value_formfield(self, form_class=forms.ModelChoiceField, **kwargs):
-               if self.content_type is None:
-                       return None
-               kwargs.update({'initial': self.object_id, 'required': False})
-               return form_class(self.content_type.model_class()._default_manager.all(), **kwargs)
-       
-       def apply_data(self, cleaned_data):
-               if 'value' in cleaned_data and cleaned_data['value'] is not None:
-                       self.value = cleaned_data['value']
-               else:
-                       self.content_type = cleaned_data.get('content_type', None)
-                       # If there is no value set in the cleaned data, clear the stored value.
+       def value_formfields(self):
+               field = self._meta.get_field('content_type')
+               fields = {field.name: field.formfield(initial=getattr(self.content_type, 'pk', None))}
+               
+               if self.content_type:
+                       kwargs = {
+                               'initial': self.object_id,
+                               'required': False,
+                               'queryset': self.content_type.model_class()._default_manager.all()
+                       }
+                       fields['value'] = forms.ModelChoiceField(**kwargs)
+               return fields
+       
+       def construct_instance(self, **kwargs):
+               field_name = self._meta.get_field('content_type').name
+               ct = kwargs.pop(field_name, None)
+               if ct is None or ct != self.content_type:
                        self.object_id = None
+                       self.content_type = ct
+               else:
+                       value = kwargs.pop('value', None)
+                       self.set_value(value)
+                       if value is None:
+                               self.content_type = ct
+       
+       def set_value(self, value):
+               self.value = value
        
        class Meta:
                app_label = 'philo'
@@ -113,57 +140,76 @@ class ManyToManyValue(AttributeValue):
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
        values = models.ManyToManyField(ForeignKeyValue, blank=True, null=True)
        
-       def get_object_id_list(self):
-               if not self.values.count():
-                       return []
-               else:
-                       return self.values.values_list('object_id', flat=True)
-       
-       def get_value(self):
-               if self.content_type is None:
-                       return None
-               
-               return self.content_type.model_class()._default_manager.filter(id__in=self.get_object_id_list())
+       def get_object_ids(self):
+               return self.values.values_list('object_id', flat=True)
+       object_ids = property(get_object_ids)
        
        def set_value(self, value):
-               # Value is probably a queryset - but allow any iterable.
+               # Value must be a queryset. Watch out for ModelMultipleChoiceField;
+               # it returns its value as a list if empty.
                
-               # These lines shouldn't be necessary; however, if value is an EmptyQuerySet,
-               # the code (specifically the object_id__in query) won't work without them. Unclear why...
-               if not value:
-                       value = []
+               self.content_type = ContentType.objects.get_for_model(value.model)
                
                # Before we can fiddle with the many-to-many to foreignkeyvalues, we need
                # a pk.
                if self.pk is None:
                        self.save()
                
-               if isinstance(value, models.query.QuerySet):
-                       value = value.values_list('id', flat=True)
+               object_ids = value.values_list('id', flat=True)
                
-               self.values.filter(~models.Q(object_id__in=value)).delete()
-               current = self.get_object_id_list()
-               
-               for v in value:
-                       if v in current:
-                               continue
-                       self.values.create(content_type=self.content_type, object_id=v)
-       
-       value = property(get_value, set_value)
+               # These lines shouldn't be necessary; however, if object_ids is an EmptyQuerySet,
+               # the code (specifically the object_id__in query) won't work without them. Unclear why...
+               # TODO: is this still the case?
+               if not object_ids:
+                       self.values.all().delete()
+               else:
+                       self.values.exclude(object_id__in=object_ids, content_type=self.content_type).delete()
+                       
+                       current_ids = self.object_ids
+                       
+                       for object_id in object_ids:
+                               if object_id in current_ids:
+                                       continue
+                               self.values.create(content_type=self.content_type, object_id=object_id)
        
-       def value_formfield(self, form_class=forms.ModelMultipleChoiceField, **kwargs):
+       def get_value(self):
                if self.content_type is None:
                        return None
-               kwargs.update({'initial': self.get_object_id_list(), 'required': False})
-               return form_class(self.content_type.model_class()._default_manager.all(), **kwargs)
+               
+               # HACK to be safely explicit until http://code.djangoproject.com/ticket/15145 is resolved
+               object_ids = self.object_ids
+               manager = self.content_type.model_class()._default_manager
+               if not object_ids:
+                       return manager.none()
+               return manager.filter(id__in=self.object_ids)
+       
+       value = property(get_value, set_value)
        
-       def apply_data(self, cleaned_data):
-               if 'value' in cleaned_data and cleaned_data['value'] is not None:
-                       self.value = cleaned_data['value']
+       def value_formfields(self):
+               field = self._meta.get_field('content_type')
+               fields = {field.name: field.formfield(initial=getattr(self.content_type, 'pk', None))}
+               
+               if self.content_type:
+                       kwargs = {
+                               'initial': self.object_ids,
+                               'required': False,
+                               'queryset': self.content_type.model_class()._default_manager.all()
+                       }
+                       fields['value'] = forms.ModelMultipleChoiceField(**kwargs)
+               return fields
+       
+       def construct_instance(self, **kwargs):
+               field_name = self._meta.get_field('content_type').name
+               ct = kwargs.pop(field_name, None)
+               if ct is None or ct != self.content_type:
+                       self.values.clear()
+                       self.content_type = ct
                else:
-                       self.content_type = cleaned_data.get('content_type', None)
-                       # If there is no value set in the cleaned data, clear the stored value.
-                       self.value = []
+                       value = kwargs.get('value', None)
+                       if not value:
+                               value = self.content_type.model_class()._default_manager.none()
+                       self.set_value(value)
+       construct_instance.alters_data = True
        
        class Meta:
                app_label = 'philo'
@@ -178,7 +224,7 @@ class Attribute(models.Model):
        value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True)
        value = generic.GenericForeignKey('value_content_type', 'value_object_id')
        
-       key = models.CharField(max_length=255)
+       key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.")
        
        def __unicode__(self):
                return u'"%s": %s' % (self.key, self.value)
@@ -242,37 +288,6 @@ class Entity(models.Model):
        def attributes(self):
                return QuerySetMapper(self.attribute_set.all())
        
-       @property
-       def _added_attribute_registry(self):
-               if not hasattr(self, '_real_added_attribute_registry'):
-                       self._real_added_attribute_registry = {}
-               return self._real_added_attribute_registry
-       
-       @property
-       def _removed_attribute_registry(self):
-               if not hasattr(self, '_real_removed_attribute_registry'):
-                       self._real_removed_attribute_registry = []
-               return self._real_removed_attribute_registry
-       
-       def save(self, *args, **kwargs):
-               super(Entity, self).save(*args, **kwargs)
-               
-               for key in self._removed_attribute_registry:
-                       self.attribute_set.filter(key__exact=key).delete()
-               del self._removed_attribute_registry[:]
-               
-               for field, value in self._added_attribute_registry.items():
-                       try:
-                               attribute = self.attribute_set.get(key__exact=field.key)
-                       except Attribute.DoesNotExist:
-                               attribute = Attribute()
-                               attribute.entity = self
-                               attribute.key = field.key
-                       
-                       field.set_attribute_value(attribute, value)
-                       attribute.save()
-               self._added_attribute_registry.clear()
-       
        class Meta:
                abstract = True
 
@@ -410,12 +425,12 @@ class TreeModel(MPTTModel):
                if root is not None and not self.is_descendant_of(root):
                        raise AncestorDoesNotExist(root)
                
-               qs = self.get_ancestors()
+               qs = self.get_ancestors(include_self=True)
                
                if root is not None:
                        qs = qs.filter(**{'%s__gt' % self._mptt_meta.level_attr: root.get_level()})
                
-               return pathsep.join([getattr(parent, field, '?') for parent in list(qs) + [self]])
+               return pathsep.join([getattr(parent, field, '?') for parent in qs])
        path = property(get_path)
        
        def __unicode__(self):
diff --git a/models/fields.py b/models/fields.py
deleted file mode 100644 (file)
index 19a6006..0000000
+++ /dev/null
@@ -1,258 +0,0 @@
-from django import forms
-from django.core.exceptions import FieldError, ValidationError
-from django.db import models
-from django.db.models.fields import NOT_PROVIDED
-from django.utils import simplejson as json
-from django.utils.text import capfirst
-from philo.signals import entity_class_prepared
-from philo.validators import TemplateValidator, json_validator
-
-
-__all__ = ('JSONAttribute', 'ForeignKeyAttribute', 'ManyToManyAttribute')
-
-
-class EntityProxyField(object):
-       descriptor_class = None
-       
-       def __init__(self, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, *args, **kwargs):
-               if self.descriptor_class is None:
-                       raise NotImplementedError('EntityProxyField subclasses must specify a descriptor_class.')
-               self.verbose_name = verbose_name
-               self.help_text = help_text
-               self.default = default
-               self.editable = editable
-       
-       def actually_contribute_to_class(self, sender, **kwargs):
-               sender._entity_meta.add_proxy_field(self)
-               setattr(sender, self.attname, self.descriptor_class(self))
-       
-       def contribute_to_class(self, cls, name):
-               from philo.models.base import Entity
-               if issubclass(cls, Entity):
-                       self.name = name
-                       self.attname = name
-                       if self.verbose_name is None and name:
-                               self.verbose_name = name.replace('_', ' ')
-                       entity_class_prepared.connect(self.actually_contribute_to_class, sender=cls)
-               else:
-                       raise FieldError('%s instances can only be declared on Entity subclasses.' % self.__class__.__name__)
-       
-       def formfield(self, *args, **kwargs):
-               raise NotImplementedError('EntityProxyField subclasses must implement a formfield method.')
-       
-       def value_from_object(self, obj):
-               return getattr(obj, self.attname)
-       
-       def has_default(self):
-               return self.default is not NOT_PROVIDED
-
-
-class AttributeFieldDescriptor(object):
-       def __init__(self, field):
-               self.field = field
-       
-       def __get__(self, instance, owner):
-               if instance:
-                       if self.field in instance._added_attribute_registry:
-                               return instance._added_attribute_registry[self.field]
-                       if self.field in instance._removed_attribute_registry:
-                               return None
-                       try:
-                               return instance.attributes[self.field.key]
-                       except KeyError:
-                               return None
-               else:
-                       return None
-       
-       def __set__(self, instance, value):
-               raise NotImplementedError('AttributeFieldDescriptor subclasses must implement a __set__ method.')
-       
-       def __delete__(self, instance):
-               if self.field in instance._added_attribute_registry:
-                       del instance._added_attribute_registry[self.field]
-               instance._removed_attribute_registry.append(self.field)
-
-
-class JSONAttributeDescriptor(AttributeFieldDescriptor):
-       def __set__(self, instance, value):
-               if self.field in instance._removed_attribute_registry:
-                       instance._removed_attribute_registry.remove(self.field)
-               instance._added_attribute_registry[self.field] = value
-
-
-class ForeignKeyAttributeDescriptor(AttributeFieldDescriptor):
-       def __set__(self, instance, value):
-               if isinstance(value, (models.Model, type(None))):
-                       if self.field in instance._removed_attribute_registry:
-                               instance._removed_attribute_registry.remove(self.field)
-                       instance._added_attribute_registry[self.field] = value
-               else:
-                       raise AttributeError('The \'%s\' attribute can only be set using existing Model objects.' % self.field.name)
-
-
-class ManyToManyAttributeDescriptor(AttributeFieldDescriptor):
-       def __set__(self, instance, value):
-               if isinstance(value, models.query.QuerySet):
-                       if self.field in instance._removed_attribute_registry:
-                               instance._removed_attribute_registry.remove(self.field)
-                       instance._added_attribute_registry[self.field] = value
-               else:
-                       raise AttributeError('The \'%s\' attribute can only be set to a QuerySet.' % self.field.name)
-
-
-class AttributeField(EntityProxyField):
-       def contribute_to_class(self, cls, name):
-               super(AttributeField, self).contribute_to_class(cls, name)
-               if self.key is None:
-                       self.key = name
-       
-       def set_attribute_value(self, attribute, value, value_class):
-               if not isinstance(attribute.value, value_class):
-                       if isinstance(attribute.value, models.Model):
-                               attribute.value.delete()
-                       new_value = value_class()
-               else:
-                       new_value = attribute.value
-               new_value.value = value
-               new_value.save()
-               attribute.value = new_value
-
-
-class JSONAttribute(AttributeField):
-       descriptor_class = JSONAttributeDescriptor
-       
-       def __init__(self, field_template=None, key=None, **kwargs):
-               super(AttributeField, self).__init__(**kwargs)
-               self.key = key
-               if field_template is None:
-                       field_template = models.CharField(max_length=255)
-               self.field_template = field_template
-       
-       def formfield(self, **kwargs):
-               defaults = {'required': False, 'label': capfirst(self.verbose_name), 'help_text': self.help_text}
-               if self.has_default():
-                       defaults['initial'] = self.default
-               defaults.update(kwargs)
-               return self.field_template.formfield(**defaults)
-       
-       def value_from_object(self, obj):
-               try:
-                       return getattr(obj, self.attname)
-               except AttributeError:
-                       return None
-       
-       def set_attribute_value(self, attribute, value, value_class=None):
-               if value_class is None:
-                       from philo.models.base import JSONValue
-                       value_class = JSONValue
-               super(JSONAttribute, self).set_attribute_value(attribute, value, value_class)
-
-
-class ForeignKeyAttribute(AttributeField):
-       descriptor_class = ForeignKeyAttributeDescriptor
-       
-       def __init__(self, model, limit_choices_to=None, key=None, **kwargs):
-               super(ForeignKeyAttribute, self).__init__(**kwargs)
-               self.key = key
-               self.model = model
-               if limit_choices_to is None:
-                       limit_choices_to = {}
-               self.limit_choices_to = limit_choices_to
-       
-       def formfield(self, form_class=forms.ModelChoiceField, **kwargs):
-               defaults = {'required': False, 'label': capfirst(self.verbose_name), 'help_text': self.help_text}
-               if self.has_default():
-                       defaults['initial'] = self.default
-               defaults.update(kwargs)
-               return form_class(self.model._default_manager.complex_filter(self.limit_choices_to), **defaults)
-       
-       def value_from_object(self, obj):
-               try:
-                       relobj = super(ForeignKeyAttribute, self).value_from_object(obj)
-               except AttributeError:
-                       return None
-               return getattr(relobj, 'pk', None)
-       
-       def set_attribute_value(self, attribute, value, value_class=None):
-               if value_class is None:
-                       from philo.models.base import ForeignKeyValue
-                       value_class = ForeignKeyValue
-               super(ForeignKeyAttribute, self).set_attribute_value(attribute, value, value_class)
-
-
-class ManyToManyAttribute(ForeignKeyAttribute):
-       descriptor_class = ManyToManyAttributeDescriptor
-       
-       def formfield(self, form_class=forms.ModelMultipleChoiceField, **kwargs):
-               return super(ManyToManyAttribute, self).formfield(form_class, **kwargs)
-       
-       def set_attribute_value(self, attribute, value, value_class=None):
-               if value_class is None:
-                       from philo.models.base import ManyToManyValue
-                       value_class = ManyToManyValue
-               super(ManyToManyAttribute, self).set_attribute_value(attribute, value, value_class)
-
-
-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 JSONFormField(forms.Field):
-       default_validators = [json_validator]
-       
-       def clean(self, value):
-               if value == '' and not self.required:
-                       return None
-               try:
-                       return json.loads(value)
-               except Exception, e:
-                       raise ValidationError(u'JSON decode error: %s' % e)
-
-
-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))
-       
-       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
diff --git a/models/fields/__init__.py b/models/fields/__init__.py
new file mode 100644 (file)
index 0000000..d8ed839
--- /dev/null
@@ -0,0 +1,64 @@
+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
diff --git a/models/fields/entities.py b/models/fields/entities.py
new file mode 100644 (file)
index 0000000..6c407d0
--- /dev/null
@@ -0,0 +1,261 @@
+"""
+The EntityProxyFields defined in this file can be assigned as fields on
+a subclass of philo.models.Entity. They act like any other model
+fields, but instead of saving their data to the database, they save it
+to attributes related to a model instance. Additionally, a new
+attribute will be created for an instance if and only if the field's
+value has been set. This is relevant i.e. for passthroughs, where the
+value of the field may be defined by some other instance's attributes.
+
+Example::
+
+       class Thing(Entity):
+               numbers = models.PositiveIntegerField()
+       
+       class ThingProxy(Thing):
+               improvised = JSONAttribute(models.BooleanField)
+"""
+from itertools import tee
+from django import forms
+from django.core.exceptions import FieldError
+from django.db import models
+from django.db.models.fields import NOT_PROVIDED
+from django.utils.text import capfirst
+from philo.signals import entity_class_prepared
+from philo.models import ManyToManyValue, JSONValue, ForeignKeyValue, Attribute, Entity
+import datetime
+
+
+__all__ = ('JSONAttribute', 'ForeignKeyAttribute', 'ManyToManyAttribute')
+
+
+ATTRIBUTE_REGISTRY = '_attribute_registry'
+
+
+class EntityProxyField(object):
+       def __init__(self, verbose_name=None, help_text=None, default=NOT_PROVIDED, editable=True, choices=None, *args, **kwargs):
+               self.verbose_name = verbose_name
+               self.help_text = help_text
+               self.default = default
+               self.editable = editable
+               self._choices = choices or []
+       
+       def actually_contribute_to_class(self, sender, **kwargs):
+               sender._entity_meta.add_proxy_field(self)
+       
+       def contribute_to_class(self, cls, name):
+               if issubclass(cls, Entity):
+                       self.name = self.attname = name
+                       self.model = cls
+                       if self.verbose_name is None and name:
+                               self.verbose_name = name.replace('_', ' ')
+                       entity_class_prepared.connect(self.actually_contribute_to_class, sender=cls)
+               else:
+                       raise FieldError('%s instances can only be declared on Entity subclasses.' % self.__class__.__name__)
+       
+       def formfield(self, form_class=forms.CharField, **kwargs):
+               defaults = {
+                       'required': False,
+                       'label': capfirst(self.verbose_name),
+                       'help_text': self.help_text
+               }
+               if self.has_default():
+                       defaults['initial'] = self.default
+               defaults.update(kwargs)
+               return form_class(**defaults)
+       
+       def value_from_object(self, obj):
+               """The return value of this method will be used by the EntityForm as
+               this field's initial value."""
+               return getattr(obj, self.name)
+       
+       def get_storage_value(self, value):
+               """Final conversion of `value` before it gets stored on an Entity instance.
+               This step is performed by the ProxyFieldForm."""
+               return value
+       
+       def has_default(self):
+               return self.default is not NOT_PROVIDED
+       
+       def _get_choices(self):
+               if hasattr(self._choices, 'next'):
+                       choices, self._choices = tee(self._choices)
+                       return choices
+               else:
+                       return self._choices
+       choices = property(_get_choices)
+
+
+class AttributeFieldDescriptor(object):
+       def __init__(self, field):
+               self.field = field
+       
+       def get_registry(self, instance):
+               if ATTRIBUTE_REGISTRY not in instance.__dict__:
+                       instance.__dict__[ATTRIBUTE_REGISTRY] = {'added': set(), 'removed': set()}
+               return instance.__dict__[ATTRIBUTE_REGISTRY]
+       
+       def __get__(self, instance, owner):
+               if instance is None:
+                       return self
+               
+               if self.field.name not in instance.__dict__:
+                       instance.__dict__[self.field.name] = instance.attributes.get(self.field.attribute_key, None)
+               
+               return instance.__dict__[self.field.name]
+       
+       def __set__(self, instance, value):
+               if instance is None:
+                       raise AttributeError("%s must be accessed via instance" % self.field.name)
+               
+               self.field.validate_value(value)
+               instance.__dict__[self.field.name] = value
+               
+               registry = self.get_registry(instance)
+               registry['added'].add(self.field)
+               registry['removed'].discard(self.field)
+       
+       def __delete__(self, instance):
+               del instance.__dict__[self.field.name]
+               
+               registry = self.get_registry(instance)
+               registry['added'].discard(self.field)
+               registry['removed'].add(self.field)
+
+
+def process_attribute_fields(sender, instance, created, **kwargs):
+       if ATTRIBUTE_REGISTRY in instance.__dict__:
+               registry = instance.__dict__[ATTRIBUTE_REGISTRY]
+               instance.attribute_set.filter(key__in=[field.attribute_key for field in registry['removed']]).delete()
+               
+               for field in registry['added']:
+                       try:
+                               attribute = instance.attribute_set.get(key=field.attribute_key)
+                       except Attribute.DoesNotExist:
+                               attribute = Attribute()
+                               attribute.entity = instance
+                               attribute.key = field.attribute_key
+                       
+                       value_class = field.value_class
+                       if isinstance(attribute.value, value_class):
+                               value = attribute.value
+                       else:
+                               if isinstance(attribute.value, models.Model):
+                                       attribute.value.delete()
+                               value = value_class()
+                       
+                       value.set_value(getattr(instance, field.name, None))
+                       value.save()
+                       
+                       attribute.value = value
+                       attribute.save()
+               del instance.__dict__[ATTRIBUTE_REGISTRY]
+
+
+class AttributeField(EntityProxyField):
+       def __init__(self, attribute_key=None, **kwargs):
+               self.attribute_key = attribute_key
+               super(AttributeField, self).__init__(**kwargs)
+       
+       def actually_contribute_to_class(self, sender, **kwargs):
+               super(AttributeField, self).actually_contribute_to_class(sender, **kwargs)
+               setattr(sender, self.name, AttributeFieldDescriptor(self))
+               opts = sender._entity_meta
+               if not hasattr(opts, '_has_attribute_fields'):
+                       opts._has_attribute_fields = True
+                       models.signals.post_save.connect(process_attribute_fields, sender=sender)
+       
+       def contribute_to_class(self, cls, name):
+               if self.attribute_key is None:
+                       self.attribute_key = name
+               super(AttributeField, self).contribute_to_class(cls, name)
+       
+       def validate_value(self, value):
+               "Confirm that the value is valid or raise an appropriate error."
+               pass
+       
+       @property
+       def value_class(self):
+               raise AttributeError("value_class must be defined on AttributeField subclasses.")
+
+
+class JSONAttribute(AttributeField):
+       value_class = JSONValue
+       
+       def __init__(self, field_template=None, **kwargs):
+               super(JSONAttribute, self).__init__(**kwargs)
+               if field_template is None:
+                       field_template = models.CharField(max_length=255)
+               self.field_template = field_template
+       
+       def formfield(self, **kwargs):
+               defaults = {
+                       'required': False,
+                       'label': capfirst(self.verbose_name),
+                       'help_text': self.help_text
+               }
+               if self.has_default():
+                       defaults['initial'] = self.default
+               defaults.update(kwargs)
+               return self.field_template.formfield(**defaults)
+       
+       def value_from_object(self, obj):
+               value = super(JSONAttribute, self).value_from_object(obj)
+               if isinstance(self.field_template, (models.DateField, models.DateTimeField)):
+                       value = self.field_template.to_python(value)
+               return value
+       
+       def get_storage_value(self, value):
+               if isinstance(value, datetime.datetime):
+                       return value.strftime("%Y-%m-%d %H:%M:%S")
+               if isinstance(value, datetime.date):
+                       return value.strftime("%Y-%m-%d")
+               return value
+
+
+class ForeignKeyAttribute(AttributeField):
+       value_class = ForeignKeyValue
+       
+       def __init__(self, model, limit_choices_to=None, **kwargs):
+               super(ForeignKeyAttribute, self).__init__(**kwargs)
+               self.to = model
+               if limit_choices_to is None:
+                       limit_choices_to = {}
+               self.limit_choices_to = limit_choices_to
+       
+       def validate_value(self, value):
+               if value is not None and not isinstance(value, self.to) :
+                       raise TypeError("The '%s' attribute can only be set to an instance of %s or None." % (self.name, self.to.__name__))
+       
+       def formfield(self, form_class=forms.ModelChoiceField, **kwargs):
+               defaults = {
+                       'queryset': self.to._default_manager.complex_filter(self.limit_choices_to)
+               }
+               defaults.update(kwargs)
+               return super(ForeignKeyAttribute, self).formfield(form_class=form_class, **defaults)
+       
+       def value_from_object(self, obj):
+               relobj = super(ForeignKeyAttribute, self).value_from_object(obj)
+               return getattr(relobj, 'pk', None)
+       
+       def get_related_field(self):
+               """Spoof being a rel from a ForeignKey."""
+               return self.to._meta.pk
+
+
+class ManyToManyAttribute(ForeignKeyAttribute):
+       value_class = ManyToManyValue
+       
+       def validate_value(self, value):
+               if not isinstance(value, models.query.QuerySet) or value.model != self.to:
+                       raise TypeError("The '%s' attribute can only be set to a %s QuerySet." % (self.name, self.to.__name__))
+       
+       def formfield(self, form_class=forms.ModelMultipleChoiceField, **kwargs):
+               return super(ManyToManyAttribute, self).formfield(form_class=form_class, **kwargs)
+       
+       def value_from_object(self, obj):
+               qs = super(ForeignKeyAttribute, self).value_from_object(obj)
+               try:
+                       return qs.values_list('pk', flat=True)
+               except:
+                       return []
\ No newline at end of file
index de10ed1..2bfb4fd 100644 (file)
@@ -127,6 +127,28 @@ class MultiView(View):
                        return '/%s/%s/' % (node.get_absolute_url().strip('/'), subpath.strip('/'))
                return subpath
        
+       def get_context(self):
+               """Hook for providing instance-specific context - such as the value of a Field - to all views."""
+               return {}
+       
+       def basic_view(self, view_name):
+               """
+               Wraps a field name and returns a simple view function that will render that view
+               with a basic context. This assumes that the field name is a ForeignKey to a
+               model with a render_to_response method.
+               """
+               field = self._meta.get_field(view_name)
+               view = getattr(self, field.name, None)
+               
+               def inner(request, extra_context=None, **kwargs):
+                       if not view:
+                               raise Http404
+                       context = self.get_context()
+                       context.update(extra_context or {})
+                       return view.render_to_response(request, extra_context=context)
+               
+               return inner
+       
        class Meta:
                abstract = True
 
index 4760397..ccead57 100644 (file)
@@ -3,7 +3,7 @@
 <!-- group -->
 <div class="group tabular{% if inline_admin_formset.opts.classes %} {{ inline_admin_formset.opts.classes|join:" " }}{% endif %}"
        id="{{ inline_admin_formset.formset.prefix }}-group" >
-       <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
+       <h2 class="collapse-handler">{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
        <ul class="tools">
                <li class="add-handler-container"><a href="javascript://" class="icon add-handler" title="{% trans 'Add Another' %}"> </a></li>
        </ul>
 
 <script type="text/javascript">
 (function($) {
-       $(document).ready(function($) {
-               
-               $("#{{ inline_admin_formset.formset.prefix }}-group").inline({
-                       prefix: "{{ inline_admin_formset.formset.prefix }}",
-                       deleteCssClass: "delete-handler",
-                       emptyCssClass: "empty-form",
-                       onAdded: tabular_onAdded
-               });
-               
-{% if inline_admin_formset.opts.sortable_field_name %}
-               /**
-                * sortable inlines
-                * uses onAdded() and onRemoved() of inline() call above
-                * uses sortable_updateFormIndex() and is_form_filled() from change_from.html
-                */
-               
-               // hide sortable_field(_name) from form
-               // hide div.td.{{ field.name }}
-               var position_nodes = $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.td.{{ inline_admin_formset.opts.sortable_field_name }}");
-               position_nodes.hide();
-               
-               // hide its header (div.th) too (hard)
-               // "div.th.{{ inline_admin_formset.opts.sortable_field_name }}" is not correct because
-               // its div.th.<field.label> (and not name, see line#18).
-               
-               // so let's get the "position/idx" the first position div
-               var tabular_row = position_nodes.first().parent().children("div.td");
-               // get the "position" (== i) in the "table"
-               for (var i = 0; i < tabular_row.length; i++) {
-                       if ($(tabular_row[i]).hasClass("{{ inline_admin_formset.opts.sortable_field_name }}")) break;
-               }
-               // we have the same order in the header of the "table"
-               // so delete the div at the "position" (== i)
-               var position_header = $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.th")[i];
-               // and hide
-               $(position_header).hide()
-               
-       {% if errors %}
-               // sort inline
-               var container = $("#{{ inline_admin_formset.formset.prefix }}-group > div.table"),
-                       dynamic_forms = container.find("div.dynamic-form"),
-                       updated = false,
-                       curr_form,
-                       real_pos;
-               
-               // loop thru all inline forms
-               for (var i = 0; i < dynamic_forms.length; i++) {
-                       curr_form = $(dynamic_forms[i]);
-                       // the real position according to the sort_field(_name)
-                       real_pos = curr_form.find("div.{{ inline_admin_formset.opts.sortable_field_name }}").find("input").val();
-                       // if there is none it's an empty inline (=> we are at the end)
-                       // TODO: klemens: maybe buggy. try continue?
-                       if (!real_pos) continue;
-                       
-                       real_pos = parseInt(real_pos, 10);
-                       
-                       // check if real position is not equal to the CURRENT position in the dom
-                       if (real_pos != container.find("div.dynamic-form").index(curr_form)) {
-                               // move to correct postition
-                               curr_form.insertBefore(container.find("div.dynamic-form")[real_pos]);
-                               // to update the inline lables
-                               updated = true;
-                       }
-               }
-               
-       {% endif %}
-               
-               $("#{{ inline_admin_formset.formset.prefix }}-group > div.table").sortable({
-                       // drag&drop the inlines with the drag-handler only
-                       handle: "a.drag-handler",
-                       // very scary magic after drap&drop operations
-                       // pretty similar to inline() widget's removeHandler()
-                       // but removeHandler() can remove the current form and just reorder the rest
-                       // if we would do the same after drag&drop we would loose some form values
-                       // because while looping inputs would have the same names and maybe overwrite each other.
-                       placeholder: 'ui-sortable-placeholder',
-                       forcePlaceholderSize: true,
-                       items: "div.dynamic-form",
-                       axis: "y",
-                       start: function(evt, ui) {
-                               ui.item.hide()
-                               ui.placeholder.height(ui.placeholder.height()-4);
-                               //sadly we have to do this every time we start dragging
-                               var template = "<div class='tr'>",
-                                       // minus 1 because we don't need the "sortable_field_name row"
-                                       len = ui.item.find("div.tr").children("div.td").length - 1;
-                               
-                               for (var i = 0; i < len; i++) {
-                                       template += "<div class='td'></div>"
-                               }
-                               
-                               template += "</div>"
-                               ui.placeholder.addClass("tbody module").append(template);
-                       },
-                       update: function(evt, ui) {
-                               ui.item.show();
-                       },
-                       appendTo: 'body',
-                       forceHelperSize: true,
-                       containment: '#{{ inline_admin_formset.formset.prefix }}-group > div.table',
-                       tolerance: 'pointer',
-                       helper: function(evt, elem) {
-                               var helper = $("<div class='module table' />");
-                               helper.html(elem.clone());
-                               return helper;
-                       },
-               });
-               
-               // sets the new positions onSubmit (0 - n)
-               $("#{{ opts.module_name }}_form").bind("submit", function(){
-                       var forms = $("#{{ inline_admin_formset.formset.prefix }}-group").find("div.dynamic-form"),
-                               form,
-                               idx = 0;
-                       for (var i = 0; i < forms.length; i++) {
-                               form = $(forms[i]);
-                               
-                               if (is_form_filled(form)) {
-                                       form.find("div.{{ inline_admin_formset.opts.sortable_field_name }}").find("input").val(idx);
-                                       idx++;
-                               }
-                       }
-               });
-               
-{% endif %}
-       
-       });     
+    $(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 %}
+        
+    });
 })(django.jQuery);
 </script>
-
index db5cea5..eb4cd68 100644 (file)
@@ -146,7 +146,6 @@ class ConstantEmbedNode(template.Node):
                        self.template = None
        
        def compile_instance(self, object_pk):
-               self.object_pk = object_pk
                model = self.content_type.model_class()
                try:
                        return model.objects.get(pk=object_pk)
@@ -275,15 +274,15 @@ def get_embedded(self):
 setattr(ConstantEmbedNode, LOADED_TEMPLATE_ATTR, property(get_embedded))
 
 
-def get_content_type(bit):
+def parse_content_type(bit, tagname):
        try:
                app_label, model = bit.split('.')
        except ValueError:
-               raise template.TemplateSyntaxError('"%s" template tag expects the first argument to be of the form app_label.model' % tag)
+               raise template.TemplateSyntaxError('"%s" template tag expects the first argument to be of the form app_label.model' % tagname)
        try:
                ct = ContentType.objects.get(app_label=app_label, model=model)
        except ContentType.DoesNotExist:
-               raise template.TemplateSyntaxError('"%s" template tag requires an argument of the form app_label.model which refers to an installed content type (see django.contrib.contenttypes)' % tag)
+               raise template.TemplateSyntaxError('"%s" template tag requires an argument of the form app_label.model which refers to an installed content type (see django.contrib.contenttypes)' % tagname)
        return ct
 
 
@@ -300,7 +299,7 @@ def do_embed(parser, token):
                raise template.TemplateSyntaxError('"%s" template tag must have at least two arguments.' % tag)
        
        if len(bits) == 3 and bits[-2] == 'with':
-               ct = get_content_type(bits[0])
+               ct = parse_content_type(bits[0], tag)
                
                if bits[2][0] in ['"', "'"] and bits[2][0] == bits[2][-1]:
                        return ConstantEmbedNode(ct, template_name=bits[2])
@@ -323,7 +322,7 @@ def do_embed(parser, token):
                return InstanceEmbedNode(instance, kwargs)
        elif len(bits) > 2:
                raise template.TemplateSyntaxError('"%s" template tag expects at most 2 non-keyword arguments when embedding instances.')
-       ct = get_content_type(bits[0])
+       ct = parse_content_type(bits[0], tag)
        pk = bits[1]
        
        try: