Projet

Général

Profil

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

Lauréline Guérin, 19 septembre 2022 16:50

Télécharger (14,1 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              | 32 +++++++++++++++++
 tests/test_templates.py           | 24 +++++++++++++
 wcs/fields.py                     | 26 +++++++++++++-
 wcs/formdef.py                    |  1 +
 wcs/qommon/form.py                |  4 +++
 wcs/qommon/misc.py                | 60 +++++++++++++++++++++++++++++++
 wcs/qommon/templatetags/qommon.py | 11 +++++-
 wcs/sql.py                        |  1 +
 10 files changed, 189 insertions(+), 3 deletions(-)
tests/admin_pages/test_form.py
3274 3274
    assert resp.form['data_source$type'].options == [('None', True, 'None'), ('carddef:baz', False, 'Baz')]
3275 3275

  
3276 3276

  
3277
def test_form_new_richtext_field(pub):
3278
    create_superuser(pub)
3279
    create_role(pub)
3280

  
3281
    pub.site_options.set('options', 'enable-richtext-field', 'true')
3282
    with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd:
3283
        pub.site_options.write(fd)
3284

  
3285
    FormDef.wipe()
3286
    formdef = FormDef()
3287
    formdef.name = 'form title'
3288
    formdef.fields = []
3289
    formdef.store()
3290

  
3291
    app = login(get_app(pub))
3292
    resp = app.get('/backoffice/forms/1/')
3293
    resp = resp.click(href='fields/')
3294

  
3295
    resp.forms[0]['label'] = 'foobar'
3296
    resp.forms[0]['type'] = 'richtext'
3297
    resp = resp.forms[0].submit().follow()
3298

  
3299
    assert len(FormDef.get(1).fields) == 1
3300
    field = FormDef.get(1).fields[0]
3301
    assert field.key == 'richtext'
3302
    assert field.label == 'foobar'
3303

  
3304

  
3277 3305
def test_form_category_management_roles(pub, backoffice_user, backoffice_role):
3278 3306
    app = login(get_app(pub), username='backoffice-user', password='backoffice-user')
3279 3307
    app.get('/backoffice/forms/', status=403)
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.RichTextField(id='6', label='rich text', varname='richtext', type='richtext'),
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
120 120
    assert 'rows="12"' in str(form.render())
121 121

  
122 122

  
123
def test_richtext(pub):
124
    assert fields.RichTextField().get_view_short_value('<p>foo</p>' * 15) == ('foo' * 10)[:27] + '(…)'
125
    assert fields.RichTextField().get_view_value('<script></script><p>foo</p>') == '<p>foo</p>'
126

  
127

  
123 128
def test_email():
124 129
    assert (
125 130
        fields.EmailField().get_view_value('foo@localhost')
......
787 792
        ('computed', 'Computed Data', 'computed'),
788 793
    ]
789 794

  
795
    pub.site_options.set('options', 'enable-richtext-field', 'true')
796
    with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd:
797
        pub.site_options.write(fd)
798

  
799
    assert fields.get_field_options(blacklisted_types=[]) == [
800
        ('string', 'Text (line)', 'string'),
801
        ('text', 'Long Text', 'text'),
802
        ('richtext', 'Long Text (Rich)', 'richtext'),
803
        ('email', 'Email', 'email'),
804
        ('bool', 'Check Box (single choice)', 'bool'),
805
        ('file', 'File Upload', 'file'),
806
        ('date', 'Date', 'date'),
807
        ('item', 'List', 'item'),
808
        ('items', 'Multiple choice list', 'items'),
809
        ('table-select', 'Table of Lists', 'table-select'),
810
        ('tablerows', 'Table with rows', 'tablerows'),
811
        ('map', 'Map', 'map'),
812
        ('ranked-items', 'Ranked Items', 'ranked-items'),
813
        ('', '—', ''),
814
        ('title', 'Title', 'title'),
815
        ('subtitle', 'Subtitle', 'subtitle'),
816
        ('comment', 'Comment', 'comment'),
817
        ('page', 'Page', 'page'),
818
        ('', '—', ''),
819
        ('computed', 'Computed Data', 'computed'),
820
    ]
821

  
790 822

  
791 823
def test_block_do_not_pickle_cache(pub):
792 824
    FormDef.wipe()
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
......
1380 1382
register_field_class(TextField)
1381 1383

  
1382 1384

  
1385
class RichTextField(TextField):
1386
    key = 'richtext'
1387
    description = _('Long Text (Rich)')
1388

  
1389
    widget_class = RichTextWidget
1390
    get_opendocument_node_value = None
1391

  
1392
    def get_view_value(self, value, **kwargs):
1393
        return htmltext(strip_some_tags(value, self.widget_class.ALL_TAGS))
1394

  
1395
    def get_view_short_value(self, value, max_len=30):
1396
        return ellipsize(str(strip_tags(value)), max_len)
1397

  
1398
    def get_json_value(self, value, **kwargs):
1399
        return str(self.get_view_value(value))
1400

  
1401

  
1402
register_field_class(RichTextField)
1403

  
1404

  
1383 1405
class EmailField(WidgetField):
1384 1406
    key = 'email'
1385 1407
    description = _('Email')
......
3992 4014
    widgets, non_widgets = [], []
3993 4015
    disabled_fields = (get_publisher().get_site_option('disabled-fields') or '').split(',')
3994 4016
    disabled_fields = [f.strip() for f in disabled_fields if f.strip()]
4017
    if not get_publisher().get_site_option('enable-richtext-field'):
4018
        disabled_fields += ['richtext']
3995 4019
    for klass in field_classes:
3996 4020
        if klass is ComputedField:
3997 4021
            continue
wcs/formdef.py
1663 1663
                    fields.CommentField,
1664 1664
                    fields.PageField,
1665 1665
                    fields.ComputedField,
1666
                    fields.RichTextField,
1666 1667
                ),
1667 1668
            ):
1668 1669
                continue
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/sql.py
78 78
    'comment': None,
79 79
    'page': None,
80 80
    'text': 'text',
81
    'richtext': 'text',
81 82
    'bool': 'boolean',
82 83
    'file': 'bytea',
83 84
    'date': 'date',
84
-