From 89ab029cab4c7190d1a29e6b9519cf46d8d31165 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 7 Sep 2023 10:31:25 +0200 Subject: [PATCH] WIP: move edit_status from Map to DataLayer --- umap/forms.py | 70 ++++++++++++------- umap/migrations/0013_datalayer_edit_status.py | 22 ++++++ umap/models.py | 43 +++++++++--- .../umap/js/umap.datalayer.permissions.js | 70 +++++++++++++++++++ umap/static/umap/js/umap.features.js | 2 +- umap/static/umap/js/umap.layer.js | 22 +++--- umap/static/umap/js/umap.permissions.js | 4 ++ umap/templates/umap/js.html | 3 +- umap/templatetags/umap_tags.py | 2 +- umap/urls.py | 5 ++ umap/views.py | 36 ++++++++-- 11 files changed, 228 insertions(+), 51 deletions(-) create mode 100644 umap/migrations/0013_datalayer_edit_status.py create mode 100644 umap/static/umap/js/umap.datalayer.permissions.js diff --git a/umap/forms.py b/umap/forms.py index dc16f096..af937ed7 100644 --- a/umap/forms.py +++ b/umap/forms.py @@ -8,8 +8,12 @@ from django.forms.utils import ErrorList from .models import Map, DataLayer -DEFAULT_LATITUDE = settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51 -DEFAULT_LONGITUDE = settings.LEAFLET_LONGITUDE if hasattr(settings, "LEAFLET_LONGITUDE") else 2 +DEFAULT_LATITUDE = ( + 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) User = get_user_model() @@ -21,8 +25,8 @@ class FlatErrorList(ErrorList): def flat(self): if not self: - return u'' - return u' — '.join([e for e in self]) + return "" + return " — ".join([e for e in self]) class SendLinkForm(forms.Form): @@ -30,69 +34,83 @@ class SendLinkForm(forms.Form): class UpdateMapPermissionsForm(forms.ModelForm): - class Meta: model = Map - fields = ('edit_status', 'editors', 'share_status', 'owner') + fields = ("edit_status", "editors", "share_status", "owner") class AnonymousMapPermissionsForm(forms.ModelForm): - def __init__(self, *args, **kwargs): super(AnonymousMapPermissionsForm, self).__init__(*args, **kwargs) - help_text = _('Secret edit link is %s') % self.instance.get_anonymous_edit_url() - self.fields['edit_status'].help_text = _(help_text) + help_text = _("Secret edit link is %s") % self.instance.get_anonymous_edit_url() + self.fields["edit_status"].help_text = _(help_text) STATUS = ( - (Map.ANONYMOUS, _('Everyone can edit')), - (Map.OWNER, _('Only editable with secret edit link')) + (Map.ANONYMOUS, _("Everyone can edit")), + (Map.OWNER, _("Only editable with secret edit link")), ) edit_status = forms.ChoiceField(choices=STATUS) class Meta: model = Map - fields = ('edit_status', ) + fields = ("edit_status",) 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: model = DataLayer - fields = ('geojson', 'name', 'display_on_load', 'rank', 'settings') + fields = ("edit_status",) class MapSettingsForm(forms.ModelForm): - def __init__(self, *args, **kwargs): super(MapSettingsForm, self).__init__(*args, **kwargs) - self.fields['slug'].required = False - self.fields['center'].widget.map_srid = 4326 + self.fields["slug"].required = False + self.fields["center"].widget.map_srid = 4326 def clean_slug(self): - slug = self.cleaned_data.get('slug', None) - name = self.cleaned_data.get('name', None) + slug = self.cleaned_data.get("slug", None) + name = self.cleaned_data.get("name", None) if not slug and name: # If name is empty, don't do nothing, validation will raise # later on the process because name is required - self.cleaned_data['slug'] = slugify(name) or "map" - return self.cleaned_data['slug'][:50] + self.cleaned_data["slug"] = slugify(name) or "map" + return self.cleaned_data["slug"][:50] else: return "" def clean_center(self): - if not self.cleaned_data['center']: + if not self.cleaned_data["center"]: point = DEFAULT_CENTER - self.cleaned_data['center'] = point - return self.cleaned_data['center'] + self.cleaned_data["center"] = point + return self.cleaned_data["center"] class Meta: - fields = ('settings', 'name', 'center', 'slug') + fields = ("settings", "name", "center", "slug") model = Map class UserProfileForm(forms.ModelForm): - class Meta: model = User - fields = ('username', 'first_name', 'last_name') + fields = ("username", "first_name", "last_name") diff --git a/umap/migrations/0013_datalayer_edit_status.py b/umap/migrations/0013_datalayer_edit_status.py new file mode 100644 index 00000000..16f3691f --- /dev/null +++ b/umap/migrations/0013_datalayer_edit_status.py @@ -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", + ), + ), + ] diff --git a/umap/models.py b/umap/models.py index 7b804642..fbeaa559 100644 --- a/umap/models.py +++ b/umap/models.py @@ -216,6 +216,9 @@ class Map(NamedModel): """ Define if a user can edit or not the instance, according to his account or the request. + + In ownership mode: only owner and editors + In anononymous mode: only "anonymous owners" (having edit cookie set) """ can = False if request and not self.owner: @@ -223,13 +226,9 @@ class Map(NamedModel): settings, "UMAP_ALLOW_ANONYMOUS", False ) and self.is_anonymous_owner(request): can = True - if self.edit_status == self.ANONYMOUS: + if user == self.owner: can = True - elif not user.is_authenticated: - pass - elif user == self.owner: - can = True - elif self.edit_status == self.EDITORS and user in self.editors.all(): + elif user in self.editors.all(): can = True return can @@ -303,6 +302,15 @@ class DataLayer(NamedModel): 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) description = models.TextField(blank=True, null=True, verbose_name=_("description")) geojson = models.FileField(upload_to=upload_to, blank=True, null=True) @@ -315,6 +323,11 @@ class DataLayer(NamedModel): settings = models.JSONField( 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: ordering = ("rank",) @@ -346,8 +359,7 @@ class DataLayer(NamedModel): path.append(str(self.map.pk)) return os.path.join(*path) - @property - def metadata(self): + def metadata(self, user=None, request=None): # Retrocompat: minimal settings for maps not saved after settings property # has been introduced obj = self.settings or { @@ -355,6 +367,8 @@ class DataLayer(NamedModel): "displayOnLoad": self.display_on_load, } obj["id"] = self.pk + obj["permissions"] = {"edit_status": self.edit_status} + obj["allowEdit"] = self.can_edit(user, request) return obj def clone(self, map_inst=None): @@ -413,6 +427,19 @@ class DataLayer(NamedModel): if name.startswith(f'{self.pk}_') and name.endswith(".gz"): 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): at = models.DateTimeField(auto_now=True) diff --git a/umap/static/umap/js/umap.datalayer.permissions.js b/umap/static/umap/js/umap.datalayer.permissions.js new file mode 100644 index 00000000..2ee16bab --- /dev/null +++ b/umap/static/umap/js/umap.datalayer.permissions.js @@ -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) + }, +}) diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 40ec7927..8063f325 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -40,7 +40,7 @@ L.U.FeatureMixin = { preInit: function () {}, isReadOnly: function () { - return this.datalayer && this.datalayer.isRemoteLayer() + return this.datalayer && (this.datalayer.isRemoteLayer() || this.datalayer.isReadOnly()) }, getSlug: function () { diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 0a83d688..6568ba23 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -261,6 +261,7 @@ L.U.DataLayer = L.Evented.extend({ } this.backupOptions() this.connectToMap() + this.permissions = new L.U.DataLayerPermissions(this) if (this.showAtLoad()) this.show() if (!this.umap_id) this.isDirty = true @@ -350,6 +351,13 @@ L.U.DataLayer = L.Evented.extend({ this.map.get(this._dataUrl(), { callback: function (geojson, response) { 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.backupOptions() 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 () { return this.map.datalayers_index.indexOf(this) }, + isReadOnly: function () { + return !this.options.allowEdit + }, + save: function () { if (this.isDeleted) return this.saveDelete() if (!this.isLoaded()) { @@ -1220,7 +1224,7 @@ L.U.DataLayer = L.Evented.extend({ this._loaded = true this.redraw() // Needed for reordering features this.isDirty = false - this.map.continueSaving() + this.permissions.save() }, context: this, headers: this._last_modified diff --git a/umap/static/umap/js/umap.permissions.js b/umap/static/umap/js/umap.permissions.js index 0454855d..13dcb750 100644 --- a/umap/static/umap/js/umap.permissions.js +++ b/umap/static/umap/js/umap.permissions.js @@ -122,6 +122,10 @@ L.U.MapPermissions = L.Class.extend({ 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' }) }, diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html index 566ec658..5b321fc4 100644 --- a/umap/templates/umap/js.html +++ b/umap/templates/umap/js.html @@ -34,11 +34,12 @@ + + - {% endcompress %} diff --git a/umap/templatetags/umap_tags.py b/umap/templatetags/umap_tags.py index c40691d1..3e7ae9c0 100644 --- a/umap/templatetags/umap_tags.py +++ b/umap/templatetags/umap_tags.py @@ -28,7 +28,7 @@ def umap_js(locale=None): @register.inclusion_tag('umap/map_fragment.html') def map_fragment(map_instance, **kwargs): 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 if "properties" not in map_settings: map_settings['properties'] = {} diff --git a/umap/urls.py b/umap/urls.py index a44aa11d..ee963ff7 100644 --- a/umap/urls.py +++ b/umap/urls.py @@ -154,6 +154,11 @@ map_urls = [ views.DataLayerDelete.as_view(), name="datalayer_delete", ), + re_path( + r"^map/(?P[\d]+)/datalayer/permissions/(?P\d+)/$", + views.UpdateDataLayerPermissions.as_view(), + name="datalayer_permissions", + ), ] if settings.FROM_EMAIL: map_urls.append( diff --git a/umap/views.py b/umap/views.py index 67d899b4..a07fbd56 100644 --- a/umap/views.py +++ b/umap/views.py @@ -47,6 +47,8 @@ from .forms import ( DEFAULT_CENTER, AnonymousMapPermissionsForm, DataLayerForm, + DataLayerPermissionsForm, + AnonymousDataLayerPermissionsForm, FlatErrorList, MapSettingsForm, SendLinkForm, @@ -551,11 +553,16 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView): return self.object.get_absolute_url() def get_datalayers(self): - datalayers = DataLayer.objects.filter(map=self.object) - return [l.metadata for l in datalayers] + return [ + l.metadata(self.request.user, self.request) + for l in self.object.datalayer_set.all() + ] 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): return self.object.pk @@ -883,7 +890,9 @@ class DataLayerCreate(FormLessEditMixin, GZipMixin, CreateView): form.instance.map = self.kwargs["map_inst"] self.object = form.save() # 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 return response @@ -896,7 +905,9 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView): self.object = form.save() # Simple response with only metadatas (client should not reload all data # 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 return response @@ -936,6 +947,21 @@ class DataLayerVersions(BaseDetailView): 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 # # ############## #