From 18cfb7ac7b2d9cb07db3f9dd0e628dfb0653eac6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Mon, 14 Sep 2020 09:44:15 +0200
Subject: [PATCH 1/3] general: switch map field value to be a dictionary
 (#46617)

---
 tests/test_admin_pages.py                     |  2 +-
 tests/test_api.py                             | 48 +++++++--------
 tests/test_backoffice_pages.py                |  6 +-
 tests/test_fields.py                          |  8 +--
 tests/test_form_pages.py                      | 26 ++++++++-
 tests/test_formdata.py                        |  9 ++-
 tests/test_publisher.py                       |  2 +-
 tests/test_sql.py                             | 47 +++++++++++++++
 tests/test_widgets.py                         |  2 +-
 tests/test_workflows.py                       |  8 +--
 wcs/backoffice/data_management.py             |  2 +-
 wcs/backoffice/management.py                  |  2 +-
 wcs/fields.py                                 | 28 ++++-----
 wcs/formdef.py                                |  2 +-
 wcs/qommon/form.py                            | 23 ++++++--
 wcs/qommon/publisher.py                       | 15 +++--
 .../templates/qommon/forms/widgets/map.html   |  4 +-
 wcs/sql.py                                    | 58 ++++++++++++++++++-
 wcs/variables.py                              |  7 +++
 19 files changed, 224 insertions(+), 75 deletions(-)

diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py
index e91d52020..c58c8f15f 100644
--- a/tests/test_admin_pages.py
+++ b/tests/test_admin_pages.py
@@ -3044,7 +3044,7 @@ def test_settings_geolocation(pub):
     resp = resp.click('Geolocation')
     assert 'value="1.234;-1.234' in resp.text
     pub.reload_cfg()
-    assert pub.cfg['misc']['default-position'] == '1.234;-1.234'
+    assert pub.cfg['misc']['default-position'] == {'lat': 1.234, 'lon': -1.234}
 
 
 def test_wscalls_new(pub):
diff --git a/tests/test_api.py b/tests/test_api.py
index b01578dd0..c81ba1eb4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -854,7 +854,7 @@ def test_formdef_submit_with_varname(pub, local_user):
 
     assert data_class.get(resp.json['data']['id']).data['4'].orig_filename == 'test.txt'
     assert data_class.get(resp.json['data']['id']).data['4'].get_content() == b'test'
-    assert data_class.get(resp.json['data']['id']).data['5'] == '1.5;2.25'
+    assert data_class.get(resp.json['data']['id']).data['5'] == {'lat': 1.5, 'lon': 2.25}
     # test bijectivity
     assert (formdef.fields[3].get_json_value(data_class.get(resp.json['data']['id']).data['3']) ==
             payload['data']['date'])
@@ -900,34 +900,36 @@ def test_formdef_submit_from_wscall(pub, local_user):
         '2_display': 'bar',
         '3': time.strptime('1970-01-01', '%Y-%m-%d'),
         '4': upload,
-        '5': '1.5;2.25',
         'bo1': 'backoffice field',
     }
     formdata.just_created()
     formdata.evolution[-1].status = 'wf-new'
     formdata.store()
 
-    payload = json.loads(
-                json.dumps(formdata.get_json_export_dict(),
-                    cls=qommon.misc.JSONEncoder))
-    signed_url = sign_url('http://example.net/api/formdefs/test/submit?orig=coucou', '1234')
-    url = signed_url[len('http://example.net'):]
-
-    resp = get_app(pub).post_json(url, payload)
-    assert resp.json['err'] == 0
-    new_formdata = formdef.data_class().get(resp.json['data']['id'])
-    assert new_formdata.data['0'] == formdata.data['0']
-    assert new_formdata.data['1'] == formdata.data['1']
-    assert new_formdata.data['1_display'] == formdata.data['1_display']
-    assert new_formdata.data['1_structured'] == formdata.data['1_structured']
-    assert new_formdata.data['2'] == formdata.data['2']
-    assert new_formdata.data['2_display'] == formdata.data['2_display']
-    assert new_formdata.data['3'] == formdata.data['3']
-    assert new_formdata.data['4'].get_content() == formdata.data['4'].get_content()
-    assert new_formdata.data['5'] == formdata.data['5']
-    assert new_formdata.data['bo1'] == formdata.data['bo1']
-    assert not new_formdata.data.get('6')
-    assert new_formdata.user_id is None
+    for map_value in ('1.5;2.25', {'lat': 1.5, 'lon': 2.25}):
+        formdata.data['5'] = map_value
+
+        payload = json.loads(
+                    json.dumps(formdata.get_json_export_dict(),
+                        cls=qommon.misc.JSONEncoder))
+        signed_url = sign_url('http://example.net/api/formdefs/test/submit?orig=coucou', '1234')
+        url = signed_url[len('http://example.net'):]
+
+        resp = get_app(pub).post_json(url, payload)
+        assert resp.json['err'] == 0
+        new_formdata = formdef.data_class().get(resp.json['data']['id'])
+        assert new_formdata.data['0'] == formdata.data['0']
+        assert new_formdata.data['1'] == formdata.data['1']
+        assert new_formdata.data['1_display'] == formdata.data['1_display']
+        assert new_formdata.data['1_structured'] == formdata.data['1_structured']
+        assert new_formdata.data['2'] == formdata.data['2']
+        assert new_formdata.data['2_display'] == formdata.data['2_display']
+        assert new_formdata.data['3'] == formdata.data['3']
+        assert new_formdata.data['4'].get_content() == formdata.data['4'].get_content()
+        assert new_formdata.data['5'] == {'lat': 1.5, 'lon': 2.25}
+        assert new_formdata.data['bo1'] == formdata.data['bo1']
+        assert not new_formdata.data.get('6')
+        assert new_formdata.user_id is None
 
     # add an extra attribute
     payload['extra'] = {'foobar6': 'YYY'}
diff --git a/tests/test_backoffice_pages.py b/tests/test_backoffice_pages.py
index e0d2c72ee..348f781bd 100644
--- a/tests/test_backoffice_pages.py
+++ b/tests/test_backoffice_pages.py
@@ -6381,7 +6381,9 @@ def test_backoffice_cards_import_data_from_csv(pub, studio):
             "will be ignored - type File Upload not supported,"
             "foo@example.com,"
             "value,"
-            "value\r\n" % (pub.get_default_position(), today))
+            "value\r\n" % (
+                '%(lat)s;%(lon)s' % pub.get_default_position(),
+                today))
 
     resp.forms[0]['file'] = Upload('test.csv', b'\0', 'text/csv')
     resp = resp.forms[0].submit()
@@ -6409,7 +6411,7 @@ def test_backoffice_cards_import_data_from_csv(pub, studio):
     assert 'Column File will be ignored: type File Upload not supported.' in resp
     assert carddef.data_class().count() == 149
     card1, card2 = carddef.data_class().select(order_by='id')[:2]
-    assert card1.data['1'] == '48.81;2.37'
+    assert card1.data['1'] == {'lat': 48.81, 'lon': 2.37}
     assert card1.data['2'] == 'data1'
     assert card1.data['3'] is True
     assert card1.data['5'].tm_mday == 2
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 15951c60f..f35bd0e03 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -313,11 +313,9 @@ def test_comment():
 
 
 def test_map():
-    assert fields.MapField().get_json_value('42.2;10.2') == {'lat': 42.2, 'lon': 10.2}
-    assert fields.MapField().get_json_value('-42.2;10.2') == {'lat': -42.2, 'lon': 10.2}
-    assert fields.MapField().get_json_value(' 42.2 ; 10.2 ') == {'lat': 42.2, 'lon': 10.2}
-    assert fields.MapField().get_json_value('') == None
-    assert fields.MapField().get_json_value('foobar') == None
+    assert fields.MapField().get_json_value({'lat': 42.2, 'lon': 10.2}) == {'lat': 42.2, 'lon': 10.2}
+    assert fields.MapField().get_json_value({'lat': -42.2, 'lon': 10.2}) == {'lat': -42.2, 'lon': 10.2}
+    assert fields.MapField().get_json_value(None) is None
 
 
 def test_item_render():
diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 43f35d52c..ec2a676f6 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -4427,7 +4427,29 @@ def test_form_map_field_back_and_submit(pub):
     assert formdef.data_class().count() == 1
     data_id = formdef.data_class().select()[0].id
     data = formdef.data_class().get(data_id)
-    assert data.data == {'1': 'bla', '0': '1.234;-1.234'}
+    assert data.data == {'0': {'lat': 1.234, 'lon': -1.234}, '1': 'bla'}
+
+
+def test_map_field_migration(pub):
+    if pub.is_using_postgresql():
+        pytest.skip('this is not relevant in SQL mode')
+        return
+
+    user = create_user(pub)
+    formdef = create_formdef()
+    formdef.data_class().wipe()
+    formdef.fields = [fields.MapField(id='0', label='map', type='map')]
+    formdef.store()
+    formdata = formdef.data_class()()
+    formdata.user_id = user.id
+    formdata.data = {'0': '1;2'}  # legacy data type
+    formdata.just_created()
+    formdata.store()
+
+    app = login(get_app(pub), username='foo', password='foo')
+    resp = app.get(formdata.get_url())
+    assert 'data-init-lat="1"' in resp
+    assert 'data-init-lng="2"' in resp
 
 
 def test_form_map_field_prefill_address(pub):
@@ -4475,7 +4497,7 @@ def test_form_map_multi_page(pub):
     assert formdef.data_class().count() == 1
     data_id = formdef.data_class().select()[0].id
     data = formdef.data_class().get(data_id)
-    assert data.data == {'1': '1.234;-1.234', '3': 'bar'}
+    assert data.data == {'1': {'lat': 1.234, 'lon': -1.234}, '3': 'bar'}
 
 
 def test_form_middle_session_change(pub):
diff --git a/tests/test_formdata.py b/tests/test_formdata.py
index 399262bc3..b8b95983b 100644
--- a/tests/test_formdata.py
+++ b/tests/test_formdata.py
@@ -614,7 +614,7 @@ def variable_test_data(pub):
         '4_display': 'aa, ac',
         '5': PicklableUpload('test.txt', 'text/plain'),
         '6': 'other',
-        '7': '2;4',  # map
+        '7': {'lat': 2, 'lon': 4},  # map
         '8': time.strptime('2018-08-31', '%Y-%m-%d'),
         '9': '2018-07-31',
         '10': '3',
@@ -1120,14 +1120,13 @@ def test_lazy_map_variable(pub, variable_test_data):
         pub.substitutions.reset()
         pub.substitutions.feed(formdef)
         with pub.substitutions.temporary_feed(formdata, force_mode=mode):
-            assert WorkflowStatusItem.compute('=form_var_map', raises=True) == '2;4'
+            assert WorkflowStatusItem.compute('=form_var_map["lat"]', raises=True) == 2
             assert WorkflowStatusItem.compute('{{ form_var_map }}', raises=True) == '2;4'
-            assert WorkflowStatusItem.compute('=form_var_map.split(";")[0]', raises=True) == '2'
             assert WorkflowStatusItem.compute('{{ form_var_map|split:";"|first }}', raises=True) == '2'
             assert WorkflowStatusItem.compute('=form_var_map_lat', raises=True) == 2
-            assert WorkflowStatusItem.compute('{{ form_var_map_lat }}', raises=True) == '2.0'
+            assert WorkflowStatusItem.compute('{{ form_var_map_lat }}', raises=True) == '2'
             assert WorkflowStatusItem.compute('=form_var_map_lon', raises=True) == 4
-            assert WorkflowStatusItem.compute('{{ form_var_map_lon }}', raises=True) == '4.0'
+            assert WorkflowStatusItem.compute('{{ form_var_map_lon }}', raises=True) == '4'
 
             assert WorkflowStatusItem.compute('{{ form_var_map|distance:form_var_map|floatformat }}', raises=True) == '0'
             assert WorkflowStatusItem.compute('{{ form_var_map|distance:"2.1;4.1"|floatformat }}', raises=True) == '15685.4'
diff --git a/tests/test_publisher.py b/tests/test_publisher.py
index ed0a4e799..87ba2225d 100644
--- a/tests/test_publisher.py
+++ b/tests/test_publisher.py
@@ -158,7 +158,7 @@ def test_register_cronjobs():
 
 
 def test_get_default_position():
