Merge pull request #236 from nephila/feature/instant_articles

preliminary work for FB instant articles
This commit is contained in:
Iacopo Spalletti 2016-04-30 10:24:28 +02:00
commit 95efd8778c
10 changed files with 229 additions and 52 deletions

View file

@ -4,16 +4,24 @@ from __future__ import absolute_import, print_function, unicode_literals
from aldryn_apphooks_config.utils import get_app_instance from aldryn_apphooks_config.utils import get_app_instance
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.core.cache import cache
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.encoding import force_text
from django.utils.feedgenerator import Rss201rev2Feed, rfc2822_date
from django.utils.safestring import mark_safe
from django.utils.translation import get_language_from_request, ugettext as _
from djangocms_blog.settings import get_setting
from djangocms_blog.views import PostDetailView
from .models import Post from .models import Post
from .settings import get_setting
class LatestEntriesFeed(Feed): class LatestEntriesFeed(Feed):
feed_type = Rss201rev2Feed
def __call__(self, request, *args, **kwargs): def __call__(self, request, *args, **kwargs):
self.request = request
self.namespace, self.config = get_app_instance(request) self.namespace, self.config = get_app_instance(request)
return super(LatestEntriesFeed, self).__call__(request, *args, **kwargs) return super(LatestEntriesFeed, self).__call__(request, *args, **kwargs)
@ -30,7 +38,7 @@ class LatestEntriesFeed(Feed):
return item.safe_translation_getter('title') return item.safe_translation_getter('title')
def item_description(self, item): def item_description(self, item):
if get_setting('USE_ABSTRACT'): if item.app_config.use_abstract:
return item.safe_translation_getter('abstract') return item.safe_translation_getter('abstract')
return item.safe_translation_getter('post_text') return item.safe_translation_getter('post_text')
@ -42,3 +50,57 @@ class TagFeed(LatestEntriesFeed):
def items(self, obj=None): def items(self, obj=None):
return Post.objects.published().filter(tags__slug=obj)[:10] return Post.objects.published().filter(tags__slug=obj)[:10]
class FBInstantFeed(Rss201rev2Feed):
def rss_attributes(self):
return {
'version': self._version,
'xmlns:content': 'http://purl.org/rss/1.0/modules/content/'
}
def add_root_elements(self, handler):
handler.addQuickElement("title", self.feed['title'])
handler.addQuickElement("link", self.feed['link'])
handler.addQuickElement("description", self.feed['description'])
if self.feed['language'] is not None:
handler.addQuickElement("language", self.feed['language'])
for cat in self.feed['categories']:
handler.addQuickElement("category", cat)
if self.feed['feed_copyright'] is not None:
handler.addQuickElement("copyright", self.feed['feed_copyright'])
handler.addQuickElement("lastBuildDate", rfc2822_date(self.latest_post_date()))
if self.feed['ttl'] is not None:
handler.addQuickElement("ttl", self.feed['ttl'])
def add_item_elements(self, handler, item):
super(FBInstantFeed, self).add_item_elements(handler, item)
handler.startElement('content:encoded', {})
handler._write('<![CDATA[')
handler._write(force_text(item['content']))
handler._write(']]>')
handler.endElement('content:encoded')
handler.addQuickElement('guid', item['guid'])
class FBInstantArticles(LatestEntriesFeed):
feed_type = FBInstantFeed
def item_extra_kwargs(self, item):
if not item:
return {}
language = get_language_from_request(self.request, check_path=True)
key = item.get_cache_key(language, 'feed')
content = cache.get(key)
if not content:
view = PostDetailView.as_view(instant_article=True)
response = view(self.request, slug=item.safe_translation_getter('slug'))
response.render()
content = mark_safe(response.content)
cache.set(key, content, timeout=get_setting('FEED_CACHE_TIMEOUT'))
return {
'content': content,
'slug': item.safe_translation_getter('slug'),
'guid': item.guid,
}

View file

@ -1,15 +1,20 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, unicode_literals from __future__ import absolute_import, print_function, unicode_literals
import hashlib
from aldryn_apphooks_config.fields import AppHookConfigField from aldryn_apphooks_config.fields import AppHookConfigField
from aldryn_apphooks_config.managers.parler import AppHookConfigTranslatableManager from aldryn_apphooks_config.managers.parler import AppHookConfigTranslatableManager
from cms.models import CMSPlugin, PlaceholderField from cms.models import CMSPlugin, PlaceholderField
from django.conf import settings as dj_settings from django.conf import settings as dj_settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import force_text, python_2_unicode_compatible from django.utils.encoding import force_bytes, force_text, python_2_unicode_compatible
from django.utils.html import escape, strip_tags from django.utils.html import escape, strip_tags
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import get_language, ugettext_lazy as _ from django.utils.translation import get_language, ugettext_lazy as _
@ -196,6 +201,16 @@ class Post(KnockerModel, ModelMeta, TranslatableModel):
def __str__(self): def __str__(self):
return self.safe_translation_getter('title') return self.safe_translation_getter('title')
@property
def guid(self, language=None):
if not language:
language = self.get_current_language()
base_string = '{0}-{1}-{2}'.format(
language, self.app_config.namespace,
self.safe_translation_getter('slug', language_code=language, any_language=True)
)
return hashlib.sha256(force_bytes(base_string)).hexdigest()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
Handle some auto configuration during save Handle some auto configuration during save
@ -329,6 +344,9 @@ class Post(KnockerModel, ModelMeta, TranslatableModel):
updated = self.app_config.send_knock_update and self.is_published updated = self.app_config.send_knock_update and self.is_published
return new or updated return new or updated
def get_cache_key(self, language, prefix):
return 'djangocms-blog:{2}:{0}:{1}'.format(language, self.guid, prefix)
class BasePostPlugin(CMSPlugin): class BasePostPlugin(CMSPlugin):
app_config = AppHookConfigField( app_config = AppHookConfigField(
@ -422,3 +440,17 @@ class GenericBlogPlugin(BasePostPlugin):
def __str__(self): def __str__(self):
return force_text(_('generic blog plugin')) return force_text(_('generic blog plugin'))
@receiver(pre_delete, sender=Post)
def pre_delete_post(sender, instance, **kwargs):
for language in instance.get_available_languages():
key = instance.get_cache_key(language, 'feed')
cache.delete(key)
@receiver(post_save, sender=Post)
def post_save_post(sender, instance, **kwargs):
for language in instance.get_available_languages():
key = instance.get_cache_key(language, 'feed')
cache.delete(key)

View file

@ -121,6 +121,8 @@ def get_setting(name):
settings, 'BLOG_CATEGORY_PLUGIN_NAME', _('Categories')), settings, 'BLOG_CATEGORY_PLUGIN_NAME', _('Categories')),
'BLOG_ARCHIVE_PLUGIN_NAME': getattr( 'BLOG_ARCHIVE_PLUGIN_NAME': getattr(
settings, 'BLOG_ARCHIVE_PLUGIN_NAME', _('Archive')), settings, 'BLOG_ARCHIVE_PLUGIN_NAME', _('Archive')),
'BLOG_FEED_CACHE_TIMEOUT': getattr(
settings, 'BLOG_FEED_CACHE_TIMEOUT', 3600),
} }
return default['BLOG_%s' % name] return default['BLOG_%s' % name]

View file

@ -23,4 +23,4 @@
<li class="tag_{{ forloop.counter }}"><a href="{% url 'djangocms_blog:posts-tagged' tag=tag.slug %}" class="blog-tag blog-tag-{{ tag.count }}">{{ tag.name }}</a>{% if not forloop.last %}, {% endif %}</li> <li class="tag_{{ forloop.counter }}"><a href="{% url 'djangocms_blog:posts-tagged' tag=tag.slug %}" class="blog-tag blog-tag-{{ tag.count }}">{{ tag.name }}</a>{% if not forloop.last %}, {% endif %}</li>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</ul> </ul>

View file

@ -0,0 +1,44 @@
{% load thumbnail cms_tags %}
<!doctype html>
<html lang="en" prefix="op: http://media.facebook.com/op#">
<head>
<meta charset="utf-8">
{% block canonical_url %}<link rel="canonical" href="{{ meta.url }}"/>{% endblock canonical_url %}
<meta property="op:markup_version" content="v1.0">
</head>
<body>
<article>
<header>
<h1>{{ post.title }}</h1>
<time class="op-published" datetime="{{ post.date_published.isoformat }}">{{ post.date_published|date:"DATE_FORMAT" }}</time>
<time class="op-modified" dateTime="{{ post.date_modified.isoformat }}">{{ post.date_modified|date:"DATE_FORMAT" }}</time>
<address>
{% if og_author_url %}<a rel="facebook" href="{{ og_author_url }}">{% endif %}
{{ post.author }}
{% if og_author_url %}</a>{% endif %}
</address>
<figure>
<img src="{{ meta.image }}" alt="{{ post.main_image.default_alt_text }}" />
{% if post.main_image.default_caption %}
<figcaption>{{ post.main_image.default_caption }}</figcaption>{% endif %}
</figure>
<h3 class="op-kicker">
{{ post.abstract|striptags }}
</h3>
</header>
{% if post.app_config.use_placeholder %}
<div class="blog-content">{% render_placeholder post.content %}</div>
{% else %}
<div class="blog-content">{% render_model post "post_text" "post_text" %}</div>
{% endif %}
</article>
</body>
</html>

View file

@ -3,7 +3,7 @@ from __future__ import absolute_import, print_function, unicode_literals
from django.conf.urls import url from django.conf.urls import url
from .feeds import LatestEntriesFeed, TagFeed from .feeds import FBInstantArticles, LatestEntriesFeed, TagFeed
from .settings import get_setting from .settings import get_setting
from .views import ( from .views import (
AuthorEntriesView, CategoryEntriesView, PostArchiveView, PostDetailView, PostListView, AuthorEntriesView, CategoryEntriesView, PostArchiveView, PostDetailView, PostListView,
@ -27,6 +27,8 @@ urlpatterns = [
PostListView.as_view(), name='posts-latest'), PostListView.as_view(), name='posts-latest'),
url(r'^feed/$', url(r'^feed/$',
LatestEntriesFeed(), name='posts-latest-feed'), LatestEntriesFeed(), name='posts-latest-feed'),
url(r'^feed/fb/$',
FBInstantArticles(), name='posts-latest-feed-fb'),
url(r'^(?P<year>\d{4})/$', url(r'^(?P<year>\d{4})/$',
PostArchiveView.as_view(), name='posts-archive'), PostArchiveView.as_view(), name='posts-archive'),
url(r'^(?P<year>\d{4})/(?P<month>\d{1,2})/$', url(r'^(?P<year>\d{4})/(?P<month>\d{1,2})/$',

View file

@ -72,6 +72,14 @@ class PostDetailView(TranslatableSlugMixin, BaseBlogView, DetailView):
base_template_name = 'post_detail.html' base_template_name = 'post_detail.html'
slug_field = 'slug' slug_field = 'slug'
view_url_name = 'djangocms_blog:post-detail' view_url_name = 'djangocms_blog:post-detail'
instant_article = False
def get_template_names(self):
if self.instant_article:
template_path = (self.config and self.config.template_prefix) or 'djangocms_blog'
return os.path.join(template_path, 'post_instant_article.html')
else:
return super(PostDetailView, self).get_template_names()
def get_queryset(self): def get_queryset(self):
queryset = self.model._default_manager.all() queryset = self.model._default_manager.all()
@ -88,6 +96,7 @@ class PostDetailView(TranslatableSlugMixin, BaseBlogView, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(PostDetailView, self).get_context_data(**kwargs) context = super(PostDetailView, self).get_context_data(**kwargs)
context['meta'] = self.get_object().as_meta() context['meta'] = self.get_object().as_meta()
context['instant_article'] = self.instant_article
context['use_placeholder'] = get_setting('USE_PLACEHOLDER') context['use_placeholder'] = get_setting('USE_PLACEHOLDER')
setattr(self.request, get_setting('CURRENT_POST_IDENTIFIER'), self.get_object()) setattr(self.request, get_setting('CURRENT_POST_IDENTIFIER'), self.get_object())
return context return context

View file

@ -14,7 +14,6 @@ from parler.utils.context import smart_override
from djangocms_blog.cms_appconfig import BlogConfig from djangocms_blog.cms_appconfig import BlogConfig
from djangocms_blog.models import BlogCategory, Post from djangocms_blog.models import BlogCategory, Post
try: try:
from filer.models import ThumbnailOption # NOQA from filer.models import ThumbnailOption # NOQA
except ImportError: except ImportError:

View file

@ -77,50 +77,6 @@ class PluginTest(BaseTest):
self.assertEqual(casted_categories.tags.count(), 0) self.assertEqual(casted_categories.tags.count(), 0)
self.assertEqual(casted_categories.categories.count(), 1) self.assertEqual(casted_categories.categories.count(), 1)
def test_plugin_authors(self):
pages = self.get_pages()
posts = self.get_posts()
posts[0].publish = True
posts[0].save()
posts[1].publish = True
posts[1].save()
ph = pages[0].placeholders.get(slot='content')
plugin = add_plugin(ph, 'BlogAuthorPostsPlugin', language='en', app_config=self.app_config_1)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('No article found') > -1)
plugin.authors.add(self.user)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('/en/blog/author/admin/') > -1)
self.assertTrue(rendered.find('2 articles') > -1)
plugin.authors.add(self.user_staff)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('/en/blog/author/staff/') > -1)
self.assertTrue(rendered.find('0 articles') > -1)
plugin.authors.add(self.user_normal)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('/en/blog/author/normal/') > -1)
self.assertTrue(rendered.find('0 articles') > -1)
# Checking copy relations
ph = pages[0].placeholders.get(slot='content')
original = ph.get_plugins('en')
pages[0].publish('en')
published = pages[0].get_public_object()
ph = published.placeholders.get(slot='content')
new = ph.get_plugins('en')
self.assertNotEqual(original, new)
casted_authors, __ = new[0].get_plugin_instance()
self.assertEqual(casted_authors.authors.count(), 3)
def test_plugin_tags(self): def test_plugin_tags(self):
pages = self.get_pages() pages = self.get_pages()
posts = self.get_posts() posts = self.get_posts()
@ -196,3 +152,50 @@ class PluginTest(BaseTest):
self.assertEqual(plugin_class.get_render_template(context, plugin, ph), os.path.join('whatever', plugin_class.base_render_template)) self.assertEqual(plugin_class.get_render_template(context, plugin, ph), os.path.join('whatever', plugin_class.base_render_template))
self.app_config_1.app_data.config.template_prefix = '' self.app_config_1.app_data.config.template_prefix = ''
self.app_config_1.save() self.app_config_1.save()
class PluginTest2(BaseTest):
def test_plugin_authors(self):
pages = self.get_pages()
posts = self.get_posts()
posts[0].publish = True
posts[0].save()
posts[1].publish = True
posts[1].save()
ph = pages[0].placeholders.get(slot='content')
plugin = add_plugin(ph, 'BlogAuthorPostsPlugin', language='en', app_config=self.app_config_1)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('No article found') > -1)
plugin.authors.add(self.user)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('/en/blog/author/admin/') > -1)
self.assertTrue(rendered.find('2 articles') > -1)
plugin.authors.add(self.user_staff)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('/en/blog/author/staff/') > -1)
self.assertTrue(rendered.find('0 articles') > -1)
plugin.authors.add(self.user_normal)
context = self.get_plugin_context(pages[0], 'en', plugin, edit=True)
rendered = plugin.render_plugin(context, ph)
self.assertTrue(rendered.find('/en/blog/author/normal/') > -1)
self.assertTrue(rendered.find('0 articles') > -1)
# Checking copy relations
ph = pages[0].placeholders.get(slot='content')
original = ph.get_plugins('en')
pages[0].publish('en')
published = pages[0].get_public_object()
ph = published.placeholders.get(slot='content')
new = ph.get_plugins('en')
self.assertNotEqual(original, new)
casted_authors, __ = new[0].get_plugin_instance()
self.assertEqual(casted_authors.authors.count(), 3)

View file

@ -9,13 +9,14 @@ from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404 from django.http import Http404
from django.utils.encoding import force_text
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from parler.tests.utils import override_parler_settings from parler.tests.utils import override_parler_settings
from parler.utils.conf import add_default_language_settings from parler.utils.conf import add_default_language_settings
from parler.utils.context import smart_override, switch_language from parler.utils.context import smart_override, switch_language
from djangocms_blog.feeds import LatestEntriesFeed, TagFeed from djangocms_blog.feeds import FBInstantArticles, LatestEntriesFeed, TagFeed
from djangocms_blog.models import BLOG_CURRENT_NAMESPACE from djangocms_blog.models import BLOG_CURRENT_NAMESPACE
from djangocms_blog.settings import get_setting from djangocms_blog.settings import get_setting
from djangocms_blog.sitemaps import BlogSitemap from djangocms_blog.sitemaps import BlogSitemap
@ -350,6 +351,29 @@ class ViewTest(BaseTest):
feed.config = self.app_config_1 feed.config = self.app_config_1
self.assertEqual(list(feed.items('tag-2')), [posts[0]]) self.assertEqual(list(feed.items('tag-2')), [posts[0]])
def test_instant_articles(self):
posts = self.get_posts()
pages = self.get_pages()
with smart_override('en'):
with switch_language(posts[0], 'en'):
request = self.get_page_request(
pages[1], self.user, path=posts[0].get_absolute_url()
)
feed = FBInstantArticles()
feed.namespace, feed.config = get_app_instance(request)
self.assertEqual(list(feed.items()), [posts[0]])
xml = feed(request)
self.assertContains(xml, '<guid>{0}</guid>'.format(posts[0].guid))
self.assertContains(xml, 'content:encoded')
self.assertContains(xml, 'class="op-published" datetime="{0}"'.format(
posts[0].date_published.isoformat()
))
self.assertContains(xml, '<link rel="canonical" href="{0}"/>'.format(
posts[0].get_full_url()
))
def test_sitemap(self): def test_sitemap(self):
posts = self.get_posts() posts = self.get_posts()
self.get_pages() self.get_pages()