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:
Yohan Boniface 2025-01-29 19:08:59 +01:00
parent 3b9a0c0951
commit fd8a1971f8
7 changed files with 89 additions and 11 deletions

View file

@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from django.core.management.base import BaseCommand
from umap.models import Map
from umap.models import DataLayer, Map
class Command(BaseCommand):
@ -33,3 +33,14 @@ class Command(BaseCommand):
if not options["dry_run"]:
map.delete()
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}")

View file

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

View file

@ -247,9 +247,13 @@ class Map(NamedModel):
except KeyError:
return ""
@property
def datalayers(self):
return self.datalayer_set.filter(share_status=DataLayer.INHERIT).all()
@property
def preview_settings(self):
layers = self.datalayer_set.all()
layers = self.datalayers
datalayer_data = [c.metadata() for c in layers]
map_settings = self.settings
if "properties" not in map_settings:
@ -278,6 +282,7 @@ class Map(NamedModel):
def delete(self, **kwargs):
# Explicitely call datalayers.delete, so we can deal with removing files
# (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():
datalayer.delete()
return super().delete(**kwargs)
@ -287,7 +292,7 @@ class Map(NamedModel):
umapjson["type"] = "umap"
umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url())
datalayers = []
for datalayer in self.datalayer_set.all():
for datalayer in self.datalayers:
with datalayer.geojson.open("rb") as f:
layer = json.loads(f.read())
if datalayer.settings:
@ -406,7 +411,7 @@ class Map(NamedModel):
new.save()
for editor in self.editors.all():
new.editors.add(editor)
for datalayer in self.datalayer_set.all():
for datalayer in self.datalayers:
datalayer.clone(map_inst=new)
return new
@ -458,6 +463,11 @@ class DataLayer(NamedModel):
ANONYMOUS = 1
COLLABORATORS = 2
OWNER = 3
DELETED = 99
SHARE_STATUS = (
(INHERIT, _("Inherit")),
(DELETED, _("Deleted")),
)
EDIT_STATUS = (
(INHERIT, _("Inherit")),
(ANONYMOUS, _("Everyone")),
@ -490,6 +500,12 @@ class DataLayer(NamedModel):
default=INHERIT,
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:
ordering = ("rank",)
@ -568,6 +584,10 @@ class DataLayer(NamedModel):
can = True
return can
def move_to_trash(self):
self.share_status = DataLayer.DELETED
self.save()
class Star(models.Model):
at = models.DateTimeField(auto_now=True)

View file

@ -158,11 +158,14 @@ def test_should_not_be_possible_to_update_with_wrong_map_id_in_url(
def test_delete(client, datalayer, map):
assert map.datalayers.count() == 1
url = reverse("datalayer_delete", args=(map.pk, datalayer.pk))
client.login(username=map.owner.username, password="123123")
response = client.post(url, {}, follow=True)
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
assert Map.objects.filter(pk=map.pk).exists()
# Test response is a json

View file

@ -4,15 +4,17 @@ from unittest import mock
import pytest
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
def test_empty_trash(user):
recent = MapFactory(owner=user)
recent_layer = DataLayerFactory(map=recent)
deleted_layer = DataLayerFactory(map=recent)
recent_deleted = MapFactory(owner=user)
recent_deleted.move_to_trash()
recent_deleted.save()
@ -20,15 +22,20 @@ def test_empty_trash(user):
mocked.return_value = datetime.utcnow() - timedelta(days=8)
old_deleted = MapFactory(owner=user)
old_deleted.move_to_trash()
old_deleted.save()
deleted_layer.move_to_trash()
old = MapFactory(owner=user)
assert Map.objects.count() == 4
assert DataLayer.objects.count() == 2
call_command("empty_trash", "--days=7", "--dry-run")
assert Map.objects.count() == 4
assert DataLayer.objects.count() == 2
call_command("empty_trash", "--days=9")
assert Map.objects.count() == 4
assert DataLayer.objects.count() == 2
call_command("empty_trash", "--days=7")
assert not Map.objects.filter(pk=old_deleted.pk)
assert Map.objects.filter(pk=old.pk)
assert Map.objects.filter(pk=recent.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)

View file

@ -810,6 +810,17 @@ def test_oembed_shared_status_map(client, map, datalayer, share_status):
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):
url = reverse("map_oembed")
response = client.get(url)

View file

@ -742,14 +742,14 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView):
def get_datalayers(self):
# When initializing datalayers from map, we cannot get the 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
def edit_mode(self):
edit_mode = "disabled"
if self.object.can_edit(self.request):
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"
return edit_mode
@ -1325,7 +1325,7 @@ class DataLayerDelete(DeleteView):
self.object = self.get_object()
if self.object.map != self.kwargs["map_inst"]:
return HttpResponseForbidden()
self.object.delete()
self.object.move_to_trash()
return simple_json_response(info=_("Layer successfully deleted."))