WIP: move edit_status from Map to DataLayer

This commit is contained in:
Yohan Boniface 2023-09-07 10:31:25 +02:00
parent 73d19e849f
commit 89ab029cab
11 changed files with 228 additions and 51 deletions

View file

@ -8,8 +8,12 @@ from django.forms.utils import ErrorList
from .models import Map, DataLayer from .models import Map, DataLayer
DEFAULT_LATITUDE = settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51 DEFAULT_LATITUDE = (
DEFAULT_LONGITUDE = settings.LEAFLET_LONGITUDE if hasattr(settings, "LEAFLET_LONGITUDE") else 2 settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51
)
DEFAULT_LONGITUDE = (
settings.LEAFLET_LONGITUDE if hasattr(settings, "LEAFLET_LONGITUDE") else 2
)
DEFAULT_CENTER = Point(DEFAULT_LONGITUDE, DEFAULT_LATITUDE) DEFAULT_CENTER = Point(DEFAULT_LONGITUDE, DEFAULT_LATITUDE)
User = get_user_model() User = get_user_model()
@ -21,8 +25,8 @@ class FlatErrorList(ErrorList):
def flat(self): def flat(self):
if not self: if not self:
return u'' return ""
return u''.join([e for e in self]) return "".join([e for e in self])
class SendLinkForm(forms.Form): class SendLinkForm(forms.Form):
@ -30,69 +34,83 @@ class SendLinkForm(forms.Form):
class UpdateMapPermissionsForm(forms.ModelForm): class UpdateMapPermissionsForm(forms.ModelForm):
class Meta: class Meta:
model = Map model = Map
fields = ('edit_status', 'editors', 'share_status', 'owner') fields = ("edit_status", "editors", "share_status", "owner")
class AnonymousMapPermissionsForm(forms.ModelForm): class AnonymousMapPermissionsForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(AnonymousMapPermissionsForm, self).__init__(*args, **kwargs) super(AnonymousMapPermissionsForm, self).__init__(*args, **kwargs)
help_text = _('Secret edit link is %s') % self.instance.get_anonymous_edit_url() help_text = _("Secret edit link is %s") % self.instance.get_anonymous_edit_url()
self.fields['edit_status'].help_text = _(help_text) self.fields["edit_status"].help_text = _(help_text)
STATUS = ( STATUS = (
(Map.ANONYMOUS, _('Everyone can edit')), (Map.ANONYMOUS, _("Everyone can edit")),
(Map.OWNER, _('Only editable with secret edit link')) (Map.OWNER, _("Only editable with secret edit link")),
) )
edit_status = forms.ChoiceField(choices=STATUS) edit_status = forms.ChoiceField(choices=STATUS)
class Meta: class Meta:
model = Map model = Map
fields = ('edit_status', ) fields = ("edit_status",)
class DataLayerForm(forms.ModelForm): class DataLayerForm(forms.ModelForm):
class Meta:
model = DataLayer
fields = ("geojson", "name", "display_on_load", "rank", "settings")
class DataLayerPermissionsForm(forms.ModelForm):
class Meta:
model = DataLayer
fields = ("edit_status",)
class AnonymousDataLayerPermissionsForm(forms.ModelForm):
STATUS = (
(Map.ANONYMOUS, _("Everyone can edit")),
(Map.OWNER, _("Only editable with secret edit link")),
)
edit_status = forms.ChoiceField(choices=STATUS)
class Meta: class Meta:
model = DataLayer model = DataLayer
fields = ('geojson', 'name', 'display_on_load', 'rank', 'settings') fields = ("edit_status",)
class MapSettingsForm(forms.ModelForm): class MapSettingsForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(MapSettingsForm, self).__init__(*args, **kwargs) super(MapSettingsForm, self).__init__(*args, **kwargs)
self.fields['slug'].required = False self.fields["slug"].required = False
self.fields['center'].widget.map_srid = 4326 self.fields["center"].widget.map_srid = 4326
def clean_slug(self): def clean_slug(self):
slug = self.cleaned_data.get('slug', None) slug = self.cleaned_data.get("slug", None)
name = self.cleaned_data.get('name', None) name = self.cleaned_data.get("name", None)
if not slug and name: if not slug and name:
# If name is empty, don't do nothing, validation will raise # If name is empty, don't do nothing, validation will raise
# later on the process because name is required # later on the process because name is required
self.cleaned_data['slug'] = slugify(name) or "map" self.cleaned_data["slug"] = slugify(name) or "map"
return self.cleaned_data['slug'][:50] return self.cleaned_data["slug"][:50]
else: else:
return "" return ""
def clean_center(self): def clean_center(self):
if not self.cleaned_data['center']: if not self.cleaned_data["center"]:
point = DEFAULT_CENTER point = DEFAULT_CENTER
self.cleaned_data['center'] = point self.cleaned_data["center"] = point
return self.cleaned_data['center'] return self.cleaned_data["center"]
class Meta: class Meta:
fields = ('settings', 'name', 'center', 'slug') fields = ("settings", "name", "center", "slug")
model = Map model = Map
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ('username', 'first_name', 'last_name') fields = ("username", "first_name", "last_name")

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.2 on 2023-09-07 06:27
from django.db import migrations, models
import umap.models
class Migration(migrations.Migration):
dependencies = [
("umap", "0012_datalayer_settings"),
]
operations = [
migrations.AddField(
model_name="datalayer",
name="edit_status",
field=models.SmallIntegerField(
choices=[(1, "Everyone"), (2, "Editors only"), (3, "Owner only")],
default=umap.models.get_default_edit_status,
verbose_name="edit status",
),
),
]

