Compare commits

..

No commits in common. "82b81706ab0192d91a68e7940cc645a344ffdd9c" and "c15ecfc29f7c3ed618267fcb05b8b8f6e0c1d05b" have entirely different histories.

21 changed files with 182 additions and 363 deletions

View file

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

@ -1,32 +0,0 @@
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

@ -0,0 +1,28 @@
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

@ -1,30 +0,0 @@
# Generated by Django 5.1.2 on 2024-12-10 16:48
from django.db import migrations, models
import umap.models
class Migration(migrations.Migration):
dependencies = [
("umap", "0023_alter_datalayer_uuid"),
]
operations = [
migrations.AlterField(
model_name="map",
name="share_status",
field=models.SmallIntegerField(
choices=[
(0, "Draft (private)"),
(1, "Everyone (public)"),
(2, "Anyone with link"),
(3, "Editors and team only"),
(9, "Blocked"),
(99, "Deleted"),
],
default=umap.models.get_default_share_status,
verbose_name="share status",
),
),
]

View file

@ -38,22 +38,13 @@ 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.DRAFT return settings.UMAP_DEFAULT_SHARE_STATUS or Map.PUBLIC
def get_default_edit_status(): def get_default_edit_status():
@ -170,30 +161,20 @@ 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")),
) )
ANONYMOUS_SHARE_STATUS = ( 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"))
@ -276,10 +257,6 @@ 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)
@ -375,20 +352,19 @@ class Map(NamedModel):
return can return can
def can_view(self, request): def can_view(self, request):
if self.share_status in [Map.BLOCKED, Map.DELETED]: if self.share_status == self.BLOCKED:
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 = settings.UMAP_ALLOW_ANONYMOUS and self.is_anonymous_owner(request) can = True
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 (
restricted self.share_status == self.PRIVATE
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()
) )
@ -468,11 +444,6 @@ 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)
@ -513,13 +484,21 @@ 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(keep=settings.UMAP_KEEP_VERSIONS) self.purge_old_versions()
def delete(self, **kwargs): def delete(self, **kwargs):
self.purge_gzip() self.purge_gzip()
self.purge_old_versions(keep=None) self.to_purgatory()
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))
@ -597,16 +576,14 @@ 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, keep=None): def purge_old_versions(self):
root = self.storage_root() root = self.storage_root()
versions = self.versions versions = self.versions[settings.UMAP_KEEP_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 keep is not None and self.geojson.name.endswith(name): if 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,6 +272,7 @@ 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,14 +50,6 @@ 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)
@ -192,11 +184,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(),
@ -236,10 +228,6 @@ 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,8 +31,7 @@ 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 hidden data-ref="saveLabel">${translate('Save')}</span> <span class="">${translate('Save')}</span>
<span hidden data-ref="saveDraftLabel">${translate('Save draft')}</span>
</button> </button>
</div> </div>
</div>` </div>`
@ -146,8 +145,6 @@ 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,7 +1321,6 @@ 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,7 +102,6 @@ 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,6 +76,8 @@ 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")
@ -90,15 +92,8 @@ 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")
expect(owner_session.locator(".umap-field-share_status select")).to_be_visible() # Those fields should not be present in anonymous maps
options = [ expect(owner_session.locator(".umap-field-share_status select")).to_be_hidden()
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()
@ -140,15 +135,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")
expect( save = page.get_by_role("button", name="Save")
page.get_by_role("button", name="Visibility: Draft (private)") expect(save).to_be_visible()
).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/.*")):
page.get_by_role("button", name="Save draft", exact=True).click() save.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")
@ -162,9 +157,6 @@ 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(
@ -240,7 +232,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 Map.objects.get(pk=anonymap.pk).share_status == Map.DELETED assert not Map.objects.count()
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.get(pk=map.pk).share_status == Map.DELETED assert Map.objects.all().count() == 0
def test_dashboard_map_preview(map, live_server, datalayer, login): def test_dashboard_map_preview(map, live_server, datalayer, login):

View file

@ -61,12 +61,8 @@ 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()
expect(page.locator(".umap-field-share_status select")).to_be_visible() select = page.locator(".umap-field-share_status select")
options = [ expect(select).to_be_visible()
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()
@ -141,7 +137,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.get(pk=map.pk).share_status == Map.DELETED assert Map.objects.all().count() == 0
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):
@ -185,31 +181,29 @@ 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")
expect( save = page.get_by_role("button", name="Save")
page.get_by_role("button", name="Visibility: Draft (private)") expect(save).to_be_visible()
).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/")):
page.get_by_role("button", name="Save draft", exact=True).click() save.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()
expect(page.locator(".umap-field-share_status select")).to_be_visible() select = page.locator(".umap-field-share_status select")
expect(page.locator("select[name='share_status'] option:checked")).to_have_text( expect(select).to_be_visible()
"Draft (private)" option = page.locator("select[name='share_status'] option:checked")
) expect(option).to_have_text("Everyone (public)")
expect(page.locator(".umap-field-owner")).to_be_visible() owner_field = page.locator(".umap-field-owner")
expect(page.locator(".umap-field-editors input")).to_be_visible() expect(owner_field).to_be_visible()
expect(page.get_by_text('Who can edit "Layer 1"')).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()
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,6 +273,7 @@ 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])
@ -291,3 +292,4 @@ 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

