Projet

Général

Profil

0001-fields-new-field-RichTextField-36498.patch

Lauréline Guérin, 20 septembre 2022 14:24

Télécharger (16,7 ko)

Voir les différences:

Subject: [PATCH 1/2] fields: new field RichTextField (#36498)

 tests/admin_pages/test_form.py    | 28 +++++++++++++++
 tests/api/test_formdata.py        |  5 ++-
 tests/test_fields.py              |  7 +++-
 tests/test_formdef_import.py      |  4 +--
 tests/test_templates.py           | 24 +++++++++++++
 wcs/fields.py                     | 56 ++++++++++++++++++++++++++---
 wcs/formdef.py                    |  2 ++
 wcs/qommon/form.py                |  4 +++
 wcs/qommon/misc.py                | 60 +++++++++++++++++++++++++++++++
 wcs/qommon/templatetags/qommon.py | 11 +++++-
 wcs/wf/export_to_model.py         |  2 ++
 11 files changed, 193 insertions(+), 10 deletions(-)
tests/admin_pages/test_form.py
2022 2022
    assert 'syntax error' in resp.text
2023 2023

  
2024 2024

  
2025
def test_form_edit_text_field(pub):
2026
    create_superuser(pub)
2027
    create_role(pub)
2028

  
2029
    FormDef.wipe()
2030
    formdef = FormDef()
2031
    formdef.name = 'form title'
2032
    formdef.fields = [fields.TextField(id='1', label='1st field', type='text')]
2033
    formdef.store()
2034

  
2035
    app = login(get_app(pub))
2036
    resp = app.get('/backoffice/forms/1/fields/1/')
2037
    assert resp.form['display_mode'].options == [
2038
        ('Plain Text (paragraphs)', True, None),
2039
        ('Preformatted Text', False, None),
2040
    ]
2041

  
2042
    pub.site_options.set('options', 'enable-richtext-field', 'true')
2043
    with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd:
2044
        pub.site_options.write(fd)
2045
    resp = app.get('/backoffice/forms/1/fields/1/')
2046
    assert resp.form['display_mode'].options == [
2047
        ('Plain Text (paragraphs)', True, None),
2048
        ('Preformatted Text', False, None),
2049
        ('Rich Text', False, None),
2050
    ]
2051

  
2052

  
2025 2053
def test_form_edit_item_field(pub):
2026 2054
    create_superuser(pub)
2027 2055
    create_role(pub)
tests/api/test_formdata.py
139 139
        fields.FileField(id='3', label='foobar4', varname='file'),
140 140
        fields.ItemField(id='4', label='foobar5', varname='item', data_source={'type': 'foobar'}),
141 141
        fields.BlockField(id='5', label='test', varname='blockdata', type='block:foobar', max_items=3),
142
        fields.TextField(id='6', label='rich text', varname='richtext', type='text', display_mode='rich'),
142 143
    ]
143 144
    Workflow.wipe()
144 145
    workflow = Workflow(name='foo')
......
168 169
            'schema': {},  # not important here
169 170
        },
170 171
        '5_display': 'hello',
172
        '6': '<script></script><p>foo</p>',
171 173
    }
172 174
    formdata.data['4_display'] = item_field.store_display_value(formdata.data, item_field.id)
173 175
    formdata.data['4_structured'] = item_field.store_structured_value(formdata.data, item_field.id)
......
186 188

  
187 189
    assert datetime.datetime.strptime(resp.json['last_update_time'], '%Y-%m-%dT%H:%M:%S')
188 190
    assert datetime.datetime.strptime(resp.json['receipt_time'], '%Y-%m-%dT%H:%M:%S')
189
    assert len(resp.json['fields']) == 8
191
    assert len(resp.json['fields']) == 9
190 192
    assert 'foobar' in resp.json['fields']
191 193
    assert 'foobar2' not in resp.json['fields']  # foobar2 has no varname, not in json
192 194
    assert resp.json['user']['name'] == local_user.name
......
204 206
    assert resp.json['fields']['blockdata_raw'] == [
205 207
        {'foo': 'plop', 'bar': 'foo', 'bar_raw': '1', 'bar_structured': 'XXX'}
206 208
    ]
209
    assert resp.json['fields']['richtext'] == '<p>foo</p>'  # only allowed tags
207 210
    assert resp.json['workflow']['status']['name'] == 'New'
208 211
    assert resp.json['workflow']['real_status']['name'] == 'New'
209 212
    assert resp.json['submission'] == {'backoffice': False, 'channel': 'web'}
tests/test_fields.py
105 105
    assert fields.TextField().get_view_short_value('foo' * 15) == ('foo' * 10)[:27] + '(…)'
106 106
    assert fields.TextField().get_view_value('foo') == '<p>foo</p>'
107 107
    assert fields.TextField().get_view_value('foo\n\nfoo') == '<p>foo\n</p><p>\nfoo</p>'
108
    assert fields.TextField(pre=True).get_view_value('foo') == '<pre>foo</pre>'
108
    assert fields.TextField(display_mode='pre').get_view_value('foo') == '<pre>foo</pre>'
109
    assert (
110
        fields.TextField(display_mode='rich').get_view_short_value('<p>foo</p>' * 15)
111
        == ('foo' * 10)[:27] + '(…)'
112
    )
113
    assert fields.TextField(display_mode='rich').get_view_value('<script></script><p>foo</p>') == '<p>foo</p>'
109 114

  
110 115
    form = Form(use_tokens=False)
111 116
    fields.TextField().add_to_form(form)
tests/test_formdef_import.py
148 148
    formdef = FormDef()
149 149
    formdef.name = 'Blah'
150 150
    formdef.fields = [
151
        fields.TextField(type='text', label='Bar', pre=True),
151
        fields.TextField(type='text', label='Bar', display_mode='pre'),
152 152
        fields.EmailField(type='email', label='Bar'),
153 153
        fields.BoolField(type='bool', label='Bar'),
154 154
        fields.DateField(type='date', label='Bar', minimum_date='2014-01-01'),
......
204 204
    formdef = FormDef()
205 205
    formdef.name = 'Blah'
206 206
    formdef.fields = [
207
        fields.TextField(type='text', label='Bar', pre=True),
207
        fields.TextField(type='text', label='Bar', display_mode='pre'),
208 208
        fields.EmailField(type='email', label='Bar'),
209 209
        fields.BoolField(type='bool', label='Bar'),
210 210
        fields.DateField(type='date', label='Bar', minimum_date='2014-01-01'),
tests/test_templates.py
1477 1477
    context = {'template': 'Hello {{ form_var_foo }}'}
1478 1478
    assert Template('{{ template }}').render(context) == 'Hello {{ form_var_foo }}'
1479 1479
    assert Template('{{ template|as_template }}').render(context) == 'Hello Foo Bar'
1480

  
1481

  
1482
def test_stripsometags(pub):
1483
    context = {
1484
        'value': (
1485
            '<h1>title 1</h1>'
1486
            '<script>my-script</script><p><em>foo</em></p><a href="link" other-attr="foobar">link</a><br />'
1487
            '<strong>strong</strong>'
1488
            '<ul><li>li 1</li><li>li 2</li></ul>'
1489
        )
1490
    }
1491

  
1492
    assert Template('{{ value|stripsometags }}').render(context) == 'title 1my-scriptfoolinkstrongli 1li 2'
1493
    assert (
1494
        Template('{{ value|stripsometags:"p,br" }}').render(context)
1495
        == 'title 1my-script<p>foo</p>link<br />strongli 1li 2'
1496
    )
1497
    assert (
1498
        Template('{{ value|stripsometags:"strong,em" }}').render(context)
1499
        == 'title 1my-script<em>foo</em>link<strong>strong</strong>li 1li 2'
1500
    )
1501
    assert Template('{{ value|stripsometags:"p,br,h1,ul,li" }}').render(context) == (
1502
        '<h1>title 1</h1>my-script<p>foo</p>link<br />strong<ul><li>li 1</li><li>li 2</li></ul>'
1503
    )
wcs/fields.py
28 28

  
29 29
from django.utils.encoding import force_bytes, force_text, smart_text
30 30
from django.utils.formats import date_format as django_date_format
31
from django.utils.html import urlize
31
from django.utils.html import strip_tags, urlize
32 32
from quixote import get_publisher, get_request, get_session
33 33
from quixote.html import TemplateIO, htmlescape, htmltag, htmltext
34 34

  
......
59 59
    PasswordEntryWidget,
60 60
    RadiobuttonsWidget,
61 61
    RankedItemsWidget,
62
    RichTextWidget,
62 63
    SingleSelectHintWidget,
63 64
    SingleSelectTableWidget,
64 65
    SingleSelectWidget,
......
80 81
    get_document_type_value_options,
81 82
    get_document_types,
82 83
    strftime,
84
    strip_some_tags,
83 85
    xml_node_text,
84 86
)
85 87
from .qommon.ods import NS as OD_NS
......
1322 1324
    widget_class = TextWidget
1323 1325
    cols = None
1324 1326
    rows = None
1325
    pre = False
1327
    pre = None
1328
    display_mode = 'plain'
1326 1329
    maxlength = None
1327 1330
    extra_attributes = ['cols', 'rows', 'maxlength']
1328 1331

  
1332
    def migrate(self):
1333
        changed = super().migrate()
1334
        if isinstance(getattr(self, 'pre', None), bool):
1335
            if self.pre:
1336
                self.display_mode = 'pre'
1337
            else:
1338
                self.display_mode = 'plain'
1339
            self.pre = None
1340
            changed = True
1341
        return changed
1342

  
1343
    def perform_more_widget_changes(self, *args, **kwargs):
1344
        if self.display_mode == 'rich':
1345
            self.widget_class = RichTextWidget
1346

  
1329 1347
    def fill_admin_form(self, form):
1330 1348
        WidgetField.fill_admin_form(self, form)
1331 1349
        if self.cols:
......
1343 1361
            form.add(HiddenWidget, 'cols', value=None)
1344 1362
        form.add(StringWidget, 'rows', title=_('Number of rows'), value=self.rows)
1345 1363
        form.add(StringWidget, 'maxlength', title=_('Maximum number of characters'), value=self.maxlength)
1346
        form.add(CheckboxWidget, 'pre', title=_('Preformatted Text'), value=self.pre, advanced=True)
1364
        display_options = [
1365
            ('plain', _('Plain Text (paragraphs)')),
1366
            ('pre', _('Preformatted Text')),
1367
        ]
1368
        if get_publisher().get_site_option('enable-richtext-field'):
1369
            display_options += [
1370
                ('rich', _('Rich Text')),
1371
            ]
1372
        form.add(
1373
            RadiobuttonsWidget,
1374
            'display_mode',
1375
            title=_('Text display'),
1376
            value=self.display_mode,
1377
            default_value='plain',
1378
            options=display_options,
1379
            extra_css_class='widget-inline-radio no-bottom-margin',
1380
            advanced=True,
1381
        )
1347 1382

  
1348 1383
    def get_admin_attributes(self):
1349
        return WidgetField.get_admin_attributes(self) + ['cols', 'rows', 'pre', 'maxlength']
1384
        return WidgetField.get_admin_attributes(self) + ['cols', 'rows', 'display_mode', 'maxlength']
1350 1385

  
1351 1386
    def convert_value_from_str(self, value):
1352 1387
        return value
1353 1388

  
1354 1389
    def get_view_value(self, value, **kwargs):
1355
        if self.pre:
1390
        if self.display_mode == 'pre':
1356 1391
            return htmltext('<pre>') + value + htmltext('</pre>')
1392
        elif self.display_mode == 'rich':
1393
            return htmltext(strip_some_tags(value, RichTextWidget.ALL_TAGS))
1357 1394
        else:
1358 1395
            try:
1359 1396
                return (
......
1365 1402
                return ''
1366 1403

  
1367 1404
    def get_opendocument_node_value(self, value, formdata=None, **kwargs):
1405
        if self.display_mode == 'rich':
1406
            return
1368 1407
        paragraphs = []
1369 1408
        for paragraph in value.splitlines():
1370 1409
            if paragraph.strip():
......
1374 1413
        return paragraphs
1375 1414

  
1376 1415
    def get_view_short_value(self, value, max_len=30):
1416
        if self.display_mode == 'rich':
1417
            return ellipsize(str(strip_tags(value)), max_len)
1377 1418
        return ellipsize(str(value), max_len)
1378 1419

  
1420
    def get_json_value(self, value, **kwargs):
1421
        if self.display_mode == 'rich':
1422
            return str(self.get_view_value(value))
1423
        return value
1424

  
1379 1425

  
1380 1426
register_field_class(TextField)
1381 1427

  
wcs/formdef.py
1666 1666
                ),
1667 1667
            ):