View file

@ -216,6 +216,9 @@ class Map(NamedModel):
""" """
Define if a user can edit or not the instance, according to his account Define if a user can edit or not the instance, according to his account
or the request. or the request.
In ownership mode: only owner and editors
In anononymous mode: only "anonymous owners" (having edit cookie set)
""" """
can = False can = False
if request and not self.owner: if request and not self.owner:
@ -223,13 +226,9 @@ class Map(NamedModel):
settings, "UMAP_ALLOW_ANONYMOUS", False settings, "UMAP_ALLOW_ANONYMOUS", False
) and self.is_anonymous_owner(request): ) and self.is_anonymous_owner(request):
can = True can = True
if self.edit_status == self.ANONYMOUS: if user == self.owner:
can = True can = True
elif not user.is_authenticated: elif user in self.editors.all():
pass
elif user == self.owner:
can = True
elif self.edit_status == self.EDITORS and user in self.editors.all():
can = True can = True
return can return can
@ -303,6 +302,15 @@ class DataLayer(NamedModel):
Layer to store Features in. Layer to store Features in.
""" """
ANONYMOUS = 1
EDITORS = 2
OWNER = 3
EDIT_STATUS = (
(ANONYMOUS, _("Everyone")),
(EDITORS, _("Editors only")),
(OWNER, _("Owner only")),
)
map = models.ForeignKey(Map, on_delete=models.CASCADE) map = models.ForeignKey(Map, on_delete=models.CASCADE)
description = models.TextField(blank=True, null=True, verbose_name=_("description")) description = models.TextField(blank=True, null=True, verbose_name=_("description"))
geojson = models.FileField(upload_to=upload_to, blank=True, null=True) geojson = models.FileField(upload_to=upload_to, blank=True, null=True)
@ -315,6 +323,11 @@ class DataLayer(NamedModel):
settings = models.JSONField( settings = models.JSONField(
blank=True, null=True, verbose_name=_("settings"), default=dict blank=True, null=True, verbose_name=_("settings"), default=dict
) )
edit_status = models.SmallIntegerField(
choices=EDIT_STATUS,
default=get_default_edit_status,
verbose_name=_("edit status"),
)
class Meta: class Meta:
ordering = ("rank",) ordering = ("rank",)
@ -346,8 +359,7 @@ class DataLayer(NamedModel):
path.append(str(self.map.pk)) path.append(str(self.map.pk))
return os.path.join(*path) return os.path.join(*path)
@property def metadata(self, user=None, request=None):
def metadata(self):
# Retrocompat: minimal settings for maps not saved after settings property # Retrocompat: minimal settings for maps not saved after settings property
# has been introduced # has been introduced
obj = self.settings or { obj = self.settings or {
@ -355,6 +367,8 @@ class DataLayer(NamedModel):
"displayOnLoad": self.display_on_load, "displayOnLoad": self.display_on_load,
} }
obj["id"] = self.pk obj["id"] = self.pk
obj["permissions"] = {"edit_status": self.edit_status}
obj["allowEdit"] = self.can_edit(user, request)
return obj return obj
def clone(self, map_inst=None): def clone(self, map_inst=None):
@ -413,6 +427,19 @@ class DataLayer(NamedModel):
if name.startswith(f'{self.pk}_') and name.endswith(".gz"): if name.startswith(f'{self.pk}_') and name.endswith(".gz"):
self.geojson.storage.delete(os.path.join(root, name)) self.geojson.storage.delete(os.path.join(root, name))
def can_edit(self, user=None, request=None):
"""
Define if a user can edit or not the instance, according to his account
or the request.
"""
can = self.map.can_edit(user, request)
if can:
# Owner or editor, no need for further checks.
return can
if self.edit_status == self.ANONYMOUS:
can = True
return can
class Star(models.Model): class Star(models.Model):
at = models.DateTimeField(auto_now=True) at = models.DateTimeField(auto_now=True)

