Diffing streamfieds

pull/5044/head
Karl Hobley 2018-11-28 20:58:35 +00:00 zatwierdzone przez Andy Chosak
rodzic 6dba61fc3a
commit 6e2b8c28d0
6 zmienionych plików z 267 dodań i 21 usunięć

Wyświetl plik

@ -7,6 +7,7 @@ Changelog
* Added support for customising EditHandler-based forms on a per-request basis (Bertrand Bordage)
* Added more informative error message when `|richtext` filter is applied to a non-string value (mukesh5)
* Automatic search indexing can now be disabled on a per-model basis via the `search_auto_update` attribute (Karl Hobley)
* Improved diffing of StreamFields when comparing page revisions (Karl Hobley)
* Fix: Set `SERVER_PORT` to 443 in `Page.dummy_request()` for HTTPS sites (Sergey Fedoseev)
* Fix: Include port number in `Host` header of `Page.dummy_request()` (Sergey Fedoseev)
* Fix: Validation error messages in `InlinePanel` no longer count towards `max_num` when disabling the 'add' button (Todd Dembrey, Thibaud Colas)

Wyświetl plik

@ -17,6 +17,7 @@ Other features
* Added support for customising EditHandler-based forms on a per-request basis (Bertrand Bordage)
* Added more informative error message when ``|richtext`` filter is applied to a non-string value (mukesh5)
* Automatic search indexing can now be disabled on a per-model basis via the ``search_auto_update`` attribute (Karl Hobley)
* Improved diffing of StreamFields when comparing page revisions (Karl Hobley)
Bug fixes

Wyświetl plik

