From: Stephen Burrows Date: Wed, 9 Feb 2011 15:30:58 +0000 (-0500) Subject: Merge branch 'master' into julian X-Git-Tag: philo-0.9~15^2~3^2~13 X-Git-Url: http://git.ithinksw.org/philo.git/commitdiff_plain/7ef5d942e4e1f7de42a389c196de3a13b31e8d45?hp=662258ae0a1423d9153c5a7528ab3a9e1672e877 Merge branch 'master' into julian --- diff --git a/README b/README index 5ce7b93..4b1a6f7 100644 --- a/README +++ b/README @@ -3,7 +3,7 @@ Philo is a foundation for developing web content management systems. Prerequisites: * Python 2.5.4+ * Django 1.2+ - * django-mptt 0.4+ + * django-mptt e734079+ * (Optional) django-grappelli 2.0+ * (Optional) recaptcha-django r6 * (Optional) south 0.7.2+ @@ -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! diff --git a/README.markdown b/README.markdown index 0e695c5..8060db8 100644 --- a/README.markdown +++ b/README.markdown @@ -4,7 +4,7 @@ Prerequisites: * [Python 2.5.4+ <http://www.python.org>](http://www.python.org/) * [Django 1.2+ <http://www.djangoproject.com/>](http://www.djangoproject.com/) - * [django-mptt 0.4+ <https://github.com/django-mptt/django-mptt/>](https://github.com/django-mptt/django-mptt/) + * [django-mptt e734079+ <https://github.com/django-mptt/django-mptt/>](https://github.com/django-mptt/django-mptt/) * (Optional) [django-grappelli 2.0+ <http://code.google.com/p/django-grappelli/>](http://code.google.com/p/django-grappelli/) * (Optional) [south 0.7.2+ <http://south.aeracode.org/)](http://south.aeracode.org/) * (Optional) [recaptcha-django r6 <http://code.google.com/p/recaptcha-django/>](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! diff --git a/admin/base.py b/admin/base.py index 0d35cf6..8151461 100644 --- a/admin/base.py +++ b/admin/base.py @@ -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,119 @@ class AttributeInline(generic.GenericTabularInline): template = 'admin/philo/edit_inline/tabular_attribute.html' +def hide_proxy_fields(cls, attname, proxy_field_set): + val_set = set(getattr(cls, attname)) + if proxy_field_set & val_set: + cls._hidden_attributes[attname] = list(val_set) + setattr(cls, attname, list(val_set - proxy_field_set)) + + +class EntityAdminMetaclass(admin.ModelAdmin.__metaclass__): + def __new__(cls, name, bases, attrs): + # HACK to bypass model validation for proxy fields by masking them as readonly fields + new_class = super(EntityAdminMetaclass, cls).__new__(cls, name, bases, attrs) + form = getattr(new_class, 'form', None) + if form: + opts = form._meta + if issubclass(form, EntityForm) and opts.model: + proxy_fields = proxy_fields_for_entity_model(opts.model).keys() + + # Store readonly fields iff they have been declared. + if 'readonly_fields' in attrs or not hasattr(new_class, '_real_readonly_fields'): + new_class._real_readonly_fields = new_class.readonly_fields + + readonly_fields = new_class.readonly_fields + new_class.readonly_fields = list(set(readonly_fields) | set(proxy_fields)) + + # Additional HACKS to handle raw_id_fields and other attributes that the admin + # uses model._meta.get_field to validate. + new_class._hidden_attributes = {} + proxy_fields = set(proxy_fields) + hide_proxy_fields(new_class, 'raw_id_fields', proxy_fields) + #END HACK + return new_class + + class EntityAdmin(admin.ModelAdmin): + __metaclass__ = EntityAdminMetaclass + form = EntityForm inlines = [AttributeInline] save_on_top = True + + def __init__(self, *args, **kwargs): + # HACK PART 2 restores the actual readonly fields etc. on __init__. + if hasattr(self, '_real_readonly_fields'): + self.readonly_fields = self.__class__._real_readonly_fields + if hasattr(self, '_hidden_attributes'): + for name, value in self._hidden_attributes.items(): + setattr(self, name, value) + # END HACK + super(EntityAdmin, self).__init__(*args, **kwargs) + + def formfield_for_dbfield(self, db_field, **kwargs): + """ + Override the default behavior to provide special formfields for EntityEntitys. + Essentially clones the ForeignKey/ManyToManyField special behavior for the Attribute versions. + """ + if not db_field.choices and isinstance(db_field, (ForeignKeyAttribute, ManyToManyAttribute)): + request = kwargs.pop("request", None) + # Combine the field kwargs with any options for formfield_overrides. + # Make sure the passed in **kwargs override anything in + # formfield_overrides because **kwargs is more specific, and should + # always win. + if db_field.__class__ in self.formfield_overrides: + kwargs = dict(self.formfield_overrides[db_field.__class__], **kwargs) + + # Get the correct formfield. + if isinstance(db_field, ManyToManyAttribute): + formfield = self.formfield_for_manytomanyattribute(db_field, request, **kwargs) + elif isinstance(db_field, ForeignKeyAttribute): + formfield = self.formfield_for_foreignkeyattribute(db_field, request, **kwargs) + + # For non-raw_id fields, wrap the widget with a wrapper that adds + # extra HTML -- the "add other" interface -- to the end of the + # rendered output. formfield can be None if it came from a + # OneToOneField with parent_link=True or a M2M intermediary. + # TODO: Implement this. + #if formfield and db_field.name not in self.raw_id_fields: + # formfield.widget = admin.widgets.RelatedFieldWidgetWrapper(formfield.widget, db_field, self.admin_site) + + return formfield + return super(EntityAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + def formfield_for_foreignkeyattribute(self, db_field, request=None, **kwargs): + """Get a form field for a ForeignKeyAttribute field.""" + db = kwargs.get('using') + if db_field.name in self.raw_id_fields: + kwargs['widget'] = admin.widgets.ForeignKeyRawIdWidget(db_field, db) + #TODO: Add support for radio fields + #elif db_field.name in self.radio_fields: + # kwargs['widget'] = widgets.AdminRadioSelect(attrs={ + # 'class': get_ul_class(self.radio_fields[db_field.name]), + # }) + # kwargs['empty_label'] = db_field.blank and _('None') or None + + return db_field.formfield(**kwargs) + + def formfield_for_manytomanyattribute(self, db_field, request=None, **kwargs): + """Get a form field for a ManyToManyAttribute field.""" + db = kwargs.get('using') + + if db_field.name in self.raw_id_fields: + kwargs['widget'] = admin.widgets.ManyToManyRawIdWidget(db_field, using=db) + kwargs['help_text'] = '' + #TODO: Add support for filtered fields. + #elif db_field.name in (list(self.filter_vertical) + list(self.filter_horizontal)): + # kwargs['widget'] = widgets.FilteredSelectMultiple(db_field.verbose_name, (db_field.name in self.filter_vertical)) + + return db_field.formfield(**kwargs) class TreeAdmin(MPTTModelAdmin): pass -class TreeEntityAdmin(TreeAdmin, EntityAdmin): +class TreeEntityAdmin(EntityAdmin, TreeAdmin): pass diff --git a/admin/forms/__init__.py b/admin/forms/__init__.py new file mode 100644 index 0000000..1906380 --- /dev/null +++ b/admin/forms/__init__.py @@ -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 index 0000000..fc77d0f --- /dev/null +++ b/admin/forms/attributes.py @@ -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 index 0000000..5991dfa --- /dev/null +++ b/admin/forms/containers.py @@ -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 diff --git a/admin/nodes.py b/admin/nodes.py index a576d44..66be107 100644 --- a/admin/nodes.py +++ b/admin/nodes.py @@ -1,10 +1,15 @@ from django.contrib import admin -from philo.admin.base import EntityAdmin, TreeEntityAdmin +from philo.admin.base import EntityAdmin, TreeEntityAdmin, COLLAPSE_CLASSES from philo.models import Node, Redirect, File class NodeAdmin(TreeEntityAdmin): list_display = ('slug', 'view', 'accepts_subpath') + related_lookup_fields = { + 'fk': [], + 'm2m': [], + 'generic': [['view_content_type', 'view_object_id']] + } def accepts_subpath(self, obj): return obj.accepts_subpath @@ -18,11 +23,19 @@ class ViewAdmin(EntityAdmin): class RedirectAdmin(ViewAdmin): fieldsets = ( (None, { - 'fields': ('target', 'status_code') + 'fields': ('target_node', 'url_or_subpath', 'status_code') }), + ('Advanced', { + 'fields': ('reversing_parameters',), + 'classes': COLLAPSE_CLASSES + }) ) - list_display = ('target', 'status_code') + list_display = ('target_url', 'status_code', 'target_node', 'url_or_subpath') list_filter = ('status_code',) + raw_id_fields = ['target_node'] + related_lookup_fields = { + 'fk': raw_id_fields + } class FileAdmin(ViewAdmin): diff --git a/admin/pages.py b/admin/pages.py index caeee05..13d4098 100644 --- a/admin/pages.py +++ b/admin/pages.py @@ -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): diff --git a/contrib/penfield/admin.py b/contrib/penfield/admin.py index 5faf4ef..950539d 100644 --- a/contrib/penfield/admin.py +++ b/contrib/penfield/admin.py @@ -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,50 @@ 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 + }) + ) + related_lookup_fields = {'fk': raw_id_fields} 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') + }), + ('General Settings', { + 'fields': ('entry_permalink_style', 'entry_permalink_base', 'tag_permalink_base', 'entries_per_page'), + 'classes': COLLAPSE_CLASSES + }), + ('Feed Settings', { + 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), + 'classes': COLLAPSE_CLASSES + }) + ) + raw_id_fields = ('index_page', 'entry_page', 'tag_page', 'entry_archive_page', 'tag_archive_page', 'item_title_template', 'item_description_template',) + related_lookup_fields = {'fk': raw_id_fields} class NewsletterAdmin(TitledAdmin): @@ -25,7 +73,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 +102,27 @@ 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': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), + 'classes': COLLAPSE_CLASSES + }) + ) + raw_id_fields = ('index_page', 'article_page', 'issue_page', 'article_archive_page', 'issue_archive_page', 'item_title_template', 'item_description_template',) + related_lookup_fields = {'fk': raw_id_fields} admin.site.register(Blog, BlogAdmin) diff --git a/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py b/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py new file mode 100644 index 0000000..1f6d829 --- /dev/null +++ b/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py @@ -0,0 +1,226 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'NewsletterView.feed_type' + db.add_column('penfield_newsletterview', 'feed_type', self.gf('django.db.models.fields.CharField')(default='atom', max_length=50), keep_default=False) + + # Adding field 'NewsletterView.item_title_template' + db.add_column('penfield_newsletterview', 'item_title_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_newsletterview_title_related', null=True, to=orm['philo.Template']), keep_default=False) + + # Adding field 'NewsletterView.item_description_template' + db.add_column('penfield_newsletterview', 'item_description_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_newsletterview_description_related', null=True, to=orm['philo.Template']), keep_default=False) + + # Adding field 'BlogView.feed_type' + db.add_column('penfield_blogview', 'feed_type', self.gf('django.db.models.fields.CharField')(default='atom', max_length=50), keep_default=False) + + # Adding field 'BlogView.item_title_template' + db.add_column('penfield_blogview', 'item_title_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_blogview_title_related', null=True, to=orm['philo.Template']), keep_default=False) + + # Adding field 'BlogView.item_description_template' + db.add_column('penfield_blogview', 'item_description_template', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='penfield_blogview_description_related', null=True, to=orm['philo.Template']), keep_default=False) + + + def backwards(self, orm): + + # Deleting field 'NewsletterView.feed_type' + db.delete_column('penfield_newsletterview', 'feed_type') + + # Deleting field 'NewsletterView.item_title_template' + db.delete_column('penfield_newsletterview', 'item_title_template_id') + + # Deleting field 'NewsletterView.item_description_template' + db.delete_column('penfield_newsletterview', 'item_description_template_id') + + # Deleting field 'BlogView.feed_type' + db.delete_column('penfield_blogview', 'feed_type') + + # Deleting field 'BlogView.item_title_template' + db.delete_column('penfield_blogview', 'item_title_template_id') + + # Deleting field 'BlogView.item_description_template' + db.delete_column('penfield_blogview', 'item_description_template_id') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'oberlin.person': { + 'Meta': {'object_name': 'Person'}, + 'bio': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '70', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'penfield.blog': { + 'Meta': {'object_name': 'Blog'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogentry': { + 'Meta': {'object_name': 'BlogEntry'}, + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['oberlin.Person']"}), + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}), + 'content': ('django.db.models.fields.TextField', [], {}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'excerpt': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'blogentries'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogview': { + 'Meta': {'object_name': 'BlogView'}, + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogviews'", 'to': "orm['penfield.Blog']"}), + 'entries_per_page': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'entry_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_entry_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'entry_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_entry_related'", 'to': "orm['philo.Page']"}), + 'entry_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'entries'", 'max_length': '255'}), + 'entry_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_index_related'", 'to': "orm['philo.Page']"}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_tag_related'", 'to': "orm['philo.Page']"}), + 'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '255'}) + }, + 'penfield.newsletter': { + 'Meta': {'object_name': 'Newsletter'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterarticle': { + 'Meta': {'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'}, + 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['oberlin.Person']"}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lede': ('philo.models.fields.TemplateField', [], {'null': 'True', 'blank': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'articles'", 'to': "orm['penfield.Newsletter']"}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'newsletterarticles'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterissue': { + 'Meta': {'unique_together': "(('newsletter', 'numbering'),)", 'object_name': 'NewsletterIssue'}, + 'articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'issues'", 'symmetrical': 'False', 'to': "orm['penfield.NewsletterArticle']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issues'", 'to': "orm['penfield.Newsletter']"}), + 'numbering': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterview': { + 'Meta': {'object_name': 'NewsletterView'}, + 'article_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_article_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'article_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_article_related'", 'to': "orm['philo.Page']"}), + 'article_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'articles'", 'max_length': '255'}), + 'article_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'atom'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_index_related'", 'to': "orm['philo.Page']"}), + 'issue_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_issue_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'issue_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_issue_related'", 'to': "orm['philo.Page']"}), + 'issue_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'issues'", 'max_length': '255'}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletterviews'", 'to': "orm['penfield.Newsletter']"}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['penfield'] diff --git a/contrib/penfield/models.py b/contrib/penfield/models.py index 9f1b61b..bb71ba2 100644 --- a/contrib/penfield/models.py +++ b/contrib/penfield/models.py @@ -1,14 +1,286 @@ -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.http import Http404 +from django.contrib.sites.models import Site, RequestSite +from django.contrib.syndication.views import add_domain +from django.db import models +from django.http import Http404, HttpResponse +from django.template import RequestContext, Template as DjangoTemplate +from django.utils import feedgenerator, tzinfo +from django.utils.datastructures import SortedDict +from django.utils.encoding import smart_unicode, force_unicode +from django.utils.html import escape from datetime import date, datetime -from philo.utils import paginate from philo.contrib.penfield.validators import validate_pagination_count -from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed -from philo.contrib.penfield.utils import FeedMultiViewMixin +from philo.exceptions import ViewCanNotProvideSubpath +from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template +from philo.utils import paginate +try: + import mimeparse +except: + mimeparse = None + +ATOM = feedgenerator.Atom1Feed.mime_type +RSS = feedgenerator.Rss201rev2Feed.mime_type +FEEDS = SortedDict([ + (ATOM, feedgenerator.Atom1Feed), + (RSS, feedgenerator.Rss201rev2Feed), +]) +FEED_CHOICES = ( + (ATOM, "Atom"), + (RSS, "RSS"), +) + + +class FeedView(MultiView): + """ + The FeedView expects to handle a number of different feeds for the + same object - i.e. patterns for a blog to handle all entries or + just entries for a certain year/month/day. + + This class would subclass django.contrib.syndication.views.Feed, but + that would make it callable, which causes problems. + """ + feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM) + feed_suffix = models.CharField(max_length=255, blank=False, default="feed") + feeds_enabled = models.BooleanField(default=True) + + item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related") + item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related") + + item_context_var = 'items' + object_attr = 'object' + + description = "" + + def feed_patterns(self, get_items_attr, page_attr, reverse_name): + """ + Given the name to be used to reverse this view and the names of + the attributes for the function that fetches the objects, returns + patterns suitable for inclusion in urlpatterns. + """ + urlpatterns = patterns('', + url(r'^$', self.page_view(get_items_attr, page_attr), name=reverse_name) + ) + if self.feeds_enabled: + feed_reverse_name = "%s_feed" % reverse_name + urlpatterns += patterns('', + url(r'^%s$' % self.feed_suffix, self.feed_view(get_items_attr, feed_reverse_name), name=feed_reverse_name), + ) + return urlpatterns + + def get_object(self, request, **kwargs): + return getattr(self, self.object_attr) + + def feed_view(self, get_items_attr, reverse_name): + """ + Returns a view function that renders a list of items as a feed. + """ + get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr) + + def inner(request, extra_context=None, *args, **kwargs): + obj = self.get_object(request, *args, **kwargs) + feed = self.get_feed(obj, request, reverse_name) + items, xxx = get_items(request, extra_context=extra_context, *args, **kwargs) + self.populate_feed(feed, items, request) + + response = HttpResponse(mimetype=feed.mime_type) + feed.write(response, 'utf-8') + return response + + return inner + + def page_view(self, get_items_attr, page_attr): + """ + Returns a view function that renders a list of items as a page. + """ + get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr) + page = isinstance(page_attr, Page) and page_attr or getattr(self, page_attr) + + def inner(request, extra_context=None, *args, **kwargs): + items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs) + items, item_context = self.process_page_items(request, items) + + context = self.get_context() + context.update(extra_context or {}) + context.update(item_context or {}) + + return page.render_to_response(request, extra_context=context) + return inner + + def process_page_items(self, request, items): + """ + Hook for handling any extra processing of items based on a + request, such as pagination or searching. This method is + expected to return a list of items and a dictionary to be + added to the page context. + """ + item_context = { + self.item_context_var: items + } + return items, item_context + + def get_feed_type(self, request): + feed_type = self.feed_type + accept = request.META.get('HTTP_ACCEPT') + if accept and feed_type not in accept and "*/*" not in accept and "%s/*" % feed_type.split("/")[0] not in accept: + # Wups! They aren't accepting the chosen format. Is there another format we can use? + if mimeparse: + feed_type = mimeparse.best_match(FEEDS.keys(), accept) + else: + for feed_type in FEEDS.keys(): + if feed_type in accept or "%s/*" % feed_type.split("/")[0] in accept: + break + else: + feed_type = None + if not feed_type: + # See RFC 2616 + return HttpResponse(status=406) + return FEEDS[feed_type] + + def get_feed(self, obj, request, reverse_name): + """ + Returns an unpopulated feedgenerator.DefaultFeed object for this object. + """ + try: + current_site = Site.objects.get_current() + except Site.DoesNotExist: + current_site = RequestSite(request) + + feed_type = self.get_feed_type(request) + node = request.node + link = node.get_absolute_url(with_domain=True, request=request, secure=request.is_secure()) + + feed = feed_type( + title = self.__get_dynamic_attr('title', obj), + subtitle = self.__get_dynamic_attr('subtitle', obj), + link = link, + description = self.__get_dynamic_attr('description', obj), + language = settings.LANGUAGE_CODE.decode(), + feed_url = add_domain( + current_site.domain, + self.__get_dynamic_attr('feed_url', obj) or node.construct_url(node.subpath, with_domain=True, request=request, secure=request.is_secure()), + request.is_secure() + ), + author_name = self.__get_dynamic_attr('author_name', obj), + author_link = self.__get_dynamic_attr('author_link', obj), + author_email = self.__get_dynamic_attr('author_email', obj), + categories = self.__get_dynamic_attr('categories', obj), + feed_copyright = self.__get_dynamic_attr('feed_copyright', obj), + feed_guid = self.__get_dynamic_attr('feed_guid', obj), + ttl = self.__get_dynamic_attr('ttl', obj), + **self.feed_extra_kwargs(obj) + ) + return feed + + def populate_feed(self, feed, items, request): + if self.item_title_template: + title_template = DjangoTemplate(self.item_title_template.code) + else: + title_template = None + if self.item_description_template: + description_template = DjangoTemplate(self.item_description_template.code) + else: + description_template = None + + node = request.node + try: + current_site = Site.objects.get_current() + except Site.DoesNotExist: + current_site = RequestSite(request) + + for item in items: + if title_template is not None: + title = title_template.render(RequestContext(request, {'obj': item})) + else: + title = self.__get_dynamic_attr('item_title', item) + if description_template is not None: + description = description_template.render(RequestContext(request, {'obj': item})) + else: + description = self.__get_dynamic_attr('item_description', item) + + link = node.construct_url(self.reverse(obj=item), with_domain=True, request=request, secure=request.is_secure()) + + enc = None + enc_url = self.__get_dynamic_attr('item_enclosure_url', item) + if enc_url: + enc = feedgenerator.Enclosure( + url = smart_unicode(add_domain( + current_site.domain, + enc_url, + request.is_secure() + )), + length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)), + mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item)) + ) + author_name = self.__get_dynamic_attr('item_author_name', item) + if author_name is not None: + author_email = self.__get_dynamic_attr('item_author_email', item) + author_link = self.__get_dynamic_attr('item_author_link', item) + else: + author_email = author_link = None + + pubdate = self.__get_dynamic_attr('item_pubdate', item) + if pubdate and not pubdate.tzinfo: + ltz = tzinfo.LocalTimezone(pubdate) + pubdate = pubdate.replace(tzinfo=ltz) + + feed.add_item( + title = title, + link = link, + description = description, + unique_id = self.__get_dynamic_attr('item_guid', item, link), + enclosure = enc, + pubdate = pubdate, + author_name = author_name, + author_email = author_email, + author_link = author_link, + categories = self.__get_dynamic_attr('item_categories', item), + item_copyright = self.__get_dynamic_attr('item_copyright', item), + **self.item_extra_kwargs(item) + ) + + def __get_dynamic_attr(self, attname, obj, default=None): + try: + attr = getattr(self, attname) + except AttributeError: + return default + if callable(attr): + # Check func_code.co_argcount rather than try/excepting the + # function and catching the TypeError, because something inside + # the function may raise the TypeError. This technique is more + # accurate. + if hasattr(attr, 'func_code'): + argcount = attr.func_code.co_argcount + else: + argcount = attr.__call__.func_code.co_argcount + if argcount == 2: # one argument is 'self' + return attr(obj) + else: + return attr() + return attr + + def feed_extra_kwargs(self, obj): + """ + Returns an extra keyword arguments dictionary that is used when + initializing the feed generator. + """ + return {} + + def item_extra_kwargs(self, item): + """ + Returns an extra keyword arguments dictionary that is used with + the `add_item` call of the feed generator. + """ + return {} + + def item_title(self, item): + return escape(force_unicode(item)) + + def item_description(self, item): + return force_unicode(item) + + class Meta: + abstract=True class Blog(Entity, Titled): @@ -29,11 +301,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" @@ -43,7 +320,7 @@ class BlogEntry(Entity, Titled): register_value_model(BlogEntry) -class BlogView(MultiView, FeedMultiViewMixin): +class BlogView(FeedView): ENTRY_PERMALINK_STYLE_CHOICES = ( ('D', 'Year, month, and day'), ('M', 'Year and month'), @@ -64,17 +341,13 @@ class BlogView(MultiView, FeedMultiViewMixin): entry_permalink_style = models.CharField(max_length=1, choices=ENTRY_PERMALINK_STYLE_CHOICES) entry_permalink_base = models.CharField(max_length=255, blank=False, default='entries') tag_permalink_base = models.CharField(max_length=255, blank=False, default='tags') - feed_suffix = models.CharField(max_length=255, blank=False, default=FeedMultiViewMixin.feed_suffix) - feeds_enabled = models.BooleanField() - list_var = 'entries' + + item_context_var = 'entries' + object_attr = 'blog' def __unicode__(self): return u'BlogView for %s' % self.blog.title - @property - def per_page(self): - return self.entries_per_page - def get_reverse_params(self, obj): if isinstance(obj, BlogEntry): if obj.blog == self.blog: @@ -86,9 +359,12 @@ class BlogView(MultiView, FeedMultiViewMixin): if self.entry_permalink_style == 'D': kwargs.update({'day': str(obj.date.day).zfill(2)}) return self.entry_view, [], kwargs - elif isinstance(obj, Tag): - if obj in self.blog.entry_tags: - return 'entries_by_tag', [], {'tag_slugs': obj.slug} + elif isinstance(obj, Tag) or (isinstance(obj, models.QuerySet) and obj.model == Tag and obj): + if isinstance(obj, Tag): + obj = [obj] + slugs = [tag.slug for tag in obj if tag in self.get_tag_queryset()] + if slugs: + return 'entries_by_tag', [], {'tag_slugs': "/".join(slugs)} elif isinstance(obj, (date, datetime)): kwargs = { 'year': str(obj.year).zfill(4), @@ -98,69 +374,75 @@ 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('', - url(r'^', include(self.feed_patterns(self.get_all_entries, self.index_page, 'index'))), + url(r'^', include(self.feed_patterns('get_all_entries', 'index_page', 'index'))), ) if self.feeds_enabled: urlpatterns += patterns('', - url(r'^%s/(?P[-\w]+[-+/\w]*)/%s/' % (self.tag_permalink_base, self.feed_suffix), self.feed_view(self.get_entries_by_tag, 'entries_by_tag_feed'), name='entries_by_tag_feed'), + url(r'^%s/(?P[-\w]+[-+/\w]*)/%s$' % (self.tag_permalink_base, self.feed_suffix), self.feed_view('get_entries_by_tag', 'entries_by_tag_feed'), name='entries_by_tag_feed'), ) urlpatterns += patterns('', - url(r'^%s/(?P[-\w]+[-+/\w]*)/' % self.tag_permalink_base, self.page_view(self.get_entries_by_tag, self.tag_page), name='entries_by_tag') + url(r'^%s/(?P[-\w]+[-+/\w]*)$' % self.tag_permalink_base, self.page_view('get_entries_by_tag', 'tag_page'), name='entries_by_tag') ) 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, name='tag_archive') ) if self.entry_archive_page: if self.entry_permalink_style in 'DMY': urlpatterns += patterns('', - url(r'^(?P\d{4})/', include(self.feed_patterns(self.get_entries_by_ymd, self.entry_archive_page, 'entries_by_year'))) + url(r'^(?P\d{4})', include(self.feed_patterns('get_entries_by_ymd', 'entry_archive_page', 'entries_by_year'))) ) if self.entry_permalink_style in 'DM': urlpatterns += patterns('', - url(r'^(?P\d{4})/(?P\d{2})/?$', include(self.feed_patterns(self.get_entries_by_ymd, self.entry_archive_page, 'entries_by_month'))), + url(r'^(?P\d{4})/(?P\d{2})$', include(self.feed_patterns('get_entries_by_ymd', 'entry_archive_page', 'entries_by_month'))), ) if self.entry_permalink_style == 'D': urlpatterns += patterns('', - url(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})/?$', include(self.feed_patterns(self.get_entries_by_ymd, self.entry_archive_page, 'entries_by_day'))) + url(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})$', include(self.feed_patterns('get_entries_by_ymd', 'entry_archive_page', 'entries_by_day'))) ) if self.entry_permalink_style == 'D': urlpatterns += patterns('', - url(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[-\w]+)/?$', self.entry_view) + url(r'^(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[-\w]+)$', self.entry_view) ) elif self.entry_permalink_style == 'M': urlpatterns += patterns('', - url(r'^(?P\d{4})/(?P\d{2})/(?P[-\w]+)/?$', self.entry_view) + url(r'^(?P\d{4})/(?P\d{2})/(?P[-\w]+)$', self.entry_view) ) elif self.entry_permalink_style == 'Y': urlpatterns += patterns('', - url(r'^(?P\d{4})/(?P[-\w]+)/?$', self.entry_view) + url(r'^(?P\d{4})/(?P[-\w]+)$', self.entry_view) ) elif self.entry_permalink_style == 'B': urlpatterns += patterns('', - url((r'^(?:%s)/(?P[-\w]+)/?$' % self.entry_permalink_base), self.entry_view) + url((r'^%s/(?P[-\w]+)$' % self.entry_permalink_base), self.entry_view) ) else: urlpatterns = patterns('', - url(r'^(?P[-\w]+)/?$', self.entry_view) + url(r'^(?P[-\w]+)$', self.entry_view) ) 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 +455,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 +496,68 @@ 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 feed_view(self, get_items_attr, reverse_name): + get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr) + + def inner(request, extra_context=None, *args, **kwargs): + obj = self.get_object(request, *args, **kwargs) + feed = self.get_feed(obj, request, reverse_name) + items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs) + self.populate_feed(feed, items, request) + + if 'tags' in extra_context: + tags = extra_context['tags'] + feed.feed['link'] = request.node.construct_url(self.reverse(tags), with_domain=True, request=request, secure=request.is_secure()) + else: + tags = obj.entry_tags + + feed.feed['categories'] = [tag.name for tag in tags] + + response = HttpResponse(mimetype=feed.mime_type) + feed.write(response, 'utf-8') + return response + + return inner + + def process_page_items(self, request, items): + if self.entries_per_page: + page_num = request.GET.get('page', 1) + paginator, paginated_page, items = paginate(items, self.entries_per_page, page_num) + item_context = { + 'paginator': paginator, + 'paginated_page': paginated_page, + self.item_context_var: items + } + else: + item_context = { + self.item_context_var: items + } + return items, item_context + + def title(self, obj): + return obj.title + + def item_title(self, item): + return item.title + + def item_description(self, item): + return item.content + + def item_author_name(self, item): + return item.author.get_full_name() + + def item_pubdate(self, item): + return item.date + + def item_categories(self, item): + return [tag.name for tag in item.tags.all()] class Newsletter(Entity, Titled): @@ -253,11 +570,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'] @@ -280,7 +602,7 @@ class NewsletterIssue(Entity, Titled): register_value_model(NewsletterIssue) -class NewsletterView(MultiView, FeedMultiViewMixin): +class NewsletterView(FeedView): ARTICLE_PERMALINK_STYLE_CHOICES = ( ('D', 'Year, month, and day'), ('M', 'Year and month'), @@ -300,12 +622,11 @@ class NewsletterView(MultiView, FeedMultiViewMixin): article_permalink_base = models.CharField(max_length=255, blank=False, default='articles') issue_permalink_base = models.CharField(max_length=255, blank=False, default='issues') - feed_suffix = models.CharField(max_length=255, blank=False, default=FeedMultiViewMixin.feed_suffix) - feeds_enabled = models.BooleanField() - list_var = 'articles' + item_context_var = 'articles' + object_attr = 'newsletter' def __unicode__(self): - return self.newsletter.__unicode__() + return "NewsletterView for %s" % self.newsletter.__unicode__() def get_reverse_params(self, obj): if isinstance(obj, NewsletterArticle): @@ -333,45 +654,45 @@ class NewsletterView(MultiView, FeedMultiViewMixin): @property def urlpatterns(self): urlpatterns = patterns('', - url(r'^', include(self.feed_patterns(self.get_all_articles, self.index_page, 'index'))), - url(r'^(?:%s)/(?P.+)/' % self.issue_permalink_base, include(self.feed_patterns(self.get_articles_by_issue, self.issue_page, 'issue'))) + url(r'^', include(self.feed_patterns('get_all_articles', 'index_page', 'index'))), + url(r'^%s/(?P.+)' % self.issue_permalink_base, include(self.feed_patterns('get_articles_by_issue', 'issue_page', 'issue'))) ) 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('', - url(r'^(?:%s)/' % self.article_permalink_base, include(self.feed_patterns(self.get_all_articles, self.article_archive_page, 'articles'))) + url(r'^%s' % self.article_permalink_base, include(self.feed_patterns('get_all_articles', 'article_archive_page', 'articles'))) ) if self.article_permalink_style in 'DMY': urlpatterns += patterns('', - url(r'^(?:%s)/(?P\d{4})/' % self.article_permalink_base, include(self.feed_patterns(self.get_articles_by_ymd, self.article_archive_page, 'articles_by_year'))) + url(r'^%s/(?P\d{4})' % self.article_permalink_base, include(self.feed_patterns('get_articles_by_ymd', 'article_archive_page', 'articles_by_year'))) ) if self.article_permalink_style in 'DM': urlpatterns += patterns('', - url(r'^(?:%s)/(?P\d{4})/(?P\d{2})/' % self.article_permalink_base, include(self.feed_patterns(self.get_articles_by_ymd, self.article_archive_page, 'articles_by_month'))) + url(r'^%s/(?P\d{4})/(?P\d{2})' % self.article_permalink_base, include(self.feed_patterns('get_articles_by_ymd', 'article_archive_page', 'articles_by_month'))) ) if self.article_permalink_style == 'D': urlpatterns += patterns('', - url(r'^(?:%s)/(?P\d{4})/(?P\d{2})/(?P\d{2})/' % self.article_permalink_base, include(self.feed_patterns(self.get_articles_by_ymd, self.article_archive_page, 'articles_by_day'))) + url(r'^%s/(?P\d{4})/(?P\d{2})/(?P\d{2})' % self.article_permalink_base, include(self.feed_patterns('get_articles_by_ymd', 'article_archive_page', 'articles_by_day'))) ) if self.article_permalink_style == 'Y': urlpatterns += patterns('', - url(r'^(?:%s)/(?P\d{4})/(?P[\w-]+)/$' % self.article_permalink_base, self.article_view) + url(r'^%s/(?P\d{4})/(?P[\w-]+)$' % self.article_permalink_base, self.article_view) ) elif self.article_permalink_style == 'M': urlpatterns += patterns('', - url(r'^(?:%s)/(?P\d{4})/(?P\d{2})/(?P[\w-]+)/$' % self.article_permalink_base, self.article_view) + url(r'^%s/(?P\d{4})/(?P\d{2})/(?P[\w-]+)$' % self.article_permalink_base, self.article_view) ) elif self.article_permalink_style == 'D': urlpatterns += patterns('', - url(r'^(?:%s)/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[\w-]+)/$' % self.article_permalink_base, self.article_view) + url(r'^%s/(?P\d{4})/(?P\d{2})/(?P\d{2})/(?P[\w-]+)$' % self.article_permalink_base, self.article_view) ) else: urlpatterns += patterns('', - url(r'^(?:%s)/(?P[-\w]+)/?$' % self.article_permalink_base, self.article_view) + url(r'^%s/(?P[-\w]+)$' % self.article_permalink_base, self.article_view) ) return urlpatterns @@ -379,28 +700,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,30 +743,36 @@ 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): - defaults = { - 'title': obj.title, - 'author_name': ', '.join([author.get_full_name() for author in obj.authors.all()]), - 'pubdate': obj.date, - 'description': obj.full_text, - 'categories': [tag.name for tag in obj.tags.all()] - } - defaults.update(kwargs or {}) - super(NewsletterView, self).add_item(feed, obj, defaults) + def title(self, obj): + return obj.title - def get_feed(self, feed_type, extra_context, kwargs=None): - title = self.newsletter.title - - defaults = { - 'title': title - } - defaults.update(kwargs or {}) - return super(NewsletterView, self).get_feed(feed_type, extra_context, defaults) + def item_title(self, item): + return item.title + + def item_description(self, item): + return item.full_text + + def item_author_name(self, item): + authors = list(item.authors.all()) + if len(authors) > 1: + return "%s and %s" % (", ".join([author.get_full_name() for author in authors[:-1]]), authors[-1].get_full_name()) + elif authors: + return authors[0].get_full_name() + else: + return '' + + def item_pubdate(self, item): + return item.date + + def item_categories(self, item): + return [tag.name for tag in item.tags.all()] \ No newline at end of file diff --git a/contrib/penfield/utils.py b/contrib/penfield/utils.py deleted file mode 100644 index 43c7c91..0000000 --- a/contrib/penfield/utils.py +++ /dev/null @@ -1,102 +0,0 @@ -from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed -from django.conf.urls.defaults import url, patterns -from django.contrib.sites.models import Site -from django.core.urlresolvers import reverse -from django.http import HttpResponse -from philo.utils import paginate - - -class FeedMultiViewMixin(object): - """ - This mixin provides common methods for adding feeds to multiviews. In order to use this mixin, - the multiview must define feed_title (probably as properties that return values - on related objects.) feed_description may also be defined; it defaults to an empty string. - """ - feed_suffix = 'feed' - feeds_enabled = True - atom_feed = Atom1Feed - rss_feed = Rss201rev2Feed - feed_title = None - feed_description = None - list_var = 'objects' - - def page_view(self, func, page): - """ - Wraps an object-fetching function and renders the results as a page. - """ - def inner(request, extra_context=None, **kwargs): - objects, extra_context = func(request=request, extra_context=extra_context, **kwargs) - - context = self.get_context() - context.update(extra_context or {}) - - if 'page' in kwargs or 'page' in request.GET or (hasattr(self, 'per_page') and self.per_page): - page_num = kwargs.get('page', request.GET.get('page', 1)) - paginator, paginated_page, objects = paginate(objects, self.per_page, page_num) - context.update({'paginator': paginator, 'paginated_page': paginated_page, self.list_var: objects}) - else: - context.update({self.list_var: objects}) - - return page.render_to_response(request, extra_context=context) - - return inner - - def feed_view(self, func, reverse_name): - """ - Wraps an object-fetching function and renders the results as a rss or atom feed. - """ - def inner(request, extra_context=None, **kwargs): - objects, extra_context = func(request=request, extra_context=extra_context, **kwargs) - - if 'HTTP_ACCEPT' in request.META and 'rss' in request.META['HTTP_ACCEPT'] and 'atom' not in request.META['HTTP_ACCEPT']: - feed_type = 'rss' - else: - feed_type = 'atom' - - current_site = Site.objects.get_current() - #Could this be done with request.path instead somehow? - feed_kwargs = { - 'link': 'http://%s/%s/%s/' % (current_site.domain, request.node.get_absolute_url().strip('/'), reverse(reverse_name, urlconf=self, kwargs=kwargs).strip('/')) - } - feed = self.get_feed(feed_type, extra_context, feed_kwargs) - - for obj in objects: - kwargs = { - 'link': 'http://%s/%s/%s/' % (current_site.domain, request.node.get_absolute_url().strip('/'), self.get_subpath(obj).strip('/')) - } - self.add_item(feed, obj, kwargs=kwargs) - - response = HttpResponse(mimetype=feed.mime_type) - feed.write(response, 'utf-8') - return response - - return inner - - def get_feed(self, feed_type, extra_context, kwargs=None): - defaults = { - 'description': '' - } - defaults.update(kwargs or {}) - - if feed_type == 'rss': - return self.rss_feed(**defaults) - - if 'description' in defaults and defaults['description'] and 'subtitle' not in defaults: - defaults['subtitle'] = defaults['description'] - - return self.atom_feed(**defaults) - - def feed_patterns(self, object_fetcher, page, base_name): - urlpatterns = patterns('', - url(r'^$', self.page_view(object_fetcher, page), name=base_name) - ) - if self.feeds_enabled: - feed_name = '%s_feed' % base_name - urlpatterns = patterns('', - url(r'^%s/$' % self.feed_suffix, self.feed_view(object_fetcher, feed_name), name=feed_name), - ) + urlpatterns - return urlpatterns - - def add_item(self, feed, obj, kwargs=None): - defaults = kwargs or {} - feed.add_item(**defaults) \ No newline at end of file diff --git a/contrib/shipherd/__init__.py b/contrib/shipherd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/contrib/shipherd/admin.py b/contrib/shipherd/admin.py new file mode 100644 index 0000000..93d21e5 --- /dev/null +++ b/contrib/shipherd/admin.py @@ -0,0 +1,109 @@ +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' + related_lookup_fields = {'fk': raw_id_fields} + + +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 + related_lookup_fields = {'fk': raw_id_fields} + inlines = [NavigationItemChildInline] + TreeEntityAdmin.inlines + + +class NavigationAdmin(EntityAdmin): + inlines = [NavigationNavigationItemInline] + raw_id_fields = ['node'] + related_lookup_fields = {'fk': raw_id_fields} + + +admin.site.unregister(Node) +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/migrations/0001_initial.py b/contrib/shipherd/migrations/0001_initial.py new file mode 100644 index 0000000..c33d64a --- /dev/null +++ b/contrib/shipherd/migrations/0001_initial.py @@ -0,0 +1,108 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding model 'Navigation' + db.create_table('shipherd_navigation', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('node', self.gf('django.db.models.fields.related.ForeignKey')(related_name='navigation_set', to=orm['philo.Node'])), + ('key', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('depth', self.gf('django.db.models.fields.PositiveSmallIntegerField')(default=3)), + )) + db.send_create_signal('shipherd', ['Navigation']) + + # Adding unique constraint on 'Navigation', fields ['node', 'key'] + db.create_unique('shipherd_navigation', ['node_id', 'key']) + + # Adding model 'NavigationItem' + db.create_table('shipherd_navigationitem', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('parent', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='children', null=True, to=orm['shipherd.NavigationItem'])), + ('slug', self.gf('django.db.models.fields.SlugField')(max_length=255, db_index=True)), + ('lft', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)), + ('rght', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)), + ('tree_id', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)), + ('level', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)), + ('navigation', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='roots', null=True, to=orm['shipherd.Navigation'])), + ('text', self.gf('django.db.models.fields.CharField')(max_length=50)), + ('target_node', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='navigation_items', null=True, to=orm['philo.Node'])), + ('url_or_subpath', self.gf('django.db.models.fields.CharField')(max_length=200, blank=True)), + ('reversing_parameters', self.gf('philo.models.fields.JSONField')(blank=True)), + ('order', self.gf('django.db.models.fields.PositiveSmallIntegerField')(default=0)), + )) + db.send_create_signal('shipherd', ['NavigationItem']) + + + def backwards(self, orm): + + # Deleting model 'Navigation' + db.delete_table('shipherd_navigation') + + # Removing unique constraint on 'Navigation', fields ['node', 'key'] + db.delete_unique('shipherd_navigation', ['node_id', 'key']) + + # Deleting model 'NavigationItem' + db.delete_table('shipherd_navigationitem') + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'shipherd.navigation': { + 'Meta': {'unique_together': "(('node', 'key'),)", 'object_name': 'Navigation'}, + 'depth': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '3'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'node': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'navigation_set'", 'to': "orm['philo.Node']"}) + }, + 'shipherd.navigationitem': { + 'Meta': {'object_name': 'NavigationItem'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'navigation': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'roots'", 'null': 'True', 'to': "orm['shipherd.Navigation']"}), + 'order': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['shipherd.NavigationItem']"}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'navigation_items'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'text': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + } + } + + complete_apps = ['shipherd'] diff --git a/contrib/shipherd/migrations/0002_auto.py b/contrib/shipherd/migrations/0002_auto.py new file mode 100644 index 0000000..00d095f --- /dev/null +++ b/contrib/shipherd/migrations/0002_auto.py @@ -0,0 +1,75 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding index on 'Navigation', fields ['key'] + db.create_index('shipherd_navigation', ['key']) + + + def backwards(self, orm): + + # Removing index on 'Navigation', fields ['key'] + db.delete_index('shipherd_navigation', ['key']) + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'shipherd.navigation': { + 'Meta': {'unique_together': "(('node', 'key'),)", 'object_name': 'Navigation'}, + 'depth': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '3'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'node': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'navigation_set'", 'to': "orm['philo.Node']"}) + }, + 'shipherd.navigationitem': { + 'Meta': {'object_name': 'NavigationItem'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'navigation': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'roots'", 'null': 'True', 'to': "orm['shipherd.Navigation']"}), + 'order': ('django.db.models.fields.PositiveSmallIntegerField', [], {'default': '0'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['shipherd.NavigationItem']"}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'shipherd_navigationitem_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'text': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + } + } + + complete_apps = ['shipherd'] diff --git a/contrib/shipherd/migrations/__init__.py b/contrib/shipherd/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/contrib/shipherd/models.py b/contrib/shipherd/models.py new file mode 100644 index 0000000..8efc57a --- /dev/null +++ b/contrib/shipherd/models.py @@ -0,0 +1,281 @@ +#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, Node, TreeManager, Entity, TargetURLModel +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(shipherd_navigationitem_related__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.", db_index=True) + depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.") + + def __init__(self, *args, **kwargs): + 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, TargetURLModel): + objects = NavigationItemManager() + + navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.") + text = models.CharField(max_length=50) + + 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): + super(NavigationItem, self).clean() + if bool(self.parent) == bool(self.navigation): + raise ValidationError("Exactly one of `parent` and `navigation` must be defined.") + + 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 index 0000000..e69de29 diff --git a/contrib/shipherd/templatetags/shipherd.py b/contrib/shipherd/templatetags/shipherd.py new file mode 100644 index 0000000..98e3e6b --- /dev/null +++ b/contrib/shipherd/templatetags/shipherd.py @@ -0,0 +1,103 @@ +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: +
    + {% recursenavigation node main %} + + {{ navigation.text }} + {% if navigation.get_children %} +
      + {{ children }} +
    + {% endif %} + + {% endrecursenavigation %} +