View file

@ -0,0 +1,70 @@
L.U.DataLayerPermissions = L.Class.extend({
options: {
edit_status: null,
},
initialize: function (datalayer) {
this.options = L.Util.setOptions(this, datalayer.options.permissions)
this.datalayer = datalayer
let isDirty = false
const self = this
try {
Object.defineProperty(this, 'isDirty', {
get: function () {
return isDirty
},
set: function (status) {
isDirty = status
if (status) self.datalayer.isDirty = status
},
})
} catch (e) {
// Certainly IE8, which has a limited version of defineProperty
}
},
getMap: function () {
return this.datalayer.map
},
edit: function (container) {
const fields = [
[
'options.edit_status',
{
handler: 'IntSelect',
label: `${L._('Who can edit')} "${this.datalayer.getName()}"`,
selectOptions: this.datalayer.map.options.edit_statuses,
},
],
],
builder = new L.U.FormBuilder(this, fields),
form = builder.build()
container.appendChild(form)
},
getUrl: function () {
return L.Util.template(this.datalayer.map.options.urls.datalayer_permissions, {
map_id: this.datalayer.map.options.umap_id,
pk: this.datalayer.umap_id,
})
},
save: function () {
if (!this.isDirty) return this.datalayer.map.continueSaving()
const formData = new FormData()
formData.append('edit_status', this.options.edit_status)
this.datalayer.map.post(this.getUrl(), {
data: formData,
context: this,
callback: function (data) {
this.commit()
this.isDirty = false
this.datalayer.map.continueSaving()
},
})
},
commit: function () {
L.Util.extend(this.datalayer.options.permissions, this.options)
},
})

View file

