From e75f306d2a639471d2a5eef2ff698acfcb1fbcb6 Mon Sep 17 00:00:00 2001 From: Stephen Burrows Date: Mon, 7 Feb 2011 13:10:52 -0500 Subject: [PATCH] Moved FeedMultiViewMixin to models.py and renamed to FeedView. Improved feed type support and clarified the API (based heavily on django.contrib.syndication.views.Feed). Adjusted NewsletterView and BlogView to use the new API. --- contrib/penfield/admin.py | 17 +- ...feed_type__add_field_newsletterview_ite.py | 226 +++++++++ contrib/penfield/models.py | 429 +++++++++++++++--- .../feeds/blog_entry/description.html | 1 - .../penfield/feeds/blog_entry/title.html | 1 - .../feeds/newsletter_article/description.html | 1 - .../feeds/newsletter_article/title.html | 1 - contrib/penfield/utils.py | 98 ---- 8 files changed, 591 insertions(+), 183 deletions(-) create mode 100644 contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py delete mode 100644 contrib/penfield/templates/penfield/feeds/blog_entry/description.html delete mode 100644 contrib/penfield/templates/penfield/feeds/blog_entry/title.html delete mode 100644 contrib/penfield/templates/penfield/feeds/newsletter_article/description.html delete mode 100644 contrib/penfield/templates/penfield/feeds/newsletter_article/title.html delete mode 100644 contrib/penfield/utils.py diff --git a/contrib/penfield/admin.py b/contrib/penfield/admin.py index d8fcd90..950539d 100644 --- a/contrib/penfield/admin.py +++ b/contrib/penfield/admin.py @@ -41,6 +41,7 @@ class BlogEntryAdmin(TitledAdmin, AddTagAdmin): 'classes': COLLAPSE_CLASSES }) ) + related_lookup_fields = {'fk': raw_id_fields} class BlogViewAdmin(EntityAdmin): @@ -54,16 +55,17 @@ class BlogViewAdmin(EntityAdmin): ('Archive Pages', { 'fields': ('entry_archive_page', 'tag_archive_page') }), - ('Permalinks', { - 'fields': ('entry_permalink_style', 'entry_permalink_base', 'tag_permalink_base'), + ('General Settings', { + 'fields': ('entry_permalink_style', 'entry_permalink_base', 'tag_permalink_base', 'entries_per_page'), 'classes': COLLAPSE_CLASSES }), - ('Feeds', { - 'fields': ('feed_suffix', 'feeds_enabled'), + ('Feed Settings', { + 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), 'classes': COLLAPSE_CLASSES }) ) - raw_id_fields = ('index_page', 'entry_page', 'tag_page', 'entry_archive_page', 'tag_archive_page',) + raw_id_fields = ('index_page', 'entry_page', 'tag_page', 'entry_archive_page', 'tag_archive_page', 'item_title_template', 'item_description_template',) + related_lookup_fields = {'fk': raw_id_fields} class NewsletterAdmin(TitledAdmin): @@ -115,11 +117,12 @@ class NewsletterViewAdmin(EntityAdmin): 'classes': COLLAPSE_CLASSES }), ('Feeds', { - 'fields': ('feed_suffix', 'feeds_enabled'), + 'fields': ( 'feeds_enabled', 'feed_suffix', 'feed_type', 'item_title_template', 'item_description_template',), 'classes': COLLAPSE_CLASSES }) ) - raw_id_fields = ('index_page', 'article_page', 'issue_page', 'article_archive_page', 'issue_archive_page',) + raw_id_fields = ('index_page', 'article_page', 'issue_page', 'article_archive_page', 'issue_archive_page', 'item_title_template', 'item_description_template',) + related_lookup_fields = {'fk': raw_id_fields} admin.site.register(Blog, BlogAdmin) diff --git a/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py b/contrib/penfield/migrations/0003_auto__add_field_newsletterview_feed_type__add_field_newsletterview_ite.py new file mode 100644 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 27e5b5d..c018247 100644 --- a/contrib/penfield/models.py +++ b/contrib/penfield/models.py @@ -2,14 +2,265 @@ from django.conf import settings from django.conf.urls.defaults import url, patterns, include from django.db import models from django.http import Http404 -from django.template import loader, Context -from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed +from django.template import RequestContext, Template as DjangoTemplate +from django.utils import feedgenerator, tzinfo +from django.utils.datastructures import SortedDict +from django.utils.encoding import smart_unicode, force_unicode +from django.utils.html import escape from datetime import date, datetime -from philo.contrib.penfield.utils import FeedMultiViewMixin from philo.contrib.penfield.validators import validate_pagination_count from philo.exceptions import ViewCanNotProvideSubpath -from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField +from philo.models import Tag, Titled, Entity, MultiView, Page, register_value_model, TemplateField, Template from philo.utils import paginate +try: + import mimeparse +except: + mimeparse = None + +ATOM = feedgenerator.Atom1Feed.mime_type +RSS = feedgenerator.Rss201rev2Feed.mime_type +FEEDS = SortedDict([ + (ATOM, feedgenerator.Atom1Feed), + (RSS, feedgenerator.Rss201rev2Feed), +]) +FEED_CHOICES = ( + (ATOM, "Atom"), + (RSS, "RSS"), +) + + +class FeedView(MultiView): + """ + The FeedView expects to handle a number of different feeds for the + same object - i.e. patterns for a blog to handle all entries or + just entries for a certain year/month/day. + + This class would subclass django.contrib.syndication.views.Feed, but + that would make it callable, which causes problems. + """ + feed_type = models.CharField(max_length=50, choices=FEED_CHOICES, default=ATOM) + feed_suffix = models.CharField(max_length=255, blank=False, default="feed") + feeds_enabled = models.BooleanField(default=True) + + item_title_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_title_related") + item_description_template = models.ForeignKey(Template, blank=True, null=True, related_name="%(app_label)s_%(class)s_description_related") + + item_context_var = 'items' + object_attr = 'object' + + description = "" + + def feed_patterns(self, 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) + + 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. + """ + 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 = node.construct_url(self.reverse(reverse_name), with_domain=True, request=request, 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 = Template(self.item_title_template.code) + else: + title_template = None + if self.item_description_template: + description_template = Template(self.item_description_template.code) + else: + description_template = None + + node = request.node + + 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(enc_url), + length = smart_unicode(self.__get_dynamic_attr('item_enclosure_length', item)), + mime_type = smart_unicode(self.__get_dynamic_attr('item_enclosure_mime_type', item)) + ) + author_name = self.__get_dynamic_attr('item_author_name', item) + if author_name is not None: + author_email = self.__get_dynamic_attr('item_author_email', item) + author_link = self.__get_dynamic_attr('item_author_link', item) + else: + author_email = author_link = None + + pubdate = self.__get_dynamic_attr('item_pubdate', item) + if pubdate and not pubdate.tzinfo: + ltz = tzinfo.LocalTimezone(pubdate) + pubdate = pubdate.replace(tzinfo=ltz) + + feed.add_item( + title = title, + link = link, + description = description, + unique_id = self.__get_dynamic_attr('item_guid', item, link), + enclosure = enc, + pubdate = pubdate, + author_name = author_name, + author_email = author_email, + author_link = author_link, + categories = self.__get_dynamic_attr('item_categories', item), + item_copyright = self.__get_dynamic_attr('item_copyright', item), + **self.item_extra_kwargs(item) + ) + + def __get_dynamic_attr(self, attname, obj, default=None): + try: + attr = getattr(self, attname) + except AttributeError: + return default + if callable(attr): + # Check func_code.co_argcount rather than try/excepting the + # function and catching the TypeError, because something inside + # the function may raise the TypeError. This technique is more + # accurate. + if hasattr(attr, 'func_code'): + argcount = attr.func_code.co_argcount + else: + argcount = attr.__call__.func_code.co_argcount + if argcount == 2: # one argument is 'self' + return attr(obj) + else: + return attr() + return attr + + def feed_extra_kwargs(self, obj): + """ + Returns an extra keyword arguments dictionary that is used when + initializing the feed generator. + """ + return {} + + def item_extra_kwargs(self, item): + """ + Returns an extra keyword arguments dictionary that is used with + the `add_item` call of the feed generator. + """ + return {} + + def item_title(self, item): + return escape(force_unicode(item)) + + def item_description(self, item): + return force_unicode(item) + + class Meta: + abstract=True class Blog(Entity, Titled): @@ -49,7 +300,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'), @@ -70,17 +321,13 @@ class BlogView(MultiView, FeedMultiViewMixin): entry_permalink_style = models.CharField(max_length=1, choices=ENTRY_PERMALINK_STYLE_CHOICES) entry_permalink_base = models.CharField(max_length=255, blank=False, default='entries') tag_permalink_base = models.CharField(max_length=255, blank=False, default='tags') - feed_suffix = models.CharField(max_length=255, blank=False, default=FeedMultiViewMixin.feed_suffix) - feeds_enabled = models.BooleanField() - list_var = 'entries' + + item_context_var = 'entries' + object_attr = 'blog' def __unicode__(self): return u'BlogView for %s' % self.blog.title - @property - def per_page(self): - return self.entries_per_page - def get_reverse_params(self, obj): if isinstance(obj, BlogEntry): if obj.blog == self.blog: @@ -92,9 +339,12 @@ class BlogView(MultiView, FeedMultiViewMixin): if self.entry_permalink_style == 'D': kwargs.update({'day': str(obj.date.day).zfill(2)}) return self.entry_view, [], kwargs - elif isinstance(obj, Tag): - if obj in self.get_tag_queryset(): - return 'entries_by_tag', [], {'tag_slugs': obj.slug} + elif isinstance(obj, Tag) or (isinstance(obj, models.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), @@ -107,14 +357,14 @@ class BlogView(MultiView, FeedMultiViewMixin): @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('', @@ -124,15 +374,15 @@ class BlogView(MultiView, FeedMultiViewMixin): 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': @@ -233,30 +483,60 @@ class BlogView(MultiView, FeedMultiViewMixin): }) return self.tag_archive_page.render_to_response(request, extra_context=context) - def add_item(self, feed, obj, kwargs=None): - title = loader.get_template("penfield/feeds/blog_entry/title.html") - description = loader.get_template("penfield/feeds/blog_entry/description.html") - defaults = { - 'title': title.render(Context({'entry': obj})), - 'description': description.render(Context({'entry': obj})), - 'author_name': obj.author.get_full_name(), - 'pubdate': obj.date - } - defaults.update(kwargs or {}) - super(BlogView, self).add_item(feed, obj, defaults) - - def get_feed(self, feed_type, extra_context, kwargs=None): - tags = (extra_context or {}).get('tags', None) - title = self.blog.title + def feed_view(self, get_items_attr, reverse_name): + get_items = callable(get_items_attr) and get_items_attr or getattr(self, get_items_attr) - if tags is not None: - title += " - %s" % ', '.join([tag.name for tag in tags]) + def inner(request, extra_context=None, *args, **kwargs): + obj = self.get_object(request, *args, **kwargs) + feed = self.get_feed(obj, request, reverse_name) + items, extra_context = get_items(request, extra_context=extra_context, *args, **kwargs) + + 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 - defaults = { - 'title': title - } - defaults.update(kwargs or {}) - return super(BlogView, self).get_feed(feed_type, extra_context, defaults) + return inner + + def process_page_items(self, request, items): + if self.entries_per_page: + page_num = request.GET.get('page', 1) + paginator, paginated_page, items = paginate(items, self.entries_per_page, page_num) + item_context = { + 'paginator': paginator, + 'paginated_page': paginated_page, + self.item_context_var: objects + } + else: + item_context = { + self.item_context_var: items + } + return items, item_context + + def title(self, obj): + return obj.title + + def item_title(self, item): + return item.title + + def item_description(self, item): + return item.content + + def item_author_name(self, item): + return item.author.get_full_name() + + def item_pubdate(self, item): + return item.date + + def item_categories(self, item): + return [tag.name for tag in item.tags.all()] class Newsletter(Entity, Titled): @@ -301,7 +581,7 @@ class NewsletterIssue(Entity, Titled): register_value_model(NewsletterIssue) -class NewsletterView(MultiView, FeedMultiViewMixin): +class NewsletterView(FeedView): ARTICLE_PERMALINK_STYLE_CHOICES = ( ('D', 'Year, month, and day'), ('M', 'Year and month'), @@ -321,12 +601,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): @@ -354,8 +633,8 @@ 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('', @@ -363,19 +642,19 @@ class NewsletterView(MultiView, FeedMultiViewMixin): ) 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': @@ -453,24 +732,26 @@ class NewsletterView(MultiView, FeedMultiViewMixin): }) return self.issue_archive_page.render_to_response(request, extra_context=context) - def add_item(self, feed, obj, kwargs=None): - title = loader.get_template("penfield/feeds/newsletter_article/title.html") - description = loader.get_template("penfield/feeds/newsletter_article/description.html") - defaults = { - 'title': title.render(Context({'article': obj})), - 'author_name': ', '.join([author.get_full_name() for author in obj.authors.all()]), - 'pubdate': obj.date, - 'description': description.render(Context({'article': obj})), - 'categories': [tag.name for tag in obj.tags.all()] - } - defaults.update(kwargs or {}) - super(NewsletterView, self).add_item(feed, obj, defaults) + def title(self, obj): + return obj.title - def get_feed(self, feed_type, extra_context, kwargs=None): - title = self.newsletter.title - - defaults = { - 'title': title - } - defaults.update(kwargs or {}) - return super(NewsletterView, self).get_feed(feed_type, extra_context, defaults) + def item_title(self, item): + return item.title + + def item_description(self, item): + return item.full_text + + def item_author_name(self, item): + authors = list(item.authors.all()) + if len(authors) > 1: + return "%s and %s" % (", ".join([author.get_full_name() for author in authors[:-1]]), authors[-1].get_full_name()) + elif authors: + return authors[0].get_full_name() + else: + return '' + + def item_pubdate(self, item): + return item.date + + def item_categories(self, item): + return [tag.name for tag in item.tags.all()] \ No newline at end of file diff --git a/contrib/penfield/templates/penfield/feeds/blog_entry/description.html b/contrib/penfield/templates/penfield/feeds/blog_entry/description.html deleted file mode 100644 index 61060d5..0000000 --- a/contrib/penfield/templates/penfield/feeds/blog_entry/description.html +++ /dev/null @@ -1 +0,0 @@ -{{ entry.content }} \ No newline at end of file diff --git a/contrib/penfield/templates/penfield/feeds/blog_entry/title.html b/contrib/penfield/templates/penfield/feeds/blog_entry/title.html deleted file mode 100644 index f7167dd..0000000 --- a/contrib/penfield/templates/penfield/feeds/blog_entry/title.html +++ /dev/null @@ -1 +0,0 @@ -{{ entry.title }} \ No newline at end of file diff --git a/contrib/penfield/templates/penfield/feeds/newsletter_article/description.html b/contrib/penfield/templates/penfield/feeds/newsletter_article/description.html deleted file mode 100644 index 78e19ce..0000000 --- a/contrib/penfield/templates/penfield/feeds/newsletter_article/description.html +++ /dev/null @@ -1 +0,0 @@ -{{ article.full_text }} \ No newline at end of file diff --git a/contrib/penfield/templates/penfield/feeds/newsletter_article/title.html b/contrib/penfield/templates/penfield/feeds/newsletter_article/title.html deleted file mode 100644 index 1f96b1f..0000000 --- a/contrib/penfield/templates/penfield/feeds/newsletter_article/title.html +++ /dev/null @@ -1 +0,0 @@ -{{ article.title }} \ No newline at end of file diff --git a/contrib/penfield/utils.py b/contrib/penfield/utils.py deleted file mode 100644 index bfa08d0..0000000 --- a/contrib/penfield/utils.py +++ /dev/null @@ -1,98 +0,0 @@ -from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed -from django.conf.urls.defaults import url, patterns -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' - - feed_kwargs = { - 'link': request.node.construct_url(subpath=self.reverse(reverse_name, kwargs=kwargs), request=request, with_domain=True) - } - feed = self.get_feed(feed_type, extra_context, feed_kwargs) - - for obj in objects: - kwargs = { - 'link': request.node.construct_url(subpath=self.reverse(obj=obj), request=request, with_domain=True) - } - 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 -- 2.20.1