+ """ + bits = token.contents.split() + if len(bits) != 3: + raise template.TemplateSyntaxError(_('%s tag requires two arguments: a node and a navigation section name') % bits[0]) + + instance_var = parser.compile_filter(bits[1]) + key = bits[2] + + template_nodes = parser.parse(('endrecursenavigation',)) + parser.delete_first_token() + + return RecurseNavigationNode(template_nodes, instance_var, key) + + +@register.filter +def has_navigation(node, key=None): + nav = node.navigation + if key is not None: + if key in nav and bool(node.navigation[key]): + return True + elif key not in node.navigation: + return False + return bool(node.navigation) + + +@register.filter +def navigation_host(node, key): + try: + return Navigation.objects.filter(node__in=node.get_ancestors(include_self=True), key=key).order_by('-node__level')[0].node + except: + if settings.TEMPLATE_DEBUG: + raise + return node \ No newline at end of file diff --git a/contrib/waldo/models.py b/contrib/waldo/models.py index e3dd079..3286aa0 100644 --- a/contrib/waldo/models.py +++ b/contrib/waldo/models.py @@ -41,36 +41,31 @@ class LoginMultiView(MultiView): @property def urlpatterns(self): urlpatterns = patterns('', - url(r'^login/$', self.login, name='login'), - url(r'^logout/$', self.logout, name='logout'), + url(r'^login$', self.login, name='login'), + url(r'^logout$', self.logout, name='logout'), - url(r'^password/reset/$', csrf_protect(self.password_reset), name='password_reset'), - url(r'^password/reset/(?P\w+)/(?P[^/]+)/$', self.password_reset_confirm, name='password_reset_confirm'), + url(r'^password/reset$', csrf_protect(self.password_reset), name='password_reset'), + url(r'^password/reset/(?P\w+)/(?P[^/]+)$', self.password_reset_confirm, name='password_reset_confirm'), - url(r'^register/$', csrf_protect(self.register), name='register'), - url(r'^register/(?P\w+)/(?P[^/]+)/$', self.register_confirm, name='register_confirm') + url(r'^register$', csrf_protect(self.register), name='register'), + url(r'^register/(?P\w+)/(?P[^/]+)$', self.register_confirm, name='register_confirm') ) if self.password_change_page: urlpatterns += patterns('', - url(r'^password/change/$', csrf_protect(self.login_required(self.password_change)), name='password_change'), + url(r'^password/change$', csrf_protect(self.login_required(self.password_change)), name='password_change'), ) return urlpatterns def make_confirmation_link(self, confirmation_view, token_generator, user, node, token_args=None, reverse_kwargs=None): - current_site = Site.objects.get_current() token = token_generator.make_token(user, *(token_args or [])) kwargs = { 'uidb36': int_to_base36(user.id), 'token': token } kwargs.update(reverse_kwargs or {}) - return 'http://%s%s' % (current_site.domain, self.reverse(confirmation_view, kwargs=kwargs, node=node)) - - def get_context(self): - """Hook for providing instance-specific context - such as the value of a Field - to all views.""" - return {} + return node.construct_url(subpath=self.reverse(confirmation_view, kwargs=kwargs), with_domain=True) def display_login_page(self, request, message, extra_context=None): request.session.set_test_cookie() @@ -340,8 +335,8 @@ class AccountMultiView(LoginMultiView): def urlpatterns(self): urlpatterns = super(AccountMultiView, self).urlpatterns urlpatterns += patterns('', - url(r'^account/$', self.login_required(self.account_view), name='account'), - url(r'^account/email/(?P\w+)/(?P[\w.]+[+][\w.]+)/(?P[^/]+)/$', self.email_change_confirm, name='email_change_confirm') + url(r'^account$', self.login_required(self.account_view), name='account'), + url(r'^account/email/(?P\w+)/(?P[\w.]+[+][\w.]+)/(?P[^/]+)$', self.email_change_confirm, name='email_change_confirm') ) return urlpatterns @@ -379,18 +374,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/exceptions.py b/exceptions.py index 1e4b9d9..f53083d 100644 --- a/exceptions.py +++ b/exceptions.py @@ -5,12 +5,12 @@ MIDDLEWARE_NOT_CONFIGURED = ImproperlyConfigured("""Philo requires the RequestNo class ViewDoesNotProvideSubpaths(Exception): - """ Raised by get_subpath when the View does not provide subpaths (the default). """ + """ Raised by View.reverse when the View does not provide subpaths (the default). """ silent_variable_failure = True class ViewCanNotProvideSubpath(Exception): - """ Raised by get_subpath when the View can not provide a subpath for the supplied object. """ + """ Raised by View.reverse when the View can not provide a subpath for the supplied arguments. """ silent_variable_failure = True diff --git a/forms.py b/forms.py deleted file mode 100644 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 index 0000000..7e0b0d9 --- /dev/null +++ b/forms/__init__.py @@ -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 index 0000000..b6259a3 --- /dev/null +++ b/forms/entities.py @@ -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 index 0000000..b148947 --- /dev/null +++ b/forms/fields.py @@ -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 diff --git a/middleware.py b/middleware.py index ad660ec..c0b1e9e 100644 --- a/middleware.py +++ b/middleware.py @@ -15,12 +15,24 @@ class LazyNode(object): except Site.DoesNotExist: current_site = None + path = request._cached_node_path + trailing_slash = False + if path[-1] == '/': + trailing_slash = True + try: - node, subpath = Node.objects.get_with_path(request._cached_node_path, root=getattr(current_site, 'root_node', None), absolute_result=False) + node, subpath = Node.objects.get_with_path(path, root=getattr(current_site, 'root_node', None), absolute_result=False) except Node.DoesNotExist: node = None if node: + if subpath is None: + subpath = "" + subpath = "/" + subpath + + if trailing_slash and subpath[-1] != "/": + subpath += "/" + node.subpath = subpath request._found_node = node diff --git a/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py b/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py new file mode 100644 index 0000000..dcacc79 --- /dev/null +++ b/migrations/0010_auto__add_field_redirect_target_node__add_field_redirect_url_or_subpat.py @@ -0,0 +1,151 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'Redirect.target_node' + db.add_column('philo_redirect', 'target_node', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='philo_redirect_related', null=True, to=orm['philo.Node']), keep_default=False) + + # Adding field 'Redirect.url_or_subpath' + db.add_column('philo_redirect', 'url_or_subpath', self.gf('django.db.models.fields.CharField')(default='', max_length=200, blank=True), keep_default=False) + + # Adding field 'Redirect.reversing_parameters' + db.add_column('philo_redirect', 'reversing_parameters', self.gf('philo.models.fields.JSONField')(default='null', blank=True), keep_default=False) + + + def backwards(self, orm): + + # Deleting field 'Redirect.target_node' + db.delete_column('philo_redirect', 'target_node_id') + + # Deleting field 'Redirect.url_or_subpath' + db.delete_column('philo_redirect', 'url_or_subpath') + + # Deleting field 'Redirect.reversing_parameters' + db.delete_column('philo_redirect', 'reversing_parameters_json') + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'"}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target': ('django.db.models.fields.CharField', [], {'max_length': '200'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/migrations/0011_move_target_url.py b/migrations/0011_move_target_url.py new file mode 100644 index 0000000..4fd4304 --- /dev/null +++ b/migrations/0011_move_target_url.py @@ -0,0 +1,141 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + +class Migration(DataMigration): + + def forwards(self, orm): + "Write your forwards methods here." + for redirect in orm.Redirect.objects.all(): + redirect.url_or_subpath = redirect.target + redirect.save() + + + def backwards(self, orm): + "This will cause data loss and is not advisable. Blurg!" + for redirect in orm.Redirect.objects.all(): + redirect.target = redirect.url_or_subpath + redirect.save() + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'"}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target': ('django.db.models.fields.CharField', [], {'max_length': '200'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/migrations/0012_auto__del_field_redirect_target.py b/migrations/0012_auto__del_field_redirect_target.py new file mode 100644 index 0000000..a536ebb --- /dev/null +++ b/migrations/0012_auto__del_field_redirect_target.py @@ -0,0 +1,138 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Deleting field 'Redirect.target' + db.delete_column('philo_redirect', 'target') + + + def backwards(self, orm): + + # Adding field 'Redirect.target' + db.add_column('philo_redirect', 'target', self.gf('django.db.models.fields.CharField')(default='', max_length=200), keep_default=False) + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'"}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/migrations/0013_auto.py b/migrations/0013_auto.py new file mode 100644 index 0000000..c8f7799 --- /dev/null +++ b/migrations/0013_auto.py @@ -0,0 +1,150 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding index on 'Attribute', fields ['entity_object_id'] + db.create_index('philo_attribute', ['entity_object_id']) + + # Adding index on 'Attribute', fields ['value_object_id'] + db.create_index('philo_attribute', ['value_object_id']) + + # Adding index on 'Attribute', fields ['key'] + db.create_index('philo_attribute', ['key']) + + + def backwards(self, orm): + + # Removing index on 'Attribute', fields ['entity_object_id'] + db.delete_index('philo_attribute', ['entity_object_id']) + + # Removing index on 'Attribute', fields ['value_object_id'] + db.delete_index('philo_attribute', ['value_object_id']) + + # Removing index on 'Attribute', fields ['key'] + db.delete_index('philo_attribute', ['key']) + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'"}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/migrations/0014_auto.py b/migrations/0014_auto.py new file mode 100644 index 0000000..6375c69 --- /dev/null +++ b/migrations/0014_auto.py @@ -0,0 +1,156 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding index on 'Contentlet', fields ['name'] + db.create_index('philo_contentlet', ['name']) + + # Adding index on 'JSONValue', fields ['value'] + db.create_index('philo_jsonvalue', ['value_json']) + + # Adding index on 'ForeignKeyValue', fields ['object_id'] + db.create_index('philo_foreignkeyvalue', ['object_id']) + + # Adding index on 'ContentReference', fields ['name'] + db.create_index('philo_contentreference', ['name']) + + + def backwards(self, orm): + + # Removing index on 'ContentReference', fields ['name'] + db.delete_index('philo_contentreference', ['name']) + + # Removing index on 'ForeignKeyValue', fields ['object_id'] + db.delete_index('philo_foreignkeyvalue', ['object_id']) + + # Removing index on 'JSONValue', fields ['value'] + db.delete_index('philo_jsonvalue', ['value_json']) + + # Removing index on 'Contentlet', fields ['name'] + db.delete_index('philo_contentlet', ['name']) + + + models = { + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.collection': { + 'Meta': {'object_name': 'Collection'}, + 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.collectionmember': { + 'Meta': {'object_name': 'CollectionMember'}, + 'collection': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'members'", 'to': "orm['philo.Collection']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'member_content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'member_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.contentlet': { + 'Meta': {'object_name': 'Contentlet'}, + 'content': ('philo.models.fields.TemplateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentlets'", 'to': "orm['philo.Page']"}) + }, + 'philo.contentreference': { + 'Meta': {'object_name': 'ContentReference'}, + 'content_id': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'contentreferences'", 'to': "orm['philo.Page']"}) + }, + 'philo.file': { + 'Meta': {'object_name': 'File'}, + 'file': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.foreignkeyvalue': { + 'Meta': {'object_name': 'ForeignKeyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.jsonvalue': { + 'Meta': {'object_name': 'JSONValue'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'value': ('philo.models.fields.JSONField', [], {'default': "'null'", 'db_index': 'True'}) + }, + 'philo.manytomanyvalue': { + 'Meta': {'object_name': 'ManyToManyValue'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'values': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['philo.ForeignKeyValue']", 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.redirect': { + 'Meta': {'object_name': 'Redirect'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reversing_parameters': ('philo.models.fields.JSONField', [], {'blank': 'True'}), + 'status_code': ('django.db.models.fields.IntegerField', [], {'default': '302'}), + 'target_node': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'philo_redirect_related'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'url_or_subpath': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'philo.tag': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['philo'] diff --git a/models/__init__.py b/models/__init__.py index 76d7812..523f789 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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 diff --git a/models/base.py b/models/base.py index c7b1c26..8370bb7 100644 --- a/models/base.py +++ b/models/base.py @@ -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): @@ -42,6 +45,9 @@ def register_value_model(model): value_content_type_limiter.register_class(model) +register_value_model(Tag) + + def unregister_value_model(model): value_content_type_limiter.unregister_class(model) @@ -53,10 +59,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 +81,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', db_index=True) def __unicode__(self): - return self.value_json + return smart_str(self.value) + + def value_formfields(self): + kwargs = {'initial': self.value_json} + field = self._meta.get_field('value') + return {field.name: field.formfield(**kwargs)} - def value_formfield(self, **kwargs): - kwargs['initial'] = self.value_json - return self._meta.get_field('value').formfield(**kwargs) + def construct_instance(self, **kwargs): + field_name = self._meta.get_field('value').name + self.set_value(kwargs.pop(field_name, None)) - def apply_data(self, cleaned_data): - self.value = cleaned_data.get('value', None) + def set_value(self, value): + self.value = value class Meta: app_label = 'philo' @@ -88,22 +104,36 @@ class JSONValue(AttributeValue): class ForeignKeyValue(AttributeValue): content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True) - object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True) + object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True) value = generic.GenericForeignKey() - def value_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 +143,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) - def apply_data(self, cleaned_data): - if 'value' in cleaned_data and cleaned_data['value'] is not None: - self.value = cleaned_data['value'] + value = property(get_value, set_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' @@ -171,14 +220,14 @@ class ManyToManyValue(AttributeValue): class Attribute(models.Model): entity_content_type = models.ForeignKey(ContentType, related_name='attribute_entity_set', verbose_name='Entity type') - entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID') + entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID', db_index=True) entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id') value_content_type = models.ForeignKey(ContentType, related_name='attribute_value_set', limit_choices_to=attribute_value_limiter, verbose_name='Value type', null=True, blank=True) - value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True) + value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True) value = generic.GenericForeignKey('value_content_type', 'value_object_id') - key = models.CharField(max_length=255) + key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True) def __unicode__(self): return u'"%s": %s' % (self.key, self.value) @@ -242,37 +291,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 @@ -297,11 +315,6 @@ class TreeManager(models.Manager): # tree structure won't be that deep. segments = path.split(pathsep) - # Check for a trailing pathsep so we can restore it later. - trailing_pathsep = False - if segments[-1] == '': - trailing_pathsep = True - # Clean out blank segments. Handles multiple consecutive pathseps. while True: try: @@ -333,12 +346,6 @@ class TreeManager(models.Manager): return kwargs - def build_path(segments): - path = pathsep.join(segments) - if trailing_pathsep and segments and segments[-1] != '': - path += pathsep - return path - def find_obj(segments, depth, deepest_found=None): if deepest_found is None: deepest_level = 0 @@ -359,7 +366,7 @@ class TreeManager(models.Manager): if deepest_level == depth: # This should happen if nothing is found with any part of the given path. if root is not None and deepest_found is None: - return root, build_path(segments) + return root, pathsep.join(segments) raise return find_obj(segments, depth, deepest_found) @@ -372,7 +379,7 @@ class TreeManager(models.Manager): # Could there be a deeper one? if obj.is_leaf_node(): - return obj, build_path(segments[deepest_level:]) or None + return obj, pathsep.join(segments[deepest_level:]) or None depth += (len(segments) - depth)/2 or len(segments) - depth @@ -380,13 +387,13 @@ class TreeManager(models.Manager): depth = deepest_level + obj.get_descendant_count() if deepest_level == depth: - return obj, build_path(segments[deepest_level:]) or None + return obj, pathsep.join(segments[deepest_level:]) or None try: return find_obj(segments, depth, obj) except self.model.DoesNotExist: # Then this was the deepest. - return obj, build_path(segments[deepest_level:]) + return obj, pathsep.join(segments[deepest_level:]) if absolute_result: return self.get(**make_query_kwargs(segments, root)) @@ -410,12 +417,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 index 19a6006..0000000 --- a/models/fields.py +++ /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 index 0000000..d8ed839 --- /dev/null +++ b/models/fields/__init__.py @@ -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 index 0000000..6c407d0 --- /dev/null +++ b/models/fields/entities.py @@ -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 diff --git a/models/nodes.py b/models/nodes.py index de10ed1..10c51b4 100644 --- a/models/nodes.py +++ b/models/nodes.py @@ -1,15 +1,16 @@ from django.db import models from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes import generic -from django.contrib.sites.models import Site -from django.http import HttpResponse, HttpResponseServerError, HttpResponseRedirect -from django.core.exceptions import ViewDoesNotExist +from django.contrib.sites.models import Site, RequestSite +from django.http import HttpResponse, HttpResponseServerError, HttpResponseRedirect, Http404 +from django.core.exceptions import ValidationError from django.core.servers.basehttp import FileWrapper from django.core.urlresolvers import resolve, clear_url_caches, reverse, NoReverseMatch from django.template import add_to_builtins as register_templatetags from inspect import getargspec from philo.exceptions import MIDDLEWARE_NOT_CONFIGURED from philo.models.base import TreeEntity, Entity, QuerySetMapper, register_value_model +from philo.models.fields import JSONField from philo.utils import ContentTypeSubclassLimiter from philo.validators import RedirectValidator from philo.exceptions import ViewCanNotProvideSubpath, ViewDoesNotProvideSubpaths, AncestorDoesNotExist @@ -30,23 +31,53 @@ class Node(TreeEntity): return self.view.accepts_subpath return False + def handles_subpath(self, subpath): + return self.view.handles_subpath(subpath) + def render_to_response(self, request, extra_context=None): return self.view.render_to_response(request, extra_context) - def get_absolute_url(self): + def get_absolute_url(self, request=None, with_domain=False, secure=False): + return self.construct_url(request=request, with_domain=with_domain, secure=secure) + + def construct_url(self, subpath="/", request=None, with_domain=False, secure=False): + """ + This method will construct a URL based on the Node's location. + If a request is passed in, that will be used as a backup in case + the Site lookup fails. The Site lookup takes precedence because + it's what's used to find the root node. This will raise: + - NoReverseMatch if philo-root is not reverseable + - Site.DoesNotExist if a domain is requested but not buildable. + - AncestorDoesNotExist if the root node of the site isn't an + ancestor of this instance. + """ + # Try reversing philo-root first, since we can't do anything if that fails. + root_url = reverse('philo-root') + try: - root = Site.objects.get_current().root_node + current_site = Site.objects.get_current() except Site.DoesNotExist: - root = None + if request is not None: + current_site = RequestSite(request) + elif with_domain: + # If they want a domain and we can't figure one out, + # best to reraise the error to let them know. + raise + else: + current_site = None - try: - path = self.get_path(root=root) - if path: - path += '/' - root_url = reverse('philo-root') - return '%s%s' % (root_url, path) - except AncestorDoesNotExist, ViewDoesNotExist: - return None + root = getattr(current_site, 'root_node', None) + path = self.get_path(root=root) + + if current_site and with_domain: + domain = "http%s://%s" % (secure and "s" or "", current_site.domain) + else: + domain = "" + + if not path or subpath == "/": + subpath = subpath[1:] + + return '%s%s%s%s' % (domain, root_url, path, subpath) class Meta: app_label = 'philo' @@ -61,15 +92,34 @@ class View(Entity): accepts_subpath = False - def get_subpath(self, obj): + def handles_subpath(self, subpath): + if not self.accepts_subpath and subpath != "/": + return False + return True + + def reverse(self, view_name=None, args=None, kwargs=None, node=None, obj=None): + """Shortcut method to handle the common pattern of getting the + absolute url for a view's subpaths.""" if not self.accepts_subpath: raise ViewDoesNotProvideSubpaths - view_name, args, kwargs = self.get_reverse_params(obj) + if obj is not None: + # Perhaps just override instead of combining? + obj_view_name, obj_args, obj_kwargs = self.get_reverse_params(obj) + if view_name is None: + view_name = obj_view_name + args = list(obj_args) + list(args or []) + obj_kwargs.update(kwargs or {}) + kwargs = obj_kwargs + try: - return reverse(view_name, args=args, kwargs=kwargs, urlconf=self) + subpath = reverse(view_name, urlconf=self, args=args or [], kwargs=kwargs or {}) except NoReverseMatch: raise ViewCanNotProvideSubpath + + if node is not None: + return node.construct_url(subpath) + return subpath def get_reverse_params(self, obj): """This method should return a view_name, args, kwargs tuple suitable for reversing a url for the given obj using self as the urlconf.""" @@ -89,7 +139,7 @@ class View(Entity): return response def actually_render_to_response(self, request, extra_context=None): - raise NotImplementedError('View subclasses must implement render_to_response.') + raise NotImplementedError('View subclasses must implement actually_render_to_response.') class Meta: abstract = True @@ -102,15 +152,21 @@ class MultiView(View): accepts_subpath = True @property - def urlpatterns(self, obj): + def urlpatterns(self): raise NotImplementedError("MultiView subclasses must implement urlpatterns.") + def handles_subpath(self, subpath): + if not super(MultiView, self).handles_subpath(subpath): + return False + try: + resolve(subpath, urlconf=self) + except Http404: + return False + return True + def actually_render_to_response(self, request, extra_context=None): clear_url_caches() subpath = request.node.subpath - if not subpath: - subpath = "" - subpath = "/" + subpath view, args, kwargs = resolve(subpath, urlconf=self) view_args = getargspec(view) if extra_context is not None and ('extra_context' in view_args[0] or view_args[2] is not None): @@ -119,28 +175,94 @@ class MultiView(View): kwargs['extra_context'] = extra_context return view(request, *args, **kwargs) - def reverse(self, view_name, args=None, kwargs=None, node=None): - """Shortcut method to handle the common pattern of getting the absolute url for a multiview's - subpaths.""" - subpath = reverse(view_name, urlconf=self, args=args or [], kwargs=kwargs or {}) - if node is not None: - return '/%s/%s/' % (node.get_absolute_url().strip('/'), subpath.strip('/')) - return subpath + def get_context(self): + """Hook for providing instance-specific context - such as the value of a Field - to all views.""" + return {} + + def basic_view(self, field_name): + """ + Given the name of a field on ``self``, accesses the value of + that field and treats it as a ``View`` instance. Creates a + basic context based on self.get_context() and any extra_context + that was passed in, then calls the ``View`` instance's + render_to_response() method. This method is meant to be called + to return a view function appropriate for urlpatterns. + """ + field = self._meta.get_field(field_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 + + +class TargetURLModel(models.Model): + target_node = models.ForeignKey(Node, blank=True, null=True, related_name="%(app_label)s_%(class)s_related") + url_or_subpath = models.CharField(max_length=200, validators=[RedirectValidator()], blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.") + reversing_parameters = JSONField(blank=True, help_text="If reversing parameters are defined, url_or_subpath will instead be interpreted as the view name to be reversed.") + + def clean(self): + # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar. + if not self.target_node and not self.url_or_subpath: + raise ValidationError("Either a target node or a url must be defined.") + + if self.reversing_parameters and not (self.url_or_subpath or self.target_node): + raise ValidationError("Reversing parameters require either a view name or a target node.") + + try: + self.get_target_url() + except NoReverseMatch, e: + raise ValidationError(e.message) + + super(TargetURLModel, self).clean() + + def get_reverse_params(self): + params = self.reversing_parameters + args = isinstance(params, list) and params or None + kwargs = isinstance(params, dict) and params or None + return self.url_or_subpath, args, kwargs + + def get_target_url(self): + node = self.target_node + if node is not None and node.accepts_subpath and self.url_or_subpath: + if self.reversing_parameters is not None: + view_name, args, kwargs = self.get_reverse_params() + subpath = node.view.reverse(view_name, args=args, kwargs=kwargs) + else: + subpath = self.url_or_subpath + if subpath[0] != '/': + subpath = '/' + subpath + return node.construct_url(subpath) + elif node is not None: + return node.get_absolute_url() + else: + if self.reversing_parameters is not None: + view_name, args, kwargs = self.get_reverse_params() + return reverse(view_name, args=args, kwargs=kwargs) + return self.url_or_subpath + target_url = property(get_target_url) class Meta: abstract = True -class Redirect(View): +class Redirect(View, TargetURLModel): STATUS_CODES = ( (302, 'Temporary'), (301, 'Permanent'), ) - target = models.CharField(max_length=200, validators=[RedirectValidator()]) status_code = models.IntegerField(choices=STATUS_CODES, default=302, verbose_name='redirect type') def actually_render_to_response(self, request, extra_context=None): - response = HttpResponseRedirect(self.target) + response = HttpResponseRedirect(self.target_url) response.status_code = self.status_code return response @@ -148,7 +270,6 @@ class Redirect(View): app_label = 'philo' -# Why does this exist? class File(View): """ For storing arbitrary files """ diff --git a/models/pages.py b/models/pages.py index 81b84c9..ef68b5f 100644 --- a/models/pages.py +++ b/models/pages.py @@ -108,7 +108,7 @@ class Page(View): class Contentlet(models.Model): page = models.ForeignKey(Page, related_name='contentlets') - name = models.CharField(max_length=255) + name = models.CharField(max_length=255, db_index=True) content = TemplateField() def __unicode__(self): @@ -120,7 +120,7 @@ class Contentlet(models.Model): class ContentReference(models.Model): page = models.ForeignKey(Page, related_name='contentreferences') - name = models.CharField(max_length=255) + name = models.CharField(max_length=255, db_index=True) content_type = models.ForeignKey(ContentType, verbose_name='Content type') content_id = models.PositiveIntegerField(verbose_name='Content ID', blank=True, null=True) content = generic.GenericForeignKey('content_type', 'content_id') diff --git a/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html b/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html index 4760397..ccead57 100644 --- a/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html +++ b/templates/admin/philo/edit_inline/grappelli_tabular_attribute.html @@ -3,7 +3,7 @@
-