@ -40,7 +40,7 @@ L.U.FeatureMixin = {
preInit: function () {}, preInit: function () {},
isReadOnly: function () { isReadOnly: function () {
return this.datalayer && this.datalayer.isRemoteLayer() return this.datalayer && (this.datalayer.isRemoteLayer() || this.datalayer.isReadOnly())
}, },
getSlug: function () { getSlug: function () {

View file

@ -261,6 +261,7 @@ L.U.DataLayer = L.Evented.extend({
} }
this.backupOptions() this.backupOptions()
this.connectToMap() this.connectToMap()
this.permissions = new L.U.DataLayerPermissions(this)
if (this.showAtLoad()) this.show() if (this.showAtLoad()) this.show()
if (!this.umap_id) this.isDirty = true if (!this.umap_id) this.isDirty = true
@ -350,6 +351,13 @@ L.U.DataLayer = L.Evented.extend({
this.map.get(this._dataUrl(), { this.map.get(this._dataUrl(), {
callback: function (geojson, response) { callback: function (geojson, response) {
this._last_modified = response.getResponseHeader('Last-Modified') this._last_modified = response.getResponseHeader('Last-Modified')
console.log(this.getName(), this.options)
// FIXME: for now this property is set dynamically from backend
// And thus it's not in the geojson file in the server
// So do not let all options to be reset
// Fix is a proper migration so all datalayers settings are
// in DB, and we remove it from geojson flat files.
geojson['_umap_options']['allowEdit'] = this.options.allowEdit
this.fromUmapGeoJSON(geojson) this.fromUmapGeoJSON(geojson)
this.backupOptions() this.backupOptions()
this.fire('loaded') this.fire('loaded')
@ -1182,18 +1190,14 @@ L.U.DataLayer = L.Evented.extend({
} }
}, },
metadata: function () {
return {
id: this.umap_id,
name: this.options.name,
displayOnLoad: this.options.displayOnLoad,
}
},
getRank: function () { getRank: function () {
return this.map.datalayers_index.indexOf(this) return this.map.datalayers_index.indexOf(this)
}, },
isReadOnly: function () {
return !this.options.allowEdit
},
save: function () { save: function () {
if (this.isDeleted) return this.saveDelete() if (this.isDeleted) return this.saveDelete()
if (!this.isLoaded()) { if (!this.isLoaded()) {
@ -1220,7 +1224,7 @@ L.U.DataLayer = L.Evented.extend({
this._loaded = true this._loaded = true
this.redraw() // Needed for reordering features this.redraw() // Needed for reordering features
this.isDirty = false this.isDirty = false
this.map.continueSaving() this.permissions.save()
}, },
context: this, context: this,
headers: this._last_modified headers: this._last_modified

View file

@ -122,6 +122,10 @@ L.U.MapPermissions = L.Class.extend({
this this
) )
} }
L.DomUtil.add('h3', '', container, L._('Datalayers'))
this.map.eachDataLayer((datalayer) => {
datalayer.permissions.edit(container)
})
this.map.ui.openPanel({ data: { html: container }, className: 'dark' }) this.map.ui.openPanel({ data: { html: container }, className: 'dark' })
}, },

View file

@ -34,11 +34,12 @@
<script src="{{ STATIC_URL }}umap/js/umap.forms.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.forms.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.icon.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.icon.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.features.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.features.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.permissions.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.datalayer.permissions.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.layer.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.layer.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.controls.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.controls.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.slideshow.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.slideshow.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.tableeditor.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.tableeditor.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.permissions.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.js"></script>
<script src="{{ STATIC_URL }}umap/js/umap.ui.js"></script> <script src="{{ STATIC_URL }}umap/js/umap.ui.js"></script>
{% endcompress %} {% endcompress %}

View file

@ -28,7 +28,7 @@ def umap_js(locale=None):
@register.inclusion_tag('umap/map_fragment.html') @register.inclusion_tag('umap/map_fragment.html')
def map_fragment(map_instance, **kwargs): def map_fragment(map_instance, **kwargs):
layers = DataLayer.objects.filter(map=map_instance) layers = DataLayer.objects.filter(map=map_instance)
datalayer_data = [c.metadata for c in layers] datalayer_data = [c.metadata() for c in layers]
map_settings = map_instance.settings map_settings = map_instance.settings
if "properties" not in map_settings: if "properties" not in map_settings:
map_settings['properties'] = {} map_settings['properties'] = {}

View file

@ -154,6 +154,11 @@ map_urls = [
views.DataLayerDelete.as_view(), views.DataLayerDelete.as_view(),
name="datalayer_delete", name="datalayer_delete",
), ),
re_path(
r"^map/(?P<map_id>[\d]+)/datalayer/permissions/(?P<pk>\d+)/$",
views.UpdateDataLayerPermissions.as_view(),
name="datalayer_permissions",
),
] ]
if settings.FROM_EMAIL: if settings.FROM_EMAIL:
map_urls.append( map_urls.append(

View file

@ -47,6 +47,8 @@ from .forms import (
DEFAULT_CENTER, DEFAULT_CENTER,
AnonymousMapPermissionsForm, AnonymousMapPermissionsForm,
DataLayerForm, DataLayerForm,
DataLayerPermissionsForm,
AnonymousDataLayerPermissionsForm,
FlatErrorList, FlatErrorList,
MapSettingsForm, MapSettingsForm,
SendLinkForm, SendLinkForm,
@ -551,11 +553,16 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView):
return self.object.get_absolute_url() return self.object.get_absolute_url()
def get_datalayers(self): def get_datalayers(self):
datalayers = DataLayer.objects.filter(map=self.object) return [
return [l.metadata for l in datalayers] l.metadata(self.request.user, self.request)
for l in self.object.datalayer_set.all()
]
def is_edit_allowed(self): def is_edit_allowed(self):
return self.object.can_edit(self.request.user, self.request) return self.object.can_edit(self.request.user, self.request) or any(
d.can_edit(self.request.user, self.request)
for d in self.object.datalayer_set.all()
)
def get_umap_id(self): def get_umap_id(self):
return self.object.pk return self.object.pk
@ -883,7 +890,9 @@ class DataLayerCreate(FormLessEditMixin, GZipMixin, CreateView):
form.instance.map = self.kwargs["map_inst"] form.instance.map = self.kwargs["map_inst"]
self.object = form.save() self.object = form.save()
# Simple response with only metadatas (including new id) # Simple response with only metadatas (including new id)
response = simple_json_response(**self.object.metadata) response = simple_json_response(
**self.object.metadata(self.request.user, self.request)
)
response["Last-Modified"] = self.last_modified response["Last-Modified"] = self.last_modified
return response return response
@ -896,7 +905,9 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView):
self.object = form.save() self.object = form.save()
# Simple response with only metadatas (client should not reload all data # Simple response with only metadatas (client should not reload all data
# on save) # on save)
response = simple_json_response(**self.object.metadata) response = simple_json_response(
**self.object.metadata(self.request.user, self.request)
)
response["Last-Modified"] = self.last_modified response["Last-Modified"] = self.last_modified
return response return response
@ -936,6 +947,21 @@ class DataLayerVersions(BaseDetailView):
return simple_json_response(versions=self.object.versions) return simple_json_response(versions=self.object.versions)
class UpdateDataLayerPermissions(FormLessEditMixin, UpdateView):
model = DataLayer
pk_url_kwarg = "pk"
def get_form_class(self):
if self.object.map.owner:
return DataLayerPermissionsForm
else:
return AnonymousDataLayerPermissionsForm
def form_valid(self, form):
self.object = form.save()
return simple_json_response(info=_("Permissions updated with success!"))
# ############## # # ############## #
# Picto # # Picto #
# ############## # # ############## #