diff --git a/umap/management/commands/empty_trash.py b/umap/management/commands/empty_trash.py index 774dabe3..26b12846 100644 --- a/umap/management/commands/empty_trash.py +++ b/umap/management/commands/empty_trash.py @@ -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}") diff --git a/umap/migrations/0026_datalayer_modified_at_datalayer_share_status.py b/umap/migrations/0026_datalayer_modified_at_datalayer_share_status.py new file mode 100644 index 00000000..3a23758a --- /dev/null +++ b/umap/migrations/0026_datalayer_modified_at_datalayer_share_status.py @@ -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", + ), + ), + ] diff --git a/umap/models.py b/umap/models.py index 90676d6e..e6367fe6 100644 --- a/umap/models.py +++ b/umap/models.py @@ -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) diff --git a/umap/tests/test_datalayer_views.py b/umap/tests/test_datalayer_views.py index 9578ff53..9aff5cde 100644 --- a/umap/tests/test_datalayer_views.py +++ b/umap/tests/test_datalayer_views.py @@ -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 diff --git a/umap/tests/test_empty_trash.py b/umap/tests/test_empty_trash.py index ad1d01e0..f6467fb0 100644 --- a/umap/tests/test_empty_trash.py +++ b/umap/tests/test_empty_trash.py @@ -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) diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index 90b70d44..24be6b80 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -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) diff --git a/umap/views.py b/umap/views.py index 59f0fbd4..1f77f4cb 100644 --- a/umap/views.py +++ b/umap/views.py @@ -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."))