mirror of
https://github.com/umap-project/umap.git
synced 2025-04-28 19:42:36 +02:00
feat: soft delete datalayers
When deleting a datalayer, it will now be moved to a state "deleted", and it will only be deleted for real when running the command `umap empty_trash`. This is what we already do for the map itself, but until now if a user deleted a only a datalayer by mistake (not the map itself) it could not retrieve it.
This commit is contained in:
parent
3b9a0c0951
commit
fd8a1971f8
7 changed files with 89 additions and 11 deletions
|
@ -2,7 +2,7 @@ from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from umap.models import Map
|
from umap.models import DataLayer, Map
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
@ -33,3 +33,14 @@ class Command(BaseCommand):
|
||||||
if not options["dry_run"]:
|
if not options["dry_run"]:
|
||||||
map.delete()
|
map.delete()
|
||||||
print(f"Deleted map {map_name} ({map_id}), trashed at {trashed_at}")
|
print(f"Deleted map {map_name} ({map_id}), trashed at {trashed_at}")
|
||||||
|
print(f"Deleting layers in trash since {since}")
|
||||||
|
layers = DataLayer.objects.filter(
|
||||||
|
share_status=DataLayer.DELETED, modified_at__lt=since
|
||||||
|
)
|
||||||
|
for layer in layers:
|
||||||
|
layer_id = layer.uuid
|
||||||
|
layer_name = layer.name
|
||||||
|
trashed_at = layer.modified_at.date()
|
||||||
|
if not options["dry_run"]:
|
||||||
|
layer.delete()
|
||||||
|
print(f"Deleted layer {layer_name} ({layer_id}), trashed at {trashed_at}")
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Generated by Django 5.1.4 on 2025-01-29 18:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("umap", "0025_alter_datalayer_geojson"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datalayer",
|
||||||
|
name="modified_at",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="datalayer",
|
||||||
|
name="share_status",
|
||||||
|
field=models.SmallIntegerField(
|
||||||
|
choices=[(0, "Inherit"), (99, "Deleted")],
|
||||||
|
default=0,
|
||||||
|
verbose_name="share status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -247,9 +247,13 @@ class Map(NamedModel):
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def datalayers(self):
|
||||||
|
return self.datalayer_set.filter(share_status=DataLayer.INHERIT).all()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def preview_settings(self):
|
def preview_settings(self):
|
||||||
layers = self.datalayer_set.all()
|
layers = self.datalayers
|
||||||
datalayer_data = [c.metadata() for c in layers]
|
datalayer_data = [c.metadata() for c in layers]
|
||||||
map_settings = self.settings
|
map_settings = self.settings
|
||||||
if "properties" not in map_settings:
|
if "properties" not in map_settings:
|
||||||
|
@ -278,6 +282,7 @@ class Map(NamedModel):
|
||||||
def delete(self, **kwargs):
|
def delete(self, **kwargs):
|
||||||
# Explicitely call datalayers.delete, so we can deal with removing files
|
# Explicitely call datalayers.delete, so we can deal with removing files
|
||||||
# (the cascade delete would not call the model delete method)
|
# (the cascade delete would not call the model delete method)
|
||||||
|
# Use datalayer_set so to get also the deleted ones.
|
||||||
for datalayer in self.datalayer_set.all():
|
for datalayer in self.datalayer_set.all():
|
||||||
datalayer.delete()
|
datalayer.delete()
|
||||||
return super().delete(**kwargs)
|
return super().delete(**kwargs)
|
||||||
|
@ -287,7 +292,7 @@ class Map(NamedModel):
|
||||||
umapjson["type"] = "umap"
|
umapjson["type"] = "umap"
|
||||||
umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url())
|
umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url())
|
||||||
datalayers = []
|
datalayers = []
|
||||||
for datalayer in self.datalayer_set.all():
|
for datalayer in self.datalayers:
|
||||||
with datalayer.geojson.open("rb") as f:
|
with datalayer.geojson.open("rb") as f:
|
||||||
layer = json.loads(f.read())
|
layer = json.loads(f.read())
|
||||||
if datalayer.settings:
|
if datalayer.settings:
|
||||||
|
@ -406,7 +411,7 @@ class Map(NamedModel):
|
||||||
new.save()
|
new.save()
|
||||||
for editor in self.editors.all():
|
for editor in self.editors.all():
|
||||||
new.editors.add(editor)
|
new.editors.add(editor)
|
||||||
for datalayer in self.datalayer_set.all():
|
for datalayer in self.datalayers:
|
||||||
datalayer.clone(map_inst=new)
|
datalayer.clone(map_inst=new)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
@ -458,6 +463,11 @@ class DataLayer(NamedModel):
|
||||||
ANONYMOUS = 1
|
ANONYMOUS = 1
|
||||||
COLLABORATORS = 2
|
COLLABORATORS = 2
|
||||||
OWNER = 3
|
OWNER = 3
|
||||||
|
DELETED = 99
|
||||||
|
SHARE_STATUS = (
|
||||||
|
(INHERIT, _("Inherit")),
|
||||||
|
(DELETED, _("Deleted")),
|
||||||
|
)
|
||||||
EDIT_STATUS = (
|
EDIT_STATUS = (
|
||||||
(INHERIT, _("Inherit")),
|
(INHERIT, _("Inherit")),
|
||||||
(ANONYMOUS, _("Everyone")),
|
(ANONYMOUS, _("Everyone")),
|
||||||
|
@ -490,6 +500,12 @@ class DataLayer(NamedModel):
|
||||||
default=INHERIT,
|
default=INHERIT,
|
||||||
verbose_name=_("edit status"),
|
verbose_name=_("edit status"),
|
||||||
)
|
)
|
||||||
|
share_status = models.SmallIntegerField(
|
||||||
|
choices=SHARE_STATUS,
|
||||||
|
default=INHERIT,
|
||||||
|
verbose_name=_("share status"),
|
||||||
|
)
|
||||||
|
modified_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("rank",)
|
ordering = ("rank",)
|
||||||
|
@ -568,6 +584,10 @@ class DataLayer(NamedModel):
|
||||||
can = True
|
can = True
|
||||||
return can
|
return can
|
||||||
|
|
||||||
|
def move_to_trash(self):
|
||||||
|
self.share_status = DataLayer.DELETED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class Star(models.Model):
|
class Star(models.Model):
|
||||||
at = models.DateTimeField(auto_now=True)
|
at = models.DateTimeField(auto_now=True)
|
||||||
|
|
|
@ -158,11 +158,14 @@ def test_should_not_be_possible_to_update_with_wrong_map_id_in_url(
|
||||||
|
|
||||||
|
|
||||||
def test_delete(client, datalayer, map):
|
def test_delete(client, datalayer, map):
|
||||||
|
assert map.datalayers.count() == 1
|
||||||
url = reverse("datalayer_delete", args=(map.pk, datalayer.pk))
|
url = reverse("datalayer_delete", args=(map.pk, datalayer.pk))
|
||||||
client.login(username=map.owner.username, password="123123")
|
client.login(username=map.owner.username, password="123123")
|
||||||
response = client.post(url, {}, follow=True)
|
response = client.post(url, {}, follow=True)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert not DataLayer.objects.filter(pk=datalayer.pk).count()
|
assert DataLayer.objects.filter(pk=datalayer.pk).count()
|
||||||
|
assert map.datalayers.count() == 0
|
||||||
|
assert DataLayer.objects.get(pk=datalayer.pk).share_status == DataLayer.DELETED
|
||||||
# Check that map has not been impacted
|
# Check that map has not been impacted
|
||||||
assert Map.objects.filter(pk=map.pk).exists()
|
assert Map.objects.filter(pk=map.pk).exists()
|
||||||
# Test response is a json
|
# Test response is a json
|
||||||
|
|
|
@ -4,15 +4,17 @@ from unittest import mock
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
|
||||||
from umap.models import Map
|
from umap.models import DataLayer, Map
|
||||||
|
|
||||||
from .base import MapFactory
|
from .base import DataLayerFactory, MapFactory
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_empty_trash(user):
|
def test_empty_trash(user):
|
||||||
recent = MapFactory(owner=user)
|
recent = MapFactory(owner=user)
|
||||||
|
recent_layer = DataLayerFactory(map=recent)
|
||||||
|
deleted_layer = DataLayerFactory(map=recent)
|
||||||
recent_deleted = MapFactory(owner=user)
|
recent_deleted = MapFactory(owner=user)
|
||||||
recent_deleted.move_to_trash()
|
recent_deleted.move_to_trash()
|
||||||
recent_deleted.save()
|
recent_deleted.save()
|
||||||
|
@ -20,15 +22,20 @@ def test_empty_trash(user):
|
||||||
mocked.return_value = datetime.utcnow() - timedelta(days=8)
|
mocked.return_value = datetime.utcnow() - timedelta(days=8)
|
||||||
old_deleted = MapFactory(owner=user)
|
old_deleted = MapFactory(owner=user)
|
||||||
old_deleted.move_to_trash()
|
old_deleted.move_to_trash()
|
||||||
old_deleted.save()
|
deleted_layer.move_to_trash()
|
||||||
old = MapFactory(owner=user)
|
old = MapFactory(owner=user)
|
||||||
assert Map.objects.count() == 4
|
assert Map.objects.count() == 4
|
||||||
|
assert DataLayer.objects.count() == 2
|
||||||
call_command("empty_trash", "--days=7", "--dry-run")
|
call_command("empty_trash", "--days=7", "--dry-run")
|
||||||
assert Map.objects.count() == 4
|
assert Map.objects.count() == 4
|
||||||
|
assert DataLayer.objects.count() == 2
|
||||||
call_command("empty_trash", "--days=9")
|
call_command("empty_trash", "--days=9")
|
||||||
assert Map.objects.count() == 4
|
assert Map.objects.count() == 4
|
||||||
|
assert DataLayer.objects.count() == 2
|
||||||
call_command("empty_trash", "--days=7")
|
call_command("empty_trash", "--days=7")
|
||||||
assert not Map.objects.filter(pk=old_deleted.pk)
|
assert not Map.objects.filter(pk=old_deleted.pk)
|
||||||
assert Map.objects.filter(pk=old.pk)
|
assert Map.objects.filter(pk=old.pk)
|
||||||
assert Map.objects.filter(pk=recent.pk)
|
assert Map.objects.filter(pk=recent.pk)
|
||||||
assert Map.objects.filter(pk=recent_deleted.pk)
|
assert Map.objects.filter(pk=recent_deleted.pk)
|
||||||
|
assert not DataLayer.objects.filter(pk=deleted_layer.pk)
|
||||||
|
assert DataLayer.objects.filter(pk=recent_layer.pk)
|
||||||
|
|
|
@ -810,6 +810,17 @@ def test_oembed_shared_status_map(client, map, datalayer, share_status):
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_does_not_include_delete_datalayers(client, map, datalayer):
|
||||||
|
datalayer.share_status = DataLayer.DELETED
|
||||||
|
datalayer.save()
|
||||||
|
url = reverse("map_download", args=(map.pk,))
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Test response is a json
|
||||||
|
j = json.loads(response.content.decode())
|
||||||
|
assert j["layers"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_oembed_no_url_map(client, map, datalayer):
|
def test_oembed_no_url_map(client, map, datalayer):
|
||||||
url = reverse("map_oembed")
|
url = reverse("map_oembed")
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
|
@ -742,14 +742,14 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView):
|
||||||
def get_datalayers(self):
|
def get_datalayers(self):
|
||||||
# When initializing datalayers from map, we cannot get the reference version
|
# When initializing datalayers from map, we cannot get the reference version
|
||||||
# the normal way, which is from the header X-Reference-Version
|
# the normal way, which is from the header X-Reference-Version
|
||||||
return [dl.metadata(self.request) for dl in self.object.datalayer_set.all()]
|
return [dl.metadata(self.request) for dl in self.object.datalayers]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def edit_mode(self):
|
def edit_mode(self):
|
||||||
edit_mode = "disabled"
|
edit_mode = "disabled"
|
||||||
if self.object.can_edit(self.request):
|
if self.object.can_edit(self.request):
|
||||||
edit_mode = "advanced"
|
edit_mode = "advanced"
|
||||||
elif any(d.can_edit(self.request) for d in self.object.datalayer_set.all()):
|
elif any(d.can_edit(self.request) for d in self.object.datalayers):
|
||||||
edit_mode = "simple"
|
edit_mode = "simple"
|
||||||
return edit_mode
|
return edit_mode
|
||||||
|
|
||||||
|
@ -1325,7 +1325,7 @@ class DataLayerDelete(DeleteView):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
if self.object.map != self.kwargs["map_inst"]:
|
if self.object.map != self.kwargs["map_inst"]:
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
self.object.delete()
|
self.object.move_to_trash()
|
||||||
return simple_json_response(info=_("Layer successfully deleted."))
|
return simple_json_response(info=_("Layer successfully deleted."))
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue