general: add page versionning (#21602)

This commit is contained in:
Frédéric Péters 2018-04-01 15:23:01 +02:00
parent b439f67a7a
commit 43da526e69
9 changed files with 308 additions and 10 deletions

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.8 on 2018-04-01 13:00
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('data', '0032_page_description'),
]
operations = [
migrations.CreateModel(
name='PageSnapshot',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(auto_now_add=True)),
('comment', models.TextField(blank=True, null=True)),
('serialization', jsonfield.fields.JSONField(blank=True, default=dict)),
('page', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='data.Page')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-timestamp',),
},
),
migrations.AddField(
model_name='page',
name='snapshot',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='temporary_page', to='data.PageSnapshot'),
),
]

View File

@ -38,6 +38,7 @@ from django.db.models import Max
from django.forms import models as model_forms
from django import forms
from django import template
from django.utils.encoding import force_text
from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from django.utils.text import slugify
@ -95,13 +96,27 @@ class Placeholder(object):
class PageManager(models.Manager):
snapshots = False
def __init__(self, *args, **kwargs):
self.snapshots = kwargs.pop('snapshots', False)
super(PageManager, self).__init__(*args, **kwargs)
def get_by_natural_key(self, path):
parts = [x for x in path.strip('/').split('/') if x] or ['index']
return self.get(slug=parts[-1])
def get_queryset(self):
queryset = super(PageManager, self).get_queryset()
if self.snapshots:
return queryset.filter(snapshot__isnull=False)
else:
return queryset.filter(snapshot__isnull=True)
class Page(models.Model):
objects = PageManager()
snapshots = PageManager(snapshots=True)
title = models.CharField(_('Title'), max_length=150)
slug = models.SlugField(_('Slug'))
@ -118,6 +133,12 @@ class Page(models.Model):
picture = models.ImageField(_('Picture'), upload_to='page-pictures/', null=True)
# mark temporarily restored snapshots, it is required to save objects
# (pages and cells) for real for viewing past snapshots as many cells are
# asynchronously loaded and must refer to a real Page object.
snapshot = models.ForeignKey('PageSnapshot', on_delete=models.CASCADE, null=True,
related_name='temporary_page')
_level = None
_children = None
@ -271,6 +292,8 @@ class Page(models.Model):
serialized_page = json.loads(serializers.serialize('json', [self],
use_natural_foreign_keys=True, use_natural_primary_keys=True))[0]
del serialized_page['model']
if 'snapshot' in serialized_page:
del serialized_page['snapshot']
serialized_page['cells'] = json.loads(serializers.serialize('json',
cells, use_natural_foreign_keys=True, use_natural_primary_keys=True))
serialized_page['fields']['groups'] = [x[0] for x in serialized_page['fields']['groups']]
@ -284,20 +307,25 @@ class Page(models.Model):
return serialized_page
@classmethod
def load_serialized_page(cls, json_page):
def load_serialized_page(cls, json_page, snapshot=None):
json_page['model'] = 'data.page'
json_page['fields']['groups'] = [[x] for x in json_page['fields']['groups'] if isinstance(x, basestring)]
page, created = Page.objects.get_or_create(slug=json_page['fields']['slug'])
page, created = Page.objects.get_or_create(slug=json_page['fields']['slug'], snapshot=snapshot)
json_page['pk'] = page.id
page = [x for x in serializers.deserialize('json', json.dumps([json_page]))][0]
page.object.snapshot = snapshot
page.save()
for cell in json_page.get('cells'):
cell['fields']['groups'] = [[x] for x in cell['fields']['groups'] if isinstance(x, basestring)]
cell['fields']['page'] = page.object.natural_key()
if snapshot:
cell['fields']['page'] = page.object.id
else:
cell['fields']['page'] = page.object.natural_key()
# if there were cells, remove them
for cell in CellBase.get_cells(page_id=page.object.id):
cell.delete()
return page.object # get page out of deserialization object
@classmethod
def load_serialized_cells(cls, cells):
@ -333,6 +361,38 @@ class Page(models.Model):
return max([self.last_update_timestamp] + [x.last_update_timestamp for x in cells])
class PageSnapshot(models.Model):
page = models.ForeignKey(Page, on_delete=models.SET_NULL, null=True)
timestamp = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True)
comment = models.TextField(blank=True, null=True)
serialization = JSONField(blank=True)
class Meta:
ordering = ('-timestamp',)
@classmethod
def take(cls, page, request=None, comment=None, deletion=False):
snapshot = cls(page=page, comment=comment)
if request and not request.user.is_anonymous:
snapshot.user = request.user
if not deletion:
snapshot.serialization = page.get_serialized_page()
else:
snapshot.serialization = {}
snapshot.comment = comment or _('deletion')
snapshot.save()
def get_page(self):
try:
# try reusing existing page
return Page.snapshots.get(snapshot=self)
except Page.DoesNotExist:
page = Page.load_serialized_page(self.serialization, snapshot=self)
page.load_serialized_cells(self.serialization['cells'])
return page
class CellMeta(MediaDefiningClass, ModelBase):
pass
@ -375,7 +435,7 @@ class CellBase(models.Model):
label = unicode(self.get_verbose_name())
additional_label = self.get_additional_label()
if label and additional_label:
return '%s (%s)' % (label, re.sub(r'\r?\n', ' ', additional_label))
return '%s (%s)' % (label, re.sub(r'\r?\n', ' ', force_text(additional_label)))
else:
return label

View File

@ -0,0 +1,32 @@
{% extends "combo/manager_base.html" %}
{% load i18n %}
{% load cells %}
{% load thumbnail %}
{% block appbar %}
<h2>{% trans 'Page History' %} - {{ view.page.title }}</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'combo-manager-page-view' pk=view.page.id %}">{% trans 'Page' %} - {{view.page.title }}</a>
<a href="{% url 'combo-manager-page-history' pk=view.page.id %}">{% trans "History" %}</a>
{% endblock %}
{% block content %}
<div>
<ul class="objects-list">
{% for snapshot in object_list %}
<li>{{ snapshot.timestamp }}, {{ snapshot.comment }}
{% if snapshot.user %} ({{ snapshot.user.get_full_name }}){% endif %}
<a href="{% url 'combo-snapshot-view' pk=snapshot.id %}">{% trans "view" %}</a>
</li>
{% endfor %}
</ul>
{% include "gadjo/pagination.html" %}
</div>
{% endblock %}

View File

@ -6,6 +6,7 @@
{% block appbar %}
<h2>{% trans 'Page' %} - {{ object.title }}</h2>
<a href="{{ object.get_online_url }}">{% trans 'see online' %}</a>
<a href="{% url 'combo-manager-page-history' pk=object.id %}">{% trans 'history' %}</a>
<a href="{% url 'combo-manager-page-export' pk=object.id %}">{% trans 'export' %}</a>
<a rel="popup" href="{% url 'combo-manager-page-delete' pk=object.id %}">{% trans 'delete' %}</a>
{% endblock %}

View File

@ -53,6 +53,8 @@ urlpatterns = [
name='combo-manager-page-delete'),
url(r'^pages/(?P<pk>\w+)/export$', views.page_export,
name='combo-manager-page-export'),
url(r'^pages/(?P<pk>\w+)/history$', views.page_history,
name='combo-manager-page-history'),
url(r'^pages/(?P<page_pk>\w+)/add-cell-to-(?P<ph_key>[\w_-]+)/(?P<cell_type>\w+)/(?P<variant>[\w-]+)/$',
views.page_add_cell,
name='combo-manager-page-add-cell'),

View File

@ -31,7 +31,7 @@ from django.views.decorators.csrf import requires_csrf_token
from django.views.generic import (TemplateView, RedirectView, DetailView,
CreateView, UpdateView, ListView, DeleteView, FormView)
from combo.data.models import Page, CellBase, ParentContentCell
from combo.data.models import Page, CellBase, ParentContentCell, PageSnapshot
from combo.data.library import get_cell_class
from combo.data.utils import export_site, import_site, MissingGroups
from combo import plugins
@ -119,6 +119,12 @@ page_add = PageAddView.as_view()
class PageEditView(UpdateView):
model = Page
template_name = 'combo/page_add.html'
comment = None
def form_valid(self, form):
result = super(PageEditView, self).form_valid(form)
PageSnapshot.take(self.get_object(), request=self.request, comment=self.comment)
return result
def get_success_url(self):
return reverse('combo-manager-page-view', kwargs={'pk': self.object.id})
@ -127,6 +133,10 @@ class PageEditView(UpdateView):
class PageSelectTemplateView(PageEditView):
form_class = PageSelectTemplateForm
@property
def comment(self):
return _('switched template to %s') % settings.COMBO_PUBLIC_TEMPLATES[self.object.template_name]['name']
def form_valid(self, form):
old_template_name = self.get_object().template_name
new_template_name = self.object.template_name
@ -150,42 +160,49 @@ page_select_template = PageSelectTemplateView.as_view()
class PageEditRedirectionView(PageEditView):
form_class = PageEditRedirectionForm
comment = _('changed redirection')
page_edit_redirection = PageEditRedirectionView.as_view()
class PageEditExcludeFromNavigationView(PageEditView):
form_class = PageEditExcludeFromNavigationForm
comment = _('changed navigation exclusion')
page_edit_exclude_from_navigation = PageEditExcludeFromNavigationView.as_view()
class PageEditSlugView(PageEditView):
form_class = PageEditSlugForm
comment = _('changed slug')
page_edit_slug = PageEditSlugView.as_view()
class PageEditDescriptionView(PageEditView):
form_class = PageEditDescriptionForm
comment = _('changed description')
page_edit_description = PageEditDescriptionView.as_view()
class PageEditTitleView(PageEditView):
form_class = PageEditTitleForm
comment = _('changed title')
page_edit_title = PageEditTitleView.as_view()
class PageVisibilityView(PageEditView):
form_class = PageVisibilityForm
comment = _('changed visibility')
page_visibility = PageVisibilityView.as_view()
class PageEditPictureView(PageEditView):
form_class = PageEditPictureForm
comment = _('changed picture')
page_edit_picture = PageEditPictureView.as_view()
@ -197,6 +214,7 @@ class PageRemovePictureView(DetailView):
page = self.get_object()
page.picture = None
page.save()
PageSnapshot.take(page, request=self.request, comment=_('removed picture'))
return HttpResponseRedirect(reverse('combo-manager-page-view', kwargs={'pk': page.id}))
page_remove_picture = PageRemovePictureView.as_view()
@ -252,6 +270,7 @@ class PageDeleteView(DeleteView):
new_parent = deleted_page.parent_id
Page.objects.filter(parent=deleted_page).update(parent=new_parent)
PageSnapshot.take(deleted_page, request=self.request, deletion=True)
return self.delete(request, *args, **kwargs)
def get_context_data(self, **kwargs):
@ -276,6 +295,19 @@ class PageExportView(DetailView):
page_export = PageExportView.as_view()
class PageHistoryView(ListView):
model = PageSnapshot
template_name = 'combo/page_history.html'
paginate_by = 20
def get_queryset(self):
self.page = Page.objects.get(id=self.kwargs['pk'])
return self.page.pagesnapshot_set.all()
page_history = PageHistoryView.as_view()
class PageAddCellView(RedirectView):
permanent = False
@ -289,6 +321,7 @@ class PageAddCellView(RedirectView):
else:
cell.order = 1
cell.save()
PageSnapshot.take(cell.page, request=self.request, comment=_('added cell "%s"') % cell)
return reverse('combo-manager-page-view', kwargs={'pk': page_pk})
page_add_cell = PageAddCellView.as_view()
@ -317,8 +350,11 @@ class PageEditCellView(UpdateView):
def form_valid(self, form):
if self.request.is_ajax():
self.object = form.save()
return self.form_invalid(form)
return super(PageEditCellView, self).form_valid(form)
response = self.form_invalid(form) # avoid redirection
else:
response = super(PageEditCellView, self).form_valid(form)
PageSnapshot.take(self.object.page, request=self.request, comment=_('changed cell "%s"') % self.object)
return response
page_edit_cell = PageEditCellView.as_view()
@ -334,6 +370,14 @@ class PageDeleteCellView(DeleteView):
except ObjectDoesNotExist:
raise Http404()
def delete(self, request, *args, **kwargs):
cell = self.get_object()
comment = _('removed cell "%s"') % cell
page = cell.page
response = super(PageDeleteCellView, self).delete(request, *args, **kwargs)
PageSnapshot.take(page, request=self.request, comment=comment)
return response
def get_success_url(self):
return reverse('combo-manager-page-view', kwargs={'pk': self.kwargs.get('page_pk')})
@ -359,6 +403,7 @@ page_cell_options = PageCellOptionsView.as_view()
def cell_order(request, page_pk):
has_changes = False
for cell in CellBase.get_cells(page_id=page_pk):
old_order = cell.order
old_placeholder = cell.placeholder
@ -373,7 +418,11 @@ def cell_order(request, page_pk):
if new_order != old_order or new_placeholder != old_placeholder:
cell.order = new_order
cell.placeholder = new_placeholder
has_changes = True
cell.save()
if has_changes:
page = Page.objects.get(id=page_pk)
PageSnapshot.take(page, request=request, comment=_('reordered cells'))
return HttpResponse(status=204)

View File

@ -16,6 +16,7 @@
from django.conf.urls import url
from combo.urls_utils import manager_required
from . import views
urlpatterns = [
@ -23,6 +24,7 @@ urlpatterns = [
url(r'^api/search/', views.api_search, name='api-search'),
url(r'^ajax/cell/(?P<page_pk>\w+)/(?P<cell_reference>[\w_-]+)/$',
views.ajax_page_cell, name='combo-public-ajax-page-cell'),
url(r'^snapshot/(?P<pk>\w+)/$', manager_required(views.snapshot), name='combo-snapshot-view'),
url(r'__style__/$', views.style),
url(r'__skeleton__/$', views.skeleton),
url(r'', views.page),

View File

@ -44,7 +44,7 @@ if 'mellon' in settings.INSTALLED_APPS:
else:
get_idps = lambda: []
from combo.data.models import CellBase, PostException, Page, ParentContentCell, TextCell
from combo.data.models import CellBase, PostException, Page, ParentContentCell, TextCell, PageSnapshot
from combo.profile.models import Profile
from combo.apps.search.models import SearchCell
from combo import utils
@ -73,7 +73,15 @@ def ajax_page_cell(request, page_pk, cell_reference):
try:
page = Page.objects.get(id=page_pk)
except Page.DoesNotExist:
raise Http404()
# check it's not from a snapshots
try:
page = Page.snapshots.get(id=page_pk)
except Page.DoesNotExist:
raise Http404()
# as it's from a snapshot access is limited to managers
if not (request.user and request.user.is_staff):
raise PermissionDenied()
if not page.is_visible(request.user):
raise PermissionDenied()
try:
@ -441,3 +449,8 @@ def api_search(request):
})
return HttpResponse(json.dumps({'data': hits}), content_type='application/json')
def snapshot(request, *args, **kwargs):
snapshot = PageSnapshot.objects.get(id=kwargs['pk'])
return publish_page(request, snapshot.get_page())

View File

