From aa9310580076f64c8b6c423e51d523dad82ba0aa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 19 May 2020 15:00:02 +0200
Subject: [PATCH] general add support for blocks of fields (#8265)

---
 tests/conftest.py                        |   5 +
 tests/test_admin_pages.py                | 202 +++++++++++++
 tests/test_form_pages.py                 | 353 +++++++++++++++++++++++
 tests/test_formdata.py                   |  60 ++++
 wcs/admin/blocks.py                      | 253 ++++++++++++++++
 wcs/admin/fields.py                      |   2 +-
 wcs/admin/forms.py                       |   6 +-
 wcs/admin/settings.py                    |  13 +-
 wcs/blocks.py                            | 317 ++++++++++++++++++++
 wcs/fields.py                            | 130 +++++++--
 wcs/formdef.py                           |   5 +-
 wcs/forms/common.py                      |  17 +-
 wcs/forms/root.py                        |   3 +
 wcs/publisher.py                         |  19 +-
 wcs/sql.py                               |  30 ++
 wcs/templates/wcs/backoffice/blocks.html |  23 ++
 wcs/variables.py                         |  89 ++++--
 17 files changed, 1474 insertions(+), 53 deletions(-)
 create mode 100644 wcs/admin/blocks.py
 create mode 100644 wcs/blocks.py
 create mode 100644 wcs/templates/wcs/backoffice/blocks.html

diff --git a/tests/conftest.py b/tests/conftest.py
index a28c12b2f..3b89bc4e9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -49,6 +49,11 @@ def studio(request, pub):
     return
 
 
+@pytest.fixture
+def blocks_feature(request, pub):
+    return site_options(request, pub, 'options', 'fields-blocks', 'true')
+
+
 @pytest.fixture
 def emails():
     with EmailsMocking() as mock:
diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py
index 5c85f3524..ddeb17436 100644
--- a/tests/test_admin_pages.py
+++ b/tests/test_admin_pages.py
@@ -50,6 +50,7 @@ from wcs.wf.redirect_to_url import RedirectToUrlWorkflowStatusItem
 from wcs.wf.create_formdata import CreateFormdataWorkflowStatusItem, Mapping
 from wcs.formdef import FormDef
 from wcs.carddef import CardDef
+from wcs.blocks import BlockDef
 from wcs import fields
 
 from utilities import get_app, login, create_temporary_pub, clean_temporary_pub, HttpRequestsMocking
@@ -5894,3 +5895,204 @@ def test_create_formdata(pub):
     resp = resp.form.submit(name='submit')
     pq = resp.pyquery.remove_namespaces()
     assert pq('.error').text() == 'Some destination fields are duplicated'
+
+
+def test_block_new(pub, blocks_feature):
+    create_superuser(pub)
+    create_role()
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/')
+    resp = resp.click('Fields blocks')
+    resp = resp.click('New field block')
+    resp.form['name'] = 'field block'
+    resp = resp.form.submit()
+    assert resp.location == 'http://example.net/backoffice/forms/blocks/1/'
+    resp = resp.follow()
+    assert '<h2>field block' in resp
+    assert 'There are not yet any fields' in resp
+
+    resp.form['label'] = 'foobar'
+    resp.form['type'] = 'string'
+    resp = resp.form.submit()
+    assert resp.location == 'http://example.net/backoffice/forms/blocks/1/'
+    resp = resp.follow()
+
+    resp.form['label'] = 'barfoo'
+    resp.form['type'] = 'string'
+    resp = resp.form.submit()
+    assert resp.location == 'http://example.net/backoffice/forms/blocks/1/'
+    resp = resp.follow()
+
+    assert len(BlockDef.get(1).fields) == 2
+    assert str(BlockDef.get(1).fields[0].id) != '1'  # don't use integers
+
+
+def test_block_options(pub, blocks_feature):
+    create_superuser(pub)
+    create_role()
+    BlockDef.wipe()
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [fields.StringField(id='123', required=True, label='Test', type='string')]
+    block.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/blocks/%s/' % block.id)
+    resp = resp.click(href=re.compile('^settings$'))
+    assert 'readonly' not in resp.form['slug'].attrs
+    resp.form['name'] = 'foo bar'
+    resp = resp.form.submit('submit')
+    assert BlockDef.get(block.id).name == 'foo bar'
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.BlockField(id='0', label='test', type='block:%s' % block.slug),
+    ]
+    formdef.store()
+
+    resp = app.get('/backoffice/forms/blocks/%s/' % block.id)
+    resp = resp.click(href=re.compile('^settings$'))
+    assert 'readonly' in resp.form['slug'].attrs
+    resp = resp.form.submit('cancel')
+    resp = resp.follow()
+
+
+def test_block_export_import(pub, blocks_feature):
+    create_superuser(pub)
+    create_role()
+    BlockDef.wipe()
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [fields.StringField(id='123', required=True, label='Test', type='string')]
+    block.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/blocks/%s/' % block.id)
+    resp = resp.click(href=re.compile('^export$'))
+    xml_export = resp.text
+
+    resp = app.get('/backoffice/forms/blocks/')
+    resp = resp.click(href='import')
+    resp = resp.form.submit('cancel')  # shouldn't block on missing file
+    resp = resp.follow()
+
+    resp = resp.click(href='import')
+    resp = resp.form.submit()
+    assert 'ere were errors processing your form.' in resp
+
+    resp.form['file'] = Upload('block', xml_export.encode('utf-8'))
+    resp = resp.form.submit()
+    resp = resp.follow()
+    assert BlockDef.count() == 2
+
+    new_blockdef = [x for x in BlockDef.select() if str(x.id) != str(block.id)][0]
+    assert new_blockdef.name == 'Copy of foobar'
+    assert new_blockdef.slug == 'foobar-1'
+    assert len(new_blockdef.fields) == 1
+    assert new_blockdef.fields[0].id == '123'
+
+    resp = app.get('/backoffice/forms/blocks/')
+    resp = resp.click(href='import')
+    resp.form['file'] = Upload('block', xml_export.encode('utf-8'))
+    resp = resp.form.submit()
+    assert 'Copy of foobar (2)' in [x.name for x in BlockDef.select()]
+
+    # import invalid content
+    resp = app.get('/backoffice/forms/blocks/')
+    resp = resp.click(href='import')
+    resp.form['file'] = Upload('block', b'whatever')
+    resp = resp.form.submit()
+    assert 'Invalid File' in resp
+
+
+def test_block_delete(pub, blocks_feature):
+    create_superuser(pub)
+    create_role()
+    BlockDef.wipe()
+    FormDef.wipe()
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [fields.StringField(id='123', required=True, label='Test', type='string')]
+    block.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/blocks/%s/' % block.id)
+    resp = resp.click(href=re.compile('^delete$'))
+    assert 'You are about to irrevocably delete this block.' in resp
+    resp = resp.form.submit()
+    resp = resp.follow()
+    assert BlockDef.count() == 0
+
+    # in use
+    BlockDef.wipe()
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [fields.StringField(id='123', required=True, label='Test', type='string')]
+    block.store()
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.BlockField(id='0', label='test', type='block:%s' % block.slug),
+    ]
+    formdef.store()
+    resp = app.get('/backoffice/forms/blocks/%s/' % block.id)
+    resp = resp.click(href=re.compile('^delete$'))
+    assert 'This block is still used' in resp
+
+
+def test_block_edit_duplicate_delete_field(pub, blocks_feature):
+    create_superuser(pub)
+    create_role()
+    BlockDef.wipe()
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [fields.StringField(id='123', required=True, label='Test', type='string')]
+    block.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/blocks/%s/' % block.id)
+    resp = resp.click(href=re.compile('123/$'))
+    resp.form['required'].checked = False
+    resp.form['varname'] = 'test'
+    resp = resp.form.submit('submit')
+    resp = resp.follow()
+    assert BlockDef.get(block.id).fields[0].required is False
+    assert BlockDef.get(block.id).fields[0].varname == 'test'
+
+    resp = resp.click(href=re.compile('123/duplicate$'))
+    resp = resp.follow()
+    assert len(BlockDef.get(block.id).fields) == 2
+
+    resp = resp.click(href='%s/delete' % BlockDef.get(block.id).fields[1].id)
+    resp = resp.form.submit('submit')
+    resp = resp.follow()
+    assert len(BlockDef.get(block.id).fields) == 1
+
+
+def test_block_use_in_formdef(pub, blocks_feature):
+    create_superuser(pub)
+    create_role()
+    FormDef.wipe()
+    BlockDef.wipe()
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [fields.StringField(id='123', required=True, label='Test', type='string')]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = []
+    formdef.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/1/fields/')
+    resp.forms[0]['label'] = 'a block field'
+    resp.forms[0]['type'] = 'block:foobar'
+    resp = resp.forms[0].submit().follow()
+    assert 'a block field' in resp.text
+    resp = resp.click('Edit', href='1/')
+    assert resp.form['max_items'].value == '1'
diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index dc0dba483..e1071f512 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -29,6 +29,7 @@ from wcs.qommon import force_str
 from wcs.qommon.emails import docutils
 from wcs.qommon.form import UploadedFile
 from wcs.qommon.ident.password_accounts import PasswordAccount
+from wcs.blocks import BlockDef
 from wcs.carddef import CardDef
 from wcs.formdef import FormDef
 from wcs.workflows import (Workflow, EditableWorkflowStatusItem,
@@ -8164,3 +8165,355 @@ def test_structured_workflow_options(pub):
         '1_structured': {'id': '1', 'text': 'un', 'more': 'foo'},
     }
     assert '2020-04-18' in formdata.evolution[0].parts[0].content
+
+
+def test_block_simple(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.StringField(id='234', required=True, label='Test2', type='string'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.BlockField(id='1', label='test', type='block:foobar'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = 'bar'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert resp.form['f1$element0$f123'].attrs['readonly']
+    assert resp.form['f1$element0$f123'].value == 'foo'
+    assert resp.form['f1$element0$f234'].attrs['readonly']
+    assert resp.form['f1$element0$f234'].value == 'bar'
+    resp = resp.form.submit('submit')  # -> end page
+    resp = resp.follow()
+    assert '>foo<' in resp
+    assert '>bar<' in resp
+
+
+def test_block_required(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.StringField(id='234', required=True, label='Test2', type='string'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.BlockField(id='1', label='test', type='block:foobar'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp = resp.form.submit('submit')  # -> error page
+    assert 'There were errors processing the form' in resp
+    assert resp.text.count('required field') == 1
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = 'bar'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+
+    resp = app.get(formdef.get_url())
+    resp.form['f1$element0$f123'] = 'foo'
+    resp = resp.form.submit('submit')  # -> error page
+    assert 'There were errors processing the form' in resp
+    assert resp.text.count('required field') == 1
+    resp.form['f1$element0$f234'] = 'bar'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+
+    # only one required
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.StringField(id='234', required=False, label='Test2', type='string'),
+    ]
+    block.store()
+
+    resp = app.get(formdef.get_url())
+    resp.form['f1$element0$f123'] = 'foo'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+
+    # none required, but globally required
+    block.fields = [
+        fields.StringField(id='123', required=False, label='Test', type='string'),
+        fields.StringField(id='234', required=False, label='Test2', type='string'),
+    ]
+    block.store()
+
+    resp = app.get(formdef.get_url())
+    resp = resp.form.submit('submit')  # -> error page
+    assert 'There were errors processing the form' in resp
+    assert resp.text.count('required field') == 1
+    resp.form['f1$element0$f234'] = 'bar'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+
+
+def test_block_date(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.DateField(id='234', required=True, label='Test2', type='date'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.BlockField(id='1', label='test', type='block:foobar'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = '2020-06-16'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+    resp = resp.form.submit('submit')  # -> submit
+    resp = resp.follow()
+    assert '>2020-06-16<' in resp
+
+
+def test_block_multipage(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.StringField(id='234', required=True, label='Test2', type='string'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.BlockField(id='1', label='test', type='block:foobar'),
+        fields.PageField(id='2', label='2nd page', type='page'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = 'bar'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    assert resp.form['f1$element0$f123'].attrs['readonly']
+    assert resp.form['f1$element0$f123'].value == 'foo'
+    resp = resp.form.submit('previous')  # -> 2nd page
+    resp = resp.form.submit('previous')  # -> 1st page
+    assert 'readonly' not in resp.form['f1$element0$f123'].attrs
+    assert resp.form['f1$element0$f123'].value == 'foo'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submit
+    resp = resp.follow()
+    assert '>foo<' in resp
+    assert '>bar<' in resp
+
+
+def test_block_repeated(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.StringField(id='234', required=True, label='Test2', type='string'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.BlockField(id='1', label='test', type='block:foobar', max_items=3),
+        fields.PageField(id='2', label='2nd page', type='page'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    assert resp.text.count('>Test<') == 1
+    assert 'Add another' in resp
+    resp = resp.form.submit('f1$add_element')
+    assert resp.text.count('>Test<') == 2
+    resp = resp.form.submit('f1$add_element')
+    assert resp.text.count('>Test<') == 3
+    assert 'Add another' not in resp
+
+    # fill items (1st and 3rd row)
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = 'bar'
+    resp.form['f1$element2$f123'] = 'foo2'
+    resp.form['f1$element2$f234'] = 'bar2'
+
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+    assert resp.form['f1$element0$f123'].value == 'foo'
+    assert resp.form['f1$element0$f234'].value == 'bar'
+    assert resp.form['f1$element1$f123'].value == 'foo2'
+    assert resp.form['f1$element1$f234'].value == 'bar2'
+
+    resp = resp.form.submit('previous')  # -> 2nd page
+    resp = resp.form.submit('previous')  # -> 1st page
+    assert 'readonly' not in resp.form['f1$element0$f123'].attrs
+    assert resp.form['f1$element0$f123'].value == 'foo'
+
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submit
+    resp = resp.follow()
+    assert '>foo<' in resp
+    assert '>bar<' in resp
+    assert '>foo2<' in resp
+    assert '>bar2<' in resp
+
+
+def test_block_repeated_files(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test', type='string'),
+        fields.FileField(id='234', required=True, label='Test2', type='file'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.BlockField(id='1', label='test', type='block:foobar', max_items=3),
+        fields.PageField(id='2', label='2nd page', type='page'),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    assert resp.text.count('>Test<') == 1
+    assert 'Add another' in resp
+    resp = resp.form.submit('f1$add_element')
+    assert resp.text.count('>Test<') == 2
+    resp = resp.form.submit('f1$add_element')
+    assert resp.text.count('>Test<') == 3
+    assert 'Add another' not in resp
+
+    # fill items (1st and 3rd row)
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234$file'] = Upload('test1.txt', b'foobar1', 'text/plain')
+    resp.form['f1$element2$f123'] = 'foo2'
+    resp.form['f1$element2$f234$file'] = Upload('test2.txt', b'foobar2', 'text/plain')
+
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp.text
+    assert resp.form['f1$element0$f123'].value == 'foo'
+    assert 'test1.txt' in resp
+    assert resp.form['f1$element1$f123'].value == 'foo2'
+    assert 'test2.txt' in resp
+
+    resp = resp.form.submit('previous')  # -> 2nd page
+    resp = resp.form.submit('previous')  # -> 1st page
+    resp = resp.form.submit('submit')  # -> 2nd page
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submit
+    resp = resp.follow()
+    assert '>foo<' in resp
+    assert 'test1.txt' in resp
+    assert '>foo2<' in resp
+    assert 'test2.txt' in resp
+
+
+def test_block_digest(pub, blocks_feature):
+    create_user(pub)
+    FormDef.wipe()
+    BlockDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test',
+            type='string', varname='foo'),
+        fields.StringField(id='234', required=True, label='Test2',
+            type='string', varname='bar'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.fields = [
+        fields.BlockField(id='1', label='test', type='block:foobar', max_items=3),
+    ]
+    formdef.store()
+
+    app = get_app(pub)
+    resp = app.get(formdef.get_url())
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = 'bar'
+    resp = resp.form.submit('f1$add_element')
+    resp.form['f1$element1$f123'] = 'foo2'
+    resp.form['f1$element1$f234'] = 'bar2'
+
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submit
+
+    assert formdef.data_class().select()[0].data['1']['data'] == [
+            {'123': 'foo', '234': 'bar'}, {'123': 'foo2', '234': 'bar2'}]
+    # by default it gets the type of object
+    assert formdef.data_class().select()[0].data['1_display'] == 'foobar, foobar'
+
+    # set a digest template
+    formdef.data_class().wipe()
+
+    block.digest_template = 'X{{foobar_var_foo}}Y'
+    block.store()
+
+    resp = app.get(formdef.get_url())
+    resp.form['f1$element0$f123'] = 'foo'
+    resp.form['f1$element0$f234'] = 'bar'
+    resp = resp.form.submit('f1$add_element')
+    resp.form['f1$element1$f123'] = 'foo2'
+    resp.form['f1$element1$f234'] = 'bar2'
+
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submit
+    assert formdef.data_class().select()[0].data['1']['data'] == [
+            {'123': 'foo', '234': 'bar'}, {'123': 'foo2', '234': 'bar2'}]
+    assert formdef.data_class().select()[0].data['1_display'] == 'XfooY, Xfoo2Y'
diff --git a/tests/test_formdata.py b/tests/test_formdata.py
index 9f1b0b8d2..c750e3baa 100644
--- a/tests/test_formdata.py
+++ b/tests/test_formdata.py
@@ -15,6 +15,7 @@ from wcs.qommon.template import Template
 from wcs.qommon.form import PicklableUpload
 from wcs.qommon.http_request import HTTPRequest
 from wcs import fields, formdef
+from wcs.blocks import BlockDef
 from wcs.categories import Category
 from wcs.conditions import Condition
 from wcs.formdef import FormDef
@@ -2038,3 +2039,62 @@ def test_form_parent(pub):
     assert str(variables['form'].var.foo) == 'world'
     assert str(variables['form'].parent['form'].var.foo) == 'hello'
     assert variables['form'].parent is not None
+
+
+def test_block_variables(pub, blocks_feature):
+    BlockDef.wipe()
+    FormDef.wipe()
+
+    block = BlockDef()
+    block.name = 'foobar'
+    block.digest_template = 'X{{foobar_var_foo}}Y'
+    block.fields = [
+        fields.StringField(id='123', required=True, label='Test',
+            type='string', varname='foo'),
+        fields.StringField(id='234', required=True, label='Test2',
+            type='string', varname='bar'),
+    ]
+    block.store()
+
+    formdef = FormDef()
+    formdef.name = 'testblock'
+    formdef.fields = [
+        fields.BlockField(id='1', label='test', type='block:foobar',
+            max_items=3, varname='block'),
+    ]
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.just_created()
+    # value from test_block_digest in tests/test_form_pages.py
+    formdata.data = {
+        '1': {
+            'data': [{'123': 'foo', '234': 'bar'}, {'123': 'foo2', '234': 'bar2'}],
+            'schema': {'123': 'string', '234': 'string'}
+            },
+        '1_display': 'XfooY, Xfoo2Y',
+    }
+    formdata.store()
+
+    variables = formdata.get_substitution_variables()
+    assert 'form_var_block' in variables.get_flat_keys()
+    assert 'form_var_block_0' in variables.get_flat_keys()
+    assert 'form_var_block_0_foo' in variables.get_flat_keys()
+    assert 'form_var_block_0_bar' in variables.get_flat_keys()
+    assert 'form_var_block_1' in variables.get_flat_keys()
+    assert 'form_var_block_1_foo' in variables.get_flat_keys()
+    assert 'form_var_block_1_bar' in variables.get_flat_keys()
+
+    assert variables.get('form_var_block_0_foo') == 'foo'
+    assert variables.get('form_var_block_1_foo') == 'foo2'
+    assert variables.get('form_var_block_var_foo') == 'foo'  # alias to 1st element
+
+    pub.substitutions.reset()
+    pub.substitutions.feed(formdata)
+
+    context = pub.substitutions.get_context_variables(mode='lazy')
+    tmpl = Template('{{ form_var_block }}')
+    assert tmpl.render(context) == 'XfooY, Xfoo2Y'
+
+    tmpl = Template('{% for sub in form_var_block %}{{ sub.foo }} {% endfor %}')
+    assert tmpl.render(context) == 'foo foo2 '
diff --git a/wcs/admin/blocks.py b/wcs/admin/blocks.py
new file mode 100644
index 000000000..42a2fc4e2
--- /dev/null
+++ b/wcs/admin/blocks.py
@@ -0,0 +1,253 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+import xml.etree.ElementTree as ET
+
+from quixote import get_response, get_session, redirect
+from quixote.directory import Directory
+from quixote.html import TemplateIO, htmltext
+
+from wcs.blocks import BlockDef, BlockdefImportError
+
+from wcs.qommon.form import Form, StringWidget, HtmlWidget, FileWidget
+from wcs.qommon import _, misc, template
+from wcs.qommon.backoffice.menu import html_top
+
+from wcs.admin.fields import FieldDefPage, FieldsDirectory
+from wcs.admin import utils
+
+
+class BlockFieldDefPage(FieldDefPage):
+    blacklisted_attributes = ['condition']
+
+    def redirect_field_anchor(self, field):
+        anchor = '#itemId_%s' % field.id if field else ''
+        return redirect('../%s' % anchor)
+
+
+class BlockDirectory(FieldsDirectory):
+    _q_exports = ['', 'update_order', 'new', 'delete', 'export', 'settings']
+    field_def_page_class = BlockFieldDefPage
+    blacklisted_types = ['page', 'table', 'table-select', 'tablerows', 'ranked-items', 'blocks']
+    field_var_prefix = ''
+    support_import = False
+
+    def __init__(self, section, *args, **kwargs):
+        self.section = section
+        super().__init__(*args, **kwargs)
+
+    def index_top(self):
+        r = TemplateIO(html=True)
+        r += htmltext('<div id="appbar">')
+        r += htmltext('<h2>%s</h2>') % self.objectdef.name
+        r += htmltext('<span class="actions">')
+        r += htmltext('<a href="delete" rel="popup">%s</a>') % _('Delete')
+        r += htmltext('<a href="export">%s</a>') % _('Export')
+        r += htmltext('<a href="settings" rel="popup">%s</a>') % _('Settings')
+        r += htmltext('</span>')
+        r += htmltext('</div>')
+        r += utils.last_modification_block(obj=self.objectdef)
+        r += get_session().display_message()
+
+        if not self.objectdef.fields:
+            r += htmltext('<div class="infonotice">%s</div>') % _('There are not yet any fields defined.')
+        return r.getvalue()
+
+    def index_bottom(self):
+        formdefs = list(self.objectdef.get_usage_formdefs())
+        formdefs.sort(key=lambda x: x.name.lower())
+        if not formdefs:
+            return
+        r = TemplateIO(html=True)
+        r += htmltext('<div class="section">')
+        r += htmltext('<h3>%s</h3>') % _('Usage')
+        r += htmltext('<ul class="objects-list single-links">')
+        for formdef in formdefs:
+            r += htmltext('<li><a href="%s">' % formdef.get_admin_url())
+            r += htmltext('%s</a></li>') % formdef.name
+        r += htmltext('</ul>')
+        r += htmltext('</div>')
+        return r.getvalue()
+
+    def delete(self):
+        form = Form(enctype='multipart/form-data')
+        if not self.objectdef.is_used():
+            form.widgets.append(
+                HtmlWidget('<p>%s</p>' % _('You are about to irrevocably delete this block.'))
+            )
+            form.add_submit('delete', _('Submit'))
+        else:
+            form.widgets.append(
+                HtmlWidget('<p>%s</p>' % _('This block is still used, it cannot be deleted.'))
+            )
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('..')
+        if not form.is_submitted() or form.has_errors():
+            get_response().breadcrumb.append(('delete', _('Delete')))
+            html_top(self.section, title=_('Delete Block'))
+            r = TemplateIO(html=True)
+            r += htmltext('<h2>%s %s</h2>') % (_('Deleting Block:'), self.objectdef.name)
+            r += form.render()
+            return r.getvalue()
+        else:
+            self.objectdef.remove_self()
+            return redirect('..')
+
+    def export(self):
+        x = self.objectdef.export_to_xml(include_id=True)
+        misc.indent_xml(x)
+        response = get_response()
+        response.set_content_type('application/x-wcs-form')
+        response.set_header('content-disposition', 'attachment; filename=block-%s.wcs' % self.objectdef.slug)
+        return '<?xml version="1.0"?>\n' + ET.tostring(x).decode('utf-8')
+
+    def settings(self):
+        form = Form()
+        form.add(StringWidget, 'name', title=_('Name'), value=self.objectdef.name, size=50)
+        form.add(
+            StringWidget,
+            'slug',
+            title=_('Identifier'),
+            value=self.objectdef.slug,
+            size=50,
+            readonly=bool(self.objectdef.is_used()),
+        )
+        form.add(
+            StringWidget, 'digest_template', title=_('Digest'), value=self.objectdef.digest_template, size=50
+        )
+        form.add_submit('submit', _('Submit'))
+        form.add_submit('cancel', _('Cancel'))
+
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+
+        if form.is_submitted() and not form.has_errors():
+            self.objectdef.name = form.get_widget('name').parse()
+            if form.get_widget('slug'):
+                self.objectdef.slug = form.get_widget('slug').parse()
+            self.objectdef.digest_template = form.get_widget('digest_template').parse()
+            self.objectdef.store()
+            return redirect('.')
+
+        html_top(self.section, title=_('Settings'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('Settings')
+        r += form.render()
+        return r.getvalue()
+
+
+class BlocksDirectory(Directory):
+    _q_exports = ['', 'new', ('import', 'p_import')]
+    do_not_call_in_templates = True
+
+    def __init__(self, section):
+        super().__init__()
+        self.section = section
+
+    def _q_traverse(self, path):
+        get_response().breadcrumb.append(('blocks/', _('Fields Blocks')))
+        return super()._q_traverse(path)
+
+    def _q_lookup(self, component):
+        return BlockDirectory(self.section, BlockDef.get(component))
+
+    def _q_index(self):
+        html_top(self.section, title=_('Fields Blocks'))
+        return template.QommonTemplateResponse(
+            templates=['wcs/backoffice/blocks.html'],
+            context={'view': self, 'blocks': BlockDef.select(order_by='name')},
+        )
+
+    def new(self):
+        form = Form(enctype='multipart/form-data')
+        form.add(StringWidget, 'name', title=_('Name'), required=True, size=50)
+        form.add_submit('submit', _('Add'))
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+
+        if form.is_submitted() and not form.has_errors():
+            block = BlockDef(name=form.get_widget('name').parse())
+            block.store()
+            return redirect('%s/' % block.id)
+
+        get_response().breadcrumb.append(('new', _('New Fields Block')))
+        html_top(self.section, title=_('New Fields Block'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('New Fields Block')
+        r += form.render()
+        return r.getvalue()
+
+    def p_import(self):
+        form = Form(enctype='multipart/form-data')
+
+        form.add(FileWidget, 'file', title=_('File'), required=True)
+        form.add_submit('submit', _('Import Fields Block'))
+        form.add_submit('cancel', _('Cancel'))
+
+        if form.get_submit() == 'cancel':
+            return redirect('.')
+
+        if form.is_submitted() and not form.has_errors():
+            try:
+                return self.import_submit(form)
+            except ValueError:
+                pass
+
+        get_response().breadcrumb.append(('import', _('Import')))
+        html_top(self.section, title=_('Import Fields Block'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('Import Fields Block')
+        r += htmltext('<p>%s</p>') % _('You can install a new fields block by uploading a file.')
+        r += form.render()
+        return r.getvalue()
+
+    def import_submit(self, form):
+        fp = form.get_widget('file').parse().fp
+
+        error, reason = False, None
+        try:
+            blockdef = BlockDef.import_from_xml(fp)
+        except BlockdefImportError as e:
+            error = True
+            reason = _(e) % e.msg_args
+        except ValueError:
+            error = True
+
+        if error:
+            if reason:
+                msg = _('Invalid File (%s)') % reason
+            else:
+                msg = _('Invalid File')
+            form.set_error('file', msg)
+            raise ValueError()
+
+        initial_blockdef_name = blockdef.name
+        blockdef_names = [x.name for x in BlockDef.select()]
+        copy_no = 1
+        while blockdef.name in blockdef_names:
+            if copy_no == 1:
+                blockdef.name = _('Copy of %s') % initial_blockdef_name
+            else:
+                blockdef.name = _('Copy of %(name)s (%(no)d)') % {
+                    'name': initial_blockdef_name,
+                    'no': copy_no,
+                }
+            copy_no += 1
+        blockdef.store()
+        get_session().message = ('info', _('This fields block has been successfully imported.'))
+        return redirect('%s/' % blockdef.id)
diff --git a/wcs/admin/fields.py b/wcs/admin/fields.py
index de3eafe1a..093e038f6 100644
--- a/wcs/admin/fields.py
+++ b/wcs/admin/fields.py
@@ -174,7 +174,7 @@ class FieldsDirectory(Directory):
     field_def_page_class = FieldDefPage
     blacklisted_types = []
     page_id = None
-    field_var_prefix = ''
+    field_var_prefix = '..._'
 
     support_import = True
 
diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py
index 4fe424871..e99f246bf 100644
--- a/wcs/admin/forms.py
+++ b/wcs/admin/forms.py
@@ -45,6 +45,7 @@ from wcs.workflows import Workflow
 from wcs.forms.root import qrcode
 
 from . import utils
+from .blocks import BlocksDirectory
 from .fields import FieldDefPage, FieldsDirectory
 from .categories import CategoriesDirectory
 from .data_sources import NamedDataSourcesDirectory
@@ -1467,9 +1468,10 @@ class NamedDataSourcesDirectoryInForms(NamedDataSourcesDirectory):
 
 class FormsDirectory(AccessControlled, Directory):
     _q_exports = ['', 'new', ('import', 'p_import'),
-            'categories', ('data-sources', 'data_sources')]
+            'blocks', 'categories', ('data-sources', 'data_sources')]
 
     categories = CategoriesDirectory()
+    blocks = BlocksDirectory(section='forms')
     data_sources = NamedDataSourcesDirectoryInForms()
     formdef_class = FormDef
     formdef_page_class = FormDefPage
@@ -1507,6 +1509,8 @@ class FormsDirectory(AccessControlled, Directory):
         if has_roles:
             r += htmltext('<span class="actions">')
             r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
+            if get_publisher().has_site_option('fields-blocks'):
+                r += htmltext('<a href="blocks/">%s</a>') % _('Fields blocks')
             if get_publisher().get_backoffice_root().is_accessible('categories'):
                 r += htmltext('<a href="categories/">%s</a>') % _('Categories')
             r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
diff --git a/wcs/admin/settings.py b/wcs/admin/settings.py
index 7640dce74..4d0c9bf7c 100644
--- a/wcs/admin/settings.py
+++ b/wcs/admin/settings.py
@@ -52,6 +52,7 @@ from wcs.qommon.admin.settings import SettingsDirectory as QommonSettingsDirecto
 from wcs.qommon.admin.logger import LoggerDirectory
 from wcs.qommon import ident
 
+from wcs.blocks import BlockDef
 from wcs.formdef import FormDef
 from wcs.carddef import CardDef
 from wcs.workflows import Workflow, WorkflowImportError
@@ -861,6 +862,8 @@ class SettingsDirectory(QommonSettingsDirectory):
         if StudioDirectory.is_visible():
             form.add(CheckboxWidget, 'carddefs', title=_('Card Models'), value=True)
         form.add(CheckboxWidget, 'workflows', title = _('Workflows'), value = True)
+        if get_publisher().has_site_option('fields-blocks'):
+            form.add(CheckboxWidget, 'blockdefs', title=_('Fields Blocks'), value=True)
         if not get_cfg('sp', {}).get('idp-manage-roles'):
             form.add(CheckboxWidget, 'roles', title = _('Roles'), value = True)
         form.add(CheckboxWidget, 'categories', title = _('Categories'), value = True)
@@ -918,6 +921,12 @@ class SettingsDirectory(QommonSettingsDirectory):
                         misc.indent_xml(node)
                         z.writestr(os.path.join('workflows_xml', str(workflow.id)),
                                 b'<?xml version="1.0"?>\n' + ET.tostring(node))
+                if 'blockdefs' in self.dirs:
+                    for blockdef in BlockDef.select():
+                        node = blockdef.export_to_xml(include_id=True)
+                        misc.indent_xml(node)
+                        z.writestr(os.path.join('blockdefs_xml', str(blockdef.id)),
+                                b'<?xml version="1.0"?>\n' + ET.tostring(node))
 
                 if self.settings:
                     z.write(os.path.join(self.app_dir, 'config.pck'), 'config.pck')
@@ -934,7 +943,7 @@ class SettingsDirectory(QommonSettingsDirectory):
 
         dirs = []
         for w in ('formdefs', 'carddefs', 'workflows', 'roles', 'categories',
-                'datasources', 'wscalls', 'mail-templates'):
+                'datasources', 'wscalls', 'mail-templates', 'blockdefs'):
             if form.get_widget(w) and form.get_widget(w).parse():
                 dirs.append(w)
         if not dirs and not form.get_widget('settings').parse():
@@ -1017,6 +1026,8 @@ class SettingsDirectory(QommonSettingsDirectory):
                     r += htmltext('<li>%d %s</li>') % (results['formdefs'], _('forms'))
                 if results['carddefs']:
                     r += htmltext('<li>%d %s</li>') % (results['carddefs'], _('cards'))
+                if results['blockdefs']:
+                    r += htmltext('<li>%d %s</li>') % (results['blockdefs'], _('fields blocks'))
                 if results['workflows']:
                     r += htmltext('<li>%d %s</li>') % (results['workflows'], _('workflows'))
                 if results['roles']:
diff --git a/wcs/blocks.py b/wcs/blocks.py
new file mode 100644
index 000000000..9b027cf1a
--- /dev/null
+++ b/wcs/blocks.py
@@ -0,0 +1,317 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2020  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+import uuid
+import time
+import xml.etree.ElementTree as ET
+
+from quixote import get_request
+
+from .qommon import _, N_, misc
+from .qommon.form import CompositeWidget, WidgetList
+from .qommon.storage import StorableObject
+from .qommon.template import Template
+
+from . import data_sources
+from . import fields
+
+
+class BlockdefImportError(Exception):
+    def __init__(self, msg, details=None):
+        self.msg = msg
+        self.details = details
+
+
+class BlockDef(StorableObject):
+    _names = 'blockdefs'
+    _indexes = ['slug']
+    xml_root_node = 'block'
+
+    name = None
+    slug = None
+    fields = None
+    digest_template = None
+
+    last_modification_time = None
+    last_modification_user_id = None
+
+    # declarations for serialization
+    TEXT_ATTRIBUTES = ['name', 'slug', 'digest_template']
+
+    def __init__(self, name=None, **kwargs):
+        super().__init__(**kwargs)
+        self.name = name
+        self.fields = []
+
+    def store(self):
+        if self.slug is None:
+            # set slug if it's not yet there
+            self.slug = self.get_new_slug()
+
+        self.last_modification_time = time.localtime()
+        if get_request() and get_request().user:
+            self.last_modification_user_id = str(get_request().user.id)
+        else:
+            self.last_modification_user_id = None
+
+        super().store()
+
+    def get_new_slug(self):
+        new_slug = misc.simplify(self.name, space='_')
+        base_new_slug = new_slug
+        suffix_no = 0
+        while True:
+            try:
+                obj = self.get_on_index(new_slug, 'slug', ignore_migration=True)
+            except KeyError:
+                break
+            if obj.id == self.id:
+                break
+            suffix_no += 1
+            new_slug = '%s-%s' % (base_new_slug, suffix_no)
+        return new_slug
+
+    def get_new_field_id(self):
+        return 'bf%s' % str(uuid.uuid4())
+
+    def get_display_value(self, value):
+        if not self.digest_template:
+            return self.name
+
+        from .qommon.substitution import CompatibilityNamesDict
+        from .variables import LazyBlockDataVar
+
+        context = CompatibilityNamesDict({self.slug + '_var': LazyBlockDataVar(self.fields, value)})
+        return Template(self.digest_template, autoescape=False).render(context)
+
+    def export_to_xml(self, include_id=False):
+        root = ET.Element(self.xml_root_node)
+        if include_id and self.id:
+            root.attrib['id'] = str(self.id)
+        for text_attribute in list(self.TEXT_ATTRIBUTES):
+            if not hasattr(self, text_attribute) or not getattr(self, text_attribute):
+                continue
+            ET.SubElement(root, text_attribute).text = getattr(self, text_attribute)
+
+        if self.last_modification_time:
+            elem = ET.SubElement(root, 'last_modification')
+            elem.text = time.strftime('%Y-%m-%d %H:%M:%S', self.last_modification_time)
+            if include_id:
+                elem.attrib['user_id'] = str(self.last_modification_user_id)
+
+        fields = ET.SubElement(root, 'fields')
+        for field in self.fields or []:
+            fields.append(field.export_to_xml(charset='utf-8', include_id=True))
+
+        return root
+
+    @classmethod
+    def import_from_xml(cls, fd, include_id=False, check_datasources=True):
+        try:
+            tree = ET.parse(fd)
+        except:
+            raise ValueError()
+        blockdef = cls.import_from_xml_tree(tree, include_id=include_id)
+
+        if blockdef.slug:
+            try:
+                cls.get_on_index(blockdef.slug, 'slug', ignore_migration=True)
+            except KeyError:
+                pass
+            else:
+                blockdef.slug = blockdef.get_new_slug()
+
+        if check_datasources:
+            # check if datasources are defined
+            unknown_datasources = set()
+            for field in blockdef.fields:
+                data_source = getattr(field, 'data_source', None)
+                if data_source:
+                    if isinstance(data_sources.get_object(data_source), data_sources.StubNamedDataSource):
+                        unknown_datasources.add(data_source.get('type'))
+            if unknown_datasources:
+                raise BlockdefImportError(
+                    N_('Unknown datasources'), details=', '.join(sorted(unknown_datasources))
+                )
+
+        return blockdef
+
+    @classmethod
+    def import_from_xml_tree(cls, tree, include_id=False):
+        charset = 'utf-8'
+        blockdef = cls()
+        if tree.find('name') is None or not tree.find('name').text:
+            raise BlockdefImportError(N_('Missing name'))
+
+        # if the tree we get is actually a ElementTree for real, we get its
+        # root element and go on happily.
+        if not ET.iselement(tree):
+            tree = tree.getroot()
+
+        if tree.tag != cls.xml_root_node:
+            raise BlockdefImportError(N_('Unexpected root node'))
+
+        if include_id and tree.attrib.get('id'):
+            blockdef.id = tree.attrib.get('id')
+        for text_attribute in list(cls.TEXT_ATTRIBUTES):
+            value = tree.find(text_attribute)
+            if value is None or value.text is None:
+                continue
+            setattr(blockdef, text_attribute, misc.xml_node_text(value))
+
+        blockdef.fields = []
+        for i, field in enumerate(tree.find('fields')):
+            try:
+                field_o = fields.get_field_class_by_type(field.findtext('type'))()
+            except KeyError:
+                raise BlockdefImportError(N_('Unknown field type'), details=field.findtext('type'))
+            field_o.init_with_xml(field, charset, include_id=True)
+            blockdef.fields.append(field_o)
+
+        if tree.find('last_modification') is not None:
+            node = tree.find('last_modification')
+            blockdef.last_modification_time = time.strptime(node.text, '%Y-%m-%d %H:%M:%S')
+            if include_id and node.attrib.get('user_id'):
+                blockdef.last_modification_user_id = node.attrib.get('user_id')
+
+        return blockdef
+
+    def get_usage_formdefs(self):
+        from wcs.formdef import get_formdefs_of_all_kinds
+
+        block_identifier = 'block:%s' % self.slug
+        for formdef in get_formdefs_of_all_kinds():
+            for field in formdef.fields:
+                if field.type == block_identifier:
+                    yield formdef
+                    break
+
+    def is_used(self):
+        return any(self.get_usage_formdefs())
+
+
+class BlockSubWidget(CompositeWidget):
+    def __init__(self, name, value=None, *args, **kwargs):
+        self.block = kwargs.pop('block')
+        self.readonly = kwargs.get('readonly')
+        super().__init__(name, value, *args, **kwargs)
+        for field in self.block.fields:
+            if 'readonly' in kwargs:
+                field.add_to_view_form(form=self)
+            else:
+                field.add_to_form(form=self)
+        if value:
+            self.set_value(value)
+
+    def set_value(self, value):
+        for widget in self.get_widgets():
+            widget.set_value(value.get(widget.field.id))
+
+    def get_field_data(self, field, widget):
+        from wcs.formdef import FormDef
+
+        return FormDef.get_field_data(field, widget)
+
+    def _parse(self, request):
+        value = {}
+        empty = True
+        for widget in self.get_widgets():
+            widget_value = self.get_field_data(widget.field, widget)
+            value.update(widget_value)
+            if widget_value.get(widget.field.id) is not None:
+                empty = False
+        if empty:
+            value = None
+        self.value = value
+
+    def add_media(self):
+        for widget in self.get_widgets():
+            widget.add_media()
+
+
+class BlockWidget(WidgetList):
+    def __init__(
+        self, name, value=None, title=None, block=None, max_items=None, add_element_label=None, **kwargs
+    ):
+        self.block = block
+        self.readonly = kwargs.get('readonly')
+        element_values = None
+        if value:
+            element_values = value.get('data')
+        element_kwargs = {'block': self.block, 'render_br': False}
+        element_kwargs.update(kwargs)
+        super().__init__(
+            name,
+            value=element_values,
+            title=title,
+            max_items=max_items,
+            element_type=BlockSubWidget,
+            element_kwargs=element_kwargs,
+            add_element_label=add_element_label or _('Add another'),
+            **kwargs,
+        )
+
+    def set_value(self, value):
+        super().set_value(value['data'] if value else None)
+        self.value = value
+
+    def _parse(self, request):
+        # iterate over existing form keys to get actual list of elements.
+        # (maybe this could be moved to WidgetList)
+        prefix = '%s$element' % self.name
+        known_prefixes = {x.split('$', 2)[1] for x in request.form.keys() if x.startswith(prefix)}
+        for i in range(len(known_prefixes) - len(self.element_names)):
+            self.add_element()
+        super()._parse(request)
+        if self.value:
+            self.value = {'data': self.value}
+            # keep "schema" next to data, this allows custom behaviour for
+            # date fields (time.struct_time) when writing/reading from
+            # database in JSON.
+            self.value['schema'] = {x.id: x.key for x in self.block.fields}
+
+    def parse(self, request=None):
+        if not self._parsed:
+            self._parsed = True
+            if request is None:
+                request = get_request()
+            self._parse(request)
+            if self.required and self.value is None:
+                self.set_error(_(self.REQUIRED_ERROR))
+        return self.value
+
+    def add_media(self):
+        for widget in self.get_widgets():
+            if hasattr(widget, 'add_media'):
+                widget.add_media()
+
+    def get_error(self, request=None):
+        request = request or get_request()
+        if request.get_method() == 'POST':
+            self.parse(request=request)
+        return self.error
+
+    def has_error(self, request=None):
+        if self.get_error():
+            return True
+        # we know subwidgets have been parsed
+        has_error = False
+        for widget in self.widgets:
+            if widget.value is None:
+                continue
+            if widget.has_error():
+                has_error = True
+        return has_error
diff --git a/wcs/fields.py b/wcs/fields.py
index f6bcc99dd..9f5dfcfdd 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -46,6 +46,7 @@ from .qommon import get_cfg, get_logger
 from . import data_sources
 from . import portfolio
 from .conditions import Condition
+from .blocks import BlockDef, BlockWidget
 
 
 class PrefillSelectionWidget(CompositeWidget):
@@ -499,7 +500,7 @@ class WidgetField(Field):
     widget_class = None
 
     def add_to_form(self, form, value = None):
-        kwargs = {'required': self.required}
+        kwargs = {'required': self.required, 'render_br': False}
         if value:
             kwargs['value'] = value
         for k in self.extra_attributes:
@@ -545,6 +546,7 @@ class WidgetField(Field):
                 value = value, readonly = 'readonly', **kwargs)
         widget = form.get_widget(self.field_key)
         widget.transfer_form_value(get_request())
+        widget.field = self
         if self.extra_css_class:
             if hasattr(widget, 'extra_css_class') and widget.extra_css_class:
                 widget.extra_css_class = '%s %s' % (widget.extra_css_class, self.extra_css_class)
@@ -601,10 +603,10 @@ class WidgetField(Field):
     def get_csv_heading(self):
         return [self.label]
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         return str(value) if value else ''
 
-    def get_view_short_value(self, value, max_len = 30):
+    def get_view_short_value(self, value, max_len=30, **kwargs):
         return self.get_view_value(value)
 
     def get_csv_value(self, element, **kwargs):
@@ -792,7 +794,7 @@ class StringField(WidgetField):
     def get_admin_attributes(self):
         return WidgetField.get_admin_attributes(self) + ['size', 'validation', 'data_source', 'anonymise']
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         value = value or ''
         if value.startswith('http://') or value.startswith('https://'):
             charset = get_publisher().site_charset
@@ -869,7 +871,7 @@ class TextField(WidgetField):
     def convert_value_from_str(self, value):
         return value
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         if self.pre:
             return htmltext('<pre>') + value + htmltext('</pre>')
         else:
@@ -904,7 +906,7 @@ class EmailField(WidgetField):
     def convert_value_from_str(self, value):
         return value
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         return htmltext('<a href="mailto:%s">%s</a>') % (value, value)
 
     def get_rst_view_value(self, value, indent=''):
@@ -937,7 +939,7 @@ class BoolField(WidgetField):
                 get_request().form[self.field_key] = 'yes'
             self.field_key = 'f%sdisabled' % self.id
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         if value is True or value == 'True':
             return _('Yes')
         elif value is False or value == 'False':
@@ -1113,28 +1115,38 @@ class FileField(WidgetField):
                 return upload
         raise ValueError('invalid data for file type (%r)' % value)
 
-    def get_view_short_value(self, value, max_len=30):
-        return self.get_view_value(value, include_image_thumbnail=False)
+    def get_view_short_value(self, value, max_len=30, **kwargs):
+        return self.get_view_value(value, include_image_thumbnail=False, **kwargs)
 
-    def get_view_value(self, value, include_image_thumbnail=True):
+    def get_download_query_string(self, **kwargs):
+        if kwargs.get('parent_field'):
+            return 'f=%s$%s$%s' % (
+                    kwargs['parent_field'].id,
+                    kwargs['parent_field_index'],
+                    self.id)
+        return 'f=%s' % self.id
+
+    def get_view_value(self, value, include_image_thumbnail=True, **kwargs):
         show_link = True
         if value.has_redirect_url():
             is_in_backoffice = bool(get_request() and get_request().is_in_backoffice())
             show_link = bool(value.get_redirect_url(backoffice=is_in_backoffice))
         t = TemplateIO(html=True)
         t += htmltext('<div class="file-field">')
+        if show_link or include_image_thumbnail:
+            download_qs = self.get_download_query_string(**kwargs)
         if show_link:
-            t += htmltext('<a href="[download]?f=%s">') % self.id
+            t += htmltext('<a href="[download]?%s">') % download_qs
         if include_image_thumbnail and value.can_thumbnail():
-            t += htmltext('<img alt="" src="[download]?f=%s&thumbnail=1"/>') % self.id
+            t += htmltext('<img alt="" src="[download]?%s&thumbnail=1"/>') % download_qs
         t += htmltext('<span>%s</span>') % value
         if show_link:
             t += htmltext('</a>')
         t += htmltext('</div>')
         return t.getvalue()
 
-    def get_download_url(self, formdata):
-        return '%sdownload?f=%s' % (formdata.get_url(), self.id)
+    def get_download_url(self, formdata, **kwargs):
+        return '%sdownload?%s' % (formdata.get_url(), self.get_download_query_string(**kwargs))
 
     def get_opendocument_node_value(self, value, formdata=None, **kwargs):
         show_link = True
@@ -1143,7 +1155,7 @@ class FileField(WidgetField):
             show_link = bool(value.get_redirect_url(backoffice=is_in_backoffice))
         if show_link and formdata:
             node = ET.Element('{%s}a' % OD_NS['text'])
-            node.attrib['{%s}href' % OD_NS['xlink']] = self.get_download_url(formdata)
+            node.attrib['{%s}href' % OD_NS['xlink']] = self.get_download_url(formdata, **kwargs)
         else:
             node = ET.Element('{%s}span' % OD_NS['text'])
         node.text = od_clean_text(force_text(value))
@@ -1155,7 +1167,7 @@ class FileField(WidgetField):
     def get_json_value(self, value, formdata=None, include_file_content=True, **kwargs):
         out = value.get_json_value(include_file_content=include_file_content)
         if formdata:
-            out['url'] = self.get_download_url(formdata)
+            out['url'] = self.get_download_url(formdata, **kwargs)
         out['field_id'] = self.id
         return out
 
@@ -1323,7 +1335,7 @@ class DateField(WidgetField):
         value = strftime(misc.date_format(), value)
         return super().add_to_view_form(form, value=value)
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         try:
             return strftime(misc.date_format(), value)
         except TypeError:
@@ -1500,7 +1512,7 @@ class ItemField(WidgetField):
 
         return data_source.get_display_value(value)
 
-    def get_view_value(self, value, value_id=None):
+    def get_view_value(self, value, value_id=None, **kwargs):
         value = super(ItemField, self).get_view_value(value)
         if not (value_id and
                 get_request() and
@@ -2050,7 +2062,7 @@ class TableField(WidgetField):
             pass
         return t
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         r = TemplateIO(html=True)
         r += htmltext('<table><thead><tr><td></td>')
         for column in self.columns:
@@ -2238,7 +2250,7 @@ class TableRowsField(WidgetField):
             pass
         return t
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         r = TemplateIO(html=True)
         r += htmltext('<table><thead><tr>')
         for column in self.columns:
@@ -2401,7 +2413,7 @@ class MapField(WidgetField):
             return (None, False)
         return ('%(lat)s;%(lon)s' % coords, False)
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         widget = self.widget_class('x%s' % random.random(), value, readonly=True)
         return widget.render_widget_content()
 
@@ -2471,7 +2483,7 @@ class RankedItemsField(WidgetField):
             attrs.remove('prefill')
         return attrs
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         r = TemplateIO(html=True)
         r += htmltext('<ul>')
         items = list(value.items())
@@ -2579,7 +2591,7 @@ class PasswordField(WidgetField):
                 title=_('Label for confirmation input'),
                 value=self.confirmation_title)
 
-    def get_view_value(self, value):
+    def get_view_value(self, value, **kwargs):
         return '●'*8
 
     def get_csv_value(self, value, **kwargs):
@@ -2591,10 +2603,74 @@ class PasswordField(WidgetField):
 register_field_class(PasswordField)
 
 
+class BlockField(WidgetField):
+    key = 'block'
+    widget_class = BlockWidget
+    max_items = 1
+    extra_attributes = ['block', 'max_items', 'add_element_label']
+    add_element_label = ''
+
+    # cache
+    _block = None
+
+    @property
+    def block(self):
+        if self._block:
+            return self._block
+        self._block = BlockDef.get_on_index(self.type[6:], 'slug')
+        return self._block
+
+    def get_type_label(self):
+        return _('Field Block (%s)') % self.block.name
+
+    def fill_admin_form(self, form):
+        super().fill_admin_form(form)
+        form.add(IntWidget, 'max_items', title=_('Maximum number of items'),
+                value=self.max_items)
+        form.add(StringWidget, 'add_element_label', title=_('Label of "Add" button'),
+                value=self.add_element_label)
+
+    def get_admin_attributes(self):
+        return super().get_admin_attributes() + ['max_items', 'add_element_label']
+
+    def store_display_value(self, data, field_id):
+        value = data.get(field_id)
+        parts = []
+        if value and value.get('data'):
+            for subvalue in value.get('data'):
+                parts.append(self.block.get_display_value(subvalue))
+        return ', '.join(parts)
+
+    def get_view_value(self, value, **kwargs):
+        if 'value_id' not in kwargs:
+            # when called from get_rst_view_value()
+            return str(value or '')
+        value = kwargs['value_id']
+        r = TemplateIO(html=True)
+        for i, row_value in enumerate(value['data']):
+            for field in self.block.fields:
+                css_classes = ['field', 'field-type-%s' % field.key]
+                if field.extra_css_class:
+                    css_classes.append(field.extra_css_class)
+                r += htmltext('<div class="%s">' % ' '.join(css_classes))
+                r += htmltext('<span class="label">%s</span> ') % field.label
+                sub_value = row_value.get(field.id)
+                if sub_value is None:
+                    r += htmltext('<div class="value"><i>%s</i></div>') % _('Not set')
+                else:
+                    r += htmltext('<div class="value">')
+                    r += field.get_view_value(sub_value, parent_field=self, parent_field_index=i)
+                    r += htmltext('</div>')
+                r += htmltext('</div>\n')
+        return r.getvalue()
+
+
 def get_field_class_by_type(type):
     for k in field_classes:
         if k.key == type:
             return k
+    if type.startswith('block:'):
+        return BlockField
     raise KeyError()
 
 
@@ -2612,4 +2688,12 @@ def get_field_options(blacklisted_types):
         else:
             non_widgets.append((klass.key, _(klass.description), klass.key))
     options = widgets + [('', '—', '')] + non_widgets
+    if get_publisher().has_site_option('fields-blocks') and (
+            not blacklisted_types or 'blocks' not in blacklisted_types):
+        position = len(options)
+        for blockdef in BlockDef.select(order_by='name'):
+            options.append(('block:%s' % blockdef.slug, blockdef.name, 'block:%s' % blockdef.slug))
+        if len(options) != position:
+            # add separator
+            options.insert(position, ('', '—', ''))
     return options
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 92b057a1e..1e137e375 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -708,7 +708,8 @@ class FormDef(StorableObject):
                     widget.live_condition_source = True
                     widget.live_condition_fields = live_condition_fields[field.varname]
 
-    def get_field_data(self, field, widget):
+    @classmethod
+    def get_field_data(cls, field, widget):
         d = {}
         d[field.id] = widget.parse()
         if d.get(field.id) is not None and field.convert_value_from_str:
@@ -1654,6 +1655,7 @@ def clean_unused_files(publisher):
 
 
 def get_formdefs_of_all_kinds():
+    from wcs.blocks import BlockDef
     from wcs.carddef import CardDef
     from wcs.wf.form import FormWorkflowStatusItem
     from wcs.admin.settings import UserFieldsFormDef
@@ -1665,6 +1667,7 @@ def get_formdefs_of_all_kinds():
     }
     formdefs = [UserFieldsFormDef()]
     formdefs += FormDef.select(**kwargs)
+    formdefs += BlockDef.select(**kwargs)
     formdefs += CardDef.select(**kwargs)
     for workflow in Workflow.select(**kwargs):
         for status in workflow.possible_status:
diff --git a/wcs/forms/common.py b/wcs/forms/common.py
index 7156ccbb0..cb9ec2ce9 100644
--- a/wcs/forms/common.py
+++ b/wcs/forms/common.py
@@ -49,8 +49,14 @@ class FileDirectory(Directory):
         self.reference = reference
 
     def lookup_file_field(self, filename):
-        if self.reference in self.formdata.data:
-            return self.formdata.data[self.reference]
+        try:
+            if '$' in self.reference:
+                fn2, idx, sub = self.reference.split('$', 2)
+                return self.formdata.data[fn2]['data'][int(idx)][sub]
+            else:
+                return self.formdata.data[self.reference]
+        except (KeyError, ValueError):
+            return None
 
     def _q_lookup(self, component):
         if component == 'thumbnail':
@@ -632,11 +638,14 @@ class FormStatusPage(Directory, FormTemplateMixin):
         self.check_receiver()
         try:
             fn = get_request().form['f']
-            f = self.filled.data[fn]
+            if '$' in fn:
+                fn2, idx, sub = fn.split('$', 2)
+                file = self.filled.data[fn2]['data'][int(idx)][sub]
+            else:
+                file = self.filled.data[fn]
         except (KeyError, ValueError):
             raise errors.TraversalError()
 
-        file = self.filled.data[fn]
         if not hasattr(file, 'content_type'):
             raise errors.TraversalError()
 
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index cc7024c08..9c3046bee 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -1285,6 +1285,9 @@ class FormPage(Directory, FormTemplateMixin):
     def validating(self, data):
         get_request().view_name = 'validation'
         self.html_top(self.formdef.name)
+        # fake a GET request to avoid previous page POST data being carried
+        # over in rendering.
+        get_request().environ['REQUEST_METHOD'] = 'GET'
         form = self.create_view_form(data)
         token_widget = form.get_widget(form.TOKEN_NAME)
         token_widget._parsed = True
diff --git a/wcs/publisher.py b/wcs/publisher.py
index b445863c9..daa81a361 100644
--- a/wcs/publisher.py
+++ b/wcs/publisher.py
@@ -163,7 +163,8 @@ class WcsPublisher(StubWcsPublisher):
     def import_zip(self, fd):
         z = zipfile.ZipFile(fd)
         results = {'formdefs': 0, 'carddefs': 0, 'workflows': 0, 'categories': 0, 'roles': 0,
-                'settings': 0, 'datasources': 0, 'wscalls': 0, 'mail-templates': 0}
+                'settings': 0, 'datasources': 0, 'wscalls': 0, 'mail-templates': 0,
+                'blockdefs': 0}
 
         def _decode_list(data):
             rv = []
@@ -193,7 +194,7 @@ class WcsPublisher(StubWcsPublisher):
         for f in z.namelist():
             if '.indexes' in f:
                 continue
-            if os.path.dirname(f) in ('formdefs_xml', 'carddefs_xml', 'workflows_xml'):
+            if os.path.dirname(f) in ('formdefs_xml', 'carddefs_xml', 'workflows_xml', 'blockdefs_xml'):
                 continue
             path = os.path.join(self.app_dir, f)
             if not os.path.exists(os.path.dirname(path)):
@@ -223,7 +224,15 @@ class WcsPublisher(StubWcsPublisher):
             if os.path.split(f)[0] in results:
                 results[os.path.split(f)[0]] += 1
 
-        # second pass, workflows
+        # second pass, fields blocks
+        from wcs.blocks import BlockDef
+        for f in z.namelist():
+            if os.path.dirname(f) == 'blockdefs_xml' and os.path.basename(f):
+                blockdef = BlockDef.import_from_xml(z.open(f), include_id=True)
+                blockdef.store()
+                results['blockdefs'] += 1
+
+        # third pass, workflows
         from wcs.workflows import Workflow
         for f in z.namelist():
             if os.path.dirname(f) == 'workflows_xml' and os.path.basename(f):
@@ -231,7 +240,7 @@ class WcsPublisher(StubWcsPublisher):
                 workflow.store()
                 results['workflows'] += 1
 
-        # third pass, forms and cards
+        # fourth pass, forms and cards
         from wcs.formdef import FormDef
         from wcs.carddef import CardDef
         formdefs = []
@@ -263,6 +272,8 @@ class WcsPublisher(StubWcsPublisher):
             elif k == 'carddefs':
                 from .carddef import CardDef
                 klass = CardDef
+            elif k == 'blockdefs':
+                klass = BlockDef
             elif k == 'categories':
                 from .categories import Category
                 klass = Category
diff --git a/wcs/sql.py b/wcs/sql.py
index 493e28439..aa3c44816 100644
--- a/wcs/sql.py
+++ b/wcs/sql.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
+import copy
 import psycopg2
 import psycopg2.extensions
 import psycopg2.extras
@@ -35,6 +36,8 @@ from wcs.qommon import force_str, PICKLE_KWARGS
 from .qommon.storage import _take, deep_bytes2str, parse_clause as parse_storage_clause
 from .qommon.substitution import invalidate_substitution_cache
 from .qommon import get_cfg
+from .qommon.upload_storage import PicklableUpload
+from .qommon.misc import strftime
 from .publisher import UnpicklerClass
 
 import wcs.categories
@@ -69,6 +72,8 @@ SQL_TYPE_MAPPING = {
     # mapping of dicts
     'ranked-items': 'text[][]',
     'password': 'text[][]',
+    # field block
+    'block': 'jsonb',
 }
 
 
@@ -1256,6 +1261,18 @@ class SqlMixin(object):
                     value = datetime.datetime(value.tm_year, value.tm_mon, value.tm_mday)
                 elif sql_type == 'bytea':
                     value = bytearray(pickle.dumps(value, protocol=2))
+                elif sql_type == 'jsonb' and value.get('schema'):
+                    # block field, adapt date/field values
+                    value = copy.deepcopy(value)
+                    for field_id, field_type in value.get('schema').items():
+                        if field_type not in ('date', 'file'):
+                            continue
+                        for entry in value.get('data') or []:
+                            subvalue = entry.get(field_id)
+                            if subvalue and field_type == 'date':
+                                entry[field_id] = strftime('%Y-%m-%d', subvalue)
+                            elif subvalue and field_type == 'file':
+                                entry[field_id] = subvalue.__getstate__()
                 elif sql_type == 'boolean':
                     pass
             sql_dict[get_field_id(field)] = value
@@ -1296,6 +1313,19 @@ class SqlMixin(object):
                     value = value.timetuple()
                 elif sql_type == 'bytea':
                     value = pickle_loads(value)
+                elif sql_type == 'jsonb' and value.get('schema'):
+                    # block field, adapt date/field values
+                    for field_id, field_type in value.get('schema').items():
+                        if field_type not in ('date', 'file'):
+                            continue
+                        for entry in value.get('data') or []:
+                            subvalue = entry.get(field_id)
+                            if subvalue and field_type == 'date':
+                                entry[field_id] = time.strptime(subvalue, '%Y-%m-%d')
+                            elif subvalue and field_type == 'file':
+                                entry[field_id] = PicklableUpload.__new__(PicklableUpload)
+                                entry[field_id].__setstate__(subvalue)
+
             obdata[field.id] = value
             i += 1
             if field.store_display_value:
diff --git a/wcs/templates/wcs/backoffice/blocks.html b/wcs/templates/wcs/backoffice/blocks.html
new file mode 100644
index 000000000..ebace97c0
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/blocks.html
@@ -0,0 +1,23 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar-title %}{% trans "Field Blocks" %}{% endblock %}
+
+{% block appbar-actions %}
+<a rel="popup" href="import">{% trans "Import" %}</a>
+<a rel="popup" href="new">{% trans "New field block" %}</a>
+{% endblock %}
+
+{% block content %}
+{% if blocks %}
+<ul class="objects-list single-links">
+  {% for block in blocks %}
+  <li><a href="{{ block.id }}/">{{ block.name }}</a></li>
+  {% endfor %}
+</ul>
+{% else %}
+<div class="infonotice">
+{% trans "There are no field blocks defined." %}
+</div>
+{% endif %}
+{% endblock %}
diff --git a/wcs/variables.py b/wcs/variables.py
index 1c6797f9b..74b0438c5 100644
--- a/wcs/variables.py
+++ b/wcs/variables.py
@@ -461,6 +461,13 @@ class LazyFormDataVar(object):
             self._varnames[field.varname] = field
         return self._varnames
 
+    def get_field_kwargs(self, field):
+        return {
+            'data': self._data,
+            'field': field,
+            'formdata': self._formdata
+        }
+
     def __getitem__(self, key):
         try:
             field = self.varnames[key]
@@ -489,18 +496,18 @@ class LazyFormDataVar(object):
         if str(field.id) not in self._data:
             raise KeyError(key)
 
-        if field.key == 'date':
-            return LazyFieldVarDate(self._data, field, self._formdata)
-        if field.key == 'map':
-            return LazyFieldVarMap(self._data, field, self._formdata)
-        if field.key == 'password':
-            return LazyFieldVarPassword(self._data, field, self._formdata)
-        if field.key == 'file':
-            return LazyFieldVarFile(self._data, field, self._formdata)
+        klass = LazyFieldVar
         if field.store_structured_value:
-            return LazyFieldVarStructured(self._data, field, self._formdata)
+            klass = LazyFieldVarStructured
+        klass = {  # custom types
+            'date': LazyFieldVarDate,
+            'map': LazyFieldVarMap,
+            'password': LazyFieldVarPassword,
+            'file': LazyFieldVarFile,
+            'block': LazyFieldVarBlock,
+        }.get(field.key, klass)
 
-        return LazyFieldVar(self._data, field, self._formdata)
+        return klass(**self.get_field_kwargs(field))
 
     def __getattr__(self, attr):
         try:
@@ -510,10 +517,11 @@ class LazyFormDataVar(object):
 
 
 class LazyFieldVar(object):
-    def __init__(self, data, field, formdata=None):
+    def __init__(self, data, field, formdata=None, **kwargs):
         self._data = data
         self._field = field
         self._formdata = formdata
+        self._field_kwargs = kwargs
 
     @property
     def raw(self):
@@ -521,12 +529,6 @@ class LazyFieldVar(object):
             return self._data.get(self._field.id)
         raise AttributeError('raw')
 
-    @property
-    def url(self):
-        if self._field.key != 'file' or not self._formdata:
-            raise AttributeError('url')
-        return '%sdownload?f=%s' % (self._formdata.get_url(), self._field.id)
-
     def get_value(self):
         if self._field.store_display_value:
             return self._data.get('%s_display' % self._field.id)
@@ -764,7 +766,58 @@ class LazyFieldVarPassword(LazyFieldVar):
 
 
 class LazyFieldVarFile(LazyFieldVar):
-    pass
+    @property
+    def url(self):
+        return self._field.get_download_url(formdata=self._formdata, **self._field_kwargs)
+
+
+class LazyBlockDataVar(LazyFormDataVar):
+    def __init__(self, fields, data, formdata=None, parent_field=None, parent_field_index=0):
+        super().__init__(fields, data, formdata=formdata)
+        self.parent_field = parent_field
+        self.parent_field_index = parent_field_index
+
+    def get_field_kwargs(self, field):
+        kwargs = super().get_field_kwargs(field)
+        kwargs['parent_field'] = self.parent_field
+        kwargs['parent_field_index'] = self.parent_field_index
+        return kwargs
+
+
+class LazyFieldVarBlock(LazyFieldVar):
+    def inspect_keys(self):
+        if self._field.max_items > 1:
+            data = self._formdata.data.get(self._field.id)['data']
+            return [str(x) for x in range(len(data))]
+        else:
+            return ['var']
+
+    def get_value(self):
+        # don't give access to underlying data dictionary.
+        return self._data.get('%s_display' % self._field.id, '---')
+
+    def __getitem__(self, key):
+        try:
+            int(key)
+        except ValueError:
+            return super().__getitem__(key)
+        data = self._formdata.data.get(self._field.id)['data'][int(key)]
+        return LazyBlockDataVar(self._field.block.fields,
+                data,
+                formdata=self._formdata,
+                parent_field=self._field,
+                parent_field_index=int(key),
+                )
+
+    @property
+    def var(self):
+        # alias when there's a single item
+        return self[0]
+
+    def __iter__(self):
+        data = self._formdata.data.get(self._field.id)['data']
+        for i in range(len(data)):
+            yield self[i]
 
 
 class LazyUser(object):
-- 
2.27.0

