From: Joseph Spiros Date: Thu, 3 Mar 2011 20:38:44 +0000 (-0500) Subject: Merge branch 'master' of git://github.com/melinath/philo X-Git-Tag: philo-0.9~17 X-Git-Url: http://git.ithinksw.org/philo.git/commitdiff_plain/3269dd0709806386523d7d3415d8eddc2d13644b?hp=6f2781d1ebfccf4c9d5f36a56402f05c161a972c Merge branch 'master' of git://github.com/melinath/philo * 'master' of git://github.com/melinath/philo: Added feed length limit to FeedView. Implements feature #111. Corrected LazyNavigationRecurser to mark its return value as safe. Switched back to setting a {{ children }} variable, but set it to a lazy recurser instead of a rendered result. Switched item and children to be set in the context directly for easy access. Improved/updated recursenavigation docstring. Refactored the RecurseNavigationNode to have less repetition. Switched from {{ children }} to {% recurse %} because it makes more sense to collect recursion only if needed. Added a number of counting variables to the context when rendering shipherd navigation. Designed to mirror the variables generated when using a for loop. Minor corrections to shipherd recursenavigation docstring. Added CSRF cookie js to TagCreation.js... apparently it isn't in the admin by default. Resolves issue 83. Added an admin action to NewsletterArticleAdmin to handle creating a NewsletterIssue from a selection of articles. Resolves issue 82. Minor LazyContainerFinder cleanup. It's not really that lazy in practice... Removed nodelist_crawl since nothing is using it and I'm starting to question its usefulness in general. Built a clearer algorithm for finding a template's containers; added support for template block overrides cancelling out containers in that block, which resolves issue #90. Corrected FeedView handling of incorrect Accept headers. Will now actually return 406 errors. Genericized nodelist_crawl to just pass each node to a callback function. Overloaded the Template.containers callback to create a blockcontext for handling containers in overridden blocks as per issue 90 and to handle contentreference/contentlet generation in a single sweep. Refactored ContainerForms to reflect a more suitable structure by storing the containers internally as a SortedDict. By handling containers more consistently, this commit resolves issue #89. Removed apparently-vestigial Entity.attribute property. --- diff --git a/admin/forms/containers.py b/admin/forms/containers.py index 5991dfa..420ba17 100644 --- a/admin/forms/containers.py +++ b/admin/forms/containers.py @@ -2,8 +2,9 @@ from django import forms from django.contrib.admin.widgets import AdminTextareaWidget from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q -from django.forms.models import ModelForm, BaseInlineFormSet +from django.forms.models import ModelForm, BaseInlineFormSet, BaseModelFormSet from django.forms.formsets import TOTAL_FORM_COUNT +from django.utils.datastructures import SortedDict from philo.admin.widgets import ModelLookupWidget from philo.models import Contentlet, ContentReference @@ -20,17 +21,19 @@ class ContainerForm(ModelForm): def __init__(self, *args, **kwargs): super(ContainerForm, self).__init__(*args, **kwargs) self.verbose_name = self.instance.name.replace('_', ' ') + self.prefix = self.instance.name class ContentletForm(ContainerForm): content = forms.CharField(required=False, widget=AdminTextareaWidget, label='Content') def should_delete(self): - return not bool(self.cleaned_data['content']) + # Delete iff: the data has changed and is now empty. + return self.has_changed() and not bool(self.cleaned_data['content']) class Meta: model = Contentlet - fields = ['name', 'content'] + fields = ['content'] class ContentReferenceForm(ContainerForm): @@ -43,72 +46,92 @@ class ContentReferenceForm(ContainerForm): pass def should_delete(self): - return (self.cleaned_data['content_id'] is None) + return self.has_changed() and (self.cleaned_data['content_id'] is None) class Meta: model = ContentReference - fields = ['name', 'content_id'] + fields = ['content_id'] class ContainerInlineFormSet(BaseInlineFormSet): - def __init__(self, containers, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None): - # Unfortunately, I need to add some things to BaseInline between its __init__ and its - # super call, so a lot of this is repetition. + @property + def containers(self): + if not hasattr(self, '_containers'): + self._containers = self.get_containers() + return self._containers + + def total_form_count(self): + # This ignores the posted management form data... but that doesn't + # seem to have any ill side effects. + return len(self.containers.keys()) + + def _get_initial_forms(self): + return [form for form in self.forms if form.instance.pk is not None] + initial_forms = property(_get_initial_forms) + + def _get_extra_forms(self): + return [form for form in self.forms if form.instance.pk is None] + extra_forms = property(_get_extra_forms) + + def _construct_form(self, i, **kwargs): + if 'instance' not in kwargs: + kwargs['instance'] = self.containers.values()[i] - # Start cribbed from BaseInline - from django.db.models.fields.related import RelatedObject - self.save_as_new = save_as_new - # is there a better way to get the object descriptor? - self.rel_name = RelatedObject(self.fk.rel.to, self.model, self.fk).get_accessor_name() - if self.fk.rel.field_name == self.fk.rel.to._meta.pk.name: - backlink_value = self.instance - else: - backlink_value = getattr(self.instance, self.fk.rel.field_name) - if queryset is None: - queryset = self.model._default_manager - qs = queryset.filter(**{self.fk.name: backlink_value}) - # End cribbed from BaseInline + # Skip over the BaseModelFormSet. We have our own way of doing things! + form = super(BaseModelFormSet, self)._construct_form(i, **kwargs) - self.container_instances, qs = self.get_container_instances(containers, qs) - self.extra_containers = containers - self.extra = len(self.extra_containers) - super(BaseInlineFormSet, self).__init__(data, files, prefix=prefix, queryset=qs) - - def get_container_instances(self, containers, qs): - raise NotImplementedError + # Since we skipped over BaseModelFormSet, we need to duplicate what BaseInlineFormSet would do. + if self.save_as_new: + # Remove the primary key from the form's data, we are only + # creating new instances + form.data[form.add_prefix(self._pk_field.name)] = None + + # Remove the foreign key from the form's data + form.data[form.add_prefix(self.fk.name)] = None + + # Set the fk value here so that the form can do it's validation. + setattr(form.instance, self.fk.get_attname(), self.instance.pk) + return form - def total_form_count(self): - if self.data or self.files: - return self.management_form.cleaned_data[TOTAL_FORM_COUNT] + def add_fields(self, form, index): + """Override the pk field's initial value with a real one.""" + super(ContainerInlineFormSet, self).add_fields(form, index) + if index is not None: + pk_value = self.containers.values()[index].pk else: - return self.initial_form_count() + self.extra + pk_value = None + form.fields[self._pk_field.name].initial = pk_value def save_existing_objects(self, commit=True): self.changed_objects = [] self.deleted_objects = [] if not self.get_queryset(): return [] - + saved_instances = [] for form in self.initial_forms: pk_name = self._pk_field.name raw_pk_value = form._raw_value(pk_name) - + # clean() for different types of PK fields can sometimes return # the model instance, and sometimes the PK. Handle either. pk_value = form.fields[pk_name].clean(raw_pk_value) pk_value = getattr(pk_value, 'pk', pk_value) - - obj = self._existing_object(pk_value) - if form.should_delete(): - self.deleted_objects.append(obj) - obj.delete() - continue - if form.has_changed(): - self.changed_objects.append((obj, form.changed_data)) - saved_instances.append(self.save_existing(form, obj, commit=commit)) - if not commit: - self.saved_forms.append(form) + + # if the pk_value is None, they have just switched to a + # template which didn't contain data about this container. + # Skip! + if pk_value is not None: + obj = self._existing_object(pk_value) + if form.should_delete(): + self.deleted_objects.append(obj) + obj.delete() + continue + if form.has_changed(): + self.changed_objects.append((obj, form.changed_data)) + saved_instances.append(self.save_existing(form, obj, commit=commit)) + if not commit: + self.saved_forms.append(form) return saved_instances def save_new_objects(self, commit=True): @@ -127,64 +150,41 @@ class ContainerInlineFormSet(BaseInlineFormSet): class ContentletInlineFormSet(ContainerInlineFormSet): - def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None): - if instance is None: - self.instance = self.fk.rel.to() - else: - self.instance = instance - + def get_containers(self): try: containers = list(self.instance.containers[0]) except ObjectDoesNotExist: containers = [] - - super(ContentletInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset) - - def get_container_instances(self, containers, qs): - qs = qs.filter(name__in=containers) - container_instances = [] - for container in qs: - container_instances.append(container) - containers.remove(container.name) - return container_instances, qs - - def _construct_form(self, i, **kwargs): - if i >= self.initial_form_count(): # and not kwargs.get('instance'): - kwargs['instance'] = self.model(name=self.extra_containers[i - self.initial_form_count() - 1]) - return super(ContentletInlineFormSet, self)._construct_form(i, **kwargs) + qs = self.get_queryset().filter(name__in=containers) + container_dict = SortedDict([(container.name, container) for container in qs]) + for name in containers: + if name not in container_dict: + container_dict[name] = self.model(name=name) + + container_dict.keyOrder = containers + return container_dict class ContentReferenceInlineFormSet(ContainerInlineFormSet): - def __init__(self, data=None, files=None, instance=None, save_as_new=False, prefix=None, queryset=None): - if instance is None: - self.instance = self.fk.rel.to() - else: - self.instance = instance - + def get_containers(self): try: - containers = list(self.instance.containers[1]) + containers = self.instance.containers[1] except ObjectDoesNotExist: - containers = [] - - super(ContentReferenceInlineFormSet, self).__init__(containers, data, files, instance, save_as_new, prefix, queryset) - - def get_container_instances(self, containers, qs): - filter = Q() + containers = {} - for name, ct in containers: + filter = Q() + for name, ct in containers.items(): filter |= Q(name=name, content_type=ct) + qs = self.get_queryset().filter(filter) - qs = qs.filter(filter) - container_instances = [] - for container in qs: - container_instances.append(container) - containers.remove((container.name, container.content_type)) - return container_instances, qs - - def _construct_form(self, i, **kwargs): - if i >= self.initial_form_count(): # and not kwargs.get('instance'): - name, content_type = self.extra_containers[i - self.initial_form_count() - 1] - kwargs['instance'] = self.model(name=name, content_type=content_type) - - return super(ContentReferenceInlineFormSet, self)._construct_form(i, **kwargs) \ No newline at end of file + container_dict = SortedDict([(container.name, container) for container in qs]) + + keyOrder = [] + for name, ct in containers.items(): + keyOrder.append(name) + if name not in container_dict: + container_dict[name] = self.model(name=name, content_type=ct) + + container_dict.keyOrder = keyOrder + return container_dict \ No newline at end of file diff --git a/contrib/penfield/admin.py b/contrib/penfield/admin.py index 950539d..5aee6f8 100644 --- a/contrib/penfield/admin.py +++ b/contrib/penfield/admin.py @@ -1,5 +1,7 @@ -from django.contrib import admin from django import forms +from django.contrib import admin +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect, QueryDict from philo.admin import EntityAdmin, AddTagAdmin, COLLAPSE_CLASSES from philo.contrib.penfield.models import BlogEntry, Blog, BlogView, Newsletter, NewsletterArticle, NewsletterIssue, NewsletterView @@ -91,10 +93,18 @@ class NewsletterArticleAdmin(TitledAdmin, AddTagAdmin): 'classes': COLLAPSE_CLASSES }) ) + actions = ['make_issue'] def author_names(self, obj): return ', '.join([author.get_full_name() for author in obj.authors.all()]) author_names.short_description = "Authors" + + def make_issue(self, request, queryset): + opts = NewsletterIssue._meta + info = opts.app_label, opts.module_name + url = reverse("admin:%s_%s_add" % info) + return HttpResponseRedirect("%s?articles=%s" % (url, ",".join([str(a.pk) for a in queryset]))) + make_issue.short_description = u"Create issue from selected %(verbose_name_plural)s" class NewsletterIssueAdmin(TitledAdmin): @@ -117,7 +127,7 @@ class NewsletterViewAdmin(EntityAdmin): 'classes': COLLAPSE_CLASSES }), ('Feeds', { - 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), + 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'feed_length', 'item_title_template', 'item_description_template',), 'classes': COLLAPSE_CLASSES }) ) diff --git a/contrib/penfield/exceptions.py b/contrib/penfield/exceptions.py new file mode 100644 index 0000000..96b96ed --- /dev/null +++ b/contrib/penfield/exceptions.py @@ -0,0 +1,3 @@ +class HttpNotAcceptable(Exception): + """This will be raised if an Http-Accept header will not accept the feed content types that are available.""" + pass \ No newline at end of file diff --git a/contrib/penfield/middleware.py b/contrib/penfield/middleware.py new file mode 100644 index 0000000..b25a28b --- /dev/null +++ b/contrib/penfield/middleware.py @@ -0,0 +1,14 @@ +from django.http import HttpResponse +from django.utils.decorators import decorator_from_middleware +from philo.contrib.penfield.exceptions import HttpNotAcceptable + + +class HttpNotAcceptableMiddleware(object): + """Middleware to catch HttpNotAcceptable errors and return an Http406 response. + See RFC 2616.""" + def process_exception(self, request, exception): + if isinstance(exception, HttpNotAcceptable): + return HttpResponse(status=406) + + +http_not_acceptable = decorator_from_middleware(HttpNotAcceptableMiddleware) \ No newline at end of file diff --git a/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py b/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py new file mode 100644 index 0000000..9b9ffa7 --- /dev/null +++ b/contrib/penfield/migrations/0004_auto__add_field_newsletterview_feed_length__add_field_blogview_feed_le.py @@ -0,0 +1,204 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'NewsletterView.feed_length' + db.add_column('penfield_newsletterview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False) + + # Adding field 'BlogView.feed_length' + db.add_column('penfield_blogview', 'feed_length', self.gf('django.db.models.fields.PositiveIntegerField')(default=15, null=True, blank=True), keep_default=False) + + + def backwards(self, orm): + + # Deleting field 'NewsletterView.feed_length' + db.delete_column('penfield_newsletterview', 'feed_length') + + # Deleting field 'BlogView.feed_length' + db.delete_column('penfield_blogview', 'feed_length') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'oberlin.person': { + 'Meta': {'object_name': 'Person'}, + 'bio': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '70', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'penfield.blog': { + 'Meta': {'object_name': 'Blog'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogentry': { + 'Meta': {'object_name': 'BlogEntry'}, + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogentries'", 'to': "orm['oberlin.Person']"}), + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'entries'", 'null': 'True', 'to': "orm['penfield.Blog']"}), + 'content': ('django.db.models.fields.TextField', [], {}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'excerpt': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'blogentries'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.blogview': { + 'Meta': {'object_name': 'BlogView'}, + 'blog': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blogviews'", 'to': "orm['penfield.Blog']"}), + 'entries_per_page': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'entry_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_entry_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'entry_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_entry_related'", 'to': "orm['philo.Page']"}), + 'entry_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'entries'", 'max_length': '255'}), + 'entry_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'application/atom+xml; charset=utf8'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_index_related'", 'to': "orm['philo.Page']"}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_blogview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'tag_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'blog_tag_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'tag_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blog_tag_related'", 'to': "orm['philo.Page']"}), + 'tag_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'tags'", 'max_length': '255'}) + }, + 'penfield.newsletter': { + 'Meta': {'object_name': 'Newsletter'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterarticle': { + 'Meta': {'unique_together': "(('newsletter', 'slug'),)", 'object_name': 'NewsletterArticle'}, + 'authors': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'newsletterarticles'", 'symmetrical': 'False', 'to': "orm['oberlin.Person']"}), + 'date': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'full_text': ('philo.models.fields.TemplateField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lede': ('philo.models.fields.TemplateField', [], {'null': 'True', 'blank': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'articles'", 'to': "orm['penfield.Newsletter']"}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'newsletterarticles'", 'null': 'True', 'symmetrical': 'False', 'to': "orm['philo.Tag']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterissue': { + 'Meta': {'unique_together': "(('newsletter', 'numbering'),)", 'object_name': 'NewsletterIssue'}, + 'articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'issues'", 'symmetrical': 'False', 'to': "orm['penfield.NewsletterArticle']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issues'", 'to': "orm['penfield.Newsletter']"}), + 'numbering': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'penfield.newsletterview': { + 'Meta': {'object_name': 'NewsletterView'}, + 'article_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_article_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'article_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_article_related'", 'to': "orm['philo.Page']"}), + 'article_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'articles'", 'max_length': '255'}), + 'article_permalink_style': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + 'feed_length': ('django.db.models.fields.PositiveIntegerField', [], {'default': '15', 'null': 'True', 'blank': 'True'}), + 'feed_suffix': ('django.db.models.fields.CharField', [], {'default': "'feed'", 'max_length': '255'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'default': "'application/atom+xml; charset=utf8'", 'max_length': '50'}), + 'feeds_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'index_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_index_related'", 'to': "orm['philo.Page']"}), + 'issue_archive_page': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'newsletter_issue_archive_related'", 'null': 'True', 'to': "orm['philo.Page']"}), + 'issue_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletter_issue_related'", 'to': "orm['philo.Page']"}), + 'issue_permalink_base': ('django.db.models.fields.CharField', [], {'default': "'issues'", 'max_length': '255'}), + 'item_description_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_description_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'item_title_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'penfield_newsletterview_title_related'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'newsletter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'newsletterviews'", 'to': "orm['penfield.Newsletter']"}) + }, + 'philo.attribute': { + 'Meta': {'unique_together': "(('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))", 'object_name': 'Attribute'}, + 'entity_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attribute_entity_set'", 'to': "orm['contenttypes.ContentType']"}), + 'entity_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value_content_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'attribute_value_set'", 'null': 'True', 'to': "orm['contenttypes.ContentType']"}), + 'value_object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'philo.node': { + 'Meta': {'object_name': 'Node'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Node']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'view_content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'node_view_set'", 'to': "orm['contenttypes.ContentType']"}), + 'view_object_id': ('django.db.models.fields.PositiveIntegerField', [], {}) + }, + 'philo.page': { + 'Meta': {'object_name': 'Page'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['philo.Template']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'philo.tag': { + 'Meta': {'object_name': 'Tag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'philo.template': { + 'Meta': {'object_name': 'Template'}, + 'code': ('philo.models.fields.TemplateField', [], {}), + 'documentation': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'mimetype': ('django.db.models.fields.CharField', [], {'default': "'text/html'", 'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['philo.Template']"}), + 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '255', 'db_index': 'True'}), + 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}) + } + } + + complete_apps = ['penfield'] diff --git a/contrib/penfield/models.py b/contrib/penfield/models.py index 98dcdd5..a03bed8 100644 --- a/contrib/penfield/models.py +++ b/contrib/penfield/models.py @@ -10,6 +10,8 @@ from django.utils.datastructures import SortedDict from django.utils.encoding import smart_unicode, force_unicode from django.utils.html import escape from datetime import date, datetime +from philo.contrib.penfield.exceptions import HttpNotAcceptable +from philo.contrib.penfield.middleware import http_not_acceptable from philo.contrib.penfield.validators import validate_pagination_count from philo.exceptions import ViewCanNotProvideSubpath from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template @@ -44,6 +46,7 @@ class FeedView(MultiView): feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM) feed_suffix = models.CharField(max_length=255, blank=False, default="feed") feeds_enabled = models.BooleanField(default=True) + feed_length = models.PositiveIntegerField(blank=True, null=True, default=15, help_text="The maximum number of items to return for this feed. All items will be returned if this field is blank.") item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related") item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related") @@ -62,7 +65,7 @@ class FeedView(MultiView): urlpatterns = patterns('') if self.feeds_enabled: feed_reverse_name = "%s_feed" % reverse_name - feed_view = self.feed_view(get_items_attr, feed_reverse_name) + feed_view = http_not_acceptable(self.feed_view(get_items_attr, feed_reverse_name)) feed_pattern = r'%s%s%s$' % (base, (base and base[-1] != "^") and "/" or "", self.feed_suffix) urlpatterns += patterns('', url(feed_pattern, feed_view, name=feed_reverse_name), @@ -139,8 +142,7 @@ class FeedView(MultiView): else: feed_type = None if not feed_type: - # See RFC 2616 - return HttpResponse(status=406) + raise HttpNotAcceptable return FEEDS[feed_type] def get_feed(self, obj, request, reverse_name): @@ -194,6 +196,9 @@ class FeedView(MultiView): except Site.DoesNotExist: current_site = RequestSite(request) + if self.feed_length is not None: + items = items[:self.feed_length] + for item in items: if title_template is not None: title = title_template.render(RequestContext(request, {'obj': item})) diff --git a/contrib/shipherd/templatetags/shipherd.py b/contrib/shipherd/templatetags/shipherd.py index fa4ec3e..57fb020 100644 --- a/contrib/shipherd/templatetags/shipherd.py +++ b/contrib/shipherd/templatetags/shipherd.py @@ -3,32 +3,75 @@ 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.safestring import mark_safe from django.utils.translation import ugettext as _ register = template.Library() -class RecurseNavigationNode(RecurseTreeNode): - def __init__(self, template_nodes, instance_var, key): +class LazyNavigationRecurser(object): + def __init__(self, template_nodes, items, context, request): self.template_nodes = template_nodes - self.instance_var = instance_var - self.key = key + self.items = items + self.context = context + self.request = request - def _render_node(self, context, item, request): - bits = [] + def __call__(self): + items = self.items + context = self.context + request = self.request + + if not items: + return '' + + if 'navloop' in context: + parentloop = context['navloop'] + else: + parentloop = {} context.push() - 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) + + depth = items[0].get_level() + len_items = len(items) + + loop_dict = context['navloop'] = { + 'parentloop': parentloop, + 'depth': depth + 1, + 'depth0': depth + } + + bits = [] + + for i, item in enumerate(items): + # First set context variables. + loop_dict['counter0'] = i + loop_dict['counter'] = i + 1 + loop_dict['revcounter'] = len_items - i + loop_dict['revcounter0'] = len_items - i - 1 + loop_dict['first'] = (i == 0) + loop_dict['last'] = (i == len_items - 1) + + # Set on loop_dict and context for backwards-compatibility. + # Eventually only allow access through the loop_dict. + loop_dict['active'] = context['active'] = item.is_active(request) + loop_dict['active_descendants'] = context['active_descendants'] = item.has_active_descendants(request) + + # Set these directly in the context for easy access. + context['item'] = item + context['children'] = self.__class__(self.template_nodes, item.get_children(), context, request) + + # Then render the nodelist bit by bit. + for node in self.template_nodes: + bits.append(node.render(context)) context.pop() - return rendered + return mark_safe(''.join(bits)) + + +class RecurseNavigationNode(template.Node): + def __init__(self, template_nodes, instance_var, key): + self.template_nodes = template_nodes + self.instance_var = instance_var + self.key = key def render(self, context): try: @@ -39,28 +82,46 @@ class RecurseNavigationNode(RecurseTreeNode): instance = self.instance_var.resolve(context) try: - navigation = instance.navigation[self.key] + items = 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) + return LazyNavigationRecurser(self.template_nodes, items, context, request)() @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. + The recursenavigation templatetag takes two arguments: + - the node for which the navigation should be found + - the navigation's key. - Note that the tag takes one variable, which is a Node instance. + It will then recursively loop over each item in the navigation and render the template + chunk within the block. recursenavigation sets the following variables in the context: - Usage: + ============================== ================================================ + Variable Description + ============================== ================================================ + ``navloop.depth`` The current depth of the loop (1 is the top level) + ``navloop.depth0`` The current depth of the loop (0 is the top level) + ``navloop.counter`` The current iteration of the current level(1-indexed) + ``navloop.counter0`` The current iteration of the current level(0-indexed) + ``navloop.first`` True if this is the first time through the current level + ``navloop.last`` True if this is the last time through the current level + ``navloop.parentloop`` This is the loop one level "above" the current one + ============================== ================================================ + ``item`` The current item in the loop (a NavigationItem instance) + ``children`` If accessed, performs the next level of recursion. + ``navloop.active`` True if the item is active for this request + ``navloop.active_descendants`` True if the item has active descendants for this request + ============================== ================================================ + + Example: