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 django.contrib.sites.models import Site
from django.contrib.syndication.views import Feed
from django.core.cache import cache
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 .settings import get_setting
class LatestEntriesFeed(Feed):
feed_type = Rss201rev2Feed
def __call__(self, request, *args, **kwargs):
self.request = request
self.namespace, self.config = get_app_instance(request)
return super(LatestEntriesFeed, self).__call__(request, *args, **kwargs)
@ -30,7 +38,7 @@ class LatestEntriesFeed(Feed):
return item.safe_translation_getter('title')
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('post_text')
@ -42,3 +50,57 @@ class TagFeed(LatestEntriesFeed):
def items(self, obj=None):
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 -*-
from __future__ import absolute_import, print_function, unicode_literals
import hashlib
from aldryn_apphooks_config.fields import AppHookConfigField
from aldryn_apphooks_config.managers.parler import AppHookConfigTranslatableManager
from cms.models import CMSPlugin, PlaceholderField
from django.conf import settings as dj_settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.urlresolvers import reverse
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.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.text import slugify
from django.utils.translation import get_language, ugettext_lazy as _
@ -196,6 +201,16 @@ class Post(KnockerModel, ModelMeta, TranslatableModel):
def __str__(self):
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):
"""
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
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):
app_config = AppHookConfigField(
@ -422,3 +440,17 @@ class GenericBlogPlugin(BasePostPlugin):
def __str__(self):
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')),
'BLOG_ARCHIVE_PLUGIN_NAME': getattr(
settings, 'BLOG_ARCHIVE_PLUGIN_NAME', _('Archive')),
'BLOG_FEED_CACHE_TIMEOUT': getattr(
settings, 'BLOG_FEED_CACHE_TIMEOUT', 3600),
}
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>
{% endfor %}
{% 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 .feeds import LatestEntriesFeed, TagFeed
from .feeds import FBInstantArticles, LatestEntriesFeed, TagFeed
from .settings import get_setting
from .views import (
AuthorEntriesView, CategoryEntriesView, PostArchiveView, PostDetailView, PostListView,
@ -27,6 +27,8 @@ urlpatterns = [
PostListView.as_view(), name='posts-latest'),
url(r'^feed/$',
LatestEntriesFeed(), name='posts-latest-feed'),
url(r'^feed/fb/$',
FBInstantArticles(), name='posts-latest-feed-fb'),
url(r'^(?P<year>\d{4})/$',
PostArchiveView.as_view(), name='posts-archive'),
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'
slug_field = 'slug'
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):
queryset = self.model._default_manager.all()
@ -88,6 +96,7 @@ class PostDetailView(TranslatableSlugMixin, BaseBlogView, DetailView):
def get_context_data(self, **kwargs):
context = super(PostDetailView, self).get_context_data(**kwargs)
context['meta'] = self.get_object().as_meta()
context['instant_article'] = self.instant_article
context['use_placeholder'] = get_setting('USE_PLACEHOLDER')
setattr(self.request, get_setting('CURRENT_POST_IDENTIFIER'), self.get_object())
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.models import BlogCategory, Post
try:
from filer.models import ThumbnailOption # NOQA
except ImportError:

View file

@ -77,50 +77,6 @@ class PluginTest(BaseTest):
self.assertEqual(casted_categories.tags.count(), 0)
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):
pages = self.get_pages()
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.app_config_1.app_data.config.template_prefix = ''
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.urlresolvers import reverse
from django.http import Http404
from django.utils.encoding import force_text
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from parler.tests.utils import override_parler_settings
from parler.utils.conf import add_default_language_settings
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.settings import get_setting
from djangocms_blog.sitemaps import BlogSitemap
@ -350,6 +351,29 @@ class ViewTest(BaseTest):
feed.config = self.app_config_1
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):
posts = self.get_posts()
self.get_pages()