{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}

+

{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}

@@ -84,132 +84,43 @@ - diff --git a/templatetags/embed.py b/templatetags/embed.py index db5cea5..eb4cd68 100644 --- a/templatetags/embed.py +++ b/templatetags/embed.py @@ -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: diff --git a/templatetags/nodes.py b/templatetags/nodes.py index 73492d4..5ae507d 100644 --- a/templatetags/nodes.py +++ b/templatetags/nodes.py @@ -55,10 +55,7 @@ class NodeURLNode(template.Node): raise return settings.TEMPLATE_STRING_IF_INVALID else: - if subpath[0] == '/': - subpath = subpath[1:] - - url = node.get_absolute_url() + subpath + url = node.construct_url(subpath) if self.as_var: context[self.as_var] = url diff --git a/views.py b/views.py index 255e54e..f5a2c7f 100644 --- a/views.py +++ b/views.py @@ -1,5 +1,6 @@ from django.conf import settings -from django.http import Http404 +from django.core.urlresolvers import resolve +from django.http import Http404, HttpResponseRedirect from django.views.decorators.vary import vary_on_headers from philo.exceptions import MIDDLEWARE_NOT_CONFIGURED @@ -10,11 +11,50 @@ def node_view(request, path=None, **kwargs): raise MIDDLEWARE_NOT_CONFIGURED if not request.node: + if settings.APPEND_SLASH and request.path != "/": + path = request.path + + if path[-1] == "/": + path = path[:-1] + else: + path += "/" + + view, args, kwargs = resolve(path) + if view != node_view: + return HttpResponseRedirect(path) raise Http404 node = request.node subpath = request.node.subpath - if subpath and not node.accepts_subpath: - raise Http404 + # Explicitly disallow trailing slashes if we are otherwise at a node's url. + if request.path and request.path != "/" and request.path[-1] == "/" and subpath == "/": + return HttpResponseRedirect(node.get_absolute_url()) + + if not node.handles_subpath(subpath): + # If the subpath isn't handled, check settings.APPEND_SLASH. If + # it's True, try to correct the subpath. + if not settings.APPEND_SLASH: + raise Http404 + + if subpath[-1] == "/": + subpath = subpath[:-1] + else: + subpath += "/" + + redirect_url = node.construct_url(subpath) + + if node.handles_subpath(subpath): + return HttpResponseRedirect(redirect_url) + + # Perhaps there is a non-philo view at this address. Can we + # resolve *something* there besides node_view? If not, + # raise a 404. + view, args, kwargs = resolve(redirect_url) + + if view == node_view: + raise Http404 + else: + return HttpResponseRedirect(redirect_url) + return node.render_to_response(request, kwargs) \ No newline at end of file