Repository: getpelican/pelican

Issue:
Request: Specify template in page metadata
I posed this request on IRC a while ago and @kylef took an initial shot at it, but I thought it might be good to formalize this in the tracker. 

I think it would make Pelican a bit more flexible if you could specify the template that you want to render a page with in that page's metadata. So for example in `publications.rst` I could have something like:

``` rst
=============
Publications
=============

:template: publications
```

and `publications.rst` would be rendered with the jinja2 template `publications.html` that I have in my theme. This would also provide a easy way of tweaking a single post's layout. 

PS - If someone could tell me what things needed to be changed to accomplish this (or point me in generally the right direction), I'd be happy to attempt to contribute the necessary code.



Discussion:
Any thoughts (is this something useful that others might want, or am I the only one) on this or suggestions on a reasonable approach to tackling this feature? Or is there a way to approximate this feature given the current state of the code that I'm just missing (using DIRECT_TEMPLATES)?

A version of the patch that @kylef wrote is the following (it's been modified slightly to properly follow the API where originally a string was being passed rather than a template object):

``` bash
diff --git a/pelican/generators.py b/pelican/generators.py
index 1ddc13c..bb659e8 100644
--- a/pelican/generators.py
+++ b/pelican/generators.py
@@ -167,11 +167,10 @@ class ArticlesGenerator(Generator):

     def generate_articles(self, write):
         """Generate the articles."""
-        article_template = self.get_template('article')
+        template =  getattr(article, 'template', 'article'),
         for article in chain(self.translations, self.articles):
-            write(article.save_as,
-                          article_template, self.context, article=article,
-                          category=article.category)
+            write(article.save_as, self.get_template(template),
+                      self.context, article=article, category=article.category)

     def generate_direct_templates(self, write):
         """Generate direct templates pages"""
@@ -382,7 +381,11 @@ class PagesGenerator(Generator):

     def generate_output(self, writer):
         for page in chain(self.translations, self.pages):
-            writer.write_file(page.save_as, self.get_template('page'),
+            template = getattr(page,'template','page')
+            print self.env.list_templates()
+            print self.env.loader
+            writer.write_file(page.save_as,
+                    self.get_template(template),
                     self.context, page=page,
                     relative_urls=self.settings.get('RELATIVE_URLS'))
```

However it doesn't quite work, although I'm not sure if the problem is directly related to the patch or something else with Pelican. Nasically my templating setup is as follows. I have a template base.html which just has the basic scaffold for the site (header, css, js, etc). I then have general.html which extends base.html and has the basic structure of the site (main content block, navigation, footer, etc). I separate this out since my index.html is going to have a different layout and extends base.html directly. So for example, my publications page, templated with publications.html (from above) extends general.html.

However when I have this setup, I get the following error when trying to render publications.html

``` bash
CRITICAL: 'general' is undefined
Traceback (most recent call last):
 File "/Users/lev/.virtualenvs/pelican-dev/bin/pelican", line 8, in <module>
   load_entry_point('pelican==3.0', 'console_scripts', 'pelican')()
 File "/Users/lev/Documents/Projects/github/pelican/pelican/__init__.py", line 263, in main
   pelican.run()
 File "/Users/lev/Documents/Projects/github/pelican/pelican/__init__.py", line 152, in run
   p.generate_output(writer)
 File "/Users/lev/Documents/Projects/github/pelican/pelican/generators.py", line 391, in generate_output
   relative_urls=self.settings.get('RELATIVE_URLS'))
 File "/Users/lev/Documents/Projects/github/pelican/pelican/writers.py", line 171, in write_file
   _write_file(template, localcontext, self.output_path, name)
 File "/Users/lev/Documents/Projects/github/pelican/pelican/writers.py", line 117, in _write_file
   output = template.render(localcontext)
 File "/Library/Frameworks/Python.framework/Versions/7.3/lib/python2.7/site-packages/jinja2/environment.py", line 894, in render
   return self.environment.handle_exception(exc_info, True)
 File "theme/templates/publications.html", line 1, in top-level template code
   {% extends general.html %}
 File "/Library/Frameworks/Python.framework/Versions/7.3/lib/python2.7/site-packages/jinja2/environment.py", line 372, in getattr
   return getattr(obj, attribute)
jinja2.exceptions.UndefinedError: 'general' is undefined
```

As far as I can tell from the debugging info, general.html is loaded into my environment:

``` bash
DEBUG: template list: ['!simple/archives.html', '!simple/article.html', '!simple/author.html', '!simple/base.html',
 '!simple/categories.html', '!simple/category.html', '!simple/index.html', '!simple/page.html', '!simple/pagination.html', 
'!simple/tag.html', '!simple/tags.html', '.general.html.swp', 'analytics.html', 'archives.html', 'article.html', 'author.html', 
'base.html', 'categories.html', 'category.html', 'general.html', 'index.html', 'page.html', 'pagination.html', 'publications.html', 
'tag.html', 'tags.html']
```

Any ideas of what might be going on, or how I might be able to fix this? I feel like I must be missing something in how things are being loaded, although I looked through generators.py and the jinja2 docs and it seems like general.html should be available.

I'll take the case!


Resolving patch:
diff --git a/pelican/contents.py b/pelican/contents.py
--- a/pelican/contents.py
+++ b/pelican/contents.py
@@ -21,6 +21,7 @@ class Page(object):
     :param content: the string to parse, containing the original content.
     """
     mandatory_properties = ('title',)
+    default_template = 'page'
 
     def __init__(self, content, metadata=None, settings=None,
                  filename=None):
@@ -44,6 +45,9 @@ def __init__(self, content, metadata=None, settings=None,
         # also keep track of the metadata attributes available
         self.metadata = local_metadata
 
+        #default template if it's not defined in page
+        self.template = self._get_template()
+
         # default author to the one in settings if not defined
         if not hasattr(self, 'author'):
             if 'AUTHOR' in settings:
@@ -153,9 +157,16 @@ def _set_summary(self, summary):
     url = property(functools.partial(get_url_setting, key='url'))
     save_as = property(functools.partial(get_url_setting, key='save_as'))
 
+    def _get_template(self):
+        if hasattr(self, 'template') and self.template is not None:
+            return self.template
+        else:
+            return self.default_template
+
 
 class Article(Page):
     mandatory_properties = ('title', 'date', 'category')
+    default_template = 'article'
 
 
 class Quote(Page):
diff --git a/pelican/generators.py b/pelican/generators.py
--- a/pelican/generators.py
+++ b/pelican/generators.py
@@ -167,11 +167,9 @@ def generate_feeds(self, writer):
 
     def generate_articles(self, write):
         """Generate the articles."""
-        article_template = self.get_template('article')
         for article in chain(self.translations, self.articles):
-            write(article.save_as,
-                          article_template, self.context, article=article,
-                          category=article.category)
+            write(article.save_as, self.get_template(article.template),
+                self.context, article=article, category=article.category)
 
     def generate_direct_templates(self, write):
         """Generate direct templates pages"""
@@ -222,10 +220,10 @@ def generate_authors(self, write):
 
     def generate_drafts(self, write):
         """Generate drafts pages."""
-        article_template = self.get_template('article')
         for article in self.drafts:
-            write('drafts/%s.html' % article.slug, article_template,
-                  self.context, article=article, category=article.category)
+            write('drafts/%s.html' % article.slug,
+                self.get_template(article.template), self.context,
+                article=article, category=article.category)
 
     def generate_pages(self, writer):
         """Generate the pages on the disk"""
@@ -385,7 +383,6 @@ def generate_context(self):
                                (repr(unicode.encode(page.status, 'utf-8')),
                                 repr(f)))
 
-
         self.pages, self.translations = process_translations(all_pages)
         self.hidden_pages, self.hidden_translations = process_translations(hidden_pages)
 
@@ -395,7 +392,7 @@ def generate_context(self):
     def generate_output(self, writer):
         for page in chain(self.translations, self.pages,
                             self.hidden_translations, self.hidden_pages):
-            writer.write_file(page.save_as, self.get_template('page'),
+            writer.write_file(page.save_as, self.get_template(page.template),
                     self.context, page=page,
                     relative_urls=self.settings.get('RELATIVE_URLS'))
 


Test patch:
diff --git a/tests/TestPages/hidden_page_with_template.rst b/tests/TestPages/hidden_page_with_template.rst
new file mode 100644
--- /dev/null
+++ b/tests/TestPages/hidden_page_with_template.rst
@@ -0,0 +1,11 @@
+This is a test hidden page with a custom template
+#################################################
+
+:status: hidden
+:template: custom
+
+The quick brown fox jumped over the lazy dog's back.
+
+This page is hidden
+
+This page has a custom template to be called when rendered
diff --git a/tests/TestPages/page_with_template.rst b/tests/TestPages/page_with_template.rst
new file mode 100644
--- /dev/null
+++ b/tests/TestPages/page_with_template.rst
@@ -0,0 +1,8 @@
+This is a test page with a preset template
+##########################################
+
+:template: custom
+
+The quick brown fox jumped over the lazy dog's back.
+
+This article has a custom template to be called when rendered
diff --git a/tests/content/article_with_template.rst b/tests/content/article_with_template.rst
new file mode 100644
--- /dev/null
+++ b/tests/content/article_with_template.rst
@@ -0,0 +1,8 @@
+Article with template
+#####################
+
+:template: custom
+
+This article has a custom template to be called when rendered
+
+This is some content. With some stuff to "typogrify".
diff --git a/tests/test_contents.py b/tests/test_contents.py
--- a/tests/test_contents.py
+++ b/tests/test_contents.py
@@ -2,7 +2,7 @@
 
 from .support import unittest
 
-from pelican.contents import Page
+from pelican.contents import Page, Article
 from pelican.settings import _DEFAULT_CONFIG
 from pelican.utils import truncate_html_words
 
@@ -135,6 +135,17 @@ def test_datetime(self):
             # will simply skip this test.
             unittest.skip("There is no locale %s in this system." % locale)
 
+    def test_template(self):
+        """
+        Pages default to page, metadata overwrites
+        """
+        default_page = Page(**self.page_kwargs)
+        self.assertEqual('page', default_page.template)
+        page_kwargs = self._copy_page_kwargs()
+        page_kwargs['metadata']['template'] = 'custom'
+        custom_page = Page(**page_kwargs)
+        self.assertEqual('custom', custom_page.template)
+
     def _copy_page_kwargs(self):
         # make a deep copy of page_kwargs
         page_kwargs = dict([(key, self.page_kwargs[key]) for key in
@@ -146,3 +157,15 @@ def _copy_page_kwargs(self):
                                      for subkey in page_kwargs[key]])
 
         return page_kwargs
+
+class TestArticle(TestPage):
+    def test_template(self):
+        """
+        Articles default to article, metadata overwrites
+        """
+        default_article = Article(**self.page_kwargs)
+        self.assertEqual('article', default_article.template)
+        article_kwargs = self._copy_page_kwargs()
+        article_kwargs['metadata']['template'] = 'custom'
+        custom_article = Article(**article_kwargs)
+        self.assertEqual('custom', custom_article.template)
diff --git a/tests/test_generators.py b/tests/test_generators.py
--- a/tests/test_generators.py
+++ b/tests/test_generators.py
@@ -13,6 +13,37 @@
 
 class TestArticlesGenerator(unittest.TestCase):
 
+    def setUp(self):
+        super(TestArticlesGenerator, self).setUp()
+        self.generator = None
+
+    def get_populated_generator(self):
+        """
+        We only need to pull all the test articles once, but read from it
+         for each test.
+        """
+        if self.generator is None:
+            settings = _DEFAULT_CONFIG.copy()
+            settings['ARTICLE_DIR'] = 'content'
+            settings['DEFAULT_CATEGORY'] = 'Default'
+            self.generator = ArticlesGenerator(settings.copy(), settings,
+                                CUR_DIR, _DEFAULT_CONFIG['THEME'], None,
+                                _DEFAULT_CONFIG['MARKUP'])
+            self.generator.generate_context()
+        return self.generator
+
+    def distill_articles(self, articles):
+        distilled = []
+        for page in articles:
+           distilled.append([
+                    page.title,
+                    page.status,
+                    page.category.name,
+                    page.template
+                ]
+           )
+        return distilled
+
     def test_generate_feeds(self):
 
         generator = ArticlesGenerator(None, {'FEED': _DEFAULT_CONFIG['FEED']},
@@ -93,6 +124,16 @@ def test_direct_templates_save_as_false(self):
         generator.generate_direct_templates(write)
         write.assert_called_count == 0
 
+    def test_per_article_template(self):
+        """
+        Custom template articles get the field but standard/unset are None
+        """
+        generator = self.get_populated_generator()
+        articles = self.distill_articles(generator.articles)
+        custom_template = ['Article with template', 'published', 'Default', 'custom']
+        standard_template = ['This is a super article !', 'published', 'Yeah', 'article']
+        self.assertIn(custom_template, articles)
+        self.assertIn(standard_template, articles)
 
 class TestPageGenerator(unittest.TestCase):
     """
@@ -107,7 +148,8 @@ def distill_pages(self, pages):
         for page in pages:
            distilled.append([
                     page.title,
-                    page.status
+                    page.status,
+                    page.template
                 ]
            )
         return distilled
@@ -124,12 +166,14 @@ def test_generate_context(self):
         hidden_pages = self.distill_pages(generator.hidden_pages)
 
         pages_expected = [
-            [u'This is a test page', 'published'],
-            [u'This is a markdown test page', 'published']
+            [u'This is a test page', 'published', 'page'],
+            [u'This is a markdown test page', 'published', 'page'],
+            [u'This is a test page with a preset template', 'published', 'custom']
         ]
         hidden_pages_expected = [
-            [u'This is a test hidden page', 'hidden'],
-            [u'This is a markdown test hidden page', 'hidden']
+            [u'This is a test hidden page', 'hidden', 'page'],
+            [u'This is a markdown test hidden page', 'hidden', 'page'],
+            [u'This is a test hidden page with a custom template', 'hidden', 'custom']
         ]
 
         self.assertItemsEqual(pages_expected,pages)