From a6f296226a2a2ab56489001429c08edd5eeb00ba Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 10 Apr 2016 19:47:36 +0200
Subject: [PATCH 01/14] Add experimental liveblogging support

---
 djangocms_blog/admin.py                       |   5 +
 djangocms_blog/liveblog/__init__.py           |   2 +
 djangocms_blog/liveblog/apps.py               |  10 +
 djangocms_blog/liveblog/cms_plugins.py        |  30 +++
 djangocms_blog/liveblog/consumers.py          |  50 ++++
 .../liveblog/migrations/0001_initial.py       |  32 +++
 .../migrations/0002_liveblog_title.py         |  20 ++
 .../liveblog/migrations/__init__.py           |   0
 djangocms_blog/liveblog/models.py             |  67 ++++++
 djangocms_blog/liveblog/routing.py            |  17 ++
 .../liveblog/static/liveblog/js/liveblog.js   |  27 +++
 .../liveblog/js/reconnecting-websocket.min.js |   1 +
 .../liveblog/includes/post_detail.html        |  11 +
 .../templates/liveblog/plugins/liveblog.html  |   4 +
 .../liveblog/plugins/unpublished.html         |   0
 .../migrations/0016_post_liveblog.py          |  21 ++
 djangocms_blog/models.py                      |   9 +
 djangocms_blog/settings.py                    |   2 +
 .../0018_auto__add_field_post_liveblog.py     | 222 ++++++++++++++++++
 djangocms_blog/views.py                       |   6 +
 20 files changed, 536 insertions(+)
 create mode 100644 djangocms_blog/liveblog/__init__.py
 create mode 100644 djangocms_blog/liveblog/apps.py
 create mode 100644 djangocms_blog/liveblog/cms_plugins.py
 create mode 100644 djangocms_blog/liveblog/consumers.py
 create mode 100644 djangocms_blog/liveblog/migrations/0001_initial.py
 create mode 100644 djangocms_blog/liveblog/migrations/0002_liveblog_title.py
 create mode 100644 djangocms_blog/liveblog/migrations/__init__.py
 create mode 100644 djangocms_blog/liveblog/models.py
 create mode 100644 djangocms_blog/liveblog/routing.py
 create mode 100644 djangocms_blog/liveblog/static/liveblog/js/liveblog.js
 create mode 100644 djangocms_blog/liveblog/static/liveblog/js/reconnecting-websocket.min.js
 create mode 100644 djangocms_blog/liveblog/templates/liveblog/includes/post_detail.html
 create mode 100644 djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
 create mode 100644 djangocms_blog/liveblog/templates/liveblog/plugins/unpublished.html
 create mode 100644 djangocms_blog/migrations/0016_post_liveblog.py
 create mode 100644 djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py

diff --git a/djangocms_blog/admin.py b/djangocms_blog/admin.py
index 1e834bc..51d32a1 100755
--- a/djangocms_blog/admin.py
+++ b/djangocms_blog/admin.py
@@ -89,6 +89,11 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin,
         urls.extend(super(PostAdmin, self).get_urls())
         return urls
 
