Merge pull request #1630 from umap-project/datalayer-uuids
chore: replace datalayer ids with uuids
This commit is contained in:
commit
6ed5ebc9fb
10 changed files with 201 additions and 27 deletions
|
@ -4,6 +4,8 @@ import six
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
|
|
||||||
|
from .utils import json_dumps
|
||||||
|
|
||||||
|
|
||||||
class DictField(models.TextField):
|
class DictField(models.TextField):
|
||||||
"""
|
"""
|
||||||
|
@ -14,7 +16,7 @@ class DictField(models.TextField):
|
||||||
if not value:
|
if not value:
|
||||||
value = {}
|
value = {}
|
||||||
if not isinstance(value, six.string_types):
|
if not isinstance(value, six.string_types):
|
||||||
value = json.dumps(value)
|
value = json_dumps(value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def from_db_value(self, value, expression, connection):
|
def from_db_value(self, value, expression, connection):
|
||||||
|
|
62
umap/migrations/0018_datalayer_uuid.py
Normal file
62
umap/migrations/0018_datalayer_uuid.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
# Generated by Django 4.2.8 on 2024-02-19 17:40
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
drop_index = """DO $$
|
||||||
|
BEGIN
|
||||||
|
EXECUTE 'ALTER TABLE umap_datalayer DROP CONSTRAINT ' || (
|
||||||
|
SELECT indexname
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = 'umap_datalayer' AND indexname LIKE '%pk%'
|
||||||
|
);
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("umap", "0017_migrate_to_openstreetmap_oauth2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add the new uuid field
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datalayer",
|
||||||
|
name="uuid",
|
||||||
|
field=models.UUIDField(
|
||||||
|
default=uuid.uuid4, editable=False, null=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Generate UUIDs for existing records
|
||||||
|
migrations.RunSQL(
|
||||||
|
"UPDATE umap_datalayer SET uuid = gen_random_uuid()",
|
||||||
|
reverse_sql=migrations.RunSQL.noop,
|
||||||
|
),
|
||||||
|
# Remove the primary key constraint
|
||||||
|
migrations.RunSQL(drop_index, reverse_sql=migrations.RunSQL.noop),
|
||||||
|
# Drop the "id" primary key…
|
||||||
|
migrations.AlterField(
|
||||||
|
"datalayer", name="id", field=models.IntegerField(null=True, blank=True)
|
||||||
|
),
|
||||||
|
# Rename "id" to "old id"
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="datalayer", old_name="id", new_name="old_id"
|
||||||
|
),
|
||||||
|
# … to put it back on the "uuid"
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="datalayer",
|
||||||
|
name="uuid",
|
||||||
|
field=models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
unique=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# When applying the migration backwards, we need to drop the pk index
|
||||||
|
# Before addding a new one.
|
||||||
|
migrations.RunSQL(migrations.RunSQL.noop, reverse_sql=drop_index),
|
||||||
|
]
|
52
umap/migrations/0019_migrate_internal_remote_datalayers.py
Normal file
52
umap/migrations/0019_migrate_internal_remote_datalayers.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 4.2 on 2024-02-26 14:09
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
from django.urls import NoReverseMatch, reverse
|
||||||
|
|
||||||
|
# Some users hacked uMap to use another map datalayer as a remote data source.
|
||||||
|
# This script gently handles the migration for them.
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_datalayers(apps, schema_editor):
|
||||||
|
DataLayer = apps.get_model("umap", "DataLayer")
|
||||||
|
|
||||||
|
datalayers = DataLayer.objects.filter(
|
||||||
|
settings__remoteData__url__icontains="datalayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in datalayers:
|
||||||
|
old_url = item.settings["remoteData"]["url"]
|
||||||
|
match = re.search(
|
||||||
|
rf"{settings.SITE_URL}/datalayer/(?P<map_id>\d+)/(?P<datalayer_id>\d+)",
|
||||||
|
old_url,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
remote_id = match.group("datalayer_id")
|
||||||
|
map_id = match.group("map_id")
|
||||||
|
try:
|
||||||
|
remote_uuid = DataLayer.objects.get(old_id=remote_id).uuid
|
||||||
|
except DataLayer.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
new_url = settings.SITE_URL + reverse(
|
||||||
|
"datalayer_view", args=[map_id, remote_uuid]
|
||||||
|
)
|
||||||
|
except NoReverseMatch:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
item.settings["remoteData"]["url"] = new_url
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("umap", "0018_datalayer_uuid"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(migrate_datalayers, reverse_code=migrations.RunPython.noop)
|
||||||
|
]
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
@ -373,7 +374,10 @@ class DataLayer(NamedModel):
|
||||||
(EDITORS, _("Editors only")),
|
(EDITORS, _("Editors only")),
|
||||||
(OWNER, _("Owner only")),
|
(OWNER, _("Owner only")),
|
||||||
)
|
)
|
||||||
|
uuid = models.UUIDField(
|
||||||
|
unique=True, primary_key=True, default=uuid.uuid4, editable=False
|
||||||
|
)
|
||||||
|
old_id = models.IntegerField(null=True, blank=True)
|
||||||
map = models.ForeignKey(Map, on_delete=models.CASCADE)
|
map = models.ForeignKey(Map, on_delete=models.CASCADE)
|
||||||
description = models.TextField(blank=True, null=True, verbose_name=_("description"))
|
description = models.TextField(blank=True, null=True, verbose_name=_("description"))
|
||||||
geojson = models.FileField(upload_to=upload_to, blank=True, null=True)
|
geojson = models.FileField(upload_to=upload_to, blank=True, null=True)
|
||||||
|
@ -436,6 +440,7 @@ class DataLayer(NamedModel):
|
||||||
|
|
||||||
def clone(self, map_inst=None):
|
def clone(self, map_inst=None):
|
||||||
new = self.__class__.objects.get(pk=self.pk)
|
new = self.__class__.objects.get(pk=self.pk)
|
||||||
|
new._state.adding = True
|
||||||
new.pk = None
|
new.pk = None
|
||||||
if map_inst:
|
if map_inst:
|
||||||
new.map = map_inst
|
new.map = map_inst
|
||||||
|
@ -444,7 +449,10 @@ class DataLayer(NamedModel):
|
||||||
return new
|
return new
|
||||||
|
|
||||||
def is_valid_version(self, name):
|
def is_valid_version(self, name):
|
||||||
return name.startswith("%s_" % self.pk) and name.endswith(".geojson")
|
valid_prefixes = [name.startswith("%s_" % self.pk)]
|
||||||
|
if self.old_id:
|
||||||
|
valid_prefixes.append(name.startswith("%s_" % self.old_id))
|
||||||
|
return any(valid_prefixes) and name.endswith(".geojson")
|
||||||
|
|
||||||
def version_metadata(self, name):
|
def version_metadata(self, name):
|
||||||
els = name.split(".")[0].split("_")
|
els = name.split(".")[0].split("_")
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import json
|
|
||||||
from copy import copy
|
from copy import copy
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from umap.utils import json_dumps
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,7 +26,7 @@ def map_fragment(map_instance, **kwargs):
|
||||||
page = kwargs.pop("page", None) or ""
|
page = kwargs.pop("page", None) or ""
|
||||||
unique_id = prefix + str(page) + "_" + str(map_instance.pk)
|
unique_id = prefix + str(page) + "_" + str(map_instance.pk)
|
||||||
return {
|
return {
|
||||||
"map_settings": json.dumps(map_settings),
|
"map_settings": json_dumps(map_settings),
|
||||||
"map": map_instance,
|
"map": map_instance,
|
||||||
"unique_id": unique_id,
|
"unique_id": unique_id,
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,7 +98,7 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
|
||||||
"name": "test datalayer",
|
"name": "test datalayer",
|
||||||
"inCaption": True,
|
"inCaption": True,
|
||||||
"editMode": "advanced",
|
"editMode": "advanced",
|
||||||
"id": datalayer.pk,
|
"id": str(datalayer.pk),
|
||||||
"permissions": {"edit_status": 1},
|
"permissions": {"edit_status": 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,7 +116,7 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
|
||||||
"name": "test datalayer",
|
"name": "test datalayer",
|
||||||
"inCaption": True,
|
"inCaption": True,
|
||||||
"editMode": "advanced",
|
"editMode": "advanced",
|
||||||
"id": datalayer.pk,
|
"id": str(datalayer.pk),
|
||||||
"permissions": {"edit_status": 1},
|
"permissions": {"edit_status": 1},
|
||||||
}
|
}
|
||||||
expect(marker_pane_p1).to_have_count(4)
|
expect(marker_pane_p1).to_have_count(4)
|
||||||
|
@ -136,7 +136,7 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
|
||||||
"name": "test datalayer",
|
"name": "test datalayer",
|
||||||
"inCaption": True,
|
"inCaption": True,
|
||||||
"editMode": "advanced",
|
"editMode": "advanced",
|
||||||
"id": datalayer.pk,
|
"id": str(datalayer.pk),
|
||||||
"permissions": {"edit_status": 1},
|
"permissions": {"edit_status": 1},
|
||||||
}
|
}
|
||||||
expect(marker_pane_p2).to_have_count(5)
|
expect(marker_pane_p2).to_have_count(5)
|
||||||
|
|
|
@ -111,7 +111,7 @@ def test_update(client, datalayer, map, post_data):
|
||||||
# Test response is a json
|
# Test response is a json
|
||||||
j = json.loads(response.content.decode())
|
j = json.loads(response.content.decode())
|
||||||
assert "id" in j
|
assert "id" in j
|
||||||
assert datalayer.pk == j["id"]
|
assert str(datalayer.pk) == j["id"]
|
||||||
assert j["browsable"] is True
|
assert j["browsable"] is True
|
||||||
assert Path(modified_datalayer.geojson.path).exists()
|
assert Path(modified_datalayer.geojson.path).exists()
|
||||||
|
|
||||||
|
@ -225,6 +225,41 @@ def test_versions_should_return_versions(client, datalayer, map, settings):
|
||||||
assert version in versions["versions"]
|
assert version in versions["versions"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_can_return_old_format(client, datalayer, map, settings):
|
||||||
|
map.share_status = Map.PUBLIC
|
||||||
|
map.save()
|
||||||
|
root = datalayer.storage_root()
|
||||||
|
datalayer.old_id = 123 # old datalayer id (now replaced by uuid)
|
||||||
|
datalayer.save()
|
||||||
|
|
||||||
|
datalayer.geojson.storage.save(
|
||||||
|
"%s/%s_1440924889.geojson" % (root, datalayer.pk), ContentFile("{}")
|
||||||
|
)
|
||||||
|
datalayer.geojson.storage.save(
|
||||||
|
"%s/%s_1440923687.geojson" % (root, datalayer.pk), ContentFile("{}")
|
||||||
|
)
|
||||||
|
|
||||||
|
# store with the id prefix (rather than the uuid)
|
||||||
|
old_format_version = "%s_1440918637.geojson" % datalayer.old_id
|
||||||
|
datalayer.geojson.storage.save(
|
||||||
|
("%s/" % root) + old_format_version, ContentFile("{}")
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse("datalayer_versions", args=(map.pk, datalayer.pk))
|
||||||
|
versions = json.loads(client.get(url).content.decode())
|
||||||
|
assert len(versions["versions"]) == 4
|
||||||
|
version = {
|
||||||
|
"name": old_format_version,
|
||||||
|
"size": 2,
|
||||||
|
"at": "1440918637",
|
||||||
|
}
|
||||||
|
assert version in versions["versions"]
|
||||||
|
|
||||||
|
client.get(
|
||||||
|
reverse("datalayer_version", args=(map.pk, datalayer.pk, old_format_version))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_version_should_return_one_version_geojson(client, datalayer, map):
|
def test_version_should_return_one_version_geojson(client, datalayer, map):
|
||||||
map.share_status = Map.PUBLIC
|
map.share_status = Map.PUBLIC
|
||||||
map.save()
|
map.save()
|
||||||
|
|
24
umap/urls.py
24
umap/urls.py
|
@ -72,18 +72,18 @@ i18n_urls = [
|
||||||
]
|
]
|
||||||
i18n_urls += decorated_patterns(
|
i18n_urls += decorated_patterns(
|
||||||
[can_view_map, cache_control(must_revalidate=True)],
|
[can_view_map, cache_control(must_revalidate=True)],
|
||||||
re_path(
|
path(
|
||||||
r"^datalayer/(?P<map_id>\d+)/(?P<pk>[\d]+)/$",
|
"datalayer/<int:map_id>/<uuid:pk>/",
|
||||||
views.DataLayerView.as_view(),
|
views.DataLayerView.as_view(),
|
||||||
name="datalayer_view",
|
name="datalayer_view",
|
||||||
),
|
),
|
||||||
re_path(
|
path(
|
||||||
r"^datalayer/(?P<map_id>\d+)/(?P<pk>[\d]+)/versions/$",
|
"datalayer/<int:map_id>/<uuid:pk>/versions/",
|
||||||
views.DataLayerVersions.as_view(),
|
views.DataLayerVersions.as_view(),
|
||||||
name="datalayer_versions",
|
name="datalayer_versions",
|
||||||
),
|
),
|
||||||
re_path(
|
path(
|
||||||
r"^datalayer/(?P<map_id>\d+)/(?P<pk>[\d]+)/(?P<name>[_\w]+.geojson)$",
|
"datalayer/<int:map_id>/<uuid:pk>/<str:name>",
|
||||||
views.DataLayerVersion.as_view(),
|
views.DataLayerVersion.as_view(),
|
||||||
name="datalayer_version",
|
name="datalayer_version",
|
||||||
),
|
),
|
||||||
|
@ -145,13 +145,13 @@ map_urls = [
|
||||||
views.DataLayerCreate.as_view(),
|
views.DataLayerCreate.as_view(),
|
||||||
name="datalayer_create",
|
name="datalayer_create",
|
||||||
),
|
),
|
||||||
re_path(
|
path(
|
||||||
r"^map/(?P<map_id>[\d]+)/datalayer/delete/(?P<pk>\d+)/$",
|
"map/<int:map_id>/datalayer/delete/<uuid:pk>/",
|
||||||
views.DataLayerDelete.as_view(),
|
views.DataLayerDelete.as_view(),
|
||||||
name="datalayer_delete",
|
name="datalayer_delete",
|
||||||
),
|
),
|
||||||
re_path(
|
path(
|
||||||
r"^map/(?P<map_id>[\d]+)/datalayer/permissions/(?P<pk>\d+)/$",
|
"map/<int:map_id>/datalayer/permissions/<uuid:pk>/",
|
||||||
views.UpdateDataLayerPermissions.as_view(),
|
views.UpdateDataLayerPermissions.as_view(),
|
||||||
name="datalayer_permissions",
|
name="datalayer_permissions",
|
||||||
),
|
),
|
||||||
|
@ -165,8 +165,8 @@ if settings.DEFAULT_FROM_EMAIL:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
datalayer_urls = [
|
datalayer_urls = [
|
||||||
re_path(
|
path(
|
||||||
r"^map/(?P<map_id>[\d]+)/datalayer/update/(?P<pk>\d+)/$",
|
"map/<int:map_id>/datalayer/update/<uuid:pk>/",
|
||||||
views.DataLayerUpdate.as_view(),
|
views.DataLayerUpdate.as_view(),
|
||||||
name="datalayer_update",
|
name="datalayer_update",
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import gzip
|
import gzip
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import URLPattern, URLResolver, get_resolver
|
from django.urls import URLPattern, URLResolver, get_resolver
|
||||||
|
|
||||||
|
|
||||||
|
@ -162,3 +164,8 @@ def merge_features(reference: list, latest: list, incoming: list):
|
||||||
merged.append(item)
|
merged.append(item)
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def json_dumps(obj, **kwargs):
|
||||||
|
"""Utility using the Django JSON Encoder when dumping objects"""
|
||||||
|
return json.dumps(obj, cls=DjangoJSONEncoder, **kwargs)
|
||||||
|
|
|
@ -67,7 +67,14 @@ from .forms import (
|
||||||
UserProfileForm,
|
UserProfileForm,
|
||||||
)
|
)
|
||||||
from .models import DataLayer, Licence, Map, Pictogram, Star, TileLayer
|
from .models import DataLayer, Licence, Map, Pictogram, Star, TileLayer
|
||||||
from .utils import ConflictError, _urls_for_js, gzip_file, is_ajax, merge_features
|
from .utils import (
|
||||||
|
ConflictError,
|
||||||
|
_urls_for_js,
|
||||||
|
gzip_file,
|
||||||
|
is_ajax,
|
||||||
|
json_dumps,
|
||||||
|
merge_features,
|
||||||
|
)
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -315,7 +322,7 @@ class UserDownload(DetailView, SearchMixin):
|
||||||
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
|
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
|
||||||
for map_ in self.get_maps():
|
for map_ in self.get_maps():
|
||||||
umapjson = map_.generate_umapjson(self.request)
|
umapjson = map_.generate_umapjson(self.request)
|
||||||
geojson_file = io.StringIO(json.dumps(umapjson))
|
geojson_file = io.StringIO(json_dumps(umapjson))
|
||||||
file_name = f"umap_backup_{map_.slug}_{map_.pk}.umap"
|
file_name = f"umap_backup_{map_.slug}_{map_.pk}.umap"
|
||||||
zip_file.writestr(file_name, geojson_file.getvalue())
|
zip_file.writestr(file_name, geojson_file.getvalue())
|
||||||
|
|
||||||
|
@ -354,7 +361,7 @@ class MapsShowCase(View):
|
||||||
}
|
}
|
||||||
|
|
||||||
geojson = {"type": "FeatureCollection", "features": [make(m) for m in maps]}
|
geojson = {"type": "FeatureCollection", "features": [make(m) for m in maps]}
|
||||||
return HttpResponse(smart_bytes(json.dumps(geojson)))
|
return HttpResponse(smart_bytes(json_dumps(geojson)))
|
||||||
|
|
||||||
|
|
||||||
showcase = MapsShowCase.as_view()
|
showcase = MapsShowCase.as_view()
|
||||||
|
@ -441,7 +448,7 @@ ajax_proxy = AjaxProxy.as_view()
|
||||||
|
|
||||||
|
|
||||||
def simple_json_response(**kwargs):
|
def simple_json_response(**kwargs):
|
||||||
return HttpResponse(json.dumps(kwargs), content_type="application/json")
|
return HttpResponse(json_dumps(kwargs), content_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
# ############## #
|
# ############## #
|
||||||
|
@ -537,7 +544,7 @@ class MapDetailMixin:
|
||||||
geojson["properties"] = {}
|
geojson["properties"] = {}
|
||||||
geojson["properties"].update(properties)
|
geojson["properties"].update(properties)
|
||||||
geojson["properties"]["datalayers"] = self.get_datalayers()
|
geojson["properties"]["datalayers"] = self.get_datalayers()
|
||||||
context["map_settings"] = json.dumps(geojson, indent=settings.DEBUG)
|
context["map_settings"] = json_dumps(geojson, indent=settings.DEBUG)
|
||||||
self.set_preconnect(geojson["properties"], context)
|
self.set_preconnect(geojson["properties"], context)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@ -1106,7 +1113,7 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView):
|
||||||
|
|
||||||
# Replace the uploaded file by the merged version.
|
# Replace the uploaded file by the merged version.
|
||||||
self.request.FILES["geojson"].file = BytesIO(
|
self.request.FILES["geojson"].file = BytesIO(
|
||||||
json.dumps(merged).encode("utf-8")
|
json_dumps(merged).encode("utf-8")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark the data to be reloaded by form_valid
|
# Mark the data to be reloaded by form_valid
|
||||||
|
|
Loading…
Reference in a new issue