-    assert pub.get_default_position() == '50.84;4.36'
+    assert pub.get_default_position() == {'lat': 50.84, 'lon': 4.36}
 
 
 def test_import_config_zip():
diff --git a/tests/test_sql.py b/tests/test_sql.py
index 67d359563..e6f2218d7 100644
--- a/tests/test_sql.py
+++ b/tests/test_sql.py
@@ -2063,3 +2063,50 @@ def test_migration_40_user_is_active():
     assert sql.SqlUser.count() == 2
     assert sql.SqlUser.get(id=user.id).is_active is False
     assert sql.SqlUser.get(id=user2.id).is_active is True
+
+
+@postgresql
+def test_migration_43_map_data_type():
+    formdef = FormDef()
+    formdef.name = 'test map migration'
+    formdef.fields = [
+        fields.MapField(id='1', label='map', type='map'),
+    ]
+    formdef.store()
+
+    formdata1 = formdef.data_class(mode='sql')()
+    formdata1.just_created()
+    formdata1.store()
+
+    formdata2 = formdef.data_class(mode='sql')()
+    formdata2.just_created()
+    formdata2.store()
+
+    formdata3 = formdef.data_class(mode='sql')()
+    formdata3.just_created()
+    formdata3.store()
+
+    conn, cur = sql.get_connection_and_cursor()
+    cur.execute('UPDATE wcs_meta SET value = 42 WHERE key = %s', ('sql_level',))
+    conn.commit()
+    cur.close()
+
+    conn, cur = sql.get_connection_and_cursor()
+    cur.execute('ALTER TABLE %s DROP COLUMN f1 CASCADE' % sql.get_formdef_table_name(formdef))
+    cur.execute('ALTER TABLE %s ADD COLUMN f1 VARCHAR' % sql.get_formdef_table_name(formdef))
+    cur.execute('UPDATE ' + sql.get_formdef_table_name(formdef) + ' SET f1 = %s WHERE id = %s', ('1;2', formdata1.id))
+    cur.execute('UPDATE ' + sql.get_formdef_table_name(formdef) + ' SET f1 = %s WHERE id = %s', ('', formdata2.id))
+    cur.execute('UPDATE ' + sql.get_formdef_table_name(formdef) + ' SET f1 = %s WHERE id = %s', (None, formdata3.id))
+    conn.commit()
+    cur.close()
+
+    sql.migrate()
+
+    conn, cur = sql.get_connection_and_cursor()
+    assert migration_level(cur) >= 43
+    conn.commit()
+    cur.close()
+
+    assert formdef.data_class(mode='sql').get(formdata1.id).data['1'] == {'lat': 1, 'lon': 2}
+    assert formdef.data_class(mode='sql').get(formdata2.id).data['1'] is None
+    assert formdef.data_class(mode='sql').get(formdata3.id).data['1'] is None
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index b477ca257..d315f3ca9 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -970,7 +970,7 @@ def test_map_widget():
     widget = MapWidget('test', title='Map')
     mock_form_submission(req, widget, hidden_html_vars={'test$latlng': '1.23;2.34'})
     assert not widget.has_error()
-    assert widget.parse() == '1.23;2.34'
+    assert widget.parse() == {'lat': 1.23, 'lon': 2.34}
 
     assert '<label' in str(widget.render())
     assert not '<label ' in str(widget.render_widget_content())
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index 1f6a4aa64..6b04a4b6a 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -2964,7 +2964,7 @@ def test_geolocate_map(pub):
     formdef.store()
 
     formdata = formdef.data_class()()
-    formdata.data = {'2': '48.8337085;2.3233693'}
+    formdata.data = {'2': {'lat': 48.8337085, 'lon': 2.3233693}}
     formdata.just_created()
     formdata.store()
     pub.substitutions.feed(formdata)
@@ -2994,7 +2994,7 @@ def test_geolocate_overwrite(pub):
     formdef.store()
 
     formdata = formdef.data_class()()
-    formdata.data = {'2': '48.8337085;2.3233693'}
+    formdata.data = {'2': {'lat': 48.8337085, 'lon': 2.3233693}}
     formdata.just_created()
     formdata.store()
     pub.substitutions.feed(formdata)