@ -2,11 +2,13 @@ import difflib
from bs4 import BeautifulSoup
from django.utils.encoding import force_text
from django.utils.html import escape
from django.utils.html import escape, format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import ugettext_lazy as _
from wagtail.core import blocks
class FieldComparison:
is_field = True
@ -55,8 +57,171 @@ class RichTextFieldComparison(TextFieldComparison):
).to_html()
class StreamFieldComparison(RichTextFieldComparison):
pass
def get_comparison_class_for_block(block):
if hasattr(block, 'get_comparison_class'):
return block.get_comparison_class()
elif isinstance(block, blocks.CharBlock):
return CharBlockComparison
elif isinstance(block, blocks.RichTextBlock):
return RichTextBlockComparison
elif isinstance(block, blocks.StructBlock):
return StructBlockComparison
else:
# As all stream field blocks have a HTML representation, fall back to diffing that.
return RichTextBlockComparison
class BlockComparison:
def __init__(self, block, exists_a, exists_b, val_a, val_b):
self.block = block
self.exists_a = exists_a
self.exists_b = exists_b
self.val_a = val_a
self.val_b = val_b
def is_new(self):
return self.exists_b and not self.exists_a
def is_deleted(self):
return self.exists_a and not self.exists_b
def has_changed(self):
return self.val_a != self.val_b
def htmlvalue(self, val):
return self.block.render_basic(val)
class CharBlockComparison(BlockComparison):
def htmldiff(self):
return diff_text(
force_text(self.val_a),
force_text(self.val_b)
).to_html()
class RichTextBlockComparison(BlockComparison):
def htmldiff(self):
return diff_text(
BeautifulSoup(force_text(self.val_a), 'html5lib').getText(),
BeautifulSoup(force_text(self.val_b), 'html5lib').getText()
).to_html()
class StructBlockComparison(BlockComparison):
def htmlvalue(self, val):
htmlvalues = []
for name, block in self.block.child_blocks.items():
label = self.block.child_blocks[name].label
comparison_class = get_comparison_class_for_block(block)
htmlvalues.append(label, comparison_class(block, True, True, val[name], val[name]).htmlvalue(val[name]))
return format_html('<dl>\n{}\n</dl>', format_html_join(
'\n', ' <dt>{}</dt>\n <dd>{}</dd>', htmlvalues))
def htmldiff(self):
htmldiffs = []
for name, block in self.block.child_blocks.items():
label = self.block.child_blocks[name].label
comparison_class = get_comparison_class_for_block(block)
htmldiffs.append(label, comparison_class(block, self.exists_a, self.exists_b, self.val_a[name], self.val_b[name]).htmldiff())
return format_html('<dl>\n{}\n</dl>', format_html_join(
'\n', ' <dt>{}</dt>\n <dd>{}</dd>', htmldiffs))
class StreamBlockComparison(BlockComparison):
def get_block_comparisons(self):
a_blocks = list(self.val_a) or []
b_blocks = list(self.val_b) or []
a_blocks_by_id = {block.id: block for block in a_blocks}
b_blocks_by_id = {block.id: block for block in b_blocks}
deleted_ids = a_blocks_by_id.keys() - b_blocks_by_id.keys()
comparisons = []
for block in b_blocks:
comparison_class = get_comparison_class_for_block(block.block)
if block.id in a_blocks_by_id:
# Changed/existing block
comparisons.append(comparison_class(block.block, True, True, a_blocks_by_id[block.id].value, block.value))
else:
# New block
comparisons.append(comparison_class(block.block, False, True, None, block.value))
# Insert deleted blocks at the index where they used to be
deleted_block_indices = [(block, i) for i, block in enumerate(a_blocks) if block.id in deleted_ids]
for block, index in deleted_block_indices:
comparison_class = get_comparison_class_for_block(block.block)
comparison_to_insert = comparison_class(block.block, True, False, block.value, None)
# Insert the block back in where it was before it was deleted.
# Note: we need to account for new blocks when finding the position.
current_index = 0
block_inserted = False
for i, comparison in enumerate(comparisons):
if comparison.is_new():
continue
if current_index == index:
comparisons.insert(i, comparison_to_insert)
block_inserted = True
break
current_index += 1
# Deleted block was from the end
if not block_inserted:
comparisons.append(comparison_to_insert)
return comparisons
def htmldiff(self):
comparisons_html = []
for comparison in self.get_block_comparisons():
classes = ['comparison__child-object']
if comparison.is_new():
classes.append('addition')
block_rendered = comparison.htmlvalue(comparison.val_b)
elif comparison.is_deleted():
classes.append('deletion')
block_rendered = comparison.htmlvalue(comparison.val_a)
elif comparison.has_changed():
block_rendered = comparison.htmldiff()
else:
block_rendered = comparison.htmlvalue(comparison.val_a)
classes = ' '.join(classes)
comparisons_html.append('<div class="{0}">{1}</div>'.format(classes, block_rendered))
return mark_safe('\n'.join(comparisons_html))
class StreamFieldComparison(FieldComparison):
def has_block_ids(self, val):
if not val:
return True
return bool(val[0].id)
def htmldiff(self):
# Our method for diffing streamfields relies on the blocks in both revisions having UUIDs.
# But as UUIDs were added in Wagtail 1.11 we can't compare revisions that were created before
# that Wagtail version.
if self.has_block_ids(self.val_a) and self.has_block_ids(self.val_b):
return StreamBlockComparison(self.field.stream_block, True, True, self.val_a, self.val_b).htmldiff()
else:
# Fall back to diffing the HTML representation
return diff_text(
BeautifulSoup(force_text(self.val_a), 'html5lib').getText(),
BeautifulSoup(force_text(self.val_b), 'html5lib').getText()
).to_html()
class ChoiceFieldComparison(FieldComparison):

Wyświetl plik

@ -10,6 +10,10 @@ $color-deletion-light: #ffebeb;
border-top: 1px dashed $color-grey-4;
padding: 1em;
dd {
margin-left: 40px;
}
&:first-child {
border-top: 0;
}

Wyświetl plik