@ -1,34 +0,0 @@
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,7 +2,6 @@ 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
@ -161,16 +160,8 @@ 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)
def test_move_to_trash(user, map): assert map.share_status == Map.PRIVATE
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": 0, "share_status": 1,
"owner": {"id": user.pk, "name": "Joe", "url": "/en/user/Joe/"}, "owner": {"id": user.pk, "name": "Joe", "url": "/en/user/Joe/"},
"editors": [], "editors": [],
} }
@ -114,10 +114,8 @@ 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 Map.objects.filter(pk=map.pk).exists() assert not Map.objects.filter(pk=map.pk).exists()
assert DataLayer.objects.filter(pk=datalayer.pk).exists() assert not 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
@ -243,46 +241,42 @@ def test_map_creation_should_allow_unicode_names(client, map, post_data):
assert created_map.slug == "map" assert created_map.slug == "map"
@pytest.mark.parametrize("share_status", [Map.PUBLIC, Map.OPEN]) def test_anonymous_can_access_map_with_share_status_public(client, map):
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 = share_status map.share_status = map.PUBLIC
map.save() map.save()
response = client.get(url) response = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.parametrize( def test_anonymous_can_access_map_with_share_status_open(client, map):
"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 = share_status map.share_status = map.OPEN
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
@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.DRAFT]) def test_owner_can_access_map_with_share_status_private(client, map):
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 = share_status map.share_status = map.PRIVATE
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
@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.DRAFT]) def test_editors_can_access_map_with_share_status_private(client, map, user):
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 = share_status map.share_status = map.PRIVATE
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")
@ -290,11 +284,10 @@ def test_editors_can_access_map_with_share_status_resricted(
assert response.status_code == 200 assert response.status_code == 200
def test_owner_cannot_access_map_with_share_status_deleted(client, map): def test_anonymous_cannot_access_map_with_share_status_blocked(client, map):
url = reverse("map", args=(map.slug, map.pk)) url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.DELETED map.share_status = map.BLOCKED
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
@ -408,16 +401,14 @@ 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 Map.objects.filter(pk=anonymap.pk).exists() assert not Map.objects.filter(pk=anonymap.pk).count()
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_cannot_delete(client, anonymap): def test_no_cookie_cant_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
@ -425,24 +416,6 @@ def test_no_cookie_cannot_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()
@ -584,6 +557,16 @@ 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")
@ -765,9 +748,7 @@ 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( @pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED])
"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()
@ -776,9 +757,8 @@ def test_download_shared_status_map(client, map, datalayer, share_status):
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.DRAFT]) def test_download_my_map(client, map, datalayer):
def test_download_my_map(client, map, datalayer, share_status): 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")
url = reverse("map_download", args=(map.pk,)) url = reverse("map_download", args=(map.pk,))
@ -789,19 +769,7 @@ def test_download_my_map(client, map, datalayer, share_status):
assert j["type"] == "umap" assert j["type"] == "umap"
@pytest.mark.parametrize("share_status", [Map.BLOCKED, Map.DELETED]) @pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED, Map.OPEN])
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

@ -0,0 +1,25 @@
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,28 +288,6 @@ 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)
@ -519,34 +497,3 @@ 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,7 +373,6 @@ 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)
@ -602,6 +601,9 @@ 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,
@ -611,22 +613,15 @@ 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):
edit_statuses = Map.EDIT_STATUS map_statuses = Map.EDIT_STATUS
datalayer_statuses = DataLayer.EDIT_STATUS datalayer_statuses = DataLayer.EDIT_STATUS
share_statuses = Map.SHARE_STATUS
else: else:
edit_statuses = Map.ANONYMOUS_EDIT_STATUS map_statuses = AnonymousMapPermissionsForm.STATUS
datalayer_statuses = DataLayer.ANONYMOUS_EDIT_STATUS datalayer_statuses = AnonymousDataLayerPermissionsForm.STATUS
share_statuses = Map.ANONYMOUS_SHARE_STATUS properties["edit_statuses"] = [(i, str(label)) for i, label in map_statuses]
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()
@ -689,9 +684,14 @@ 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"] = self.object.owner.get_metadata() permissions["owner"] = {
"id": self.object.owner.pk,
"name": str(self.object.owner),
"url": self.object.owner.get_url(),
}
permissions["editors"] = [ permissions["editors"] = [
editor.get_metadata() for editor in self.object.editors.all() {"id": editor.pk, "name": str(editor)}
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,17 +847,6 @@ 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"
@ -1022,7 +1011,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.move_to_trash() self.object.delete()
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):