@@ -3007,12 +3007,12 @@ def test_geolocate_overwrite(pub):
     assert int(formdata.geolocations['base']['lat']) == 48
     assert int(formdata.geolocations['base']['lon']) == 2
 
-    formdata.data = {'2': '48.8337085;3.3233693'}
+    formdata.data = {'2': {'lat': 48.8337085, 'lon': 3.3233693}}
     item.perform(formdata)
     assert int(formdata.geolocations['base']['lat']) == 48
     assert int(formdata.geolocations['base']['lon']) == 3
 
-    formdata.data = {'2': '48.8337085;4.3233693'}
+    formdata.data = {'2': {'lat': 48.8337085, 'lon': 4.3233693}}
     item.overwrite = False
     item.perform(formdata)
     assert int(formdata.geolocations['base']['lat']) == 48
diff --git a/wcs/backoffice/data_management.py b/wcs/backoffice/data_management.py
index 59c1a3f76..40beb38e1 100644
--- a/wcs/backoffice/data_management.py
+++ b/wcs/backoffice/data_management.py
@@ -149,7 +149,7 @@ class CardPage(FormPage):
             elif isinstance(f, fields.EmailField):
                 value = 'foo@example.com'
             elif isinstance(f, fields.MapField):
-                value = get_publisher().get_default_position()
+                value = '%(lat)s;%(lon)s' % get_publisher().get_default_position()
             else:
                 value = 'value'
             sample_line.append(value)
diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py
index 933b2becb..bb91330da 100644
--- a/wcs/backoffice/management.py
+++ b/wcs/backoffice/management.py
@@ -2792,7 +2792,7 @@ class FormBackOfficeStatusPage(FormStatusPage):
                 geoloc_value = formdata.geolocations[geoloc_key]
                 map_widget = MapWidget('geoloc_%s' % geoloc_key,
                         readonly=True,
-                        value='%(lat)s;%(lon)s' % geoloc_value,
+                        value=geoloc_value,
                         render_br=False)
                 r += map_widget.render()
             r += htmltext('</div>')
diff --git a/wcs/fields.py b/wcs/fields.py
index 0a90b1f1f..90f4b04f5 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -2449,40 +2449,32 @@ class MapField(WidgetField):
         geolocate.method = 'address_string'
         geolocate.address_string = self.prefill.get('value')
         coords = geolocate.geolocate_address_string(None)
-        if not coords:
-            return (None, False)
-        return ('%(lat)s;%(lon)s' % coords, False)
+        return (coords, False)
 
     def get_view_value(self, value, **kwargs):
         widget = self.widget_class('x%s' % random.random(), value, readonly=True)
         return widget.render_widget_content()
 
     def get_rst_view_value(self, value, indent=''):
-        return indent + value
+        if isinstance(value, str):  # compatiblity with old pickled data
+            return indent + value
+        return indent + '%(lat)s;%(lon)s' % value
 
     def convert_value_from_str(self, value):
         try:
             lat, lon = [float(x) for x in value.split(';')]
         except (AttributeError, ValueError):
             return None
-        return value
+        return {'lat': lat, 'lon': lon}
 
     def get_json_value(self, value, **kwargs):
-        if not value or ';' not in value:
-            return None
-        lat, lon = value.split(';')
-        try:
-            lat = float(lat)
-            lon = float(lon)
-        except ValueError:
-            return None
-        return {'lat': lat, 'lon': lon}
+        return value
 
     def from_json_value(self, value):
-        if 'lat' in value and 'lon' in value:
-            return '%s;%s' % (float(value['lat']), float(value['lon']))
-        else:
-            return None
+        if isinstance(value, str):
+            # backward compatibility
+            return self.convert_value_from_str(value)
+        return value
 
     def get_structured_value(self, data):
         return self.get_json_value(data.get(self.id))
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 59e5331ee..5c50fa7c9 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -714,7 +714,7 @@ class FormDef(StorableObject):
     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:
+        if isinstance(d.get(field.id), str) and field.convert_value_from_str:
             d[field.id] = field.convert_value_from_str(d[field.id])
         if d.get(field.id) is not None and field.store_display_value:
             display_value = field.store_display_value(d, field.id)
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index d5a8dfd92..f20c044f8 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -2364,7 +2364,12 @@ class MapWidget(CompositeWidget):
 
     def __init__(self, name, value=None, **kwargs):
         CompositeWidget.__init__(self, name, value, **kwargs)