@ -1,4 +1,3 @@
import unittest
from functools import partial
from django.test import TestCase
@ -105,17 +104,17 @@ class TestStreamFieldComparison(TestCase):
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content"),
('text', "Content", '1'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content"),
('text', "Content", '1'),
])),
)
self.assertTrue(comparison.is_field)
self.assertFalse(comparison.is_child_relation)
self.assertEqual(comparison.field_label(), "Body")
self.assertEqual(comparison.htmldiff(), 'Content')
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Content</div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)
self.assertFalse(comparison.has_changed())
@ -125,32 +124,90 @@ class TestStreamFieldComparison(TestCase):
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('text', "Original content"),
('text', "Original content", '1'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('text', "Modified content"),
('text', "Modified content", '1'),
])),
)
self.assertEqual(comparison.htmldiff(), '<span class="deletion">Original</span><span class="addition">Modified</span> content')
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object"><span class="deletion">Original</span><span class="addition">Modified</span> content</div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)
self.assertTrue(comparison.has_changed())
def test_add_block(self):
field = StreamPage._meta.get_field('body')
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content", '1'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content", '1'),
('text', "New Content", '2'),
])),
)
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Content</div>\n<div class="comparison__child-object addition">New Content</div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)
self.assertTrue(comparison.has_changed())
def test_delete_block(self):
field = StreamPage._meta.get_field('body')
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content", '1'),
('text', "Content Foo", '2'),
('text', "Content Bar", '3'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content", '1'),
('text', "Content Bar", '3'),
])),
)
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Content</div>\n<div class="comparison__child-object deletion">Content Foo</div>\n<div class="comparison__child-object">Content Bar</div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)
self.assertTrue(comparison.has_changed())
def test_edit_block(self):
field = StreamPage._meta.get_field('body')
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content", '1'),
('text', "Content Foo", '2'),
('text', "Content Bar", '3'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('text', "Content", '1'),
('text', "Content Baz", '2'),
('text', "Content Bar", '3'),
])),
)
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Content</div>\n<div class="comparison__child-object">Content <span class="deletion">Foo</span><span class="addition">Baz</span></div>\n<div class="comparison__child-object">Content Bar</div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)
self.assertTrue(comparison.has_changed())
@unittest.expectedFailure
def test_has_changed_richtext(self):
field = StreamPage._meta.get_field('body')
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('rich_text', "<b>Original</b> content"),
('rich_text', "<b>Original</b> content", '1'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('rich_text', "Modified <i>content</i>"),
('rich_text', "Modified <i>content</i>", '1'),
])),
)
self.assertEqual(comparison.htmldiff(), '<span class="deletion">Original</span><span class="addition">Modified</span> content')
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object"><span class="deletion">Original</span><span class="addition">Modified</span> content</div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)
self.assertTrue(comparison.has_changed())
@ -160,31 +217,30 @@ class TestStreamFieldComparison(TestCase):
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('text', "Original content"),
('text', "Original content", '1'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('text', '<script type="text/javascript">doSomethingBad();</script>'),
('text', '<script type="text/javascript">doSomethingBad();</script>', '1'),
])),
)
self.assertEqual(comparison.htmldiff(), '<span class="deletion">Original content</span><span class="addition">&lt;script type=&quot;text/javascript&quot;&gt;doSomethingBad();&lt;/script&gt;</span>')
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object"><span class="deletion">Original content</span><span class="addition">&lt;script type=&quot;text/javascript&quot;&gt;doSomethingBad();&lt;/script&gt;</span></div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)
@unittest.expectedFailure
def test_htmldiff_escapes_value_richtext(self):
field = StreamPage._meta.get_field('body')
comparison = self.comparison_class(
field,
StreamPage(body=StreamValue(field.stream_block, [
('rich_text', "Original content"),
('rich_text', "Original content", '1'),
])),
StreamPage(body=StreamValue(field.stream_block, [
('rich_text', '<script type="text/javascript">doSomethingBad();</script>'),
('rich_text', '<script type="text/javascript">doSomethingBad();</script>', '1'),
])),
)
self.assertEqual(comparison.htmldiff(), '<span class="deletion">Original content</span><span class="addition">doSomethingBad();</span>')
self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object"><span class="deletion">Original content</span><span class="addition">doSomethingBad();</span></div>')
self.assertIsInstance(comparison.htmldiff(), SafeText)

Wyświetl plik

@ -1,5 +1,7 @@
from django.template.loader import render_to_string
from django.utils.functional import cached_property
from wagtail.admin.compare import BlockComparison
from wagtail.core.blocks import ChooserBlock
from .shortcuts import get_rendition_or_not_found
@ -22,5 +24,22 @@ class ImageChooserBlock(ChooserBlock):
else:
return ''
def get_comparison_class(self):
return ImageChooserBlockComparison
class Meta:
icon = "image"
class ImageChooserBlockComparison(BlockComparison):
def htmlvalue(self, val):
return render_to_string("wagtailimages/widgets/compare.html", {
'image_a': val,
'image_b': val,
})
def htmldiff(self):
return render_to_string("wagtailimages/widgets/compare.html", {
'image_a': self.val_a,
'image_b': self.val_b,
})