1668 1668
                continue
1669
            if isinstance(field, fields.TextField) and field.display_mode == 'rich':
1670
                continue
1669 1671
            if data is None:
1670 1672
                continue
1671 1673
            if data.get(field.id) is None:
wcs/qommon/form.py
2440 2440
        get_response().add_css_include('../xstatic/css/godo.css')
2441 2441

  
2442 2442

  
2443
class RichTextWidget(MiniRichTextWidget):
2444
    ALL_TAGS = ['p', 'b', 'strong', 'i', 'em', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'li']
2445

  
2446

  
2443 2447
class TableWidget(CompositeWidget):
2444 2448
    readonly = False
2445 2449

  
wcs/qommon/misc.py
52 52
from django.utils import datetime_safe
53 53
from django.utils.encoding import force_bytes, force_text
54 54
from django.utils.formats import localize
55
from django.utils.functional import keep_lazy_text
56
from django.utils.html import MLStripper as DjangoMLStripper
55 57
from django.utils.html import strip_tags
58
from django.utils.safestring import mark_safe
56 59
from django.utils.text import Truncator
57 60
from django.utils.timezone import is_aware, make_naive
58 61
from quixote import get_publisher, get_request, get_response, redirect
......
1147 1150
        raise UnauthorizedPythonUsage()
1148 1151
    # noqa pylint: disable=eval-used
1149 1152
    return eval(expression, *args, **kwargs)
1153

  
1154

  
1155
class MLStripper(DjangoMLStripper):
1156
    def __init__(self, allowed_tags):
1157
        super().__init__()
1158
        self.reset()
1159
        self.fed = []
1160
        self.allowed_tags = allowed_tags
1161

  
1162
    def handle_starttag(self, tag, attrs):
1163
        if tag not in self.allowed_tags:
1164
            return
1165

  
1166
        if tag == 'a':
1167
            for attr in attrs:
1168
                if attr[0] == 'href':
1169
                    self.fed.append('<a href="%s">' % attr[1])
1170
                    return
1171
        if tag == 'br':
1172
            self.fed.append('<br />')
1173
            return
1174

  
1175
        self.fed.append('<%s>' % tag)
1176

  
1177
    def handle_endtag(self, tag):
1178
        if tag not in self.allowed_tags:
1179
            return
1180

  
1181
        if tag == 'br':
1182
            return
1183

  
1184
        self.fed.append('</%s>' % tag)
1185

  
1186

  
1187
def _strip_once(value, allowed_tags):
1188
    """
1189
    Internal tag stripping utility used by strip_some_tags.
1190
    """
1191
    s = MLStripper(allowed_tags)
1192
    s.feed(value)
1193
    s.close()
1194
    return s.get_data()
1195

  
1196

  
1197
@keep_lazy_text
1198
def strip_some_tags(value, allowed_tags):
1199
    """Return the given HTML with all tags stripped except allowed_tags."""
1200
    # Note: in typical case this loop executes _strip_once once. Loop condition
1201
    # is redundant, but helps to reduce number of executions of _strip_once.
1202
    value = str(value)
1203
    while '<' in value and '>' in value:
1204
        new_value = _strip_once(value, allowed_tags)
1205
        if value.count('<') == new_value.count('<'):
1206
            # _strip_once wasn't able to detect more tags.
1207
            break
1208
        value = new_value
1209
    return mark_safe(value)
wcs/qommon/templatetags/qommon.py
53 53
from wcs.qommon import calendar, evalutils, upload_storage
54 54
from wcs.qommon.admin.texts import TextsDirectory
55 55
from wcs.qommon.humantime import seconds2humanduration
56
from wcs.qommon.misc import validate_phone_fr
56
from wcs.qommon.misc import strip_some_tags, validate_phone_fr
57 57

  
58 58
register = template.Library()
59 59

  
......
1022 1022
    from wcs.workflows import WorkflowStatusItem
1023 1023

  
1024 1024
    return WorkflowStatusItem.compute(unlazy(value))
1025

  
1026

  
1027
@register.filter(is_safe=True)
1028
def stripsometags(value, arg=None):
1029
    arg = arg or ''
1030
    allowed_tags = arg.split(',')
1031
    allowed_tags = [t.strip() for t in allowed_tags]
1032
    allowed_tags = [t for t in allowed_tags if t]
1033
    return strip_some_tags(unlazy(value), allowed_tags)
wcs/wf/export_to_model.py
729 729
                unset_value_i.text = _('Not set')
730 730
            else:
731 731
                node_value = f.get_opendocument_node_value(value, formdata)
732
                if node_value is None:
733
                    continue
732 734
                if isinstance(node_value, list):
733 735
                    for node in node_value:
734 736
                        section_node.append(node)
735
-