+    def post_add_plugin(self, request, placeholder, plugin):
+        if plugin.plugin_type in get_setting('LIVEBLOG_PLUGINS'):
+            plugin = plugin.move(plugin.get_siblings().first(), 'first-sibling')
+        return super(PostAdmin, self).post_add_plugin(request, placeholder, plugin)
+
     def publish_post(self, request, pk):
         """
         Admin view to publish a single post
diff --git a/djangocms_blog/liveblog/__init__.py b/djangocms_blog/liveblog/__init__.py
new file mode 100644
index 0000000..ba25ec7
--- /dev/null
+++ b/djangocms_blog/liveblog/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
diff --git a/djangocms_blog/liveblog/apps.py b/djangocms_blog/liveblog/apps.py
new file mode 100644
index 0000000..7655e85
--- /dev/null
+++ b/djangocms_blog/liveblog/apps.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+from django.apps import AppConfig
+from django.utils.translation import ugettext_lazy as _
+
+
+class LiveBlogAppConfig(AppConfig):
+    name = 'djangocms_blog.liveblog'
+    verbose_name = _('Liveblog')
diff --git a/djangocms_blog/liveblog/cms_plugins.py b/djangocms_blog/liveblog/cms_plugins.py
new file mode 100644
index 0000000..5487a85
--- /dev/null
+++ b/djangocms_blog/liveblog/cms_plugins.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+from cms.plugin_pool import plugin_pool
+from django.utils.translation import ugettext_lazy as _
+from djangocms_text_ckeditor.cms_plugins import TextPlugin
+
+from djangocms_blog.settings import get_setting
+
+from .models import Liveblog
+
+
+class LiveblogPlugin(TextPlugin):
+    module = get_setting('PLUGIN_MODULE_NAME')
+    name = _('Liveblog item')
+    model = Liveblog
+
+    def _get_render_template(self, context, instance, placeholder):
+        if instance.publish:
+            return 'liveblog/plugins/liveblog.html'
+        else:
+            return 'liveblog/plugins/unpublished.html'
+
+    def render(self, context, instance, placeholder):
+        context = super(LiveblogPlugin, self).render(context, instance, placeholder)
+        instance.content = context['body']
+        context['instance'] = instance
+        return context
+
+plugin_pool.register_plugin(LiveblogPlugin)
diff --git a/djangocms_blog/liveblog/consumers.py b/djangocms_blog/liveblog/consumers.py
new file mode 100644
index 0000000..91041e9
--- /dev/null
+++ b/djangocms_blog/liveblog/consumers.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+import json
+
+from channels import Group
+
+from djangocms_blog.models import Post
+
+
+def liveblog_connect(message, apphook, lang, post):
+    """
+    Connect users to the group of the given post according to the given language
+
+    Return with an error message if a post cannot be found
+
+    :param message: channel connect message
+    :param apphook: apphook config namespace
+    :param lang: language
+    :param post: post slug
+    """
+    try:
+        post = Post.objects.namespace(apphook).language(lang).active_translations(slug=post).get()
+    except Post.DoesNotExist:
+        message.reply_channel.send({
+            'text': json.dumps({'error': 'no_post'}),
+        })
+        return
+    Group(post.liveblog_group).add(message.reply_channel)
+
+
+def liveblog_disconnect(message, apphook, lang, post):
+    """
+    Disconnect users to the group of the given post according to the given language
+
+    Return with an error message if a post cannot be found
+
+    :param message: channel connect message
+    :param apphook: apphook config namespace
+    :param lang: language
+    :param post: post slug
+    """
+    try:
+        post = Post.objects.namespace(apphook).language(lang).active_translations(slug=post).get()
+    except Post.DoesNotExist:
+        message.reply_channel.send({
+            'text': json.dumps({'error': 'no_post'}),
+        })
+        return
+    Group(post.liveblog_group).discard(message.reply_channel)
diff --git a/djangocms_blog/liveblog/migrations/0001_initial.py b/djangocms_blog/liveblog/migrations/0001_initial.py
new file mode 100644
index 0000000..6ceb53e
--- /dev/null
+++ b/djangocms_blog/liveblog/migrations/0001_initial.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import filer.fields.image
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cms', '0013_urlconfrevision'),
+        ('filer', '0003_thumbnailoption'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Liveblog',
+            fields=[
+                ('cmsplugin_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='cms.CMSPlugin')),
+                ('body', models.TextField(verbose_name='body')),
+                ('publish', models.BooleanField(default=False, verbose_name='publish liveblog entry')),
+                ('image', filer.fields.image.FilerImageField(related_name='djangocms_blog_liveblog_image', on_delete=django.db.models.deletion.SET_NULL, verbose_name='image', blank=True, to='filer.Image', null=True)),
+                ('thumbnail', models.ForeignKey(related_name='djangocms_blog_liveblog_thumbnail', on_delete=django.db.models.deletion.SET_NULL, verbose_name='thumbnail size', blank=True, to='filer.ThumbnailOption', null=True)),
+            ],
+            options={
+                'verbose_name': 'liveblog entry',
+                'verbose_name_plural': 'liveblog entries',
+            },
+            bases=('cms.cmsplugin',),
+        ),
+    ]
diff --git a/djangocms_blog/liveblog/migrations/0002_liveblog_title.py b/djangocms_blog/liveblog/migrations/0002_liveblog_title.py
new file mode 100644
index 0000000..b59c5ad
--- /dev/null
+++ b/djangocms_blog/liveblog/migrations/0002_liveblog_title.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('liveblog', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='liveblog',
+            name='title',
+            field=models.CharField(default='', max_length=255, verbose_name='title'),
+            preserve_default=False,
+        ),
+    ]
diff --git a/djangocms_blog/liveblog/migrations/__init__.py b/djangocms_blog/liveblog/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/djangocms_blog/liveblog/models.py b/djangocms_blog/liveblog/models.py
new file mode 100644
index 0000000..b0172f0
--- /dev/null
+++ b/djangocms_blog/liveblog/models.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+import json
+
+from channels import Group
+from cms.models import CMSPlugin
+from cms.utils.plugins import reorder_plugins
+from django.db import models
+from djangocms_text_ckeditor.models import AbstractText
+from django.utils.translation import ugettext_lazy as _
+from filer.fields.image import FilerImageField
+
+from djangocms_blog.models import thumbnail_model, Post
+
+DATE_FORMAT = "%a %d %b %Y %H:%M"
+
+
+class Liveblog(AbstractText):
+    title = models.CharField(_('title'), max_length=255)
+    image = FilerImageField(
+        verbose_name=_('image'), blank=True, null=True, on_delete=models.SET_NULL,
+        related_name='djangocms_blog_liveblog_image'
+    )
+    thumbnail = models.ForeignKey(
+        thumbnail_model, verbose_name=_('thumbnail size'), on_delete=models.SET_NULL,
+        blank=True, null=True, related_name='djangocms_blog_liveblog_thumbnail'
+    )
+    publish = models.BooleanField(_('publish liveblog entry'), default=False)
+    node_order_by = '-changed_date'
+
+    class Meta:
+        verbose_name = _('liveblog entry')
+        verbose_name_plural = _('liveblog entries')
+
+    def save(self, no_signals=False, *args, **kwargs):
+        if not self.pk:
+            self.position = 0
+        saved = super(Liveblog, self).save(*args, **kwargs)
+        if self.publish:
+            self.send()
+        order = CMSPlugin.objects.filter(placeholder=self.placeholder).order_by('placeholder', 'path').values_list('pk', flat=True)
+        reorder_plugins(self.placeholder, None, self.language, order)
+        return saved
+
+    @property
+    def liveblog_group(self):
+        post = Post.objects.language(self.language).filter(liveblog=self.placeholder).first()
+        return post.liveblog_group
+
+    def render(self):
+        print(self.position, self.path)
+        return self.render_plugin()
+
+    def send(self):
+        """
+        Render the content and send to the related group
+        """
+        notification = {
+            'id': self.pk,
+            'content': self.render(),
+            'creation_date': self.creation_date.strftime(DATE_FORMAT),
+            'changed_date': self.changed_date.strftime(DATE_FORMAT),
+        }
+        Group(self.liveblog_group).send({
+            'text': json.dumps(notification),
+        })
diff --git a/djangocms_blog/liveblog/routing.py b/djangocms_blog/liveblog/routing.py
new file mode 100644
index 0000000..7751c4c
--- /dev/null
+++ b/djangocms_blog/liveblog/routing.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+from channels import route
+
+from .consumers import liveblog_connect, liveblog_disconnect
+
+channel_routing = [
+    route(
+        'websocket.connect', liveblog_connect,
+        path=r'^/liveblog/(?P<apphook>[a-zA-Z0-9_-]+)/(?P<lang>[a-zA-Z_-]+)/(?P<post>[a-zA-Z0-9_-]+)/$'
+    ),
+    route(
+        'websocket.disconnect', liveblog_disconnect,
+        path=r'^/liveblog/(?P<apphook>[a-zA-Z0-9_-]+)/(?P<lang>[a-zA-Z_-]+)/(?P<post>[a-zA-Z0-9_-]+)/$'
+    ),
+]
diff --git a/djangocms_blog/liveblog/static/liveblog/js/liveblog.js b/djangocms_blog/liveblog/static/liveblog/js/liveblog.js
new file mode 100644
index 0000000..1a2105f
--- /dev/null
+++ b/djangocms_blog/liveblog/static/liveblog/js/liveblog.js
@@ -0,0 +1,27 @@
+$(function () {
+  // Correctly decide between ws:// and wss://
+  var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws";
+  var ws_path = ws_scheme + '://' + window.location.host + "/liveblog/liveblog/" + liveblog_apphook + "/" + liveblog_language + "/" + liveblog_post + "/";
+  console.log("Connecting to " + ws_path);
+  var socket = new ReconnectingWebSocket(ws_path);
+  // Handle incoming messages
+  socket.onmessage = function (message) {
+    // Decode the JSON
+    console.log("Got message " + message.data);
+    var data = JSON.parse(message.data);
+    // See if there's a div to replace it in, or if we should add a new one
+    var existing = $("div[data-post-id=" + data.id + "]");
+    if (existing.length) {
+      existing.replaceWith(data.content);
+    } else {
+      $("#liveblog-posts").prepend(data.content);
+    }
+  };
+  // Helpful debugging
+  socket.onopen = function () {
+    console.log("Connected to notification socket");
+  }
+  socket.onclose = function () {
+    console.log("Disconnected to notification socket");
+  }
+});
diff --git a/djangocms_blog/liveblog/static/liveblog/js/reconnecting-websocket.min.js b/djangocms_blog/liveblog/static/liveblog/js/reconnecting-websocket.min.js
new file mode 100644
index 0000000..3015099
--- /dev/null
+++ b/djangocms_blog/liveblog/static/liveblog/js/reconnecting-websocket.min.js
@@ -0,0 +1 @@
+!function(a,b){"function"==typeof define&&define.amd?define([],b):"undefined"!=typeof module&&module.exports?module.exports=b():a.ReconnectingWebSocket=b()}(this,function(){function a(b,c,d){function l(a,b){var c=document.createEvent("CustomEvent");return c.initCustomEvent(a,!1,!1,b),c}var e={debug:!1,automaticOpen:!0,reconnectInterval:1e3,maxReconnectInterval:3e4,reconnectDecay:1.5,timeoutInterval:2e3};d||(d={});for(var f in e)this[f]="undefined"!=typeof d[f]?d[f]:e[f];this.url=b,this.reconnectAttempts=0,this.readyState=WebSocket.CONNECTING,this.protocol=null;var h,g=this,i=!1,j=!1,k=document.createElement("div");k.addEventListener("open",function(a){g.onopen(a)}),k.addEventListener("close",function(a){g.onclose(a)}),k.addEventListener("connecting",function(a){g.onconnecting(a)}),k.addEventListener("message",function(a){g.onmessage(a)}),k.addEventListener("error",function(a){g.onerror(a)}),this.addEventListener=k.addEventListener.bind(k),this.removeEventListener=k.removeEventListener.bind(k),this.dispatchEvent=k.dispatchEvent.bind(k),this.open=function(b){h=new WebSocket(g.url,c||[]),b||k.dispatchEvent(l("connecting")),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","attempt-connect",g.url);var d=h,e=setTimeout(function(){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","connection-timeout",g.url),j=!0,d.close(),j=!1},g.timeoutInterval);h.onopen=function(){clearTimeout(e),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onopen",g.url),g.protocol=h.protocol,g.readyState=WebSocket.OPEN,g.reconnectAttempts=0;var d=l("open");d.isReconnect=b,b=!1,k.dispatchEvent(d)},h.onclose=function(c){if(clearTimeout(e),h=null,i)g.readyState=WebSocket.CLOSED,k.dispatchEvent(l("close"));else{g.readyState=WebSocket.CONNECTING;var d=l("connecting");d.code=c.code,d.reason=c.reason,d.wasClean=c.wasClean,k.dispatchEvent(d),b||j||((g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onclose",g.url),k.dispatchEvent(l("close")));var e=g.reconnectInterval*Math.pow(g.reconnectDecay,g.reconnectAttempts);setTimeout(function(){g.reconnectAttempts++,g.open(!0)},e>g.maxReconnectInterval?g.maxReconnectInterval:e)}},h.onmessage=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onmessage",g.url,b.data);var c=l("message");c.data=b.data,k.dispatchEvent(c)},h.onerror=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onerror",g.url,b),k.dispatchEvent(l("error"))}},1==this.automaticOpen&&this.open(!1),this.send=function(b){if(h)return(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","send",g.url,b),h.send(b);throw"INVALID_STATE_ERR : Pausing to reconnect websocket"},this.close=function(a,b){"undefined"==typeof a&&(a=1e3),i=!0,h&&h.close(a,b)},this.refresh=function(){h&&h.close()}}return a.prototype.onopen=function(){},a.prototype.onclose=function(){},a.prototype.onconnecting=function(){},a.prototype.onmessage=function(){},a.prototype.onerror=function(){},a.debugAll=!1,a.CONNECTING=WebSocket.CONNECTING,a.OPEN=WebSocket.OPEN,a.CLOSING=WebSocket.CLOSING,a.CLOSED=WebSocket.CLOSED,a});
diff --git a/djangocms_blog/liveblog/templates/liveblog/includes/post_detail.html b/djangocms_blog/liveblog/templates/liveblog/includes/post_detail.html
new file mode 100644
index 0000000..a318eb6
--- /dev/null
+++ b/djangocms_blog/liveblog/templates/liveblog/includes/post_detail.html
@@ -0,0 +1,11 @@
+{% load cms_tags sekizai_tags %}
+{% add_data "js-script" "liveblog/js/reconnecting-websocket.min.js" %}
+{% add_data "js-script" "liveblog/js/liveblog.js" %}
+<script>
+    var liveblog_apphook = '{{ post.app_config.namespace }}';
+    var liveblog_language = '{{ post.get_current_language }}';
+    var liveblog_post = '{{ post.slug }}';
+</script>
+<div class="blog-content--live" id="liveblog-posts">
+    {% render_placeholder post.liveblog %}
+</div>
diff --git a/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html b/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
new file mode 100644
index 0000000..526af0e
--- /dev/null
+++ b/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
@@ -0,0 +1,4 @@
+<div class="post" data-post-id="{{ instance.id }}">
+    <h2>{{ instance.title }}{{ instance.creation_date|date:"D d M Y H:i" }}</h2>
+    {{ instance.content|safe }}
+</div>
diff --git a/djangocms_blog/liveblog/templates/liveblog/plugins/unpublished.html b/djangocms_blog/liveblog/templates/liveblog/plugins/unpublished.html
new file mode 100644
index 0000000..e69de29
diff --git a/djangocms_blog/migrations/0016_post_liveblog.py b/djangocms_blog/migrations/0016_post_liveblog.py
new file mode 100644
index 0000000..52555d3
--- /dev/null
+++ b/djangocms_blog/migrations/0016_post_liveblog.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import cms.models.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cms', '0013_urlconfrevision'),
+        ('djangocms_blog', '0015_auto_20160408_1849'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='post',
+            name='liveblog',
+            field=cms.models.fields.PlaceholderField(related_name='live_blog', slotname='live_blog', editable=False, to='cms.Placeholder', null=True),
+        ),
+    ]
diff --git a/djangocms_blog/models.py b/djangocms_blog/models.py
index 3a62aee..afa8212 100644
--- a/djangocms_blog/models.py
+++ b/djangocms_blog/models.py
@@ -163,6 +163,7 @@ class Post(KnockerModel, ModelMeta, TranslatableModel):
         meta={'unique_together': (('language_code', 'slug'),)}
     )
     content = PlaceholderField('post_content', related_name='post_content')
+    liveblog = PlaceholderField('live_blog', related_name='live_blog')
 
     objects = GenericDateTaggedManager()
     tags = TaggableManager(blank=True, related_name='djangocms_blog_tags')
@@ -350,6 +351,14 @@ class Post(KnockerModel, ModelMeta, TranslatableModel):
     def get_cache_key(self, language, prefix):
         return 'djangocms-blog:{2}:{0}:{1}'.format(language, self.guid, prefix)
 
+    @property
+    def liveblog_group(self):
+        return 'liveblog/{apphook}/{lang}/{post}'.format(
+            lang=self.get_current_language(),
+            apphook=self.app_config.namespace,
+            post=self.safe_translation_getter('slug', any_language=True)
+        )
+
 
 class BasePostPlugin(CMSPlugin):
     app_config = AppHookConfigField(
diff --git a/djangocms_blog/settings.py b/djangocms_blog/settings.py
index de47dfb..a1b220c 100644
--- a/djangocms_blog/settings.py
+++ b/djangocms_blog/settings.py
@@ -129,6 +129,8 @@ def get_setting(name):
             settings, 'BLOG_FEED_LATEST_ITEMS', 10),
         'BLOG_FEED_TAGS_ITEMS': getattr(
             settings, 'BLOG_FEED_TAGS_ITEMS', 10),
+        'BLOG_LIVEBLOG_PLUGINS': getattr(
+            settings, 'BLOG_LIVEBLOG_PLUGINS', ('LiveblogPlugin',)),
 
     }
     return default['BLOG_%s' % name]
diff --git a/djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py b/djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py
new file mode 100644
index 0000000..4cebb73
--- /dev/null
+++ b/djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py
@@ -0,0 +1,222 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as 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 'Post.liveblog'
+        db.add_column('djangocms_blog_post', 'liveblog',
+                      self.gf('django.db.models.fields.related.ForeignKey')(to=orm['cms.Placeholder'], null=True, related_name='live_blog'),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Deleting field 'Post.liveblog'
+        db.delete_column('djangocms_blog_post', 'liveblog_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']", 'blank': 'True', 'symmetrical': 'False'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", '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', [], {'blank': 'True', 'max_length': '75'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'blank': 'True', 'related_name': "'user_set'", 'symmetrical': 'False'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True', 'related_name': "'user_set'", 'symmetrical': 'False'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'cms.cmsplugin': {
+            'Meta': {'object_name': 'CMSPlugin'},
+            'changed_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
+            'creation_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'depth': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'language': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+            'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['cms.CMSPlugin']", 'null': 'True'}),
+            'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+            'placeholder': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True'}),
+            'plugin_type': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+            'position': ('django.db.models.fields.PositiveSmallIntegerField', [], {'blank': 'True', 'null': 'True'})
+        },
+        'cms.placeholder': {
+            'Meta': {'object_name': 'Placeholder'},
+            'default_width': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'slot': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
+        },
+        'cmsplugin_filer_image.thumbnailoption': {
+            'Meta': {'ordering': "('width', 'height')", 'object_name': 'ThumbnailOption'},
+            'crop': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'height': ('django.db.models.fields.IntegerField', [], {}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'upscale': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'width': ('django.db.models.fields.IntegerField', [], {})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'db_table': "'django_content_type'", 'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType'},
+            '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'})
+        },
+        'djangocms_blog.authorentriesplugin': {
+            'Meta': {'object_name': 'AuthorEntriesPlugin'},
+            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
+            'authors': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False'}),
+            'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'primary_key': 'True', 'unique': 'True'}),
+            'latest_posts': ('django.db.models.fields.IntegerField', [], {'default': '5'})
+        },
+        'djangocms_blog.blogcategory': {
+            'Meta': {'object_name': 'BlogCategory'},
+            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'date_modified': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogCategory']", 'null': 'True'})
+        },
+        'djangocms_blog.blogcategorytranslation': {
+            'Meta': {'db_table': "'djangocms_blog_blogcategory_translation'", 'unique_together': "[('language_code', 'slug'), ('language_code', 'master')]", 'object_name': 'BlogCategoryTranslation'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'language_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+            'master': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['djangocms_blog.BlogCategory']", 'null': 'True', 'related_name': "'translations'"}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '50'})
+        },
+        'djangocms_blog.blogconfig': {
+            'Meta': {'object_name': 'BlogConfig'},
+            'app_data': ('app_data.fields.AppDataField', [], {'default': "'{}'"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'namespace': ('django.db.models.fields.CharField', [], {'unique': 'True', 'default': 'None', 'max_length': '100'}),
+            'type': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'djangocms_blog.blogconfigtranslation': {
+            'Meta': {'db_table': "'djangocms_blog_blogconfig_translation'", 'unique_together': "[('language_code', 'master')]", 'object_name': 'BlogConfigTranslation'},
+            'app_title': ('django.db.models.fields.CharField', [], {'max_length': '234'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'language_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+            'master': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True', 'related_name': "'translations'"}),
+            'object_name': ('django.db.models.fields.CharField', [], {'default': "'Article'", 'max_length': '234'})
+        },
+        'djangocms_blog.genericblogplugin': {
+            'Meta': {'object_name': 'GenericBlogPlugin'},
+            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
+            'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'primary_key': 'True', 'unique': 'True'})
+        },
+        'djangocms_blog.latestpostsplugin': {
+            'Meta': {'object_name': 'LatestPostsPlugin'},
+            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
+            'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['djangocms_blog.BlogCategory']", 'blank': 'True', 'symmetrical': 'False'}),
+            'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'primary_key': 'True', 'unique': 'True'}),
+            'latest_posts': ('django.db.models.fields.IntegerField', [], {'default': '5'})
+        },
+        'djangocms_blog.post': {
+            'Meta': {'ordering': "('-date_published', '-date_created')", 'object_name': 'Post'},
+            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
+            'author': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['auth.User']", 'null': 'True', 'related_name': "'djangocms_blog_post_author'"}),
+            'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['djangocms_blog.BlogCategory']", 'blank': 'True', 'related_name': "'blog_posts'", 'symmetrical': 'False'}),
+            'content': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True', 'related_name': "'post_content'"}),
+            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'date_modified': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
+            'date_published': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'null': 'True'}),
+            'date_published_end': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'null': 'True'}),
+            'enable_comments': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'liveblog': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True', 'related_name': "'live_blog'"}),
+            'main_image': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['filer.Image']", 'on_delete': 'models.SET_NULL', 'null': 'True', 'related_name': "'djangocms_blog_post_image'"}),
+            'main_image_full': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['cmsplugin_filer_image.ThumbnailOption']", 'on_delete': 'models.SET_NULL', 'null': 'True', 'related_name': "'djangocms_blog_post_full'"}),
+            'main_image_thumbnail': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['cmsplugin_filer_image.ThumbnailOption']", 'on_delete': 'models.SET_NULL', 'null': 'True', 'related_name': "'djangocms_blog_post_thumbnail'"}),
+            'publish': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'sites': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['sites.Site']", 'blank': 'True', 'symmetrical': 'False'})
+        },
+        'djangocms_blog.posttranslation': {
+            'Meta': {'db_table': "'djangocms_blog_post_translation'", 'unique_together': "[('language_code', 'slug'), ('language_code', 'master')]", 'object_name': 'PostTranslation'},
+            'abstract': ('djangocms_text_ckeditor.fields.HTMLField', [], {'blank': 'True', 'default': "''"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'language_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+            'master': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['djangocms_blog.Post']", 'null': 'True', 'related_name': "'translations'"}),
+            'meta_description': ('django.db.models.fields.TextField', [], {'blank': 'True', 'default': "''"}),
+            'meta_keywords': ('django.db.models.fields.TextField', [], {'blank': 'True', 'default': "''"}),
+            'meta_title': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '255'}),
+            'post_text': ('djangocms_text_ckeditor.fields.HTMLField', [], {'blank': 'True', 'default': "''"}),
+            'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '50'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        },
+        'filer.file': {
+            'Meta': {'object_name': 'File'},
+            '_file_size': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
+            'description': ('django.db.models.fields.TextField', [], {'blank': 'True', 'null': 'True'}),
+            'file': ('django.db.models.fields.files.FileField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
+            'folder': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['filer.Folder']", 'null': 'True', 'related_name': "'all_files'"}),
+            'has_all_mandatory_data': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'modified_at': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '255'}),
+            'original_filename': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['auth.User']", 'null': 'True', 'related_name': "'owned_files'"}),
+            'polymorphic_ctype': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'related_name': "'polymorphic_filer.file_set+'"}),
+            'sha1': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '40'}),
+            'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'})
+        },
+        'filer.folder': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('parent', 'name'),)", 'object_name': 'Folder'},
+            'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': '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'}),
+            'modified_at': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['auth.User']", 'null': 'True', 'related_name': "'filer_owned_folders'"}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['filer.Folder']", 'null': 'True', 'related_name': "'children'"}),
+            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
+            'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'})
+        },
+        'filer.image': {
+            'Meta': {'object_name': 'Image'},
+            '_height': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
+            '_width': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
+            'author': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
+            'date_taken': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'null': 'True'}),
+            'default_alt_text': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
+            'default_caption': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
+            'file_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['filer.File']", 'primary_key': 'True', 'unique': 'True'}),
+            'must_always_publish_author_credit': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'must_always_publish_copyright': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'subject_location': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '64'})
+        },
+        'sites.site': {
+            'Meta': {'db_table': "'django_site'", 'ordering': "('domain',)", 'object_name': 'Site'},
+            'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        }
+    }
+
+    complete_apps = ['djangocms_blog']
\ No newline at end of file
diff --git a/djangocms_blog/views.py b/djangocms_blog/views.py
index 6e09503..2e99e14 100644
--- a/djangocms_blog/views.py
+++ b/djangocms_blog/views.py
@@ -51,6 +51,12 @@ class BaseBlogView(AppConfigMixin, ViewUrlMixin):
         template_path = (self.config and self.config.template_prefix) or 'djangocms_blog'
         return os.path.join(template_path, self.base_template_name)
 
+    def liveblog_enabled(self):
+        try:
+            from django.apps import apps
+            return apps.is_installed('djangocms_blog.liveblog')
+        except ImportError:
+            return False
 
 class BaseBlogListView(BaseBlogView):
     context_object_name = 'post_list'

From c0002ddd5fbbdf21d9ee7074c95dba4e18e19caf Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 17 Apr 2016 12:02:04 +0200
Subject: [PATCH 02/14] cleanup PoC

---
 djangocms_blog/liveblog/cms_plugins.py                         | 1 +
 djangocms_blog/liveblog/models.py                              | 1 -
 .../liveblog/templates/liveblog/plugins/liveblog.html          | 3 ++-
 3 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/djangocms_blog/liveblog/cms_plugins.py b/djangocms_blog/liveblog/cms_plugins.py
index 5487a85..6c1895d 100644
--- a/djangocms_blog/liveblog/cms_plugins.py
+++ b/djangocms_blog/liveblog/cms_plugins.py
@@ -14,6 +14,7 @@ class LiveblogPlugin(TextPlugin):
     module = get_setting('PLUGIN_MODULE_NAME')
     name = _('Liveblog item')
     model = Liveblog
+    fields = ('title', 'body', 'publish')
 
     def _get_render_template(self, context, instance, placeholder):
         if instance.publish:
diff --git a/djangocms_blog/liveblog/models.py b/djangocms_blog/liveblog/models.py
index b0172f0..7a3df89 100644
--- a/djangocms_blog/liveblog/models.py
+++ b/djangocms_blog/liveblog/models.py
@@ -49,7 +49,6 @@ class Liveblog(AbstractText):
         return post.liveblog_group
 
     def render(self):
-        print(self.position, self.path)
         return self.render_plugin()
 
     def send(self):
diff --git a/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html b/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
index 526af0e..402a2a5 100644
--- a/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
+++ b/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
@@ -1,4 +1,5 @@
 <div class="post" data-post-id="{{ instance.id }}">
-    <h2>{{ instance.title }}{{ instance.creation_date|date:"D d M Y H:i" }}</h2>
+    <h3>{{ instance.title }}</h3>
+    <h4>{{ instance.creation_date|date:"D d M Y H:i" }}</h4>
     {{ instance.content|safe }}
 </div>

From eb5d98c9e34f8aa93a4ad9c717f4c505505f8cca Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 5 Jun 2016 18:22:48 +0200
Subject: [PATCH 03/14] Fix migrations

---
 .../{0016_post_liveblog.py => 0018_post_liveblog.py}          | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)
 rename djangocms_blog/migrations/{0016_post_liveblog.py => 0018_post_liveblog.py} (83%)

diff --git a/djangocms_blog/migrations/0016_post_liveblog.py b/djangocms_blog/migrations/0018_post_liveblog.py
similarity index 83%
rename from djangocms_blog/migrations/0016_post_liveblog.py
rename to djangocms_blog/migrations/0018_post_liveblog.py
index 52555d3..db8d1aa 100644
--- a/djangocms_blog/migrations/0016_post_liveblog.py
+++ b/djangocms_blog/migrations/0018_post_liveblog.py
@@ -8,8 +8,8 @@ import cms.models.fields
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('cms', '0013_urlconfrevision'),
-        ('djangocms_blog', '0015_auto_20160408_1849'),
+        ('cms', '__first__'),
+        ('djangocms_blog', '0017_thumbnail_move'),
     ]
 
     operations = [

From d9dd192ca829690d59e91f213da202235cc0a0a3 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 5 Jun 2016 22:49:17 +0200
Subject: [PATCH 04/14] Update channels configuration

---
 cms_helper.py               |  4 +++-
 tests/test_utils/routing.py | 12 ++++++++++++
 2 files changed, 15 insertions(+), 1 deletion(-)
 create mode 100644 tests/test_utils/routing.py

diff --git a/cms_helper.py b/cms_helper.py
index 7a2b661..1643ff7 100755
--- a/cms_helper.py
+++ b/cms_helper.py
@@ -110,10 +110,12 @@ except ImportError:
 try:
     import knocker  # pragma: no cover # NOQA
     HELPER_SETTINGS['INSTALLED_APPS'].append('knocker')
+    HELPER_SETTINGS['INSTALLED_APPS'].append('channels')
+    HELPER_SETTINGS['INSTALLED_APPS'].append('djangocms_blog.liveblog',)
     HELPER_SETTINGS['CHANNEL_LAYERS'] = {
         'default': {
             'BACKEND': 'asgiref.inmemory.ChannelLayer',
-            'ROUTING': 'knocker.routing.channel_routing',
+            'ROUTING': 'tests.test_utils.routing.channel_routing',
         },
     }
 except ImportError:
diff --git a/tests/test_utils/routing.py b/tests/test_utils/routing.py
new file mode 100644
index 0000000..94188ea
--- /dev/null
+++ b/tests/test_utils/routing.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+from channels import include
+
+from djangocms_blog.liveblog.routing import channel_routing as djangocms_blog_routing
+from knocker.routing import channel_routing as knocker_routing
+
+channel_routing = [
+    include(djangocms_blog_routing, path=r'^/liveblog'),
+    include(knocker_routing, path=r'^/knocker'),
+]

From 3c4eebdd02fe24e79fcc0d1eb4c2a38efd754af7 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 5 Jun 2016 22:49:54 +0200
Subject: [PATCH 05/14] Remove jQuery dependency

---
 .../liveblog/static/liveblog/js/liveblog.js   | 25 ++++++++-----------
 1 file changed, 11 insertions(+), 14 deletions(-)

diff --git a/djangocms_blog/liveblog/static/liveblog/js/liveblog.js b/djangocms_blog/liveblog/static/liveblog/js/liveblog.js
index 1a2105f..3f7ff3a 100644
--- a/djangocms_blog/liveblog/static/liveblog/js/liveblog.js
+++ b/djangocms_blog/liveblog/static/liveblog/js/liveblog.js
@@ -1,27 +1,24 @@
-$(function () {
+document.addEventListener("DOMContentLoaded", function() {
   // Correctly decide between ws:// and wss://
   var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws";
   var ws_path = ws_scheme + '://' + window.location.host + "/liveblog/liveblog/" + liveblog_apphook + "/" + liveblog_language + "/" + liveblog_post + "/";
-  console.log("Connecting to " + ws_path);
   var socket = new ReconnectingWebSocket(ws_path);
   // Handle incoming messages
   socket.onmessage = function (message) {
     // Decode the JSON
-    console.log("Got message " + message.data);
     var data = JSON.parse(message.data);
     // See if there's a div to replace it in, or if we should add a new one
-    var existing = $("div[data-post-id=" + data.id + "]");
+    var existing = document.querySelectorAll("div[data-post-id*='" + data.id + "']");
     if (existing.length) {
-      existing.replaceWith(data.content);
+      existing.parentNode.replaceChild(data.content, existing);
     } else {
-      $("#liveblog-posts").prepend(data.content);
+      var item = document.createElement('div');
+      item.innerHTML = data.content;
+      document.getElementById("liveblog-posts").insertBefore(
+        item.children[0], document.getElementById("liveblog-posts").children[0]
+      );
     }
   };
-  // Helpful debugging
-  socket.onopen = function () {
-    console.log("Connected to notification socket");
-  }
-  socket.onclose = function () {
-    console.log("Disconnected to notification socket");
-  }
-});
+
+}, false);
+

From eab9083d900bdf0f60bbda3fbcf4233b44f510b0 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 5 Jun 2016 22:50:26 +0200
Subject: [PATCH 06/14] Update code

---
 djangocms_blog/liveblog/models.py                  | 14 +++++++-------
 .../templates/djangocms_blog/post_detail.html      |  3 +++
 djangocms_blog/views.py                            |  1 +
 3 files changed, 11 insertions(+), 7 deletions(-)

diff --git a/djangocms_blog/liveblog/models.py b/djangocms_blog/liveblog/models.py
index 7a3df89..48fe277 100644
--- a/djangocms_blog/liveblog/models.py
+++ b/djangocms_blog/liveblog/models.py
@@ -34,19 +34,18 @@ class Liveblog(AbstractText):
         verbose_name_plural = _('liveblog entries')
 
     def save(self, no_signals=False, *args, **kwargs):
-        if not self.pk:
-            self.position = 0
         saved = super(Liveblog, self).save(*args, **kwargs)
         if self.publish:
             self.send()
-        order = CMSPlugin.objects.filter(placeholder=self.placeholder).order_by('placeholder', 'path').values_list('pk', flat=True)
+        order = CMSPlugin.objects.filter(placeholder=self.placeholder).order_by('placeholder', '-path').values_list('pk', flat=True)
         reorder_plugins(self.placeholder, None, self.language, order)
         return saved
 
     @property
     def liveblog_group(self):
         post = Post.objects.language(self.language).filter(liveblog=self.placeholder).first()
-        return post.liveblog_group
+        if post:
+            return post.liveblog_group
 
     def render(self):
         return self.render_plugin()
@@ -61,6 +60,7 @@ class Liveblog(AbstractText):
             'creation_date': self.creation_date.strftime(DATE_FORMAT),
             'changed_date': self.changed_date.strftime(DATE_FORMAT),
         }
-        Group(self.liveblog_group).send({
-            'text': json.dumps(notification),
-        })
+        if self.liveblog_group:
+            Group(self.liveblog_group).send({
+                'text': json.dumps(notification),
+            })
diff --git a/djangocms_blog/templates/djangocms_blog/post_detail.html b/djangocms_blog/templates/djangocms_blog/post_detail.html
index 6c5fc8b..d9ab105 100644
--- a/djangocms_blog/templates/djangocms_blog/post_detail.html
+++ b/djangocms_blog/templates/djangocms_blog/post_detail.html
@@ -21,6 +21,9 @@
     </div>
     {% endif %}
     {% endspaceless %}
+    {% if view.liveblog_enabled %}
+        {% include "liveblog/includes/post_detail.html" %}
+    {% endif %}
     {% if post.app_config.use_placeholder %}
         <div class="blog-content">{% render_placeholder post.content %}</div>
     {% else %}
diff --git a/djangocms_blog/views.py b/djangocms_blog/views.py
index 2e99e14..ee6845f 100644
--- a/djangocms_blog/views.py
+++ b/djangocms_blog/views.py
@@ -58,6 +58,7 @@ class BaseBlogView(AppConfigMixin, ViewUrlMixin):
         except ImportError:
             return False
 
+
 class BaseBlogListView(BaseBlogView):
     context_object_name = 'post_list'
     base_template_name = 'post_list.html'

From 13a26650ad604b267fdaf7fc1df25c561d1d2d38 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 5 Jun 2016 23:11:58 +0200
Subject: [PATCH 07/14] Add flag to enable liveblog

---
 djangocms_blog/admin.py                       |  8 ++++-
 .../migrations/0019_auto_20160605_2305.py     | 31 +++++++++++++++++++
 djangocms_blog/models.py                      |  1 +
 djangocms_blog/views.py                       | 11 +++----
 4 files changed, 43 insertions(+), 8 deletions(-)
 create mode 100644 djangocms_blog/migrations/0019_auto_20160605_2305.py

diff --git a/djangocms_blog/admin.py b/djangocms_blog/admin.py
index 51d32a1..e74e6f1 100755
--- a/djangocms_blog/admin.py
+++ b/djangocms_blog/admin.py
@@ -6,6 +6,7 @@ from copy import deepcopy
 from aldryn_apphooks_config.admin import BaseAppHookConfig, ModelAppHookConfig
 from cms.admin.placeholderadmin import FrontendEditableAdminMixin, PlaceholderAdminMixin
 from django import forms
+from django.apps import apps
 from django.conf import settings
 from django.conf.urls import url
 from django.contrib import admin
@@ -60,7 +61,12 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin,
         }),
         ('Info', {
             'fields': (['slug', 'tags'],
-                       ('date_published', 'date_published_end', 'enable_comments')),
+                       ('date_published', 'date_published_end',),
+                       (
+                           'enable_comments',
+                           'enable_liveblog' if apps.is_installed('djangocms_blog.liveblog')
+                               else None
+                       )),
             'classes': ('collapse',)
         }),
         ('Images', {
diff --git a/djangocms_blog/migrations/0019_auto_20160605_2305.py b/djangocms_blog/migrations/0019_auto_20160605_2305.py
new file mode 100644
index 0000000..8d43417
--- /dev/null
+++ b/djangocms_blog/migrations/0019_auto_20160605_2305.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-06-05 21:05
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('djangocms_blog', '0018_post_liveblog'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='post',
+            name='enable_liveblog',
+            field=models.BooleanField(default=False, verbose_name='enable liveblog on post'),
+        ),
+        migrations.AlterField(
+            model_name='post',
+            name='main_image_full',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='djangocms_blog_post_full', to='cmsplugin_filer_image.ThumbnailOption', verbose_name='main image full'),
+        ),
+        migrations.AlterField(
+            model_name='post',
+            name='main_image_thumbnail',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='djangocms_blog_post_thumbnail', to='cmsplugin_filer_image.ThumbnailOption', verbose_name='main image thumbnail'),
+        ),
+    ]
diff --git a/djangocms_blog/models.py b/djangocms_blog/models.py
index afa8212..e8ef178 100644
--- a/djangocms_blog/models.py
+++ b/djangocms_blog/models.py
@@ -164,6 +164,7 @@ class Post(KnockerModel, ModelMeta, TranslatableModel):
     )
     content = PlaceholderField('post_content', related_name='post_content')
     liveblog = PlaceholderField('live_blog', related_name='live_blog')
+    enable_liveblog = models.BooleanField(verbose_name=_('enable liveblog on post'), default=False)
 
     objects = GenericDateTaggedManager()
     tags = TaggableManager(blank=True, related_name='djangocms_blog_tags')
diff --git a/djangocms_blog/views.py b/djangocms_blog/views.py
index ee6845f..10ee0b0 100644
--- a/djangocms_blog/views.py
+++ b/djangocms_blog/views.py
@@ -4,6 +4,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 import os.path
 
 from aldryn_apphooks_config.mixins import AppConfigMixin
+from django.apps import apps
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ImproperlyConfigured
 from django.core.urlresolvers import reverse
@@ -51,13 +52,6 @@ class BaseBlogView(AppConfigMixin, ViewUrlMixin):
         template_path = (self.config and self.config.template_prefix) or 'djangocms_blog'
         return os.path.join(template_path, self.base_template_name)
 
-    def liveblog_enabled(self):
-        try:
-            from django.apps import apps
-            return apps.is_installed('djangocms_blog.liveblog')
-        except ImportError:
-            return False
-
 
 class BaseBlogListView(BaseBlogView):
     context_object_name = 'post_list'
@@ -79,6 +73,9 @@ class PostDetailView(TranslatableSlugMixin, BaseBlogView, DetailView):
     view_url_name = 'djangocms_blog:post-detail'
     instant_article = False
 
+    def liveblog_enabled(self):
+        return self.object.enable_liveblog and apps.is_installed('djangocms_blog.liveblog')
+
     def get_template_names(self):
         if self.instant_article:
             template_path = (self.config and self.config.template_prefix) or 'djangocms_blog'

From 0f4056f67856f702de5f010f8752fc5ceb9bcce2 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Wed, 22 Jun 2016 23:43:16 +0200
Subject: [PATCH 08/14] Fix migrations

---
 djangocms_blog/migrations/0019_auto_20160605_2305.py | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/djangocms_blog/migrations/0019_auto_20160605_2305.py b/djangocms_blog/migrations/0019_auto_20160605_2305.py
index 8d43417..f2b0f8d 100644
--- a/djangocms_blog/migrations/0019_auto_20160605_2305.py
+++ b/djangocms_blog/migrations/0019_auto_20160605_2305.py
@@ -18,14 +18,4 @@ class Migration(migrations.Migration):
             name='enable_liveblog',
             field=models.BooleanField(default=False, verbose_name='enable liveblog on post'),
         ),
-        migrations.AlterField(
-            model_name='post',
-            name='main_image_full',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='djangocms_blog_post_full', to='cmsplugin_filer_image.ThumbnailOption', verbose_name='main image full'),
-        ),
-        migrations.AlterField(
-            model_name='post',
-            name='main_image_thumbnail',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='djangocms_blog_post_thumbnail', to='cmsplugin_filer_image.ThumbnailOption', verbose_name='main image thumbnail'),
-        ),
     ]

From a6feb4629ddc87171b41c36b4857e0c44e352ad9 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Wed, 22 Jun 2016 23:55:34 +0200
Subject: [PATCH 09/14] Fix lieblog channels group

---
 djangocms_blog/models.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/djangocms_blog/models.py b/djangocms_blog/models.py
index e8ef178..ba72459 100644
--- a/djangocms_blog/models.py
+++ b/djangocms_blog/models.py
@@ -354,7 +354,7 @@ class Post(KnockerModel, ModelMeta, TranslatableModel):
 
     @property
     def liveblog_group(self):
-        return 'liveblog/{apphook}/{lang}/{post}'.format(
+        return 'liveblog-{apphook}-{lang}-{post}'.format(
             lang=self.get_current_language(),
             apphook=self.app_config.namespace,
             post=self.safe_translation_getter('slug', any_language=True)

From 9bf5fa88952f4dd9e751401c1701091539708e37 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Wed, 22 Jun 2016 23:55:51 +0200
Subject: [PATCH 10/14] Fix plugins order

---
 djangocms_blog/liveblog/models.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/djangocms_blog/liveblog/models.py b/djangocms_blog/liveblog/models.py
index 48fe277..161db29 100644
--- a/djangocms_blog/liveblog/models.py
+++ b/djangocms_blog/liveblog/models.py
@@ -37,7 +37,9 @@ class Liveblog(AbstractText):
         saved = super(Liveblog, self).save(*args, **kwargs)
         if self.publish:
             self.send()
-        order = CMSPlugin.objects.filter(placeholder=self.placeholder).order_by('placeholder', '-path').values_list('pk', flat=True)
+        order = CMSPlugin.objects.filter(
+            placeholder=self.placeholder
+        ).order_by('placeholder', 'path').values_list('pk', flat=True)
         reorder_plugins(self.placeholder, None, self.language, order)
         return saved
 

From 413c9bb3aaf960c1cd5ebefcc83e1b678a869d6f Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Wed, 22 Jun 2016 23:57:29 +0200
Subject: [PATCH 11/14] Fix code style

---
 djangocms_blog/admin.py                       |   2 +-
 djangocms_blog/liveblog/models.py             |   4 +-
 djangocms_blog/liveblog/routing.py            |   6 +-
 .../0018_auto__add_field_post_liveblog.py     | 222 ------------------
 tests/test_utils/routing.py                   |   2 +-
 5 files changed, 8 insertions(+), 228 deletions(-)
 delete mode 100644 djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py

diff --git a/djangocms_blog/admin.py b/djangocms_blog/admin.py
index e74e6f1..3a21949 100755
--- a/djangocms_blog/admin.py
+++ b/djangocms_blog/admin.py
@@ -65,7 +65,7 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin,
                        (
                            'enable_comments',
                            'enable_liveblog' if apps.is_installed('djangocms_blog.liveblog')
-                               else None
+                           else None
                        )),
             'classes': ('collapse',)
         }),
diff --git a/djangocms_blog/liveblog/models.py b/djangocms_blog/liveblog/models.py
index 161db29..f6a4396 100644
--- a/djangocms_blog/liveblog/models.py
+++ b/djangocms_blog/liveblog/models.py
@@ -7,11 +7,11 @@ from channels import Group
 from cms.models import CMSPlugin
 from cms.utils.plugins import reorder_plugins
 from django.db import models
-from djangocms_text_ckeditor.models import AbstractText
 from django.utils.translation import ugettext_lazy as _
+from djangocms_text_ckeditor.models import AbstractText
 from filer.fields.image import FilerImageField
 
-from djangocms_blog.models import thumbnail_model, Post
+from djangocms_blog.models import Post, thumbnail_model
 
 DATE_FORMAT = "%a %d %b %Y %H:%M"
 
diff --git a/djangocms_blog/liveblog/routing.py b/djangocms_blog/liveblog/routing.py
index 7751c4c..fb1ff06 100644
--- a/djangocms_blog/liveblog/routing.py
+++ b/djangocms_blog/liveblog/routing.py
@@ -8,10 +8,12 @@ from .consumers import liveblog_connect, liveblog_disconnect
 channel_routing = [
     route(
         'websocket.connect', liveblog_connect,
-        path=r'^/liveblog/(?P<apphook>[a-zA-Z0-9_-]+)/(?P<lang>[a-zA-Z_-]+)/(?P<post>[a-zA-Z0-9_-]+)/$'
+        path=r'^/liveblog/(?P<apphook>[a-zA-Z0-9_-]+)/'
+             r'(?P<lang>[a-zA-Z_-]+)/(?P<post>[a-zA-Z0-9_-]+)/$'
     ),
     route(
         'websocket.disconnect', liveblog_disconnect,
-        path=r'^/liveblog/(?P<apphook>[a-zA-Z0-9_-]+)/(?P<lang>[a-zA-Z_-]+)/(?P<post>[a-zA-Z0-9_-]+)/$'
+        path=r'^/liveblog/(?P<apphook>[a-zA-Z0-9_-]+)/'
+             r'(?P<lang>[a-zA-Z_-]+)/(?P<post>[a-zA-Z0-9_-]+)/$'
     ),
 ]
diff --git a/djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py b/djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py
deleted file mode 100644
index 4cebb73..0000000
--- a/djangocms_blog/south_migrations/0018_auto__add_field_post_liveblog.py
+++ /dev/null
@@ -1,222 +0,0 @@
-# -*- coding: utf-8 -*-
-from south.utils import datetime_utils as 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 'Post.liveblog'
-        db.add_column('djangocms_blog_post', 'liveblog',
-                      self.gf('django.db.models.fields.related.ForeignKey')(to=orm['cms.Placeholder'], null=True, related_name='live_blog'),
-                      keep_default=False)
-
-
-    def backwards(self, orm):
-        # Deleting field 'Post.liveblog'
-        db.delete_column('djangocms_blog_post', 'liveblog_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']", 'blank': 'True', 'symmetrical': 'False'})
-        },
-        'auth.permission': {
-            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", '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', [], {'blank': 'True', 'max_length': '75'}),
-            'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
-            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'blank': 'True', 'related_name': "'user_set'", 'symmetrical': 'False'}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
-            'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
-            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
-            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True', 'related_name': "'user_set'", 'symmetrical': 'False'}),
-            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
-        },
-        'cms.cmsplugin': {
-            'Meta': {'object_name': 'CMSPlugin'},
-            'changed_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
-            'creation_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
-            'depth': ('django.db.models.fields.PositiveIntegerField', [], {}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'language': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
-            'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
-            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['cms.CMSPlugin']", 'null': 'True'}),
-            'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
-            'placeholder': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True'}),
-            'plugin_type': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
-            'position': ('django.db.models.fields.PositiveSmallIntegerField', [], {'blank': 'True', 'null': 'True'})
-        },
-        'cms.placeholder': {
-            'Meta': {'object_name': 'Placeholder'},
-            'default_width': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True'}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'slot': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
-        },
-        'cmsplugin_filer_image.thumbnailoption': {
-            'Meta': {'ordering': "('width', 'height')", 'object_name': 'ThumbnailOption'},
-            'crop': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'height': ('django.db.models.fields.IntegerField', [], {}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
-            'upscale': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'width': ('django.db.models.fields.IntegerField', [], {})
-        },
-        'contenttypes.contenttype': {
-            'Meta': {'db_table': "'django_content_type'", 'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType'},
-            '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'})
-        },
-        'djangocms_blog.authorentriesplugin': {
-            'Meta': {'object_name': 'AuthorEntriesPlugin'},
-            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
-            'authors': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False'}),
-            'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'primary_key': 'True', 'unique': 'True'}),
-            'latest_posts': ('django.db.models.fields.IntegerField', [], {'default': '5'})
-        },
-        'djangocms_blog.blogcategory': {
-            'Meta': {'object_name': 'BlogCategory'},
-            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
-            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
-            'date_modified': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogCategory']", 'null': 'True'})
-        },
-        'djangocms_blog.blogcategorytranslation': {
-            'Meta': {'db_table': "'djangocms_blog_blogcategory_translation'", 'unique_together': "[('language_code', 'slug'), ('language_code', 'master')]", 'object_name': 'BlogCategoryTranslation'},
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'language_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
-            'master': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['djangocms_blog.BlogCategory']", 'null': 'True', 'related_name': "'translations'"}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '50'})
-        },
-        'djangocms_blog.blogconfig': {
-            'Meta': {'object_name': 'BlogConfig'},
-            'app_data': ('app_data.fields.AppDataField', [], {'default': "'{}'"}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'namespace': ('django.db.models.fields.CharField', [], {'unique': 'True', 'default': 'None', 'max_length': '100'}),
-            'type': ('django.db.models.fields.CharField', [], {'max_length': '100'})
-        },
-        'djangocms_blog.blogconfigtranslation': {
-            'Meta': {'db_table': "'djangocms_blog_blogconfig_translation'", 'unique_together': "[('language_code', 'master')]", 'object_name': 'BlogConfigTranslation'},
-            'app_title': ('django.db.models.fields.CharField', [], {'max_length': '234'}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'language_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
-            'master': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True', 'related_name': "'translations'"}),
-            'object_name': ('django.db.models.fields.CharField', [], {'default': "'Article'", 'max_length': '234'})
-        },
-        'djangocms_blog.genericblogplugin': {
-            'Meta': {'object_name': 'GenericBlogPlugin'},
-            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
-            'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'primary_key': 'True', 'unique': 'True'})
-        },
-        'djangocms_blog.latestpostsplugin': {
-            'Meta': {'object_name': 'LatestPostsPlugin'},
-            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'blank': 'True', 'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
-            'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['djangocms_blog.BlogCategory']", 'blank': 'True', 'symmetrical': 'False'}),
-            'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'primary_key': 'True', 'unique': 'True'}),
-            'latest_posts': ('django.db.models.fields.IntegerField', [], {'default': '5'})
-        },
-        'djangocms_blog.post': {
-            'Meta': {'ordering': "('-date_published', '-date_created')", 'object_name': 'Post'},
-            'app_config': ('aldryn_apphooks_config.fields.AppHookConfigField', [], {'to': "orm['djangocms_blog.BlogConfig']", 'null': 'True'}),
-            'author': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['auth.User']", 'null': 'True', 'related_name': "'djangocms_blog_post_author'"}),
-            'categories': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['djangocms_blog.BlogCategory']", 'blank': 'True', 'related_name': "'blog_posts'", 'symmetrical': 'False'}),
-            'content': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True', 'related_name': "'post_content'"}),
-            'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
-            'date_modified': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
-            'date_published': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'null': 'True'}),
-            'date_published_end': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'null': 'True'}),
-            'enable_comments': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'liveblog': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True', 'related_name': "'live_blog'"}),
-            'main_image': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['filer.Image']", 'on_delete': 'models.SET_NULL', 'null': 'True', 'related_name': "'djangocms_blog_post_image'"}),
-            'main_image_full': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['cmsplugin_filer_image.ThumbnailOption']", 'on_delete': 'models.SET_NULL', 'null': 'True', 'related_name': "'djangocms_blog_post_full'"}),
-            'main_image_thumbnail': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['cmsplugin_filer_image.ThumbnailOption']", 'on_delete': 'models.SET_NULL', 'null': 'True', 'related_name': "'djangocms_blog_post_thumbnail'"}),
-            'publish': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'sites': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['sites.Site']", 'blank': 'True', 'symmetrical': 'False'})
-        },
-        'djangocms_blog.posttranslation': {
-            'Meta': {'db_table': "'djangocms_blog_post_translation'", 'unique_together': "[('language_code', 'slug'), ('language_code', 'master')]", 'object_name': 'PostTranslation'},
-            'abstract': ('djangocms_text_ckeditor.fields.HTMLField', [], {'blank': 'True', 'default': "''"}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'language_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
-            'master': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['djangocms_blog.Post']", 'null': 'True', 'related_name': "'translations'"}),
-            'meta_description': ('django.db.models.fields.TextField', [], {'blank': 'True', 'default': "''"}),
-            'meta_keywords': ('django.db.models.fields.TextField', [], {'blank': 'True', 'default': "''"}),
-            'meta_title': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '255'}),
-            'post_text': ('djangocms_text_ckeditor.fields.HTMLField', [], {'blank': 'True', 'default': "''"}),
-            'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '50'}),
-            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'})
-        },
-        'filer.file': {
-            'Meta': {'object_name': 'File'},
-            '_file_size': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
-            'description': ('django.db.models.fields.TextField', [], {'blank': 'True', 'null': 'True'}),
-            'file': ('django.db.models.fields.files.FileField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
-            'folder': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['filer.Folder']", 'null': 'True', 'related_name': "'all_files'"}),
-            'has_all_mandatory_data': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'is_public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
-            'modified_at': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '255'}),
-            'original_filename': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
-            'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['auth.User']", 'null': 'True', 'related_name': "'owned_files'"}),
-            'polymorphic_ctype': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'related_name': "'polymorphic_filer.file_set+'"}),
-            'sha1': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '40'}),
-            'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'})
-        },
-        'filer.folder': {
-            'Meta': {'ordering': "('name',)", 'unique_together': "(('parent', 'name'),)", 'object_name': 'Folder'},
-            'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': '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'}),
-            'modified_at': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
-            'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['auth.User']", 'null': 'True', 'related_name': "'filer_owned_folders'"}),
-            'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'to': "orm['filer.Folder']", 'null': 'True', 'related_name': "'children'"}),
-            'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
-            'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'})
-        },
-        'filer.image': {
-            'Meta': {'object_name': 'Image'},
-            '_height': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
-            '_width': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
-            'author': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
-            'date_taken': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'null': 'True'}),
-            'default_alt_text': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
-            'default_caption': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '255'}),
-            'file_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['filer.File']", 'primary_key': 'True', 'unique': 'True'}),
-            'must_always_publish_author_credit': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'must_always_publish_copyright': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
-            'subject_location': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '64'})
-        },
-        'sites.site': {
-            'Meta': {'db_table': "'django_site'", 'ordering': "('domain',)", 'object_name': 'Site'},
-            'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
-            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
-            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
-        }
-    }
-
-    complete_apps = ['djangocms_blog']
\ No newline at end of file
diff --git a/tests/test_utils/routing.py b/tests/test_utils/routing.py
index 94188ea..38d1771 100644
--- a/tests/test_utils/routing.py
+++ b/tests/test_utils/routing.py
@@ -2,9 +2,9 @@
 from __future__ import absolute_import, print_function, unicode_literals
 
 from channels import include
+from knocker.routing import channel_routing as knocker_routing
 
 from djangocms_blog.liveblog.routing import channel_routing as djangocms_blog_routing
-from knocker.routing import channel_routing as knocker_routing
 
 channel_routing = [
     include(djangocms_blog_routing, path=r'^/liveblog'),

From 52146269f62796dacbe3f35b25829109488e2111 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Thu, 23 Jun 2016 09:57:08 +0200
Subject: [PATCH 12/14] Fix admin

---
 djangocms_blog/__init__.py          |  2 ++
 djangocms_blog/admin.py             | 18 ++++++++----------
 djangocms_blog/liveblog/__init__.py |  1 -
 3 files changed, 10 insertions(+), 11 deletions(-)

diff --git a/djangocms_blog/__init__.py b/djangocms_blog/__init__.py
index 61f6854..17ba9e4 100644
--- a/djangocms_blog/__init__.py
+++ b/djangocms_blog/__init__.py
@@ -1,4 +1,6 @@
 # -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
 __author__ = 'Iacopo Spalletti'
 __email__ = 'i.spalletti@nephila.it'
 __version__ = '0.9.pre4'
diff --git a/djangocms_blog/admin.py b/djangocms_blog/admin.py
index 3a21949..7ea2e7c 100755
--- a/djangocms_blog/admin.py
+++ b/djangocms_blog/admin.py
@@ -57,24 +57,20 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin,
     enhance_exclude = ('main_image', 'tags')
     _fieldsets = [
         (None, {
-            'fields': [('title', 'categories', 'publish', 'app_config')]
+            'fields': [['title', 'categories', 'publish', 'app_config']]
         }),
         ('Info', {
-            'fields': (['slug', 'tags'],
-                       ('date_published', 'date_published_end',),
-                       (
-                           'enable_comments',
-                           'enable_liveblog' if apps.is_installed('djangocms_blog.liveblog')
-                           else None
-                       )),
+            'fields': [['slug', 'tags'],
+                       ['date_published', 'date_published_end'],
+                       ['enable_comments']],
             'classes': ('collapse',)
         }),
         ('Images', {
-            'fields': (('main_image', 'main_image_thumbnail', 'main_image_full'),),
+            'fields': [['main_image', 'main_image_thumbnail', 'main_image_full']],
             'classes': ('collapse',)
         }),
         ('SEO', {
-            'fields': [('meta_description', 'meta_title', 'meta_keywords')],
+            'fields': [['meta_description', 'meta_title', 'meta_keywords']],
             'classes': ('collapse',)
         }),
     ]
@@ -198,6 +194,8 @@ class PostAdmin(PlaceholderAdminMixin, FrontendEditableAdminMixin,
             fsets[1][1]['fields'][0].append('sites')
         if request.user.is_superuser:
             fsets[1][1]['fields'][0].append('author')
+        if apps.is_installed('djangocms_blog.liveblog'):
+            fsets[1][1]['fields'][2].append('enable_liveblog')
         filter_function = get_setting('ADMIN_POST_FIELDSET_FILTER')
         if callable(filter_function):
             fsets = filter_function(fsets, request, obj=obj)
diff --git a/djangocms_blog/liveblog/__init__.py b/djangocms_blog/liveblog/__init__.py
index ba25ec7..40a96af 100644
--- a/djangocms_blog/liveblog/__init__.py
+++ b/djangocms_blog/liveblog/__init__.py
@@ -1,2 +1 @@
 # -*- coding: utf-8 -*-
-from __future__ import absolute_import, print_function, unicode_literals

From abf2c1bb6102089f2ec67f4c389791143688cce5 Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 26 Jun 2016 11:54:40 +0200
Subject: [PATCH 13/14] Add tests for liveblog

---
 djangocms_blog/liveblog/__init__.py           |   3 +
 djangocms_blog/liveblog/models.py             |  57 ++++--
 ...post_liveblog.py => 0021_post_liveblog.py} |   2 +-
 ...605_2305.py => 0022_auto_20160605_2305.py} |   2 +-
 tests/test_liveblog.py                        | 178 ++++++++++++++++++
 tox.ini                                       |   1 +
 6 files changed, 222 insertions(+), 21 deletions(-)
 rename djangocms_blog/migrations/{0018_post_liveblog.py => 0021_post_liveblog.py} (90%)
 rename djangocms_blog/migrations/{0019_auto_20160605_2305.py => 0022_auto_20160605_2305.py} (90%)
 create mode 100644 tests/test_liveblog.py

diff --git a/djangocms_blog/liveblog/__init__.py b/djangocms_blog/liveblog/__init__.py
index 40a96af..f3a5710 100644
--- a/djangocms_blog/liveblog/__init__.py
+++ b/djangocms_blog/liveblog/__init__.py
@@ -1 +1,4 @@
 # -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+default_app_config = 'djangocms_blog.liveblog.apps.LiveBlogAppConfig'
diff --git a/djangocms_blog/liveblog/models.py b/djangocms_blog/liveblog/models.py
index f6a4396..0f50605 100644
--- a/djangocms_blog/liveblog/models.py
+++ b/djangocms_blog/liveblog/models.py
@@ -16,32 +16,28 @@ from djangocms_blog.models import Post, thumbnail_model
 DATE_FORMAT = "%a %d %b %Y %H:%M"
 
 
-class Liveblog(AbstractText):
-    title = models.CharField(_('title'), max_length=255)
-    image = FilerImageField(
-        verbose_name=_('image'), blank=True, null=True, on_delete=models.SET_NULL,
-        related_name='djangocms_blog_liveblog_image'
-    )
-    thumbnail = models.ForeignKey(
-        thumbnail_model, verbose_name=_('thumbnail size'), on_delete=models.SET_NULL,
-        blank=True, null=True, related_name='djangocms_blog_liveblog_thumbnail'
-    )
+class LiveblogInterface(models.Model):
+    """
+    Abstract Liveblog plugin model, reusable to customize the liveblogging plugins.
+
+    When implementing this, you **must** call ``self._post_save()`` in the concrete
+    plugin model ``save`` method.
+    """
     publish = models.BooleanField(_('publish liveblog entry'), default=False)
     node_order_by = '-changed_date'
 
     class Meta:
         verbose_name = _('liveblog entry')
         verbose_name_plural = _('liveblog entries')
+        abstract = True
 
-    def save(self, no_signals=False, *args, **kwargs):
-        saved = super(Liveblog, self).save(*args, **kwargs)
+    def _post_save(self):
         if self.publish:
             self.send()
         order = CMSPlugin.objects.filter(
             placeholder=self.placeholder
         ).order_by('placeholder', 'path').values_list('pk', flat=True)
         reorder_plugins(self.placeholder, None, self.language, order)
-        return saved
 
     @property
     def liveblog_group(self):
@@ -56,13 +52,36 @@ class Liveblog(AbstractText):
         """
         Render the content and send to the related group
         """
-        notification = {
-            'id': self.pk,
-            'content': self.render(),
-            'creation_date': self.creation_date.strftime(DATE_FORMAT),
-            'changed_date': self.changed_date.strftime(DATE_FORMAT),
-        }
         if self.liveblog_group:
+            notification = {
+                'id': self.pk,
+                'content': self.render(),
+                'creation_date': self.creation_date.strftime(DATE_FORMAT),
+                'changed_date': self.changed_date.strftime(DATE_FORMAT),
+            }
             Group(self.liveblog_group).send({
                 'text': json.dumps(notification),
             })
+
+
+class Liveblog(LiveblogInterface, AbstractText):
+    """
+    Basic liveblog plugin model
+    """
+    title = models.CharField(_('title'), max_length=255)
+    image = FilerImageField(
+        verbose_name=_('image'), blank=True, null=True, on_delete=models.SET_NULL,
+        related_name='djangocms_blog_liveblog_image'
+    )
+    thumbnail = models.ForeignKey(
+        thumbnail_model, verbose_name=_('thumbnail size'), on_delete=models.SET_NULL,
+        blank=True, null=True, related_name='djangocms_blog_liveblog_thumbnail'
+    )
+
+    class Meta:
+        verbose_name = _('liveblog entry')
+        verbose_name_plural = _('liveblog entries')
+
+    def save(self, *args, **kwargs):
+        super(Liveblog, self).save(*args, **kwargs)
+        self._post_save()
diff --git a/djangocms_blog/migrations/0018_post_liveblog.py b/djangocms_blog/migrations/0021_post_liveblog.py
similarity index 90%
rename from djangocms_blog/migrations/0018_post_liveblog.py
rename to djangocms_blog/migrations/0021_post_liveblog.py
index db8d1aa..28e9aa4 100644
--- a/djangocms_blog/migrations/0018_post_liveblog.py
+++ b/djangocms_blog/migrations/0021_post_liveblog.py
@@ -9,7 +9,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('cms', '__first__'),
-        ('djangocms_blog', '0017_thumbnail_move'),
+        ('djangocms_blog', '0020_thumbnail_move4'),
     ]
 
     operations = [
diff --git a/djangocms_blog/migrations/0019_auto_20160605_2305.py b/djangocms_blog/migrations/0022_auto_20160605_2305.py
similarity index 90%
rename from djangocms_blog/migrations/0019_auto_20160605_2305.py
rename to djangocms_blog/migrations/0022_auto_20160605_2305.py
index f2b0f8d..25e7892 100644
--- a/djangocms_blog/migrations/0019_auto_20160605_2305.py
+++ b/djangocms_blog/migrations/0022_auto_20160605_2305.py
@@ -9,7 +9,7 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('djangocms_blog', '0018_post_liveblog'),
+        ('djangocms_blog', '0021_post_liveblog'),
     ]
 
     operations = [
diff --git a/tests/test_liveblog.py b/tests/test_liveblog.py
new file mode 100644
index 0000000..09ab014
--- /dev/null
+++ b/tests/test_liveblog.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, print_function, unicode_literals
+
+import json
+from unittest import SkipTest
+
+try:
+    from channels import Channel
+    from channels.tests import ChannelTestCase
+    from cms.api import add_plugin
+
+    from djangocms_blog.liveblog.consumers import liveblog_connect, liveblog_disconnect
+    from djangocms_blog.liveblog.models import DATE_FORMAT
+    from .base import BaseTest
+
+
+    class LiveBlogTest(BaseTest, ChannelTestCase):
+
+        @classmethod
+        def setUpClass(cls):
+            try:
+                import knocker
+                super(LiveBlogTest, cls).setUpClass()
+            except ImportError:
+                raise SkipTest('channels not installed, skipping tests')
+
+        def test_add_plugin(self):
+            posts = self.get_posts()
+            self.get_pages()
+            post = posts[0]
+            post.enable_liveblog = True
+            post.save()
+
+            Channel('setup').send({'connect': 1, 'reply_channel': 'reply'})
+            message = self.get_next_message('setup', require=True)
+            liveblog_connect(message, self.app_config_1.namespace, 'en', post.slug)
+
+            plugin = add_plugin(
+                post.liveblog, 'LiveblogPlugin', language='en', body='live text', publish=True
+            )
+            result = self.get_next_message(message.reply_channel.name, require=True)
+            self.assertTrue(result['text'])
+
+            rendered = json.loads(result['text'])
+            self.assertEqual(plugin.pk, rendered['id'])
+            self.assertEqual(plugin.creation_date.strftime(DATE_FORMAT), rendered['creation_date'])
+            self.assertEqual(plugin.changed_date.strftime(DATE_FORMAT), rendered['changed_date'])
+            self.assertTrue(rendered['content'].find('data-post-id="{}"'.format(plugin.pk)) > -1)
+            self.assertTrue(rendered['content'].find('live text') > -1)
+
+            plugin.body = 'modified text'
+            plugin.save()
+
+            result = self.get_next_message(message.reply_channel.name, require=True)
+            self.assertTrue(result['text'])
+
+            rendered = json.loads(result['text'])
+            self.assertEqual(plugin.pk, rendered['id'])
+            self.assertEqual(plugin.creation_date.strftime(DATE_FORMAT), rendered['creation_date'])
+            self.assertEqual(plugin.changed_date.strftime(DATE_FORMAT), rendered['changed_date'])
+            self.assertTrue(rendered['content'].find('data-post-id="{}"'.format(plugin.pk)) > -1)
+            self.assertTrue(rendered['content'].find('modified text') > -1)
+            self.assertTrue(rendered['content'].find('live text') == -1)
+
+        def test_add_plugin_no_publish(self):
+            posts = self.get_posts()
+            self.get_pages()
+            post = posts[0]
+            post.enable_liveblog = True
+            post.save()
+
+            Channel('setup').send({'connect': 1, 'reply_channel': 'reply'})
+            message = self.get_next_message('setup', require=True)
+            liveblog_connect(message, self.app_config_1.namespace, 'en', post.slug)
+
+            plugin = add_plugin(
+                post.liveblog, 'LiveblogPlugin', language='en', body='live text', publish=False
+            )
+            result = self.get_next_message(message.reply_channel.name, require=False)
+            self.assertIsNone(result)
+
+            plugin.publish = True
+            plugin.save()
+
+            result = self.get_next_message(message.reply_channel.name, require=True)
+            self.assertTrue(result['text'])
+
+            rendered = json.loads(result['text'])
+            self.assertEqual(plugin.pk, rendered['id'])
+            self.assertEqual(plugin.creation_date.strftime(DATE_FORMAT), rendered['creation_date'])
+            self.assertEqual(plugin.changed_date.strftime(DATE_FORMAT), rendered['changed_date'])
+            self.assertTrue(rendered['content'].find('data-post-id="{}"'.format(plugin.pk)) > -1)
+            self.assertTrue(rendered['content'].find('live text') > -1)
+
+        def test_disconnect(self):
+            posts = self.get_posts()
+            self.get_pages()
+            post = posts[0]
+            post.enable_liveblog = True
+            post.save()
+
+            Channel('setup').send({'connect': 1, 'reply_channel': 'reply'})
+            message = self.get_next_message('setup', require=True)
+            liveblog_connect(message, self.app_config_1.namespace, 'en', post.slug)
+
+            plugin = add_plugin(
+                post.liveblog, 'LiveblogPlugin', language='en', body='live text', publish=True
+            )
+            result = self.get_next_message(message.reply_channel.name, require=True)
+            self.assertTrue(result['text'])
+
+            liveblog_disconnect(message, self.app_config_1.namespace, 'en', post.slug)
+
+            plugin.body = 'modified text'
+            plugin.save()
+
+            result = self.get_next_message(message.reply_channel.name, require=False)
+            self.assertIsNone(result)
+
+        def test_nopost(self):
+
+            self.get_pages()
+
+            Channel('setup').send({'connect': 1, 'reply_channel': 'reply'})
+            message = self.get_next_message('setup', require=True)
+            liveblog_connect(message, self.app_config_1.namespace, 'en', 'random-post')
+
+            result = self.get_next_message(message.reply_channel.name, require=True)
+            self.assertTrue(result['text'])
+            rendered = json.loads(result['text'])
+            self.assertTrue(rendered['error'], 'no_post')
+
+            liveblog_disconnect(message, self.app_config_1.namespace, 'en', 'random-post')
+            result = self.get_next_message(message.reply_channel.name, require=True)
+            self.assertTrue(result['text'])
+            rendered = json.loads(result['text'])
+            self.assertTrue(rendered['error'], 'no_post')
+
+        def test_plugin_without_post(self):
+
+            pages = self.get_pages()
+
+            placeholder = pages[0].get_placeholders().get(slot='content')
+
+            Channel('setup').send({'connect': 1, 'reply_channel': 'reply'})
+            message = self.get_next_message('setup', require=True)
+            liveblog_connect(message, self.app_config_1.namespace, 'en', 'random post')
+            self.get_next_message(message.reply_channel.name, require=True)
+
+            plugin = add_plugin(
+                placeholder, 'LiveblogPlugin', language='en', body='live text', publish=True
+            )
+            self.assertIsNone(plugin.liveblog_group)
+            result = self.get_next_message(message.reply_channel.name, require=False)
+            self.assertIsNone(result)
+
+        def test_plugin_render(self):
+            posts = self.get_posts()
+            pages = self.get_pages()
+            post = posts[0]
+            post.enable_liveblog = True
+            post.save()
+            plugin = add_plugin(
+                post.liveblog, 'LiveblogPlugin', language='en', body='live text', publish=False
+            )
+            context = self.get_plugin_context(pages[0], 'en', plugin, edit=False)
+            rendered = plugin.render_plugin(context, post.liveblog)
+            self.assertFalse(rendered)
+
+            plugin.publish = True
+            plugin.save()
+            context = self.get_plugin_context(pages[0], 'en', plugin, edit=False)
+            rendered = plugin.render_plugin(context, post.liveblog)
+            self.assertTrue(rendered.find('data-post-id="{}"'.format(plugin.pk)) > -1)
+            self.assertTrue(rendered.find('live text') > -1)
+
+except ImportError:  # pragma: no cover
+    pass
diff --git a/tox.ini b/tox.ini
index 454f361..e1f679f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,6 +17,7 @@ deps =
     cms33: https://github.com/divio/django-cms/archive/release/3.3.x.zip
     cms33: djangocms-text-ckeditor>=3.0
     knocker: https://github.com/divio/django-cms/archive/release/3.2.x.zip
+    knocker: channels>=0.15
     knocker: https://github.com/nephila/django-knocker/archive/master.zip?0.1.1
     knocker: djangocms-text-ckeditor<3.0
     django-meta>=1.2

From 41834f6bf931beb903cac8618a88cd5993910a9f Mon Sep 17 00:00:00 2001
From: Iacopo Spalletti <i.spalletti@nephila.it>
Date: Sun, 26 Jun 2016 12:48:40 +0200
Subject: [PATCH 14/14] Better plugin templates

---
 djangocms_blog/liveblog/cms_plugins.py                   | 7 +------
 djangocms_blog/liveblog/models.py                        | 9 ++++++++-
 .../liveblog/templates/liveblog/plugins/liveblog.html    | 2 ++
 tests/test_liveblog.py                                   | 2 +-
 4 files changed, 12 insertions(+), 8 deletions(-)

diff --git a/djangocms_blog/liveblog/cms_plugins.py b/djangocms_blog/liveblog/cms_plugins.py
index 6c1895d..75a90ba 100644
--- a/djangocms_blog/liveblog/cms_plugins.py
+++ b/djangocms_blog/liveblog/cms_plugins.py
@@ -15,12 +15,7 @@ class LiveblogPlugin(TextPlugin):
     name = _('Liveblog item')
     model = Liveblog
     fields = ('title', 'body', 'publish')
-
-    def _get_render_template(self, context, instance, placeholder):
-        if instance.publish:
-            return 'liveblog/plugins/liveblog.html'
-        else:
-            return 'liveblog/plugins/unpublished.html'
+    render_template = 'liveblog/plugins/liveblog.html'
 
     def render(self, context, instance, placeholder):
         context = super(LiveblogPlugin, self).render(context, instance, placeholder)
diff --git a/djangocms_blog/liveblog/models.py b/djangocms_blog/liveblog/models.py
index 0f50605..87fa8d1 100644
--- a/djangocms_blog/liveblog/models.py
+++ b/djangocms_blog/liveblog/models.py
@@ -4,7 +4,7 @@ from __future__ import absolute_import, print_function, unicode_literals
 import json
 
 from channels import Group
-from cms.models import CMSPlugin
+from cms.models import CMSPlugin, python_2_unicode_compatible
 from cms.utils.plugins import reorder_plugins
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
@@ -16,6 +16,7 @@ from djangocms_blog.models import Post, thumbnail_model
 DATE_FORMAT = "%a %d %b %Y %H:%M"
 
 
+@python_2_unicode_compatible
 class LiveblogInterface(models.Model):
     """
     Abstract Liveblog plugin model, reusable to customize the liveblogging plugins.
@@ -31,6 +32,9 @@ class LiveblogInterface(models.Model):
         verbose_name_plural = _('liveblog entries')
         abstract = True
 
+    def __str__(self):
+        return str(self.pk)
+
     def _post_save(self):
         if self.publish:
             self.send()
@@ -85,3 +89,6 @@ class Liveblog(LiveblogInterface, AbstractText):
     def save(self, *args, **kwargs):
         super(Liveblog, self).save(*args, **kwargs)
         self._post_save()
+
+    def __str__(self):
+        return AbstractText.__str__(self)
diff --git a/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html b/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
index 402a2a5..2fdd5a6 100644
--- a/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
+++ b/djangocms_blog/liveblog/templates/liveblog/plugins/liveblog.html
@@ -1,5 +1,7 @@
+{% spaceless %}{% if instance.publish %}
 <div class="post" data-post-id="{{ instance.id }}">
     <h3>{{ instance.title }}</h3>
     <h4>{{ instance.creation_date|date:"D d M Y H:i" }}</h4>
     {{ instance.content|safe }}
 </div>
+{% endif %}{% endspaceless %}
diff --git a/tests/test_liveblog.py b/tests/test_liveblog.py
index 09ab014..e9b06ed 100644
--- a/tests/test_liveblog.py
+++ b/tests/test_liveblog.py
@@ -165,7 +165,7 @@ try:
             )
             context = self.get_plugin_context(pages[0], 'en', plugin, edit=False)
             rendered = plugin.render_plugin(context, post.liveblog)
-            self.assertFalse(rendered)
+            self.assertFalse(rendered.strip())
 
             plugin.publish = True
             plugin.save()