diff --git a/requirements.txt b/requirements.txt index f75f92f8..7713efcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Django==2.0.5 +django-agnocomplete==0.12.2 django-compressor==2.1.1 Pillow==5.1.0 psycopg2==2.7.4 diff --git a/umap/autocomplete.py b/umap/autocomplete.py new file mode 100644 index 00000000..6a485182 --- /dev/null +++ b/umap/autocomplete.py @@ -0,0 +1,19 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.urls import reverse + + +from agnocomplete.register import register +from agnocomplete.core import AgnocompleteModel + + +@register +class AutocompleteUser(AgnocompleteModel): + model = get_user_model() + fields = ['^username'] + + def item(self, current_item): + data = super().item(current_item) + data['url'] = reverse(settings.USER_MAPS_URL, + args=(current_item.get_username(), )) + return data diff --git a/umap/forms.py b/umap/forms.py index 136cbba0..4a240ff4 100644 --- a/umap/forms.py +++ b/umap/forms.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from django import forms from django.contrib.gis.geos import Point from django.contrib.auth import get_user_model @@ -28,7 +26,6 @@ class FlatErrorList(ErrorList): class UpdateMapPermissionsForm(forms.ModelForm): - owner = forms.ModelChoiceField(User.objects, required=True) class Meta: model = Map diff --git a/umap/models.py b/umap/models.py index 66caa7e3..0f7e8f16 100644 --- a/umap/models.py +++ b/umap/models.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import os import time @@ -175,6 +173,8 @@ class Map(NamedModel): and self.is_anonymous_owner(request)): can = True if user and user.is_authenticated: + # TODO: only when using the anonymous-edit URL with an + # authenticated user # if user is authenticated, attach as owner self.owner = user self.save() diff --git a/umap/settings/base.py b/umap/settings/base.py index 619d616d..23a93bc3 100644 --- a/umap/settings/base.py +++ b/umap/settings/base.py @@ -78,6 +78,7 @@ INSTALLED_APPS = ( 'umap', 'compressor', 'social_django', + 'agnocomplete', ) # ============================================================================= @@ -159,8 +160,6 @@ MIDDLEWARE = ( # ============================================================================= ENABLE_ACCOUNT_LOGIN = False -AUTHENTICATION_BACKENDS += ( -) # ============================================================================= # Miscellaneous project settings diff --git a/umap/static/umap/js/umap.autocomplete.js b/umap/static/umap/js/umap.autocomplete.js index 5cb887a9..0e180ef7 100644 --- a/umap/static/umap/js/umap.autocomplete.js +++ b/umap/static/umap/js/umap.autocomplete.js @@ -12,8 +12,10 @@ L.U.AutoComplete = L.Class.extend({ RESULTS: [], initialize: function (el, options) { - this.el = L.DomUtil.get(el); - L.setOptions(options); + this.el = el; + var ui = new L.U.UI(document.querySelector('header')); + this.xhr = new L.U.Xhr(ui); + L.setOptions(this, options); var CURRENT = null; try { Object.defineProperty(this, 'CURRENT', { @@ -37,9 +39,9 @@ L.U.AutoComplete = L.Class.extend({ this.input = L.DomUtil.element('input', { type: 'text', placeholder: this.options.placeholder, - autocomplete: 'off' - }); - L.DomUtil.before(this.el, this.input); + autocomplete: 'off', + className: this.options.className + }, this.el); L.DomEvent.on(this.input, 'keydown', this.onKeyDown, this); L.DomEvent.on(this.input, 'keyup', this.onKeyUp, this); L.DomEvent.on(this.input, 'blur', this.onBlur, this); @@ -63,10 +65,7 @@ L.U.AutoComplete = L.Class.extend({ onKeyDown: function (e) { switch (e.keyCode) { case L.U.Keys.TAB: - if(this.CURRENT !== null) - { - this.setChoice(); - } + if(this.CURRENT !== null) this.setChoice(); L.DomEvent.stop(e); break; case L.U.Keys.ENTER: @@ -149,8 +148,8 @@ L.U.AutoComplete = L.Class.extend({ setChoice: function (choice) { choice = choice || this.RESULTS[this.CURRENT]; if (choice) { - this.input.value = choice.display; - this.select(choice); + this.input.value = choice.item.label; + this.options.on_select(choice); this.displaySelected(choice); this.hide(); if (this.options.callback) { @@ -165,26 +164,18 @@ L.U.AutoComplete = L.Class.extend({ this.clear(); return; } - if(!val) { - this.clear(); - return; - } - if( val + '' === this.CACHE + '') { - return; - } - else { - this.CACHE = val; - } - var results = this._do_search(val); - return this.handleResults(results); + if( val + '' === this.CACHE + '') return; + else this.CACHE = val; + this._do_search(val, (data) => { + this.handleResults(data.data); + }); }, createResult: function (item) { var el = L.DomUtil.element('li', {}, this.container); - el.innerHTML = item.display; + el.innerHTML = item.label; var result = { - value: item.value, - display: item.display, + item: item, el: el }; L.DomEvent.on(el, 'mouseover', function () { @@ -223,12 +214,12 @@ L.U.AutoComplete = L.Class.extend({ highlight: function () { var self = this; - this.forEach(this.RESULTS, function (item, index) { + this.forEach(this.RESULTS, function (result, index) { if (index === self.CURRENT) { - L.DomUtil.addClass(item.el, 'on'); + L.DomUtil.addClass(result.el, 'on'); } else { - L.DomUtil.removeClass(item.el, 'on'); + L.DomUtil.removeClass(result.el, 'on'); } }); }, @@ -260,114 +251,69 @@ L.U.AutoComplete = L.Class.extend({ }); -L.U.AutoComplete.BaseSelect = L.U.AutoComplete.extend({ +L.U.AutoComplete.Ajax = L.U.AutoComplete.extend({ initialize: function (el, options) { L.U.AutoComplete.prototype.initialize.call(this, el, options); if (!this.el) return this; - this.el.style.display = 'none'; this.createInput(); this.createContainer(); - this.initSelectedContainer(); + this.selected_container = this.initSelectedContainer(); }, optionToResult: function (option) { return { value: option.value, - display: option.innerHTML + label: option.innerHTML }; }, - _do_search: function (val) { - var results = [], - self = this, - count = 0; - val = val.toLowerCase(); - this.forEach(this.el, function (item) { - var candidate = item.innerHTML.toLowerCase(); - if (candidate === val || (candidate.indexOf(val) !== -1 && !item.selected && count < self.options.maxResults)) { - results.push(self.optionToResult(item)); - count++; - } - }); - return results; - }, - - select: function (option) { - this.forEach(this.el, function (item) { - if (item.value == option.value) { - item.selected = true; - } - }); - }, - - unselect: function (option) { - this.forEach(this.el, function (item) { - if (item.value == option.value) { - item.selected = false; - } - }); + _do_search: function (val, callback) { + val = val.toLowerCase(); + this.xhr.get('/agnocomplete/AutocompleteUser/?q=' + encodeURIComponent(val), {callback: callback}); } }); -L.U.AutoComplete.MultiSelect = L.U.AutoComplete.BaseSelect.extend({ +L.U.AutoComplete.Ajax.SelectMultiple = L.U.AutoComplete.Ajax.extend({ initSelectedContainer: function () { - this.selected_container = L.DomUtil.after(this.input, L.DomUtil.element('ul', {className: 'umap-multiresult'})); - var self = this; - this.forEach(this.el, function (option) { - if (option.selected) { - self.displaySelected(self.optionToResult(option)); - } - }); + return L.DomUtil.after(this.input, L.DomUtil.element('ul', {className: 'umap-multiresult'})); }, displaySelected: function (result) { var result_el = L.DomUtil.element('li', {}, this.selected_container); - result_el.innerHTML = result.display; + result_el.innerHTML = result.item.label; var close = L.DomUtil.element('span', {className: 'close'}, result_el); close.innerHTML = '×'; L.DomEvent.on(close, 'click', function () { this.selected_container.removeChild(result_el); - this.unselect(result); + this.options.on_unselect(result); }, this); this.hide(); } }); -L.U.AutoComplete.multiSelect = function (el, options) { - return new L.U.AutoComplete.MultiSelect(el, options); -}; - -L.U.AutoComplete.Select = L.U.AutoComplete.BaseSelect.extend({ +L.U.AutoComplete.Ajax.Select = L.U.AutoComplete.Ajax.extend({ initSelectedContainer: function () { - this.selected_container = L.DomUtil.after(this.input, L.DomUtil.element('div', {className: 'umap-singleresult'})); - var self = this; - if (this.el.selectedIndex !== -1 && this.el[this.el.selectedIndex].value !== '') { - self.displaySelected(self.optionToResult(this.el[this.el.selectedIndex])); - } + return L.DomUtil.after(this.input, L.DomUtil.element('div', {className: 'umap-singleresult'})); }, displaySelected: function (result) { var result_el = L.DomUtil.element('div', {}, this.selected_container); - result_el.innerHTML = result.display; + result_el.innerHTML = result.item.label; var close = L.DomUtil.element('span', {className: 'close'}, result_el); close.innerHTML = '×'; this.input.style.display = 'none'; L.DomEvent.on(close, 'click', function () { this.selected_container.innerHTML = ''; - this.unselect(result); + this.options.on_unselect(result); this.input.style.display = 'block'; }, this); this.hide(); } }); - -L.U.AutoComplete.select = function (el, options) { - return new L.U.AutoComplete.Select(el, options); -}; diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index f56d5e6c..f00e2ec9 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -88,7 +88,7 @@ L.U.UpdatePermsAction = L.U.BaseAction.extend({ }, addHooks: function () { - this.map.updatePermissions(); + this.map.permissions.panel(); } }); diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 428c980a..6ff6d801 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -322,6 +322,7 @@ L.FormBuilder.LicenceChooser = L.FormBuilder.Select.extend({ }); + L.FormBuilder.NullableBoolean = L.FormBuilder.Select.extend({ selectOptions: [ [undefined, L._('inherit')], @@ -601,6 +602,61 @@ L.FormBuilder.Range = L.FormBuilder.Input.extend({ }); + +L.FormBuilder.ManageOwner = L.FormBuilder.Element.extend({ + + build: function () { + var options = {className: 'edit-owner'}; + options.on_select = (choice) => { + this._value = { + 'id': choice.item.value, + 'name': choice.item.label, + 'url': choice.item.url + }; + this.set(); + } + this.autocomplete = new L.U.AutoComplete.Ajax.Select(this.parentNode, options); + var owner = this.toHTML(); + if (owner) this.autocomplete.displaySelected({'item': {'value': owner.id, 'label': owner.name}}); + }, + + value: function () { + return this._value; + } + +}); + + +L.FormBuilder.ManageEditors = L.FormBuilder.Element.extend({ + + build: function () { + var options = {className: 'edit-editors'}; + options.on_select = (choice) => { + this._values.push({ + 'id': choice.item.value, + 'name': choice.item.label, + 'url': choice.item.url + }); + this.set(); + } + options.on_unselect = (choice) => { + var index = this._values.findIndex((item) => item.id === choice.item.value); + if (index !== -1) { + this._values.splice(index, 1); + this.set(); + } + } + this.autocomplete = new L.U.AutoComplete.Ajax.SelectMultiple(this.parentNode, options); + this._values = this.toHTML(); + if (this._values) for (var i = 0; i < this._values.length; i++) this.autocomplete.displaySelected({'item': {'value': this._values[i].id, 'label': this._values[i].name}}); + }, + + value: function () { + return this._values; + } + +}); + L.U.FormBuilder = L.FormBuilder.extend({ options: { diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 4e9ce433..ff61488c 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -198,6 +198,7 @@ L.U.Map.include({ this.help = new L.U.Help(this); this.slideshow = new L.U.Slideshow(this, this.options.slideshow); + this.permissions = new L.U.MapPermissions(this, this.options.permissions); this.initCaptionBar(); if (this.options.allowEdit) { this.editTools = new L.U.Editable(this); @@ -235,9 +236,9 @@ L.U.Map.include({ L.U.EditPropertiesAction, L.U.ChangeTileLayerAction, L.U.ManageDatalayersAction, - L.U.UpdateExtentAction + L.U.UpdateExtentAction, + L.U.UpdatePermsAction ]; - if (this.options.urls.map_update_permissions) editActions.push(L.U.UpdatePermsAction); new L.U.SettingsToolbar({actions: editActions}).addTo(this); } this._controls.zoom = new L.Control.Zoom({zoomInTitle: L._('Zoom in'), zoomOutTitle: L._('Zoom out')}); @@ -345,11 +346,13 @@ L.U.Map.include({ this._backupOptions = L.extend({}, this.options); this._backupOptions.tilelayer = L.extend({}, this.options.tilelayer); this._backupOptions.limitBounds = L.extend({}, this.options.limitBounds); + this._backupOptions.permissions = L.extend({}, this.permissions.options); }, resetOptions: function () { this.options = L.extend({}, this._backupOptions); this.options.tilelayer = L.extend({}, this._backupOptions.tilelayer); + this.permissions.options = L.extend({}, this._backupOptions.permissions); }, initShortcuts: function () { @@ -676,15 +679,6 @@ L.U.Map.include({ return geojson; }, - updatePermissions: function () { - if (!this.options.umap_id) return this.ui.alert({content: L._('Please save the map before'), level: 'info'}); - var url = L.Util.template(this.options.urls.map_update_permissions, {'map_id': this.options.umap_id}); - this.get(url, { - listen_form: {'id': 'map_edit'}, - className: 'dark' - }); - }, - importPanel: function () { var container = L.DomUtil.create('div', 'umap-upload'), title = L.DomUtil.create('h4', '', container), @@ -862,13 +856,7 @@ L.U.Map.include({ var container = L.DomUtil.create('div', 'umap-caption'), title = L.DomUtil.create('h3', '', container); title.innerHTML = this.options.name; - if (this.options.author && this.options.author.name && this.options.author.link) { - var authorContainer = L.DomUtil.add('h5', 'umap-map-author', container, L._('by') + ' '), - author = L.DomUtil.create('a'); - author.href = this.options.author.link; - author.innerHTML = this.options.author.name; - authorContainer.appendChild(author); - } + this.permissions.addOwnerLink('h5', container); if (this.options.description) { var description = L.DomUtil.create('div', 'umap-map-description', container); description.innerHTML = L.Util.toHTML(this.options.description); @@ -1079,6 +1067,7 @@ L.U.Map.include({ formData.append('settings', JSON.stringify(geojson)); this.post(this.getSaveUrl(), { data: formData, + context: this, callback: function (data) { var duration = 3000; if (!this.options.umap_id) { @@ -1094,9 +1083,8 @@ L.U.Map.include({ this.ui.alert({content: msg, level: 'info', duration: duration}); }); this.ui.closePanel(); - this.continueSaving(); - }, - context: this + this.permissions.save(); + } }); }, @@ -1376,13 +1364,7 @@ L.U.Map.include({ var container = L.DomUtil.create('div', 'umap-caption-bar', this._controlContainer), name = L.DomUtil.create('h3', '', container); L.DomEvent.disableClickPropagation(container); - if (this.options.author && this.options.author.name && this.options.author.link) { - var authorContainer = L.DomUtil.add('span', 'umap-map-author', container, ' ' + L._('by') + ' '), - author = L.DomUtil.create('a'); - author.href = this.options.author.link; - author.innerHTML = this.options.author.name; - authorContainer.appendChild(author); - } + this.permissions.addOwnerLink('span', container); var about = L.DomUtil.add('a', 'umap-about-link', container, ' — ' + L._('About')); about.href = '#'; L.DomEvent.on(about, 'click', this.displayCaption, this); diff --git a/umap/static/umap/js/umap.permissions.js b/umap/static/umap/js/umap.permissions.js new file mode 100644 index 00000000..4cb5cc6c --- /dev/null +++ b/umap/static/umap/js/umap.permissions.js @@ -0,0 +1,119 @@ +// Dedicated object so we can deal with a separate dirty status, and thus +// call the endpoint only when needed, saving one call at each save. +L.U.MapPermissions = L.Class.extend({ + + options: { + owner: null, + editors: [], + share_status: null, + edit_status: null + }, + + initialize: function (map) { + this.options = map.options.permissions || {}; + this.map = map; + var isDirty = false, + self = this; + try { + Object.defineProperty(this, 'isDirty', { + get: function () { + return isDirty; + }, + set: function (status) { + isDirty = status; + if (status) self.map.isDirty = status; + } + }); + } + catch (e) { + // Certainly IE8, which has a limited version of defineProperty + } + + }, + + isOwner: function () { + return this.map.options.user && this.options.owner && this.map.options.user.id == this.options.owner.id; + }, + + isAnonymousMap: function () { + return !this.options.owner; + }, + + getMap: function () { + return this.map; + }, + + panel: function () { + if (!this.map.options.umap_id) return this.map.ui.alert({content: L._('Please save the map before'), level: 'info'}); + var container = L.DomUtil.create('div', 'permissions-panel'), + fields = [], + title = L.DomUtil.create('h4', '', container); + if (this.isAnonymousMap()) { + if (this.map.options.anonymous_edit_url) { + var helpText = L._('Secret edit link is:
{link}', {link: this.map.options.anonymous_edit_url}); + fields.push(['options.edit_status', {handler: 'IntSelect', label: L._('Who can edit'), selectOptions: this.map.options.anonymous_edit_statuses, helpText: helpText}]); + } + } else { + if (this.isOwner()) { + fields.push(['options.edit_status', {handler: 'IntSelect', label: L._('Who can edit'), selectOptions: this.map.options.edit_statuses}]); + fields.push(['options.share_status', {handler: 'IntSelect', label: L._('Who can view'), selectOptions: this.map.options.share_statuses}]); + fields.push(['options.owner', {handler: 'ManageOwner', label: L._("Map's owner")}]); + } + fields.push(['options.editors', {handler: 'ManageEditors', label: L._("Map's editors")}]); + } + title.innerHTML = L._('Update permissions'); + var builder = new L.U.FormBuilder(this, fields); + var form = builder.build(); + container.appendChild(form); + this.map.ui.openPanel({data: {html: container}, className: 'dark'}); + }, + + anonymousMapPanel: function () { + var container = L.DomUtil.create('div'), + fields = [], + title = L.DomUtil.create('h4', '', container); + fields.push(['options.edit_status', {handler: 'IntSelect', label: L._('Who can edit'), selectOptions: this.map.options.edit_statuses}]); + title.innerHTML = L._('Update permissions'); + var builder = new L.U.FormBuilder(this, fields); + var form = builder.build(); + container.appendChild(form); + this.map.ui.openPanel({data: {html: container}, className: 'dark'}); + }, + + save: function () { + if (!this.isDirty) return this.map.continueSaving(); + var formData = new FormData(); + if (!this.isAnonymousMap() && this.options.editors) { + const editors = this.options.editors.map((u) => u.id); + for (var i = 0; i < this.options.editors.length; i++) formData.append('editors', this.options.editors[i].id); + } + if (this.isOwner() || this.isAnonymousMap()) formData.append('edit_status', this.options.edit_status); + if (this.isOwner()) { + formData.append('owner', this.options.owner && this.options.owner.id); + formData.append('share_status', this.options.share_status); + } + this.map.post(this.getUrl(), { + data: formData, + context: this, + callback: function (data) { + this.isDirty = false; + this.map.continueSaving(); + } + }); + }, + + getUrl: function () { + return L.Util.template(this.map.options.urls.map_update_permissions, {'map_id': this.map.options.umap_id}); + }, + + addOwnerLink: function (element, container) { + if (this.options.owner && this.options.owner.name && this.options.owner.url) { + var ownerContainer = L.DomUtil.add(element, 'umap-map-owner', container, ' ' + L._('by') + ' '), + owner = L.DomUtil.create('a'); + owner.href = this.options.owner.url; + owner.innerHTML = this.options.owner.name; + ownerContainer.appendChild(owner); + } + } + +}); diff --git a/umap/static/umap/test/Map.js b/umap/static/umap/test/Map.js index dd790df2..11d0eff6 100644 --- a/umap/static/umap/test/Map.js +++ b/umap/static/umap/test/Map.js @@ -1,4 +1,4 @@ -describe('L.Utorage.Map', function(){ +describe('L.Umap.Map', function(){ before(function () { this.server = sinon.fakeServer.create(); diff --git a/umap/static/umap/test/Permissions.js b/umap/static/umap/test/Permissions.js new file mode 100644 index 00000000..f214dcd9 --- /dev/null +++ b/umap/static/umap/test/Permissions.js @@ -0,0 +1,77 @@ +describe('L.Permissions', function () { + var path = '/map/99/datalayer/edit/62/'; + + before(function () { + this.server = sinon.fakeServer.create(); + this.server.respondWith('GET', '/datalayer/62/', JSON.stringify(RESPONSES.datalayer62_GET)); + this.map = initMap({umap_id: 99}); + this.datalayer = this.map.getDataLayerByUmapId(62); + this.server.respond(); + enableEdit(); + }); + after(function () { + clickCancel(); + this.server.restore(); + resetMap(); + }); + + describe('#open()', function () { + var button; + + it('should exist update permissions link', function () { + button = qs('a.update-map-permissions'); + expect(button).to.be.ok; + }); + + it('should open table button click', function () { + happen.click(button); + expect(qs('.permissions-panel')).to.be.ok; + }); + + }); + describe('#anonymous with cookie', function () { + var button; + + it('should only allow edit_status', function () { + this.map.options.anonymous_edit_url = 'http://anonymous.url' + button = qs('a.update-map-permissions'); + happen.click(button); + expect(qs('select[name="edit_status"]')).to.be.ok; + expect(qs('select[name="share_status"]')).not.to.be.ok; + expect(qs('input.edit-owner')).not.to.be.ok; + }); + + }); + + describe('#editor', function () { + var button; + + it('should only allow editors', function () { + this.map.permissions.options.owner = {id: 1, url: '/url', name: 'jojo'}; + button = qs('a.update-map-permissions'); + happen.click(button); + expect(qs('select[name="edit_status"]')).not.to.be.ok; + expect(qs('select[name="share_status"]')).not.to.be.ok; + expect(qs('input.edit-owner')).not.to.be.ok; + expect(qs('input.edit-editors')).to.be.ok; + }); + + }); + + describe('#owner', function () { + var button; + + it('should allow everything', function () { + this.map.permissions.options.owner = {id: 1, url: '/url', name: 'jojo'}; + this.map.options.user = {id: 1, url: '/url', name: 'jojo'}; + button = qs('a.update-map-permissions'); + happen.click(button); + expect(qs('select[name="edit_status"]')).to.be.ok; + expect(qs('select[name="share_status"]')).to.be.ok; + expect(qs('input.edit-owner')).to.be.ok; + expect(qs('input.edit-editors')).to.be.ok; + }); + + }); + +}); diff --git a/umap/static/umap/test/index.html b/umap/static/umap/test/index.html index 4e455a84..639f31aa 100644 --- a/umap/static/umap/test/index.html +++ b/umap/static/umap/test/index.html @@ -22,6 +22,7 @@ + @@ -31,6 +32,7 @@ + @@ -41,8 +43,12 @@ - - + + + + + + @@ -68,6 +74,7 @@ +