Merge pull request #1630 from umap-project/datalayer-uuids

chore: replace datalayer ids with uuids
This commit is contained in:
Yohan Boniface 2024-03-05 17:26:50 +01:00 committed by GitHub
commit 6ed5ebc9fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 201 additions and 27 deletions

View file

@ -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):

View 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),
]

View 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)
]

View file

@ -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("_")

View file

@ -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,
} }

View file

@ -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)

View file

@ -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()

View file

@ -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",
), ),

View file

@ -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)

View file

@ -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