-        self.add(HiddenWidget, 'latlng', value=value)
+        latlng_value = None
+        if isinstance(value, str):  # legacy data type
+            latlng_value = value
+        elif value:
+            latlng_value = '%s;%s' % (value['lat'], value['lon'])
+        self.add(HiddenWidget, 'latlng', value=latlng_value)
         widget = self.get_widget('latlng')
         self.readonly = kwargs.pop('readonly', False)
         self.map_attributes = {}
@@ -2377,10 +2382,21 @@ class MapWidget(CompositeWidget):
             self.map_attributes['data-def-lat'] = kwargs['default_position'].split(';')[0]
             self.map_attributes['data-def-lng'] = kwargs['default_position'].split(';')[1]
 
+    def point2str(self, value):
+        if not value:
+            return None
+        return '%s;%s' % (value['lat'], value['lon'])
+
+    def transfer_form_value(self, request):
+        request.form[self.get_widget('latlng').name] = self.point2str(self.value)
+
     def initial_position(self):
-        if self.value:
+        if isinstance(self.value, str):
             return {'lat': self.value.split(';')[0],
                     'lng': self.value.split(';')[1]}
+        if isinstance(self.value, dict):
+            return {'lat': self.value['lat'],
+                    'lng': self.value['lon']}
         return None
 
     def add_media(self):
@@ -2391,8 +2407,7 @@ class MapWidget(CompositeWidget):
         self.value = self.get('latlng')
         if self.value:
             lat, lon = self.value.split(';')
-            lat_lon = misc.normalize_geolocation({'lat': lat, 'lon': lon})
-            self.value = '%s;%s' % (lat_lon['lat'], lat_lon['lon'])
+            self.value = misc.normalize_geolocation({'lat': lat, 'lon': lon})
 
 
 class HiddenErrorWidget(HiddenWidget):
diff --git a/wcs/qommon/publisher.py b/wcs/qommon/publisher.py
index 1121bf5c3..f43025c46 100644
--- a/wcs/qommon/publisher.py
+++ b/wcs/qommon/publisher.py
@@ -855,14 +855,21 @@ class QommonPublisher(Publisher, object):
     def get_default_position(self):
         default_position = self.cfg.get('misc', {}).get('default-position', None)
         if not default_position:
-            default_position = self.get_site_option('default_position')
-        if not default_position:
-            default_position = '50.84;4.36'
+            default_position = self.get_site_option('default_position') or '50.84;4.36'
+
+        if isinstance(default_position, str):
+            default_position = {
+                'lat': float(default_position.split(';')[0]),
+                'lon': float(default_position.split(';')[1]),
+            }
+
         return default_position
 
     def get_map_attributes(self):
         attrs = {}
-        attrs['data-def-lat'], attrs['data-def-lng'] = self.get_default_position().split(';')
+        default_position = self.get_default_position()
+        attrs['data-def-lat'] = default_position['lat']
+        attrs['data-def-lng'] = default_position['lon']
         if self.get_site_option('map-bounds-top-left'):
             attrs['data-max-bounds-lat1'], attrs['data-max-bounds-lng1'] = \
                     self.get_site_option('map-bounds-top-left').split(';')
diff --git a/wcs/qommon/templates/qommon/forms/widgets/map.html b/wcs/qommon/templates/qommon/forms/widgets/map.html
index a9a48d1bf..f8375a006 100644
--- a/wcs/qommon/templates/qommon/forms/widgets/map.html
+++ b/wcs/qommon/templates/qommon/forms/widgets/map.html
@@ -1,7 +1,8 @@
 {% extends "qommon/forms/widget.html" %}
 
 {% block widget-control %}
-<input type="hidden" name="{{widget.name}}$latlng" {% if widget.value %}value="{{widget.value}}"{% endif %}>
+{% localize off %}
+<input type="hidden" name="{{widget.name}}$latlng" {% if widget.value %}value="{{widget.value.lat}};{{widget.value.lon}}"{% endif %}>
 <div id="map-{{widget.name}}" class="qommon-map"
   {% if widget.readonly %}data-readonly="true"{% endif %}
   {% if widget.sync_map_and_address_fields %}data-address-sync="true"{% endif %}
