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 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}")
|
||||
|
|
|
@ -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:
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."))
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue