From 9e40e57a60794b6ed105834fe50591fbfd5ce461 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Mon, 20 Jan 2020 16:36:00 +0100
Subject: [PATCH] kb: add cell to display last updated pages (#39091)

---
 combo/apps/kb/__init__.py                     |  29 ++++
 combo/apps/kb/migrations/0001_initial.py      |  39 +++++
 combo/apps/kb/migrations/__init__.py          |   0
 combo/apps/kb/models.py                       |  61 ++++++++
 .../kb/templates/combo/last-updated-cell.html |  20 +++
 combo/apps/kb/urls.py                         |  20 +++
 combo/settings.py                             |   1 +
 tests/test_kb.py                              | 148 ++++++++++++++++++
 8 files changed, 318 insertions(+)
 create mode 100644 combo/apps/kb/__init__.py
 create mode 100644 combo/apps/kb/migrations/0001_initial.py
 create mode 100644 combo/apps/kb/migrations/__init__.py
 create mode 100644 combo/apps/kb/models.py
 create mode 100644 combo/apps/kb/templates/combo/last-updated-cell.html
 create mode 100644 combo/apps/kb/urls.py
 create mode 100644 tests/test_kb.py

diff --git a/combo/apps/kb/__init__.py b/combo/apps/kb/__init__.py
new file mode 100644
index 00000000..9ffeba03
--- /dev/null
+++ b/combo/apps/kb/__init__.py
@@ -0,0 +1,29 @@
+# combo - content management system
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import django.apps
+from django.utils.translation import ugettext_lazy as _
+
+
+class AppConfig(django.apps.AppConfig):
+    name = 'combo.apps.kb'
+    verbose_name = _('Knowledge base')
+
+    def get_before_urls(self):
+        from . import urls
+        return urls.urlpatterns
+
+default_app_config = 'combo.apps.kb.AppConfig'
diff --git a/combo/apps/kb/migrations/0001_initial.py b/combo/apps/kb/migrations/0001_initial.py
new file mode 100644
index 00000000..9953f6bd
--- /dev/null
+++ b/combo/apps/kb/migrations/0001_initial.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-01-21 14:30
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('auth', '0008_alter_user_username_max_length'),
+        ('data', '0038_increase_jsoncell_url_max_length'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='LastUpdatedCell',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('placeholder', models.CharField(max_length=20)),
+                ('order', models.PositiveIntegerField()),
+                ('slug', models.SlugField(blank=True, verbose_name='Slug')),
+                ('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')),
+                ('public', models.BooleanField(default=True, verbose_name='Public')),
+                ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
+                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
+                ('limit', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Maximum number of entries')),
+                ('follow_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lastupdated_cell', to='data.Page', verbose_name='Followed parent page')),
+                ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
+                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
+            ],
+            options={
+                'verbose_name': 'Last updated content',
+            },
+        ),
+    ]
diff --git a/combo/apps/kb/migrations/__init__.py b/combo/apps/kb/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/combo/apps/kb/models.py b/combo/apps/kb/models.py
new file mode 100644
index 00000000..3abe831a
--- /dev/null
+++ b/combo/apps/kb/models.py
@@ -0,0 +1,61 @@
+# combo - content management system
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from combo.data.models import CellBase, Page, PageSnapshot
+from combo.data.library import register_cell_class
+
+@register_cell_class
+class LastUpdatedCell(CellBase):
+    follow_page = models.ForeignKey(
+        'data.Page', blank=True, null=True, on_delete=models.CASCADE,
+        related_name='lastupdated_cell',verbose_name=_('Followed parent page'))
+    limit = models.PositiveSmallIntegerField(_('Maximum number of entries'),
+            null=True, blank=True)
+
+    template_name = 'combo/last-updated-cell.html'
+
+    class Meta:
+        verbose_name = _('Last updated pages')
+
+    def get_cell_extra_context(self, context):
+        extra_context = super(LastUpdatedCell, self).get_cell_extra_context(context)
+
+        def get_descendants(page):
+            descendants = [page]
+            for item in page.get_children():
+                descendants.extend(get_descendants(item))
+            return descendants
+
+        if self.follow_page:
+            followed_page_ids = [x.id for x in get_descendants(self.follow_page)]
+            followed_pages = Page.objects.filter(id__in=followed_page_ids)
+        else:
+            followed_pages = Page.objects.all()
+        last_update_times = [(x.get_last_update_time(), x) for x in followed_pages]
+        last_update_times.sort(key=lambda ts: ts[0], reverse=True)
+        data = []
+        for (last_update_time, page) in last_update_times[:self.limit]:
+            item = {}
+            item['url'] = page.get_online_url()
+            item['title'] = page.title
+            item['last_update_time'] = last_update_time
+            item['is_new'] = not PageSnapshot.objects.filter(page=page)
+            data.append(item)
+        extra_context['pages'] = data
+        return extra_context
diff --git a/combo/apps/kb/templates/combo/last-updated-cell.html b/combo/apps/kb/templates/combo/last-updated-cell.html
new file mode 100644
index 00000000..e9cafdee
--- /dev/null
+++ b/combo/apps/kb/templates/combo/last-updated-cell.html
@@ -0,0 +1,20 @@
+{% load i18n %}
+<h2>{% trans "Last updated pages" %}</h2>
+<div class="links-list">
+  <ul>
+    {% for page in pages %}
+    <li>
+      <a href="{{ page.url }}">
+        {{ page.title }}
+        <em>
+          &nbsp;
+          {% trans "on"%} {{ page.last_update_time }}
+          {% if page.is_new %}
+          {% trans "(new page)"%}
+          {% endif %}
+        </em>
+      </a>
+    </li>
+    {% endfor %}
+  </ul>
+</div>
diff --git a/combo/apps/kb/urls.py b/combo/apps/kb/urls.py
new file mode 100644
index 00000000..150e6dee
--- /dev/null
+++ b/combo/apps/kb/urls.py
@@ -0,0 +1,20 @@
+# combo - content management system
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.conf.urls import url
+
+urlpatterns = [
+]
diff --git a/combo/settings.py b/combo/settings.py
index eb0f3d29..44fcc9c0 100644
--- a/combo/settings.py
+++ b/combo/settings.py
@@ -76,6 +76,7 @@ INSTALLED_APPS = (
     'combo.apps.calendar',
     'combo.apps.pwa',
     'combo.apps.gallery',
+    'combo.apps.kb',
     'haystack',
     'xstatic.pkg.josefinsans',
     'xstatic.pkg.leaflet',
diff --git a/tests/test_kb.py b/tests/test_kb.py
new file mode 100644
index 00000000..f73b91e0
--- /dev/null
+++ b/tests/test_kb.py
@@ -0,0 +1,148 @@
+# combo - content management system
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.import pytest
+
+import pytest
+
+import freezegun
+from bs4 import BeautifulSoup
+
+from combo.data.models import CellBase, Page, PageSnapshot, TextCell
+from combo.apps.kb.models import LastUpdatedCell
+
+pytestmark = pytest.mark.django_db
+
+
+def login(app, username='admin', password='admin'):
+    login_page = app.get('/login/')
+    login_form = login_page.forms[0]
+    login_form['username'] = username
+    login_form['password'] = password
+    resp = login_form.submit()
+    assert resp.status_int == 302
+    return app
+
+
+@pytest.mark.freeze_time('2020-01-01')
+def test_manage_last_updated_content_cell(app, admin_user):
+    page = Page(title='example page', slug='example-page')
+    page.save()
+    app = login(app)
+    resp = app.get('/manage/pages/%s/' % page.id, status=200)
+
+    optgroup = resp.html.find('optgroup', attrs={'label': 'Knowledge base'})
+    add_cell_url = optgroup.findChild().attrs['data-add-url']
+    assert 'kb_lastupdatedcell' in add_cell_url
+    resp = app.get(add_cell_url, status=302)
+    resp = resp.follow()
+    cells = CellBase.get_cells(page_id=page.id)
+    assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
+    resp = resp.forms[0].submit()
+    assert resp.status_int == 302
+
+    resp = app.get('/example-page/', status=200)
+    div = resp.html.find('div', attrs={'class': 'links-list'})
+    assert 'on Jan. 1, 2020' in div.findChild('em').text
+
+def test_last_updated_content_cell_new_page(freezer):
+    freezer.move_to('2020-01-01')
+    page = Page(title='example page', slug='example-page')
+    page.save()
+    cell = LastUpdatedCell(page=page, order=0)
+    cell.save()
+    ctx = {}
+    assert 'Jan. 1, 2020' in cell.render(ctx)
+    assert page.snapshot is None
+    assert '(new page)' in cell.render(ctx)
+
+    # modified page
+    PageSnapshot.take(page)
+    assert '(new page)' not in cell.render(ctx)
+
+def test_last_updated_content_cell_limit(freezer):
+    for i in range(1, 4):
+        page = Page(title='page %s' %i, slug='page-%i' %i)
+        page.save()
+    cell = LastUpdatedCell(page=page, order = 0)
+    cell.save()
+    ctx = {}
+    html = BeautifulSoup(cell.render(ctx))
+    assert len(html.find_all('li')) == 3
+
+    cell.limit = 2
+    cell.save()
+    html = BeautifulSoup(cell.render(ctx))
+    assert len(html.find_all('li')) == 2
+
+def test_last_updated_content_cell_sort(freezer):
+    for i in [30, 11, 2, 22]:
+        freezer.move_to('2020-01-%02i' %i)
+        page = Page(title='page %s' %i, slug='page-%i' %i)
+        page.save()
+        cell = TextCell(page=page, order=0, slug='cell-%i' %i, text='foo')
+        cell.save()
+    cell = LastUpdatedCell(page=page, order=0, limit=3)
+    cell.save()
+    ctx = {}
+    html = BeautifulSoup(cell.render(ctx))
+    ems = html.find_all('em')
+    assert 'on Jan. 30, 2020' in ems[0].text
+    assert 'on Jan. 22, 2020' in ems[1].text
+    assert 'on Jan. 11, 2020' in ems[2].text
+
+    # update contained cell
+    freezer.move_to('2020-01-31')
+    text_cell = TextCell.objects.get(slug='cell-2')
+    text_cell.text='bar'
+    text_cell.save()
+    html = BeautifulSoup(cell.render(ctx))
+    ems = html.find_all('em')
+    assert 'on Jan. 31, 2020' in ems[0].text
+    assert 'on Jan. 30, 2020' in ems[1].text
+    assert 'on Jan. 22, 2020' in ems[2].text
+
+def test_last_updated_content_cell_follow_page():
+    ''' 1  6  11
+       /   |   \
+     2 4  7 9  12 14
+     | |  | |   | |
+     3 5  8 10 13 15
+    '''
+    def add_pages(depth=0, parent_id=None):
+        page = Page(title='page %s' % add_pages.num, slug='page-%s' % add_pages.num)
+        page.parent_id = parent_id
+        page.save()
+        add_pages.num += 1
+        for m in range(0, 2-depth):
+            add_pages(depth+1, page.id)
+
+    add_pages.num = 1
+    for i in range(1, 4):
+        add_pages()
+    cell = LastUpdatedCell()
+    cell.page = Page.objects.get(slug='page-15')
+    cell.order = 0
+    cell.save()
+    ctx = {}
+    html = BeautifulSoup(cell.render(ctx))
+    assert len(html.find_all('li')) == 15
+
+    cell.follow_page = Page.objects.get(slug='page-1')
+    html = BeautifulSoup(cell.render(ctx))
+    assert len(html.find_all('li')) == 5
+
+    cell.follow_page = Page.objects.get(slug='page-3')
+    html = BeautifulSoup(cell.render(ctx))
+    assert len(html.find_all('li')) == 1
-- 
2.24.1