@ -19,7 +19,7 @@ from webtest import TestApp
from webtest import Upload
from combo.wsgi import application
from combo.data.models import Page, CellBase, TextCell, LinkCell, ConfigJsonCell, JsonCell
from combo.data.models import Page, CellBase, TextCell, LinkCell, ConfigJsonCell, JsonCell, PageSnapshot
from combo.apps.family.models import FamilyInfosCell
from combo.apps.search.models import SearchCell
@ -727,3 +727,104 @@ def test_page_discover_placeholder_with_error_cells(app, admin_user):
resp = app.get('/manage/pages/%s/' % page.id)
assert re.findall('data-placeholder-key="(.*)">', resp.body) == ['content', 'footer']
def test_page_versionning(app, admin_user):
Page.objects.all().delete()
PageSnapshot.objects.all()
page = Page(title='One', slug='one')
page.save()
cell1 = TextCell(page=page, placeholder='content', text='Foobar1', order=0)
cell1.save()
cell2 = TextCell(page=page, placeholder='content', text='Foobar2', order=1)
cell2.save()
cell3 = TextCell(page=page, placeholder='content', text='Foobar3', order=1)
cell3.save()
anonymous_app = app
app = login(app)
resp = app.get('/manage/pages/%s/' % page.id, status=200)
# update title
resp = resp.click(href='.*/title')
resp.form['title'].value = 'One Two'
resp = resp.form.submit()
resp = resp.follow()
assert Page.objects.all()[0].title == 'One Two'
assert PageSnapshot.objects.all().count() == 1
# change cell text
resp.forms[0]['c%s-text' % cell1.get_reference()].value = 'Hello world'
resp = resp.forms[0].submit().follow()
assert PageSnapshot.objects.all().count() == 2
# reorder cells
params = []
for i, cell in enumerate([cell3, cell1, cell2]):
params.append(('ph_data_textcell-%s' % cell.id, 'content')) # no placeholder change
params.append(('pos_data_textcell-%s' % cell.id, str(i)))
app.get('/manage/pages/%s/order?%s' % (page.id, urllib.urlencode(params)))
assert PageSnapshot.objects.all().count() == 3
resp = resp.click('history')
assert resp.body.index('reordered cells') < resp.body.index('changed cell') < resp.body.index('changed title')
resp2 = resp.click('view', index=1)
assert resp2.body.index('Hello world') < resp2.body.index('Foobar3')
resp2 = resp.click('view', index=0)
assert resp2.body.index('Hello world') > resp2.body.index('Foobar3')
resp2 = resp.click('view', index=2)
assert 'Foobar1' in resp2.body
assert not 'Hello world' in resp2.body
assert Page.objects.all().count() == 1
# check with asynchronous cells
resp = app.get('/manage/pages/%s/add-cell-to-content/data_jsoncell/default/' % page.id)
resp = resp.follow()
resp.forms[3]['cdata_jsoncell-1-template_string'].value = 'A{{json.data.0.text}}B'
resp.forms[3]['cdata_jsoncell-1-url'].value = 'http://example.com'
resp = resp.forms[3].submit().follow()
assert PageSnapshot.objects.all().count() == 5 # add + change
resp.forms[3]['cdata_jsoncell-1-template_string'].value = 'C{{json.data.0.text}}D'
resp = resp.forms[3].submit().follow()
assert PageSnapshot.objects.all().count() == 6
resp.forms[1]['c%s-text' % cell1.get_reference()].value = 'Foo back to 1'
resp = resp.forms[0].submit().follow()
resp = resp.click('history')
assert 'added cell' in resp.body
resp2 = resp.click('view', index=1)
json_cell_url = re.findall(r'/ajax/cell/.*/data_jsoncell-.*/', resp2.body)[0]
with mock.patch('combo.utils.requests.get') as requests_get:
data = {'data': [{'url': 'xxx', 'text': 'xxx'}]}
requests_get.return_value = mock.Mock(content=json.dumps(data), status_code=200)
resp3 = app.get(json_cell_url)
assert resp3.body.strip() == 'CxxxD'
# previous version should return AxxxB
resp2 = resp.click('view', index=2)
json_cell_url = re.findall(r'/ajax/cell/.*/data_jsoncell-.*/', resp2.body)[0]
with mock.patch('combo.utils.requests.get') as requests_get:
data = {'data': [{'url': 'xxx', 'text': 'xxx'}]}
requests_get.return_value = mock.Mock(content=json.dumps(data), status_code=200)
resp3 = app.get(json_cell_url)
assert resp3.body.strip() == 'AxxxB'
# check anonymous users can't get to cells from snapshots
app.get('/logout/')
resp3 = app.get(json_cell_url, status=403)
# clean it up
Page.snapshots.all().delete()
assert JsonCell.objects.count() == 1