diff --git a/docs/config/settings.md b/docs/config/settings.md index 88ed527d1..4cea33fef 100644 --- a/docs/config/settings.md +++ b/docs/config/settings.md @@ -287,14 +287,6 @@ How many total maps to return in the search. How many maps to show in the user "my maps" page. -#### UMAP_PURGATORY_ROOT - -Path where files are moved when a datalayer is deleted. They will stay there until -`umap purge_purgatory` is run. May be useful in case a user deletes by mistake -a datalayer, or even a map. -Default is `/tmp/umappurgatory/`, so beware that this folder will be deleted on -each server restart. - #### UMAP_SEARCH_CONFIGURATION Use it if you take control over the search configuration. diff --git a/umap/forms.py b/umap/forms.py index a6afade73..e982799e2 100644 --- a/umap/forms.py +++ b/umap/forms.py @@ -40,16 +40,12 @@ class Meta: class AnonymousMapPermissionsForm(forms.ModelForm): - STATUS = ( - (Map.OWNER, _("Only editable with secret edit link")), - (Map.ANONYMOUS, _("Everyone can edit")), - ) - - edit_status = forms.ChoiceField(choices=STATUS) + edit_status = forms.ChoiceField(choices=Map.ANONYMOUS_EDIT_STATUS) + share_status = forms.ChoiceField(choices=Map.ANONYMOUS_SHARE_STATUS) class Meta: model = Map - fields = ("edit_status",) + fields = ("edit_status", "share_status") class DataLayerForm(forms.ModelForm): @@ -65,13 +61,7 @@ class Meta: class AnonymousDataLayerPermissionsForm(forms.ModelForm): - STATUS = ( - (DataLayer.INHERIT, _("Inherit")), - (DataLayer.OWNER, _("Only editable with secret edit link")), - (DataLayer.ANONYMOUS, _("Everyone can edit")), - ) - - edit_status = forms.ChoiceField(choices=STATUS) + edit_status = forms.ChoiceField(choices=DataLayer.ANONYMOUS_EDIT_STATUS) class Meta: model = DataLayer diff --git a/umap/management/commands/empty_trash.py b/umap/management/commands/empty_trash.py new file mode 100644 index 000000000..3a79e7127 --- /dev/null +++ b/umap/management/commands/empty_trash.py @@ -0,0 +1,32 @@ +from datetime import datetime, timedelta + +from django.core.management.base import BaseCommand + +from umap.models import Map + + +class Command(BaseCommand): + help = "Remove maps in trash. Eg.: umap empty_trash --days 7" + + def add_arguments(self, parser): + parser.add_argument( + "--days", + help="Number of days to consider maps for removal", + default=30, + type=int, + ) + parser.add_argument( + "--dry-run", + help="Pretend to delete but just report", + action="store_true", + ) + + def handle(self, *args, **options): + days = options["days"] + since = datetime.utcnow() - timedelta(days=days) + print(f"Deleting map in trash since {since}") + maps = Map.objects.filter(share_status=Map.DELETED, modified_at__lt=since) + for map in maps: + if not options["dry_run"]: + map.delete() + print(f"Deleted map {map.name} ({map.id}), trashed on {map.modified_at}") diff --git a/umap/management/commands/purge_purgatory.py b/umap/management/commands/purge_purgatory.py deleted file mode 100644 index ee4be29d2..000000000 --- a/umap/management/commands/purge_purgatory.py +++ /dev/null @@ -1,28 +0,0 @@ -import time -from pathlib import Path - -from django.conf import settings -from django.core.management.base import BaseCommand - - -class Command(BaseCommand): - help = "Remove old files from purgatory. Eg.: umap purge_purgatory --days 7" - - def add_arguments(self, parser): - parser.add_argument( - "--days", - help="Number of days to consider files for removal", - default=30, - type=int, - ) - - def handle(self, *args, **options): - days = options["days"] - root = Path(settings.UMAP_PURGATORY_ROOT) - threshold = time.time() - days * 86400 - for path in root.iterdir(): - stats = path.stat() - filestamp = stats.st_mtime - if filestamp < threshold: - path.unlink() - print(f"Removed old file {path}") diff --git a/umap/models.py b/umap/models.py index c7e1d3882..7dc834b18 100644 --- a/umap/models.py +++ b/umap/models.py @@ -38,13 +38,22 @@ def get_user_stars_url(self): return reverse("user_stars", kwargs={"identifier": identifier}) +def get_user_metadata(self): + return { + "id": self.pk, + "name": str(self), + "url": self.get_url(), + } + + User.add_to_class("__str__", display_name) User.add_to_class("get_url", get_user_url) User.add_to_class("get_stars_url", get_user_stars_url) +User.add_to_class("get_metadata", get_user_metadata) def get_default_share_status(): - return settings.UMAP_DEFAULT_SHARE_STATUS or Map.PUBLIC + return settings.UMAP_DEFAULT_SHARE_STATUS or Map.DRAFT def get_default_edit_status(): @@ -161,20 +170,30 @@ class Map(NamedModel): ANONYMOUS = 1 COLLABORATORS = 2 OWNER = 3 + DRAFT = 0 PUBLIC = 1 OPEN = 2 PRIVATE = 3 BLOCKED = 9 + DELETED = 99 + ANONYMOUS_EDIT_STATUS = ( + (OWNER, _("Only editable with secret edit link")), + (ANONYMOUS, _("Everyone can edit")), + ) EDIT_STATUS = ( (ANONYMOUS, _("Everyone")), (COLLABORATORS, _("Editors and team only")), (OWNER, _("Owner only")), ) - SHARE_STATUS = ( + ANONYMOUS_SHARE_STATUS = ( + (DRAFT, _("Draft (private)")), (PUBLIC, _("Everyone (public)")), + ) + SHARE_STATUS = ANONYMOUS_SHARE_STATUS + ( (OPEN, _("Anyone with link")), (PRIVATE, _("Editors and team only")), (BLOCKED, _("Blocked")), + (DELETED, _("Deleted")), ) slug = models.SlugField(db_index=True) center = models.PointField(geography=True, verbose_name=_("center")) @@ -257,6 +276,10 @@ def preview_settings(self): ) return map_settings + def move_to_trash(self): + self.share_status = Map.DELETED + self.save() + 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) @@ -352,19 +375,20 @@ def can_edit(self, request=None): return can def can_view(self, request): - if self.share_status == self.BLOCKED: + if self.share_status in [Map.BLOCKED, Map.DELETED]: can = False - elif self.owner is None: - can = True - elif self.share_status in [self.PUBLIC, self.OPEN]: + elif self.share_status in [Map.PUBLIC, Map.OPEN]: can = True + elif self.owner is None: + can = settings.UMAP_ALLOW_ANONYMOUS and self.is_anonymous_owner(request) elif not request.user.is_authenticated: can = False elif request.user == self.owner: can = True else: + restricted = self.share_status in [Map.PRIVATE, Map.DRAFT] can = not ( - self.share_status == self.PRIVATE + restricted and request.user not in self.editors.all() and self.team not in request.user.teams.all() ) @@ -444,6 +468,11 @@ class DataLayer(NamedModel): (COLLABORATORS, _("Editors and team only")), (OWNER, _("Owner only")), ) + ANONYMOUS_EDIT_STATUS = ( + (INHERIT, _("Inherit")), + (OWNER, _("Only editable with secret edit link")), + (ANONYMOUS, _("Everyone can edit")), + ) uuid = models.UUIDField(unique=True, primary_key=True, editable=False) old_id = models.IntegerField(null=True, blank=True) map = models.ForeignKey(Map, on_delete=models.CASCADE) @@ -484,21 +513,13 @@ def save(self, force_insert=False, force_update=False, **kwargs): force_insert=force_insert, force_update=force_update, **kwargs ) self.purge_gzip() - self.purge_old_versions() + self.purge_old_versions(keep=settings.UMAP_KEEP_VERSIONS) def delete(self, **kwargs): self.purge_gzip() - self.to_purgatory() + self.purge_old_versions(keep=None) return super().delete(**kwargs) - def to_purgatory(self): - dest = Path(settings.UMAP_PURGATORY_ROOT) - dest.mkdir(parents=True, exist_ok=True) - src = Path(self.geojson.storage.location) / self.storage_root() - for version in self.versions: - name = version["name"] - shutil.move(src / name, dest / f"{self.map.pk}_{name}") - def upload_to(self): root = self.storage_root() name = "%s_%s.geojson" % (self.pk, int(time.time() * 1000)) @@ -576,14 +597,16 @@ def get_version(self, name): def get_version_path(self, name): return "{root}/{name}".format(root=self.storage_root(), name=name) - def purge_old_versions(self): + def purge_old_versions(self, keep=None): root = self.storage_root() - versions = self.versions[settings.UMAP_KEEP_VERSIONS :] + versions = self.versions + if keep is not None: + versions = versions[keep:] for version in versions: name = version["name"] # Should not be in the list, but ensure to not delete the file # currently used in database - if self.geojson.name.endswith(name): + if keep is not None and self.geojson.name.endswith(name): continue try: self.geojson.storage.delete(os.path.join(root, name)) diff --git a/umap/settings/base.py b/umap/settings/base.py index 8c61146c4..ceea26af5 100644 --- a/umap/settings/base.py +++ b/umap/settings/base.py @@ -272,7 +272,6 @@ UMAP_HOME_FEED = "latest" UMAP_IMPORTERS = {} UMAP_HOST_INFOS = {} -UMAP_PURGATORY_ROOT = "/tmp/umappurgatory" UMAP_LABEL_KEYS = ["name", "title"] UMAP_READONLY = env("UMAP_READONLY", default=False) diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index 17552afd2..b2c7650fe 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -50,6 +50,14 @@ export class MapPermissions extends ServerStored { selectOptions: this._umap.properties.edit_statuses, }, ]) + fields.push([ + 'properties.share_status', + { + handler: 'IntSelect', + label: translate('Who can view'), + selectOptions: this._umap.properties.share_statuses, + }, + ]) const builder = new U.FormBuilder(this, fields) const form = builder.build() container.appendChild(form) @@ -184,11 +192,11 @@ export class MapPermissions extends ServerStored { } if (this.isOwner() || this.isAnonymousMap()) { formData.append('edit_status', this.properties.edit_status) + formData.append('share_status', this.properties.share_status) } if (this.isOwner()) { formData.append('owner', this.properties.owner?.id) formData.append('team', this.properties.team?.id || '') - formData.append('share_status', this.properties.share_status) } const [data, response, error] = await this._umap.server.post( this.getUrl(), @@ -228,6 +236,10 @@ export class MapPermissions extends ServerStored { ] } } + + isDraft() { + return this.properties.share_status === 0 + } } export class DataLayerPermissions extends ServerStored { diff --git a/umap/static/umap/js/modules/ui/bar.js b/umap/static/umap/js/modules/ui/bar.js index 87e70e8e6..734027037 100644 --- a/umap/static/umap/js/modules/ui/bar.js +++ b/umap/static/umap/js/modules/ui/bar.js @@ -31,7 +31,8 @@ const TOP_BAR_TEMPLATE = ` ` @@ -145,6 +146,8 @@ export class TopBar extends WithTemplate { redraw() { this.elements.peers.hidden = !this._umap.getProperty('syncEnabled') + this.elements.saveLabel.hidden = this._umap.permissions.isDraft() + this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft() } } diff --git a/umap/static/umap/js/modules/umap.js b/umap/static/umap/js/modules/umap.js index 56199b087..1f6827b0b 100644 --- a/umap/static/umap/js/modules/umap.js +++ b/umap/static/umap/js/modules/umap.js @@ -1321,6 +1321,7 @@ export default class Umap extends ServerStored { }) }) } + this.topBar.redraw() }, numberOfConnectedPeers: () => { Utils.eachElement('.connected-peers span', (el) => { diff --git a/umap/tests/base.py b/umap/tests/base.py index 81aaac3c8..c1bb44e27 100644 --- a/umap/tests/base.py +++ b/umap/tests/base.py @@ -102,6 +102,7 @@ class MapFactory(factory.django.DjangoModelFactory): licence = factory.SubFactory(LicenceFactory) owner = factory.SubFactory(UserFactory) + share_status = Map.PUBLIC @classmethod def _adjust_kwargs(cls, **kwargs): diff --git a/umap/tests/integration/test_anonymous_owned_map.py b/umap/tests/integration/test_anonymous_owned_map.py index 69d7e1b53..85d719030 100644 --- a/umap/tests/integration/test_anonymous_owned_map.py +++ b/umap/tests/integration/test_anonymous_owned_map.py @@ -76,8 +76,6 @@ def test_owner_permissions_form(map, datalayer, live_server, owner_session): edit_permissions = owner_session.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() edit_permissions.click() - select = owner_session.locator(".umap-field-share_status select") - expect(select).to_be_hidden() owner_field = owner_session.locator(".umap-field-owner") expect(owner_field).to_be_hidden() editors_field = owner_session.locator(".umap-field-editors input") @@ -92,8 +90,15 @@ def test_owner_permissions_form(map, datalayer, live_server, owner_session): ".datalayer-permissions select[name='edit_status'] option:checked" ) expect(option).to_have_text("Inherit") - # Those fields should not be present in anonymous maps - expect(owner_session.locator(".umap-field-share_status select")).to_be_hidden() + expect(owner_session.locator(".umap-field-share_status select")).to_be_visible() + options = [ + int(option.get_attribute("value")) + for option in owner_session.locator( + ".umap-field-share_status select option" + ).all() + ] + assert options == [Map.DRAFT, Map.PUBLIC] + # This field should not be present in anonymous maps expect(owner_session.locator(".umap-field-owner")).to_be_hidden() @@ -135,15 +140,15 @@ def test_can_change_perms_after_create(tilelayer, live_server, page): page.get_by_title("Manage layers").click() page.get_by_title("Add a layer").click() page.locator("input[name=name]").fill("Layer 1") - save = page.get_by_role("button", name="Save") - expect(save).to_be_visible() + expect( + page.get_by_role("button", name="Visibility: Draft (private)") + ).to_be_visible() + expect(page.get_by_role("button", name="Save", exact=True)).to_be_hidden() with page.expect_response(re.compile(r".*/datalayer/create/.*")): - save.click() + page.get_by_role("button", name="Save draft", exact=True).click() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() edit_permissions.click() - select = page.locator(".umap-field-share_status select") - expect(select).to_be_hidden() owner_field = page.locator(".umap-field-owner") expect(owner_field).to_be_hidden() editors_field = page.locator(".umap-field-editors input") @@ -157,6 +162,9 @@ def test_can_change_perms_after_create(tilelayer, live_server, page): ) expect(option).to_have_text("Inherit") expect(page.get_by_label("Secret edit link:")).to_be_visible() + page.locator('select[name="share_status"]').select_option("1") + expect(page.get_by_role("button", name="Save draft", exact=True)).to_be_hidden() + expect(page.get_by_role("button", name="Save", exact=True)).to_be_visible() def test_alert_message_after_create( @@ -232,7 +240,7 @@ def test_anonymous_owner_can_delete_the_map(anonymap, live_server, owner_session owner_session.get_by_role("button", name="Delete").click() with owner_session.expect_response(re.compile(r".*/update/delete/.*")): owner_session.get_by_role("button", name="OK").click() - assert not Map.objects.count() + assert Map.objects.get(pk=anonymap.pk).share_status == Map.DELETED def test_non_owner_cannot_see_delete_button(anonymap, live_server, page): diff --git a/umap/tests/integration/test_dashboard.py b/umap/tests/integration/test_dashboard.py index 1d0d003f2..b2cff1fc8 100644 --- a/umap/tests/integration/test_dashboard.py +++ b/umap/tests/integration/test_dashboard.py @@ -22,7 +22,7 @@ def handle_dialog(dialog): with page.expect_navigation(): delete_button.click() assert dialog_shown - assert Map.objects.all().count() == 0 + assert Map.objects.get(pk=map.pk).share_status == Map.DELETED def test_dashboard_map_preview(map, live_server, datalayer, login): diff --git a/umap/tests/integration/test_owned_map.py b/umap/tests/integration/test_owned_map.py index 342226500..2eaa9e6aa 100644 --- a/umap/tests/integration/test_owned_map.py +++ b/umap/tests/integration/test_owned_map.py @@ -61,8 +61,12 @@ def test_owner_permissions_form(map, datalayer, live_server, login): edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() edit_permissions.click() - select = page.locator(".umap-field-share_status select") - expect(select).to_be_visible() + expect(page.locator(".umap-field-share_status select")).to_be_visible() + options = [ + int(option.get_attribute("value")) + for option in page.locator(".umap-field-share_status select option").all() + ] + assert options == [Map.DRAFT, Map.PUBLIC, Map.OPEN, Map.PRIVATE] # expect(select).to_have_value(Map.PUBLIC) # Does not work owner_field = page.locator(".umap-field-owner") expect(owner_field).to_be_visible() @@ -137,7 +141,7 @@ def test_owner_has_delete_map_button(map, live_server, login): delete.click() with page.expect_navigation(): page.get_by_role("button", name="OK").click() - assert Map.objects.all().count() == 0 + assert Map.objects.get(pk=map.pk).share_status == Map.DELETED def test_editor_do_not_have_delete_map_button(map, live_server, login, user): @@ -181,29 +185,31 @@ def test_can_change_perms_after_create(tilelayer, live_server, login, user): page.get_by_title("Manage layers").click() page.get_by_title("Add a layer").click() page.locator("input[name=name]").fill("Layer 1") - save = page.get_by_role("button", name="Save") - expect(save).to_be_visible() + expect( + page.get_by_role("button", name="Visibility: Draft (private)") + ).to_be_visible() + expect(page.get_by_role("button", name="Save", exact=True)).to_be_hidden() with page.expect_response(re.compile(r".*/map/create/")): - save.click() + page.get_by_role("button", name="Save draft", exact=True).click() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() edit_permissions.click() - select = page.locator(".umap-field-share_status select") - expect(select).to_be_visible() - option = page.locator("select[name='share_status'] option:checked") - expect(option).to_have_text("Everyone (public)") - owner_field = page.locator(".umap-field-owner") - expect(owner_field).to_be_visible() - editors_field = page.locator(".umap-field-editors input") - expect(editors_field).to_be_visible() - datalayer_label = page.get_by_text('Who can edit "Layer 1"') - expect(datalayer_label).to_be_visible() + expect(page.locator(".umap-field-share_status select")).to_be_visible() + expect(page.locator("select[name='share_status'] option:checked")).to_have_text( + "Draft (private)" + ) + expect(page.locator(".umap-field-owner")).to_be_visible() + expect(page.locator(".umap-field-editors input")).to_be_visible() + expect(page.get_by_text('Who can edit "Layer 1"')).to_be_visible() options = page.locator(".datalayer-permissions select[name='edit_status'] option") expect(options).to_have_count(4) option = page.locator( ".datalayer-permissions select[name='edit_status'] option:checked" ) expect(option).to_have_text("Inherit") + page.locator('select[name="share_status"]').select_option("1") + expect(page.get_by_role("button", name="Save draft", exact=True)).to_be_hidden() + expect(page.get_by_role("button", name="Save", exact=True)).to_be_visible() def test_can_change_owner(map, live_server, login, user): diff --git a/umap/tests/test_datalayer.py b/umap/tests/test_datalayer.py index 1019ff582..9ef18d20a 100644 --- a/umap/tests/test_datalayer.py +++ b/umap/tests/test_datalayer.py @@ -273,7 +273,6 @@ def test_anonymous_can_edit_in_inherit_mode_and_map_in_public_mode( def test_should_remove_all_versions_on_delete(map, settings): - settings.UMAP_PURGATORY_ROOT = tempfile.mkdtemp() datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17) root = Path(datalayer.storage_root()) before = len(datalayer.geojson.storage.listdir(root)[1]) @@ -292,4 +291,3 @@ def test_should_remove_all_versions_on_delete(map, settings): datalayer.delete() found = datalayer.geojson.storage.listdir(root)[1] assert found == [other, f"{other}.gz"] - assert len(list(Path(settings.UMAP_PURGATORY_ROOT).iterdir())) == 4 + before diff --git a/umap/tests/test_empty_trash.py b/umap/tests/test_empty_trash.py new file mode 100644 index 000000000..ad1d01e05 --- /dev/null +++ b/umap/tests/test_empty_trash.py @@ -0,0 +1,34 @@ +from datetime import datetime, timedelta +from unittest import mock + +import pytest +from django.core.management import call_command + +from umap.models import Map + +from .base import MapFactory + +pytestmark = pytest.mark.django_db + + +def test_empty_trash(user): + recent = MapFactory(owner=user) + recent_deleted = MapFactory(owner=user) + recent_deleted.move_to_trash() + recent_deleted.save() + with mock.patch("django.utils.timezone.now") as mocked: + mocked.return_value = datetime.utcnow() - timedelta(days=8) + old_deleted = MapFactory(owner=user) + old_deleted.move_to_trash() + old_deleted.save() + old = MapFactory(owner=user) + assert Map.objects.count() == 4 + call_command("empty_trash", "--days=7", "--dry-run") + assert Map.objects.count() == 4 + call_command("empty_trash", "--days=9") + assert Map.objects.count() == 4 + 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) diff --git a/umap/tests/test_map.py b/umap/tests/test_map.py index 07af12be8..e02095b3c 100644 --- a/umap/tests/test_map.py +++ b/umap/tests/test_map.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import AnonymousUser from django.urls import reverse +from umap.forms import DEFAULT_CENTER from umap.models import Map from .base import MapFactory @@ -160,8 +161,16 @@ def test_can_change_default_edit_status(user, settings): def test_can_change_default_share_status(user, settings): + map = Map.objects.create(owner=user, center=DEFAULT_CENTER) + assert map.share_status == Map.DRAFT + settings.UMAP_DEFAULT_SHARE_STATUS = Map.PUBLIC + map = Map.objects.create(owner=user, center=DEFAULT_CENTER) map = MapFactory(owner=user) assert map.share_status == Map.PUBLIC - settings.UMAP_DEFAULT_SHARE_STATUS = Map.PRIVATE - map = MapFactory(owner=user) - assert map.share_status == Map.PRIVATE + + +def test_move_to_trash(user, map): + map.move_to_trash() + map.save() + reloaded = Map.objects.get(pk=map.pk) + assert reloaded.share_status == Map.DELETED diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index 5f63d3ea4..90b70d441 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -42,7 +42,7 @@ def test_create(client, user, post_data): assert created_map.center.y == 48.94415123418794 assert j["permissions"] == { "edit_status": 3, - "share_status": 1, + "share_status": 0, "owner": {"id": user.pk, "name": "Joe", "url": "/en/user/Joe/"}, "editors": [], } @@ -114,8 +114,10 @@ def test_delete(client, map, datalayer): url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True ) assert response.status_code == 200 - assert not Map.objects.filter(pk=map.pk).exists() - assert not DataLayer.objects.filter(pk=datalayer.pk).exists() + assert Map.objects.filter(pk=map.pk).exists() + assert DataLayer.objects.filter(pk=datalayer.pk).exists() + reloaded = Map.objects.get(pk=map.pk) + assert reloaded.share_status == Map.DELETED # Check that user has not been impacted assert User.objects.filter(pk=map.owner.pk).exists() # Test response is a json @@ -241,42 +243,46 @@ def test_map_creation_should_allow_unicode_names(client, map, post_data): assert created_map.slug == "map" -def test_anonymous_can_access_map_with_share_status_public(client, map): - url = reverse("map", args=(map.slug, map.pk)) - map.share_status = map.PUBLIC - map.save() - response = client.get(url) - assert response.status_code == 200 - - -def test_anonymous_can_access_map_with_share_status_open(client, map): +@pytest.mark.parametrize("share_status", [Map.PUBLIC, Map.OPEN]) +def test_anonymous_can_access_map_with_share_status_accessible( + client, map, share_status +): url = reverse("map", args=(map.slug, map.pk)) - map.share_status = map.OPEN + map.share_status = share_status map.save() response = client.get(url) assert response.status_code == 200 -def test_anonymous_cannot_access_map_with_share_status_private(client, map): +@pytest.mark.parametrize( + "share_status", [Map.PRIVATE, Map.DRAFT, Map.BLOCKED, Map.DELETED] +) +def test_anonymous_cannot_access_map_with_share_status_restricted( + client, map, share_status +): url = reverse("map", args=(map.slug, map.pk)) - map.share_status = map.PRIVATE + map.share_status = share_status map.save() response = client.get(url) assert response.status_code == 403 -def test_owner_can_access_map_with_share_status_private(client, map): +@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.DRAFT]) +def test_owner_can_access_map_with_share_status_restricted(client, map, share_status): url = reverse("map", args=(map.slug, map.pk)) - map.share_status = map.PRIVATE + map.share_status = share_status map.save() client.login(username=map.owner.username, password="123123") response = client.get(url) assert response.status_code == 200 -def test_editors_can_access_map_with_share_status_private(client, map, user): +@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.DRAFT]) +def test_editors_can_access_map_with_share_status_resricted( + client, map, user, share_status +): url = reverse("map", args=(map.slug, map.pk)) - map.share_status = map.PRIVATE + map.share_status = share_status map.editors.add(user) map.save() client.login(username=user.username, password="123123") @@ -284,10 +290,11 @@ def test_editors_can_access_map_with_share_status_private(client, map, user): assert response.status_code == 200 -def test_anonymous_cannot_access_map_with_share_status_blocked(client, map): +def test_owner_cannot_access_map_with_share_status_deleted(client, map): url = reverse("map", args=(map.slug, map.pk)) - map.share_status = map.BLOCKED + map.share_status = map.DELETED map.save() + client.login(username=map.owner.username, password="123123") response = client.get(url) assert response.status_code == 403 @@ -401,14 +408,16 @@ def test_anonymous_delete(cookieclient, anonymap): url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True ) assert response.status_code == 200 - assert not Map.objects.filter(pk=anonymap.pk).count() + assert Map.objects.filter(pk=anonymap.pk).exists() + reloaded = Map.objects.get(pk=anonymap.pk) + assert reloaded.share_status == Map.DELETED # Test response is a json j = json.loads(response.content.decode()) assert "redirect" in j @pytest.mark.usefixtures("allow_anonymous") -def test_no_cookie_cant_delete(client, anonymap): +def test_no_cookie_cannot_delete(client, anonymap): url = reverse("map_delete", args=(anonymap.pk,)) response = client.post( url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True @@ -416,6 +425,24 @@ def test_no_cookie_cant_delete(client, anonymap): assert response.status_code == 403 +@pytest.mark.usefixtures("allow_anonymous") +def test_no_cookie_cannot_view_anonymous_owned_map_in_draft(client, anonymap): + anonymap.share_status = Map.DRAFT + anonymap.save() + url = reverse("map", kwargs={"map_id": anonymap.pk, "slug": anonymap.slug}) + response = client.get(url) + assert response.status_code == 403 + + +@pytest.mark.usefixtures("allow_anonymous") +def test_owner_can_view_anonymous_owned_map_in_draft(cookieclient, anonymap): + anonymap.share_status = Map.DRAFT + anonymap.save() + url = reverse("map", kwargs={"map_id": anonymap.pk, "slug": anonymap.slug}) + response = cookieclient.get(url) + assert response.status_code == 200 + + @pytest.mark.usefixtures("allow_anonymous") def test_anonymous_edit_url(cookieclient, anonymap): url = anonymap.get_anonymous_edit_url() @@ -557,16 +584,6 @@ def test_create_readonly(client, user, post_data, settings): assert response.content == b"Site is readonly for maintenance" -def test_search(client, map): - # Very basic search, that do not deal with accent nor case. - # See install.md for how to have a smarter dict + index. - map.name = "Blé dur" - map.save() - url = reverse("search") - response = client.get(url + "?q=Blé") - assert "Blé dur" in response.content.decode() - - def test_authenticated_user_can_star_map(client, map, user): url = reverse("map_star", args=(map.pk,)) client.login(username=user.username, password="123123") @@ -748,7 +765,9 @@ def test_download_multiple_maps_editor(client, map, datalayer): assert f.infolist()[1].filename == f"umap_backup_test-map_{map.id}.umap" -@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED]) +@pytest.mark.parametrize( + "share_status", [Map.PRIVATE, Map.BLOCKED, Map.DRAFT, Map.DELETED] +) def test_download_shared_status_map(client, map, datalayer, share_status): map.share_status = share_status map.save() @@ -757,8 +776,9 @@ def test_download_shared_status_map(client, map, datalayer, share_status): assert response.status_code == 403 -def test_download_my_map(client, map, datalayer): - map.share_status = Map.PRIVATE +@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.DRAFT]) +def test_download_my_map(client, map, datalayer, share_status): + map.share_status = share_status map.save() client.login(username=map.owner.username, password="123123") url = reverse("map_download", args=(map.pk,)) @@ -769,7 +789,19 @@ def test_download_my_map(client, map, datalayer): assert j["type"] == "umap" -@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED, Map.OPEN]) +@pytest.mark.parametrize("share_status", [Map.BLOCKED, Map.DELETED]) +def test_download_my_map_blocked_or_deleted(client, map, datalayer, share_status): + map.share_status = share_status + map.save() + client.login(username=map.owner.username, password="123123") + url = reverse("map_download", args=(map.pk,)) + response = client.get(url) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "share_status", [Map.PRIVATE, Map.BLOCKED, Map.OPEN, Map.DRAFT] +) def test_oembed_shared_status_map(client, map, datalayer, share_status): map.share_status = share_status map.save() diff --git a/umap/tests/test_purge_purgatory.py b/umap/tests/test_purge_purgatory.py deleted file mode 100644 index d4d29e43b..000000000 --- a/umap/tests/test_purge_purgatory.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -import tempfile -from pathlib import Path - -from django.core.management import call_command - - -def test_purge_purgatory(settings): - settings.UMAP_PURGATORY_ROOT = tempfile.mkdtemp() - root = Path(settings.UMAP_PURGATORY_ROOT) - old = root / "old.json" - old.write_text("{}") - stat = old.stat() - os.utime(old, times=(stat.st_mtime - 31 * 86400, stat.st_mtime - 31 * 86400)) - recent = root / "recent.json" - recent.write_text("{}") - stat = recent.stat() - os.utime(recent, times=(stat.st_mtime - 8 * 86400, stat.st_mtime - 8 * 86400)) - now = root / "now.json" - now.write_text("{}") - assert {f.name for f in root.iterdir()} == {"old.json", "recent.json", "now.json"} - call_command("purge_purgatory") - assert {f.name for f in root.iterdir()} == {"recent.json", "now.json"} - call_command("purge_purgatory", "--days=7") - assert {f.name for f in root.iterdir()} == {"now.json"} diff --git a/umap/tests/test_views.py b/umap/tests/test_views.py index c6a6dbc3e..420fe16e6 100644 --- a/umap/tests/test_views.py +++ b/umap/tests/test_views.py @@ -288,6 +288,28 @@ def test_user_dashboard_display_user_maps(client, map): assert "Owner only" in body +@pytest.mark.django_db +def test_user_dashboard_do_not_display_blocked_user_maps(client, map): + map.share_status = Map.BLOCKED + map.save() + client.login(username=map.owner.username, password="123123") + response = client.get(reverse("user_dashboard")) + assert response.status_code == 200 + body = response.content.decode() + assert map.name not in body + + +@pytest.mark.django_db +def test_user_dashboard_do_not_display_deleted_user_maps(client, map): + map.share_status = Map.DELETED + map.save() + client.login(username=map.owner.username, password="123123") + response = client.get(reverse("user_dashboard")) + assert response.status_code == 200 + body = response.content.decode() + assert map.name not in body + + @pytest.mark.django_db def test_user_dashboard_display_user_team_maps(client, map, team, user): user.teams.add(team) @@ -497,3 +519,34 @@ def test_websocket_token_is_generated_for_editors(client, user, user2, map): resp = client.get(token_url) token = resp.json().get("token") assert TimestampSigner().unsign_object(token, max_age=30) + + +@pytest.mark.django_db +def test_search(client, map): + # Very basic search, that do not deal with accent nor case. + # See install.md for how to have a smarter dict + index. + map.name = "Blé dur" + map.save() + url = reverse("search") + response = client.get(url + "?q=Blé") + assert "Blé dur" in response.content.decode() + + +@pytest.mark.django_db +def test_cannot_search_blocked_map(client, map): + map.name = "Blé dur" + map.share_status = Map.BLOCKED + map.save() + url = reverse("search") + response = client.get(url + "?q=Blé") + assert "Blé dur" not in response.content.decode() + + +@pytest.mark.django_db +def test_cannot_search_deleted_map(client, map): + map.name = "Blé dur" + map.share_status = Map.DELETED + map.save() + url = reverse("search") + response = client.get(url + "?q=Blé") + assert "Blé dur" not in response.content.decode() diff --git a/umap/views.py b/umap/views.py index e56d02006..e2c166efa 100644 --- a/umap/views.py +++ b/umap/views.py @@ -373,6 +373,7 @@ def get_object(self): def get_maps(self): qs = self.get_search_queryset() or Map.objects.all() + qs = qs.exclude(share_status__in=[Map.DELETED, Map.BLOCKED]) teams = self.object.teams.all() qs = ( qs.filter(owner=self.object) @@ -601,9 +602,6 @@ def get_map_properties(self): "id": self.get_id(), "starred": self.is_starred(), "licences": dict((l.name, l.json) for l in Licence.objects.all()), - "share_statuses": [ - (i, str(label)) for i, label in Map.SHARE_STATUS if i != Map.BLOCKED - ], "umap_version": VERSION, "featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS, "websocketEnabled": settings.WEBSOCKET_ENABLED, @@ -613,15 +611,22 @@ def get_map_properties(self): } created = bool(getattr(self, "object", None)) if (created and self.object.owner) or (not created and not user.is_anonymous): - map_statuses = Map.EDIT_STATUS + edit_statuses = Map.EDIT_STATUS datalayer_statuses = DataLayer.EDIT_STATUS + share_statuses = Map.SHARE_STATUS else: - map_statuses = AnonymousMapPermissionsForm.STATUS - datalayer_statuses = AnonymousDataLayerPermissionsForm.STATUS - properties["edit_statuses"] = [(i, str(label)) for i, label in map_statuses] + edit_statuses = Map.ANONYMOUS_EDIT_STATUS + datalayer_statuses = DataLayer.ANONYMOUS_EDIT_STATUS + share_statuses = Map.ANONYMOUS_SHARE_STATUS + properties["edit_statuses"] = [(i, str(label)) for i, label in edit_statuses] properties["datalayer_edit_statuses"] = [ (i, str(label)) for i, label in datalayer_statuses ] + properties["share_statuses"] = [ + (i, str(label)) + for i, label in share_statuses + if i not in [Map.BLOCKED, Map.DELETED] + ] if self.get_short_url(): properties["shortUrl"] = self.get_short_url() @@ -684,14 +689,9 @@ def get_permissions(self): permissions["edit_status"] = self.object.edit_status permissions["share_status"] = self.object.share_status if self.object.owner: - permissions["owner"] = { - "id": self.object.owner.pk, - "name": str(self.object.owner), - "url": self.object.owner.get_url(), - } + permissions["owner"] = self.object.owner.get_metadata() permissions["editors"] = [ - {"id": editor.pk, "name": str(editor)} - for editor in self.object.editors.all() + editor.get_metadata() for editor in self.object.editors.all() ] if self.object.team: permissions["team"] = self.object.team.get_metadata() @@ -847,6 +847,17 @@ def render_to_response(self, context, *args, **kwargs): class MapNew(MapDetailMixin, TemplateView): template_name = "umap/map_detail.html" + def get_map_properties(self): + properties = super().get_map_properties() + properties["permissions"] = { + "edit_status": Map.edit_status.field.default(), + "share_status": Map.share_status.field.default(), + } + if self.request.user.is_authenticated: + user = self.request.user + properties["permissions"]["owner"] = user.get_metadata() + return properties + class MapPreview(MapDetailMixin, TemplateView): template_name = "umap/map_detail.html" @@ -1011,7 +1022,7 @@ def form_valid(self, form): self.object = self.get_object() if not self.object.can_delete(self.request): return HttpResponseForbidden(_("Only its owner can delete the map.")) - self.object.delete() + self.object.move_to_trash() home_url = reverse("home") messages.info(self.request, _("Map successfully deleted.")) if is_ajax(self.request):