@@ -11,4 +12,5 @@
     data-init-lng="{{ widget.initial_position.lng }}"
   {% endif %}
 ></div>
+{% endlocalize %}
 {% endblock %}
diff --git a/wcs/sql.py b/wcs/sql.py
index 653c509a8..a61ffd23a 100644
--- a/wcs/sql.py
+++ b/wcs/sql.py
@@ -67,6 +67,7 @@ SQL_TYPE_MAPPING = {
     'bool': 'boolean',
     'file': 'bytea',
     'date': 'date',
+    'map': 'jsonb',
     'items': 'text[]',
     'table': 'text[][]',
     'table-select': 'text[][]',
@@ -2672,7 +2673,59 @@ def get_yearly_totals(period_start=None, period_end=None, criterias=None):
     return result
 
 
-SQL_LEVEL = 42
+SQL_LEVEL = 43
+
+
+@guard_postgres
+def migrate_map_data_type():
+    conn, cur = get_connection_and_cursor()
+
+    from wcs.formdef import FormDef
+    from wcs.carddef import CardDef
+    had_changes = False
+    for formdef in FormDef.select() + CardDef.select():
+        table_name = get_formdef_table_name(formdef)
+        cur.execute('''SELECT column_name, udt_name FROM information_schema.columns
+                        WHERE table_schema = 'public'
+                          AND table_name = %s''', (table_name,))
+        existing_fields = {x[0]: x[1] for x in cur.fetchall()}
+
+        for field in formdef.get_all_fields():
+            if field.type != 'map':
+                continue
+            database_field_id = get_field_id(field)
+            if existing_fields.get(database_field_id) == 'jsonb':
+                # already ok
+                continue
+            sql_type = SQL_TYPE_MAPPING.get(field.key, 'varchar')
+            cur.execute('''ALTER TABLE %s ADD COLUMN %s %s''' % (
+                                    table_name,
+                                    'tmp_' + database_field_id,
+                                    sql_type))
+            cur.execute('''UPDATE %(table_name)s
+                              SET tmp_%(column)s = jsonb_build_object(
+                                        'lat', split_part(%(column)s, ';', 1)::float,
+                                        'lon', split_part(%(column)s, ';', 2)::float)
+                              WHERE %(column)s IS NOT NULL
+                                AND %(column)s != '' ''' % {
+                             'table_name': table_name,
+                             'column': database_field_id
+                        })
+            cur.execute('''ALTER TABLE %s DROP COLUMN %s CASCADE''' % (
+                table_name,
+                database_field_id))
+            cur.execute('''ALTER TABLE %s RENAME COLUMN tmp_%s TO %s''' % (
+                table_name,
+                database_field_id,
+                database_field_id))
+            had_changes = True
+
+    if had_changes:
+        # views have to be recreated
+        migrate_views(conn, cur)
+
+    conn.commit()
+    cur.close()
 
 
 def migrate_global_views(conn, cur):
@@ -2823,6 +2876,9 @@ def migrate():
     if sql_level < 42:
         # 42: create snapshots table
         do_snapshots_table()
+    if sql_level < 43:
+        # 43: migrate map field data type
+        migrate_map_data_type()
 
     cur.execute('''UPDATE wcs_meta SET value = %s WHERE key = %s''', (
         str(SQL_LEVEL), 'sql_level'))
diff --git a/wcs/variables.py b/wcs/variables.py
index abc43a212..3720ef3b5 100644
--- a/wcs/variables.py
+++ b/wcs/variables.py
@@ -822,6 +822,13 @@ class LazyFieldVarMap(LazyFieldVarStructured):
     def inspect_keys(self):
         return ['lat', 'lon']
 
+    def __str__(self):
+        # backward compatibility
+        value = self._data.get(self._field.id)
+        if not value:
+            return ''
+        return '%(lat)s;%(lon)s' % value
+
 
 class LazyFieldVarPassword(LazyFieldVar):
     def __getitem__(self, key):
-- 
2.28.0

