feat: introduce Map.share_status=DRAFT and DELETED (#2357)

This PR introduce two new share_status: `DRAFT` and `DELETED`.

So all status are now:

        (DRAFT, _("Draft (private)")),
        (PUBLIC, _("Everyone (public)")),
        (OPEN, _("Anyone with link")),
        (PRIVATE, _("Editors and team only")),
        (BLOCKED, _("Blocked")),
        (DELETED, _("Deleted")),

Here are the impact of such introduction, on the draft side:
- by default maps are now create in draft status, and they are not
visible from others than owner (or collaborators if any); this can be
changed for a given instance with the setting
`UMAP_DEFAULT_SHARE_STATUS`
- now even anonymous owned maps have a share status, given one of the
goals is to make a better distinction between maps ready to be shared
and other, this also apply to maps without logged in owners

![image](https://github.com/user-attachments/assets/41dae9fe-0ae6-4ada-ace3-dc782c6cf972)
- when the map in in draft mode, the "Save" button on the frontend says
"Save draft", so to make the state clear

![image](https://github.com/user-attachments/assets/ca2ffb79-17fa-4a1c-9e81-a759c2a415a1)
- now the visibility of the map is immediately visible on the top bar
(while before this was only displayed after first save)

![image](https://github.com/user-attachments/assets/3d9efa86-3fac-4150-b01e-b7f1ea79114b)

Note: add the end `DRAFT` and `PRIVATE` are very similar, but I made the
choice to keep the two, so one can still distinguish their draft maps
from their ready map they want to keep private.

On the delete side:
- when deleting a map, it's now set as `share_status=DELETED`, which act
as a sort of trash; so it become easier to recover a map, with it's full
datalayers, etc. (the only thing which will not be restored is the
previous share_status, which should be draft again after a restore; the
restore function itself is not implemented)
- there is a new command `empty_trash` which delete for real maps in
DELETED status and with last_modified > 30 days (by default, can be
changed with a command line argument)
- deleted maps disappear from all views: home, search, dashboard…
- in the future, we could create a new view "My Trash", where one could
see their deleted map not yet deleted for real (and that should be the
opportunity to also add the `restore` function, which for now can be
done by simply changing the share_status from the shell or the admin)
- all the purgatory related code has been removed

fix #2207
This commit is contained in:
Yohan Boniface 2024-12-10 17:48:08 +01:00 committed by GitHub
commit 73e7f60cdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 333 additions and 182 deletions

View file

@ -287,14 +287,6 @@ How many total maps to return in the search.
How many maps to show in the user "my maps" page. 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 #### UMAP_SEARCH_CONFIGURATION
Use it if you take control over the search configuration. Use it if you take control over the search configuration.

View file

@ -40,16 +40,12 @@ class UpdateMapPermissionsForm(forms.ModelForm):
class AnonymousMapPermissionsForm(forms.ModelForm): class AnonymousMapPermissionsForm(forms.ModelForm):
STATUS = ( edit_status = forms.ChoiceField(choices=Map.ANONYMOUS_EDIT_STATUS)
(Map.OWNER, _("Only editable with secret edit link")), share_status = forms.ChoiceField(choices=Map.ANONYMOUS_SHARE_STATUS)
(Map.ANONYMOUS, _("Everyone can edit")),
)
edit_status = forms.ChoiceField(choices=STATUS)
class Meta: class Meta:
model = Map model = Map
fields = ("edit_status",) fields = ("edit_status", "share_status")
class DataLayerForm(forms.ModelForm): class DataLayerForm(forms.ModelForm):
@ -65,13 +61,7 @@ class DataLayerPermissionsForm(forms.ModelForm):
class AnonymousDataLayerPermissionsForm(forms.ModelForm): class AnonymousDataLayerPermissionsForm(forms.ModelForm):
STATUS = ( edit_status = forms.ChoiceField(choices=DataLayer.ANONYMOUS_EDIT_STATUS)
(DataLayer.INHERIT, _("Inherit")),
(DataLayer.OWNER, _("Only editable with secret edit link")),
(DataLayer.ANONYMOUS, _("Everyone can edit")),
)
edit_status = forms.ChoiceField(choices=STATUS)
class Meta: class Meta:
model = DataLayer model = DataLayer

View file

@ -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}")

View file

@ -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}")

View file

@ -38,13 +38,22 @@ def get_user_stars_url(self):
return reverse("user_stars", kwargs={"identifier": identifier}) 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("__str__", display_name)
User.add_to_class("get_url", get_user_url) 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_stars_url", get_user_stars_url)
User.add_to_class("get_metadata", get_user_metadata)
def get_default_share_status(): 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(): def get_default_edit_status():
@ -161,20 +170,30 @@ class Map(NamedModel):
ANONYMOUS = 1 ANONYMOUS = 1
COLLABORATORS = 2 COLLABORATORS = 2
OWNER = 3 OWNER = 3
DRAFT = 0
PUBLIC = 1 PUBLIC = 1
OPEN = 2 OPEN = 2
PRIVATE = 3 PRIVATE = 3
BLOCKED = 9 BLOCKED = 9
DELETED = 99
ANONYMOUS_EDIT_STATUS = (
(OWNER, _("Only editable with secret edit link")),
(ANONYMOUS, _("Everyone can edit")),
)
EDIT_STATUS = ( EDIT_STATUS = (
(ANONYMOUS, _("Everyone")), (ANONYMOUS, _("Everyone")),
(COLLABORATORS, _("Editors and team only")), (COLLABORATORS, _("Editors and team only")),
(OWNER, _("Owner only")), (OWNER, _("Owner only")),
) )
SHARE_STATUS = ( ANONYMOUS_SHARE_STATUS = (
(DRAFT, _("Draft (private)")),
(PUBLIC, _("Everyone (public)")), (PUBLIC, _("Everyone (public)")),
)
SHARE_STATUS = ANONYMOUS_SHARE_STATUS + (
(OPEN, _("Anyone with link")), (OPEN, _("Anyone with link")),
(PRIVATE, _("Editors and team only")), (PRIVATE, _("Editors and team only")),
(BLOCKED, _("Blocked")), (BLOCKED, _("Blocked")),
(DELETED, _("Deleted")),
) )
slug = models.SlugField(db_index=True) slug = models.SlugField(db_index=True)
center = models.PointField(geography=True, verbose_name=_("center")) center = models.PointField(geography=True, verbose_name=_("center"))
@ -257,6 +276,10 @@ class Map(NamedModel):
) )
return map_settings return map_settings
def move_to_trash(self):
self.share_status = Map.DELETED
self.save()
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)
@ -352,19 +375,20 @@ class Map(NamedModel):
return can return can
def can_view(self, request): def can_view(self, request):
if self.share_status == self.BLOCKED: if self.share_status in [Map.BLOCKED, Map.DELETED]:
can = False can = False
elif self.share_status in [Map.PUBLIC, Map.OPEN]:
can = True
elif self.owner is None: elif self.owner is None:
can = True can = settings.UMAP_ALLOW_ANONYMOUS and self.is_anonymous_owner(request)
elif self.share_status in [self.PUBLIC, self.OPEN]:
can = True
elif not request.user.is_authenticated: elif not request.user.is_authenticated:
can = False can = False
elif request.user == self.owner: elif request.user == self.owner:
can = True can = True
else: else:
restricted = self.share_status in [Map.PRIVATE, Map.DRAFT]
can = not ( can = not (
self.share_status == self.PRIVATE restricted
and request.user not in self.editors.all() and request.user not in self.editors.all()
and self.team not in request.user.teams.all() and self.team not in request.user.teams.all()
) )
@ -444,6 +468,11 @@ class DataLayer(NamedModel):
(COLLABORATORS, _("Editors and team only")), (COLLABORATORS, _("Editors and team only")),
(OWNER, _("Owner 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) uuid = models.UUIDField(unique=True, primary_key=True, editable=False)
old_id = models.IntegerField(null=True, blank=True) old_id = models.IntegerField(null=True, blank=True)
map = models.ForeignKey(Map, on_delete=models.CASCADE) map = models.ForeignKey(Map, on_delete=models.CASCADE)
@ -484,21 +513,13 @@ class DataLayer(NamedModel):
force_insert=force_insert, force_update=force_update, **kwargs force_insert=force_insert, force_update=force_update, **kwargs
) )
self.purge_gzip() self.purge_gzip()
self.purge_old_versions() self.purge_old_versions(keep=settings.UMAP_KEEP_VERSIONS)
def delete(self, **kwargs): def delete(self, **kwargs):
self.purge_gzip() self.purge_gzip()
self.to_purgatory() self.purge_old_versions(keep=None)
return super().delete(**kwargs) 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): def upload_to(self):
root = self.storage_root() root = self.storage_root()
name = "%s_%s.geojson" % (self.pk, int(time.time() * 1000)) name = "%s_%s.geojson" % (self.pk, int(time.time() * 1000))
@ -576,14 +597,16 @@ class DataLayer(NamedModel):
def get_version_path(self, name): def get_version_path(self, name):
return "{root}/{name}".format(root=self.storage_root(), name=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() 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: for version in versions:
name = version["name"] name = version["name"]
# Should not be in the list, but ensure to not delete the file # Should not be in the list, but ensure to not delete the file
# currently used in database # currently used in database
if self.geojson.name.endswith(name): if keep is not None and self.geojson.name.endswith(name):
continue continue
try: try:
self.geojson.storage.delete(os.path.join(root, name)) self.geojson.storage.delete(os.path.join(root, name))

View file

@ -272,7 +272,6 @@ UMAP_DEFAULT_FEATURES_HAVE_OWNERS = False
UMAP_HOME_FEED = "latest" UMAP_HOME_FEED = "latest"
UMAP_IMPORTERS = {} UMAP_IMPORTERS = {}
UMAP_HOST_INFOS = {} UMAP_HOST_INFOS = {}
UMAP_PURGATORY_ROOT = "/tmp/umappurgatory"
UMAP_LABEL_KEYS = ["name", "title"] UMAP_LABEL_KEYS = ["name", "title"]
UMAP_READONLY = env("UMAP_READONLY", default=False) UMAP_READONLY = env("UMAP_READONLY", default=False)

View file

@ -50,6 +50,14 @@ export class MapPermissions extends ServerStored {
selectOptions: this._umap.properties.edit_statuses, 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 builder = new U.FormBuilder(this, fields)
const form = builder.build() const form = builder.build()
container.appendChild(form) container.appendChild(form)
@ -184,11 +192,11 @@ export class MapPermissions extends ServerStored {
} }
if (this.isOwner() || this.isAnonymousMap()) { if (this.isOwner() || this.isAnonymousMap()) {
formData.append('edit_status', this.properties.edit_status) formData.append('edit_status', this.properties.edit_status)
formData.append('share_status', this.properties.share_status)
} }
if (this.isOwner()) { if (this.isOwner()) {
formData.append('owner', this.properties.owner?.id) formData.append('owner', this.properties.owner?.id)
formData.append('team', this.properties.team?.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( const [data, response, error] = await this._umap.server.post(
this.getUrl(), this.getUrl(),
@ -228,6 +236,10 @@ export class MapPermissions extends ServerStored {
] ]
} }
} }
isDraft() {
return this.properties.share_status === 0
}
} }
export class DataLayerPermissions extends ServerStored { export class DataLayerPermissions extends ServerStored {

View file

@ -31,7 +31,8 @@ const TOP_BAR_TEMPLATE = `
<button class="edit-save button round" type="button" data-ref="save"> <button class="edit-save button round" type="button" data-ref="save">
<i class="icon icon-16 icon-save"></i> <i class="icon icon-16 icon-save"></i>
<i class="icon icon-16 icon-save-disabled"></i> <i class="icon icon-16 icon-save-disabled"></i>
<span class="">${translate('Save')}</span> <span hidden data-ref="saveLabel">${translate('Save')}</span>
<span hidden data-ref="saveDraftLabel">${translate('Save draft')}</span>
</button> </button>
</div> </div>
</div>` </div>`
@ -145,6 +146,8 @@ export class TopBar extends WithTemplate {
redraw() { redraw() {
this.elements.peers.hidden = !this._umap.getProperty('syncEnabled') this.elements.peers.hidden = !this._umap.getProperty('syncEnabled')
this.elements.saveLabel.hidden = this._umap.permissions.isDraft()
this.elements.saveDraftLabel.hidden = !this._umap.permissions.isDraft()
} }
} }

View file

@ -1321,6 +1321,7 @@ export default class Umap extends ServerStored {
}) })
}) })
} }
this.topBar.redraw()
}, },
numberOfConnectedPeers: () => { numberOfConnectedPeers: () => {
Utils.eachElement('.connected-peers span', (el) => { Utils.eachElement('.connected-peers span', (el) => {

View file

@ -102,6 +102,7 @@ class MapFactory(factory.django.DjangoModelFactory):
licence = factory.SubFactory(LicenceFactory) licence = factory.SubFactory(LicenceFactory)
owner = factory.SubFactory(UserFactory) owner = factory.SubFactory(UserFactory)
share_status = Map.PUBLIC
@classmethod @classmethod
def _adjust_kwargs(cls, **kwargs): def _adjust_kwargs(cls, **kwargs):

View file

@ -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") edit_permissions = owner_session.get_by_title("Update permissions and editors")
expect(edit_permissions).to_be_visible() expect(edit_permissions).to_be_visible()
edit_permissions.click() 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") owner_field = owner_session.locator(".umap-field-owner")
expect(owner_field).to_be_hidden() expect(owner_field).to_be_hidden()
editors_field = owner_session.locator(".umap-field-editors input") 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" ".datalayer-permissions select[name='edit_status'] option:checked"
) )
expect(option).to_have_text("Inherit") 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_visible()
expect(owner_session.locator(".umap-field-share_status select")).to_be_hidden() 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() 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("Manage layers").click()
page.get_by_title("Add a layer").click() page.get_by_title("Add a layer").click()
page.locator("input[name=name]").fill("Layer 1") page.locator("input[name=name]").fill("Layer 1")
save = page.get_by_role("button", name="Save") expect(
expect(save).to_be_visible() 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/.*")): 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") edit_permissions = page.get_by_title("Update permissions and editors")
expect(edit_permissions).to_be_visible() expect(edit_permissions).to_be_visible()
edit_permissions.click() edit_permissions.click()
select = page.locator(".umap-field-share_status select")
expect(select).to_be_hidden()
owner_field = page.locator(".umap-field-owner") owner_field = page.locator(".umap-field-owner")
expect(owner_field).to_be_hidden() expect(owner_field).to_be_hidden()
editors_field = page.locator(".umap-field-editors input") 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(option).to_have_text("Inherit")
expect(page.get_by_label("Secret edit link:")).to_be_visible() 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( 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() owner_session.get_by_role("button", name="Delete").click()
with owner_session.expect_response(re.compile(r".*/update/delete/.*")): with owner_session.expect_response(re.compile(r".*/update/delete/.*")):
owner_session.get_by_role("button", name="OK").click() 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): def test_non_owner_cannot_see_delete_button(anonymap, live_server, page):

View file

@ -22,7 +22,7 @@ def test_owner_can_delete_map_after_confirmation(map, live_server, login):
with page.expect_navigation(): with page.expect_navigation():
delete_button.click() delete_button.click()
assert dialog_shown 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): def test_dashboard_map_preview(map, live_server, datalayer, login):

View file

@ -61,8 +61,12 @@ def test_owner_permissions_form(map, datalayer, live_server, login):
edit_permissions = page.get_by_title("Update permissions and editors") edit_permissions = page.get_by_title("Update permissions and editors")
expect(edit_permissions).to_be_visible() expect(edit_permissions).to_be_visible()
edit_permissions.click() edit_permissions.click()
select = page.locator(".umap-field-share_status select") expect(page.locator(".umap-field-share_status select")).to_be_visible()
expect(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 # expect(select).to_have_value(Map.PUBLIC) # Does not work
owner_field = page.locator(".umap-field-owner") owner_field = page.locator(".umap-field-owner")
expect(owner_field).to_be_visible() expect(owner_field).to_be_visible()
@ -137,7 +141,7 @@ def test_owner_has_delete_map_button(map, live_server, login):
delete.click() delete.click()
with page.expect_navigation(): with page.expect_navigation():
page.get_by_role("button", name="OK").click() 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): 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("Manage layers").click()
page.get_by_title("Add a layer").click() page.get_by_title("Add a layer").click()
page.locator("input[name=name]").fill("Layer 1") page.locator("input[name=name]").fill("Layer 1")
save = page.get_by_role("button", name="Save") expect(
expect(save).to_be_visible() 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/")): 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") edit_permissions = page.get_by_title("Update permissions and editors")
expect(edit_permissions).to_be_visible() expect(edit_permissions).to_be_visible()
edit_permissions.click() edit_permissions.click()
select = page.locator(".umap-field-share_status select") expect(page.locator(".umap-field-share_status select")).to_be_visible()
expect(select).to_be_visible() expect(page.locator("select[name='share_status'] option:checked")).to_have_text(
option = page.locator("select[name='share_status'] option:checked") "Draft (private)"
expect(option).to_have_text("Everyone (public)") )
owner_field = page.locator(".umap-field-owner") expect(page.locator(".umap-field-owner")).to_be_visible()
expect(owner_field).to_be_visible() expect(page.locator(".umap-field-editors input")).to_be_visible()
editors_field = page.locator(".umap-field-editors input") expect(page.get_by_text('Who can edit "Layer 1"')).to_be_visible()
expect(editors_field).to_be_visible()
datalayer_label = page.get_by_text('Who can edit "Layer 1"')
expect(datalayer_label).to_be_visible()
options = page.locator(".datalayer-permissions select[name='edit_status'] option") options = page.locator(".datalayer-permissions select[name='edit_status'] option")
expect(options).to_have_count(4) expect(options).to_have_count(4)
option = page.locator( option = page.locator(
".datalayer-permissions select[name='edit_status'] option:checked" ".datalayer-permissions select[name='edit_status'] option:checked"
) )
expect(option).to_have_text("Inherit") 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): def test_can_change_owner(map, live_server, login, user):

View file

@ -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): 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) datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17)
root = Path(datalayer.storage_root()) root = Path(datalayer.storage_root())
before = len(datalayer.geojson.storage.listdir(root)[1]) before = len(datalayer.geojson.storage.listdir(root)[1])
@ -292,4 +291,3 @@ def test_should_remove_all_versions_on_delete(map, settings):
datalayer.delete() datalayer.delete()
found = datalayer.geojson.storage.listdir(root)[1] found = datalayer.geojson.storage.listdir(root)[1]
assert found == [other, f"{other}.gz"] assert found == [other, f"{other}.gz"]
assert len(list(Path(settings.UMAP_PURGATORY_ROOT).iterdir())) == 4 + before

View file

@ -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)

View file

@ -2,6 +2,7 @@ import pytest
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.urls import reverse from django.urls import reverse
from umap.forms import DEFAULT_CENTER
from umap.models import Map from umap.models import Map
from .base import MapFactory 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): 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) map = MapFactory(owner=user)
assert map.share_status == Map.PUBLIC 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

View file

@ -42,7 +42,7 @@ def test_create(client, user, post_data):
assert created_map.center.y == 48.94415123418794 assert created_map.center.y == 48.94415123418794
assert j["permissions"] == { assert j["permissions"] == {
"edit_status": 3, "edit_status": 3,
"share_status": 1, "share_status": 0,
"owner": {"id": user.pk, "name": "Joe", "url": "/en/user/Joe/"}, "owner": {"id": user.pk, "name": "Joe", "url": "/en/user/Joe/"},
"editors": [], "editors": [],
} }
@ -114,8 +114,10 @@ def test_delete(client, map, datalayer):
url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True
) )
assert response.status_code == 200 assert response.status_code == 200
assert not Map.objects.filter(pk=map.pk).exists() assert Map.objects.filter(pk=map.pk).exists()
assert not DataLayer.objects.filter(pk=datalayer.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 # Check that user has not been impacted
assert User.objects.filter(pk=map.owner.pk).exists() assert User.objects.filter(pk=map.owner.pk).exists()
# Test response is a json # 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" assert created_map.slug == "map"
def test_anonymous_can_access_map_with_share_status_public(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)) url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.PUBLIC map.share_status = share_status
map.save() map.save()
response = client.get(url) response = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
def test_anonymous_can_access_map_with_share_status_open(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)) 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):
url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.PRIVATE
map.save() map.save()
response = client.get(url) response = client.get(url)
assert response.status_code == 403 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)) url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.PRIVATE map.share_status = share_status
map.save() map.save()
client.login(username=map.owner.username, password="123123") client.login(username=map.owner.username, password="123123")
response = client.get(url) response = client.get(url)
assert response.status_code == 200 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)) url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.PRIVATE map.share_status = share_status
map.editors.add(user) map.editors.add(user)
map.save() map.save()
client.login(username=user.username, password="123123") 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 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)) url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.BLOCKED map.share_status = map.DELETED
map.save() map.save()
client.login(username=map.owner.username, password="123123")
response = client.get(url) response = client.get(url)
assert response.status_code == 403 assert response.status_code == 403
@ -401,14 +408,16 @@ def test_anonymous_delete(cookieclient, anonymap):
url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True
) )
assert response.status_code == 200 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 # Test response is a json
j = json.loads(response.content.decode()) j = json.loads(response.content.decode())
assert "redirect" in j assert "redirect" in j
@pytest.mark.usefixtures("allow_anonymous") @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,)) url = reverse("map_delete", args=(anonymap.pk,))
response = client.post( response = client.post(
url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True 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 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") @pytest.mark.usefixtures("allow_anonymous")
def test_anonymous_edit_url(cookieclient, anonymap): def test_anonymous_edit_url(cookieclient, anonymap):
url = anonymap.get_anonymous_edit_url() 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" 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): def test_authenticated_user_can_star_map(client, map, user):
url = reverse("map_star", args=(map.pk,)) url = reverse("map_star", args=(map.pk,))
client.login(username=user.username, password="123123") 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" 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): def test_download_shared_status_map(client, map, datalayer, share_status):
map.share_status = share_status map.share_status = share_status
map.save() map.save()
@ -757,8 +776,9 @@ def test_download_shared_status_map(client, map, datalayer, share_status):
assert response.status_code == 403 assert response.status_code == 403
def test_download_my_map(client, map, datalayer): @pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.DRAFT])
map.share_status = Map.PRIVATE def test_download_my_map(client, map, datalayer, share_status):
map.share_status = share_status
map.save() map.save()
client.login(username=map.owner.username, password="123123") client.login(username=map.owner.username, password="123123")
url = reverse("map_download", args=(map.pk,)) url = reverse("map_download", args=(map.pk,))
@ -769,7 +789,19 @@ def test_download_my_map(client, map, datalayer):
assert j["type"] == "umap" 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): def test_oembed_shared_status_map(client, map, datalayer, share_status):
map.share_status = share_status map.share_status = share_status
map.save() map.save()

View file

@ -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"}

View file

@ -288,6 +288,28 @@ def test_user_dashboard_display_user_maps(client, map):
assert "Owner only" in body 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 @pytest.mark.django_db
def test_user_dashboard_display_user_team_maps(client, map, team, user): def test_user_dashboard_display_user_team_maps(client, map, team, user):
user.teams.add(team) 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) resp = client.get(token_url)
token = resp.json().get("token") token = resp.json().get("token")
assert TimestampSigner().unsign_object(token, max_age=30) 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()

View file

@ -373,6 +373,7 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin):
def get_maps(self): def get_maps(self):
qs = self.get_search_queryset() or Map.objects.all() qs = self.get_search_queryset() or Map.objects.all()
qs = qs.exclude(share_status__in=[Map.DELETED, Map.BLOCKED])
teams = self.object.teams.all() teams = self.object.teams.all()
qs = ( qs = (
qs.filter(owner=self.object) qs.filter(owner=self.object)
@ -601,9 +602,6 @@ class MapDetailMixin(SessionMixin):
"id": self.get_id(), "id": self.get_id(),
"starred": self.is_starred(), "starred": self.is_starred(),
"licences": dict((l.name, l.json) for l in Licence.objects.all()), "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, "umap_version": VERSION,
"featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS, "featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS,
"websocketEnabled": settings.WEBSOCKET_ENABLED, "websocketEnabled": settings.WEBSOCKET_ENABLED,
@ -613,15 +611,22 @@ class MapDetailMixin(SessionMixin):
} }
created = bool(getattr(self, "object", None)) created = bool(getattr(self, "object", None))
if (created and self.object.owner) or (not created and not user.is_anonymous): 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 datalayer_statuses = DataLayer.EDIT_STATUS
share_statuses = Map.SHARE_STATUS
else: else:
map_statuses = AnonymousMapPermissionsForm.STATUS edit_statuses = Map.ANONYMOUS_EDIT_STATUS
datalayer_statuses = AnonymousDataLayerPermissionsForm.STATUS datalayer_statuses = DataLayer.ANONYMOUS_EDIT_STATUS
properties["edit_statuses"] = [(i, str(label)) for i, label in map_statuses] share_statuses = Map.ANONYMOUS_SHARE_STATUS
properties["edit_statuses"] = [(i, str(label)) for i, label in edit_statuses]
properties["datalayer_edit_statuses"] = [ properties["datalayer_edit_statuses"] = [
(i, str(label)) for i, label in datalayer_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(): if self.get_short_url():
properties["shortUrl"] = self.get_short_url() properties["shortUrl"] = self.get_short_url()
@ -684,14 +689,9 @@ class PermissionsMixin:
permissions["edit_status"] = self.object.edit_status permissions["edit_status"] = self.object.edit_status
permissions["share_status"] = self.object.share_status permissions["share_status"] = self.object.share_status
if self.object.owner: if self.object.owner:
permissions["owner"] = { permissions["owner"] = self.object.owner.get_metadata()
"id": self.object.owner.pk,
"name": str(self.object.owner),
"url": self.object.owner.get_url(),
}
permissions["editors"] = [ permissions["editors"] = [
{"id": editor.pk, "name": str(editor)} editor.get_metadata() for editor in self.object.editors.all()
for editor in self.object.editors.all()
] ]
if self.object.team: if self.object.team:
permissions["team"] = self.object.team.get_metadata() permissions["team"] = self.object.team.get_metadata()
@ -847,6 +847,17 @@ class MapViewGeoJSON(MapView):
class MapNew(MapDetailMixin, TemplateView): class MapNew(MapDetailMixin, TemplateView):
template_name = "umap/map_detail.html" 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): class MapPreview(MapDetailMixin, TemplateView):
template_name = "umap/map_detail.html" template_name = "umap/map_detail.html"
@ -1011,7 +1022,7 @@ class MapDelete(DeleteView):
self.object = self.get_object() self.object = self.get_object()
if not self.object.can_delete(self.request): if not self.object.can_delete(self.request):
return HttpResponseForbidden(_("Only its owner can delete the map.")) return HttpResponseForbidden(_("Only its owner can delete the map."))
self.object.delete() self.object.move_to_trash()
home_url = reverse("home") home_url = reverse("home")
messages.info(self.request, _("Map successfully deleted.")) messages.info(self.request, _("Map successfully deleted."))
if is_ajax(self.request): if is_ajax(self.request):