diff --git a/docs/install.md b/docs/install.md index 1aa9c8e3..2254cb39 100644 --- a/docs/install.md +++ b/docs/install.md @@ -94,3 +94,24 @@ may want to add an index. For that, you should do so: CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', name), share_status); And change `UMAP_SEARCH_CONFIGURATION = "umapdict"` in your settings. + + +## Emails + +UMap can send the anonymous edit link by email. For this to work, you need to +add email specific settings. See [Django](https://docs.djangoproject.com/en/4.2/topics/email/#smtp-backend) +documentation. + +In general, you'll need to add something like this in your local settings: + +``` +FROM_EMAIL = "youradmin@email.org" +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = "smtp.provider.org" +EMAIL_PORT = 456 +EMAIL_HOST_USER = "username" +EMAIL_HOST_PASSWORD = "xxxx" +EMAIL_USE_TLS = True +# or +EMAIL_USE_SSL = True +``` diff --git a/umap/forms.py b/umap/forms.py index 09d0ab3a..cff7150b 100644 --- a/umap/forms.py +++ b/umap/forms.py @@ -25,6 +25,10 @@ class FlatErrorList(ErrorList): return u' — '.join([e for e in self]) +class SendLinkForm(forms.Form): + email = forms.EmailField() + + class UpdateMapPermissionsForm(forms.ModelForm): class Meta: @@ -36,8 +40,7 @@ class AnonymousMapPermissionsForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(AnonymousMapPermissionsForm, self).__init__(*args, **kwargs) - full_secret_link = "%s%s" % (settings.SITE_URL, self.instance.get_anonymous_edit_url()) - help_text = _('Secret edit link is %s') % full_secret_link + help_text = _('Secret edit link is %s') % self.instance.get_anonymous_edit_url() self.fields['edit_status'].help_text = _(help_text) STATUS = ( diff --git a/umap/management/commands/anonymous_edit_url.py b/umap/management/commands/anonymous_edit_url.py index f23d0f56..41c4c0a4 100644 --- a/umap/management/commands/anonymous_edit_url.py +++ b/umap/management/commands/anonymous_edit_url.py @@ -1,7 +1,6 @@ import sys from django.core.management.base import BaseCommand -from django.conf import settings from umap.models import Map @@ -25,4 +24,4 @@ class Command(BaseCommand): self.abort('Map with pk {} not found'.format(pk)) if map_.owner: self.abort('Map is not anonymous (owner: {})'.format(map_.owner)) - print(settings.SITE_URL + map_.get_anonymous_edit_url()) + print(map_.get_anonymous_edit_url()) diff --git a/umap/models.py b/umap/models.py index 6613a25c..fc9b8c40 100644 --- a/umap/models.py +++ b/umap/models.py @@ -162,7 +162,8 @@ class Map(NamedModel): def get_anonymous_edit_url(self): signer = Signer() signature = signer.sign(self.pk) - return reverse("map_anonymous_edit_url", kwargs={"signature": signature}) + path = reverse("map_anonymous_edit_url", kwargs={"signature": signature}) + return settings.SITE_URL + path def is_anonymous_owner(self, request): if self.owner: diff --git a/umap/settings/base.py b/umap/settings/base.py index 10b91139..4e051c40 100644 --- a/umap/settings/base.py +++ b/umap/settings/base.py @@ -114,6 +114,9 @@ INSTALLED_APPS = ( ) DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +FROM_EMAIL = None + # ============================================================================= # Calculation of directories relative to the project module location # ============================================================================= diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index af18a94a..13719a88 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -734,7 +734,7 @@ input[type=hidden].blur + .button { .umap-alert .umap-action { margin-left: 10px; background-color: #fff; - color: #999; + color: #000; padding: 5px; border-radius: 4px; } @@ -748,6 +748,10 @@ input[type=hidden].blur + .button { .umap-alert .error .umap-action:hover { color: #fff; } +.umap-alert input { + padding: 5px; + border-radius: 4px; +} /* *********** */ /* Tooltip */ diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index ce0a5ef3..036a7abf 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1332,15 +1332,75 @@ L.U.Map.include({ formData.append('name', this.options.name) formData.append('center', JSON.stringify(this.geometry())) formData.append('settings', JSON.stringify(geojson)) + + function copyToClipboard(textToCopy) { + // https://stackoverflow.com/a/65996386 + // Navigator clipboard api needs a secure context (https) + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(textToCopy) + } else { + // Use the 'out of viewport hidden text area' trick + const textArea = document.createElement('textarea') + textArea.value = textToCopy + + // Move textarea out of the viewport so it's not visible + textArea.style.position = 'absolute' + textArea.style.left = '-999999px' + + document.body.prepend(textArea) + textArea.select() + + try { + document.execCommand('copy') + } catch (error) { + console.error(error) + } finally { + textArea.remove() + } + } + } + this.post(this.getSaveUrl(), { data: formData, context: this, callback: function (data) { - let duration = 3000 + let duration = 3000, + alert = { content: L._('Map has been saved!'), level: 'info' } if (!this.options.umap_id) { - duration = 100000 // we want a longer message at map creation (TODO UGLY) + alert.content = L._('Congratulations, your map has been created!') this.options.umap_id = data.id this.permissions.setOptions(data.permissions) + if ( + data.permissions && + data.permissions.anonymous_edit_url && + this.options.urls.map_send_edit_link + ) { + alert.duration = Infinity + alert.content = + L._( + 'Your map has been created! As you are not logged in, here is your secret link to edit the map, please keep it safe:' + ) + `
${data.permissions.anonymous_edit_url}` + + alert.actions = [ + { + label: L._('Send me the link'), + input: L._('Email'), + callback: this.sendEditLink, + callbackContext: this, + }, + { + label: L._('Copy link'), + callback: () => { + copyToClipboard(data.permissions.anonymous_edit_url) + this.ui.alert({ + content: L._('Secret edit link copied to clipboard!'), + level: 'info', + }) + }, + callbackContext: this, + }, + ] + } } else if (!this.permissions.isDirty) { // Do not override local changes to permissions, // but update in case some other editors changed them in the meantime. @@ -1350,11 +1410,10 @@ L.U.Map.include({ if (history && history.pushState) history.pushState({}, this.options.name, data.url) else window.location = data.url - if (data.info) msg = data.info - else msg = L._('Map has been saved!') + alert.content = data.info || alert.content this.once('saved', function () { this.isDirty = false - this.ui.alert({ content: msg, level: 'info', duration: duration }) + this.ui.alert(alert) }) this.ui.closePanel() this.permissions.save() @@ -1362,6 +1421,20 @@ L.U.Map.include({ }) }, + sendEditLink: function () { + const url = L.Util.template(this.options.urls.map_send_edit_link, { + map_id: this.options.umap_id, + }), + input = this.ui._alert.querySelector('input'), + email = input.value + + const formData = new FormData() + formData.append('email', email) + this.post(url, { + data: formData, + }) + }, + getEditUrl: function () { return L.Util.template(this.options.urls.map_update, { map_id: this.options.umap_id, diff --git a/umap/static/umap/js/umap.ui.js b/umap/static/umap/js/umap.ui.js index acdb0817..bf5d338f 100644 --- a/umap/static/umap/js/umap.ui.js +++ b/umap/static/umap/js/umap.ui.js @@ -75,7 +75,6 @@ L.U.UI = L.Evented.extend({ }, popAlert: function (e) { - const self = this if (!e) { if (this.ALERTS.length) e = this.ALERTS.pop() else return @@ -85,8 +84,8 @@ L.U.UI = L.Evented.extend({ this._alert.innerHTML = '' L.DomUtil.addClass(this.parent, 'umap-alert') L.DomUtil.addClass(this._alert, level_class) - function close() { - if (timeoutID !== this.ALERT_ID) { + const close = () => { + if (timeoutID && timeoutID !== this.ALERT_ID) { return } // Another alert has been forced this._alert.innerHTML = '' @@ -108,26 +107,32 @@ L.U.UI = L.Evented.extend({ ) L.DomUtil.add('div', '', this._alert, e.content) if (e.actions) { - let action, el + let action, el, input for (let i = 0; i < e.actions.length; i++) { action = e.actions[i] + if (action.input) { + input = L.DomUtil.element( + 'input', + { className: 'umap-alert-input', placeholder: action.input }, + this._alert + ) + } el = L.DomUtil.element('a', { className: 'umap-action' }, this._alert) el.href = '#' el.textContent = action.label - L.DomEvent.on(el, 'click', L.DomEvent.stop).on(el, 'click', close, this) - if (action.callback) - L.DomEvent.on( - el, - 'click', - action.callback, - action.callbackContext || this.map - ) + L.DomEvent.on(el, 'click', L.DomEvent.stop) + if (action.callback) { + L.DomEvent.on(el, 'click', action.callback, action.callbackContext || this.map) + } + L.DomEvent.on(el, 'click', close, this) } } - self.ALERT_ID = timeoutID = window.setTimeout( - L.bind(close, this), - e.duration || 3000 - ) + if (e.duration !== Infinity) { + this.ALERT_ID = timeoutID = window.setTimeout( + L.bind(close, this), + e.duration || 3000 + ) + } }, tooltip: function (e) { diff --git a/umap/tests/settings.py b/umap/tests/settings.py index 286c6860..d8706f5d 100644 --- a/umap/tests/settings.py +++ b/umap/tests/settings.py @@ -4,6 +4,8 @@ from umap.settings.base import * # pylint: disable=W0614,W0401 SECRET_KEY = "justfortests" COMPRESS_ENABLED = False +FROM_EMAIL = "test@test.org" +EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' if "TRAVIS" in os.environ: DATABASES = { diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index 7a8f2f49..2b7489ec 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -2,9 +2,10 @@ import json import pytest from django.contrib.auth import get_user_model +from django.core import mail from django.urls import reverse - from django.core.signing import Signer + from umap.models import DataLayer, Map, Star from .base import login_required @@ -16,50 +17,45 @@ User = get_user_model() @pytest.fixture def post_data(): return { - 'name': 'name', - 'center': '{"type":"Point","coordinates":[13.447265624999998,48.94415123418794]}', # noqa - 'settings': '{"type":"Feature","geometry":{"type":"Point","coordinates":[5.0592041015625,52.05924589011585]},"properties":{"tilelayer":{"maxZoom":20,"url_template":"http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png","minZoom":0,"attribution":"HOT and friends"},"licence":"","description":"","name":"test enrhûmé","tilelayersControl":true,"displayDataBrowserOnLoad":false,"displayPopupFooter":true,"displayCaptionOnLoad":false,"miniMap":true,"moreControl":true,"scaleControl":true,"zoomControl":true,"datalayersControl":true,"zoom":8}}' # noqa + "name": "name", + "center": '{"type":"Point","coordinates":[13.447265624999998,48.94415123418794]}', # noqa + "settings": '{"type":"Feature","geometry":{"type":"Point","coordinates":[5.0592041015625,52.05924589011585]},"properties":{"tilelayer":{"maxZoom":20,"url_template":"http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png","minZoom":0,"attribution":"HOT and friends"},"licence":"","description":"","name":"test enrhûmé","tilelayersControl":true,"displayDataBrowserOnLoad":false,"displayPopupFooter":true,"displayCaptionOnLoad":false,"miniMap":true,"moreControl":true,"scaleControl":true,"zoomControl":true,"datalayersControl":true,"zoom":8}}', # noqa } def test_create(client, user, post_data): - url = reverse('map_create') + url = reverse("map_create") # POST only mendatory fields - name = 'test-map-with-new-name' - post_data['name'] = name + name = "test-map-with-new-name" + post_data["name"] = name client.login(username=user.username, password="123123") response = client.post(url, post_data) assert response.status_code == 200 j = json.loads(response.content.decode()) - created_map = Map.objects.latest('pk') - assert j['id'] == created_map.pk + created_map = Map.objects.latest("pk") + assert j["id"] == created_map.pk assert created_map.name == name assert created_map.center.x == 13.447265624999998 assert created_map.center.y == 48.94415123418794 - assert j['permissions'] == { - 'edit_status': 3, - 'share_status': 1, - 'owner': { - 'id': user.pk, - 'name': 'Joe', - 'url': '/en/user/Joe/' - }, - 'editors': [], - 'anonymous_edit_url': ('http://umap.org' - + created_map.get_anonymous_edit_url()) + assert j["permissions"] == { + "edit_status": 3, + "share_status": 1, + "owner": {"id": user.pk, "name": "Joe", "url": "/en/user/Joe/"}, + "editors": [], + "anonymous_edit_url": created_map.get_anonymous_edit_url(), } def test_map_create_permissions(client, settings): settings.UMAP_ALLOW_ANONYMOUS = False - url = reverse('map_create') + url = reverse("map_create") # POST anonymous response = client.post(url, {}) assert login_required(response) def test_map_update_access(client, map, user): - url = reverse('map_update', kwargs={'map_id': map.pk}) + url = reverse("map_update", kwargs={"map_id": map.pk}) # GET anonymous response = client.get(url) assert login_required(response) @@ -77,7 +73,7 @@ def test_map_update_access(client, map, user): def test_map_update_permissions_access(client, map, user): - url = reverse('map_update_permissions', kwargs={'map_id': map.pk}) + url = reverse("map_update_permissions", kwargs={"map_id": map.pk}) # GET anonymous response = client.get(url) assert login_required(response) @@ -95,22 +91,22 @@ def test_map_update_permissions_access(client, map, user): def test_update(client, map, post_data): - url = reverse('map_update', kwargs={'map_id': map.pk}) + url = reverse("map_update", kwargs={"map_id": map.pk}) # POST only mendatory fields - name = 'new map name' - post_data['name'] = name + name = "new map name" + post_data["name"] = name client.login(username=map.owner.username, password="123123") response = client.post(url, post_data) assert response.status_code == 200 j = json.loads(response.content.decode()) - assert 'html' not in j + assert "html" not in j updated_map = Map.objects.get(pk=map.pk) - assert j['id'] == updated_map.pk + assert j["id"] == updated_map.pk assert updated_map.name == name def test_delete(client, map, datalayer): - url = reverse('map_delete', args=(map.pk, )) + url = reverse("map_delete", args=(map.pk,)) client.login(username=map.owner.username, password="123123") response = client.post(url, {}, follow=True) assert response.status_code == 200 @@ -120,61 +116,58 @@ def test_delete(client, map, datalayer): assert User.objects.filter(pk=map.owner.pk).exists() # Test response is a json j = json.loads(response.content.decode()) - assert 'redirect' in j + assert "redirect" in j def test_wrong_slug_should_redirect_to_canonical(client, map): - url = reverse('map', kwargs={'pk': map.pk, 'slug': 'wrong-slug'}) - canonical = reverse('map', kwargs={'pk': map.pk, - 'slug': map.slug}) + url = reverse("map", kwargs={"pk": map.pk, "slug": "wrong-slug"}) + canonical = reverse("map", kwargs={"pk": map.pk, "slug": map.slug}) response = client.get(url) assert response.status_code == 301 - assert response['Location'] == canonical + assert response["Location"] == canonical def test_wrong_slug_should_redirect_with_query_string(client, map): - url = reverse('map', kwargs={'pk': map.pk, 'slug': 'wrong-slug'}) + url = reverse("map", kwargs={"pk": map.pk, "slug": "wrong-slug"}) url = "{}?allowEdit=0".format(url) - canonical = reverse('map', kwargs={'pk': map.pk, - 'slug': map.slug}) + canonical = reverse("map", kwargs={"pk": map.pk, "slug": map.slug}) canonical = "{}?allowEdit=0".format(canonical) response = client.get(url) assert response.status_code == 301 - assert response['Location'] == canonical + assert response["Location"] == canonical def test_should_not_consider_the_query_string_for_canonical_check(client, map): - url = reverse('map', kwargs={'pk': map.pk, 'slug': map.slug}) + url = reverse("map", kwargs={"pk": map.pk, "slug": map.slug}) url = "{}?allowEdit=0".format(url) response = client.get(url) assert response.status_code == 200 def test_short_url_should_redirect_to_canonical(client, map): - url = reverse('map_short_url', kwargs={'pk': map.pk}) - canonical = reverse('map', kwargs={'pk': map.pk, - 'slug': map.slug}) + url = reverse("map_short_url", kwargs={"pk": map.pk}) + canonical = reverse("map", kwargs={"pk": map.pk, "slug": map.slug}) response = client.get(url) assert response.status_code == 301 - assert response['Location'] == canonical + assert response["Location"] == canonical def test_clone_map_should_create_a_new_instance(client, map): assert Map.objects.count() == 1 - url = reverse('map_clone', kwargs={'map_id': map.pk}) + url = reverse("map_clone", kwargs={"map_id": map.pk}) client.login(username=map.owner.username, password="123123") response = client.post(url) assert response.status_code == 200 assert Map.objects.count() == 2 - clone = Map.objects.latest('pk') + clone = Map.objects.latest("pk") assert clone.pk != map.pk - assert clone.name == u"Clone of " + map.name + assert clone.name == "Clone of " + map.name def test_user_not_allowed_should_not_clone_map(client, map, user, settings): settings.UMAP_ALLOW_ANONYMOUS = False assert Map.objects.count() == 1 - url = reverse('map_clone', kwargs={'map_id': map.pk}) + url = reverse("map_clone", kwargs={"map_id": map.pk}) map.edit_status = map.OWNER map.save() response = client.post(url) @@ -191,7 +184,7 @@ def test_user_not_allowed_should_not_clone_map(client, map, user, settings): def test_clone_should_set_cloner_as_owner(client, map, user): - url = reverse('map_clone', kwargs={'map_id': map.pk}) + url = reverse("map_clone", kwargs={"map_id": map.pk}) map.edit_status = map.EDITORS map.editors.add(user) map.save() @@ -199,32 +192,32 @@ def test_clone_should_set_cloner_as_owner(client, map, user): response = client.post(url) assert response.status_code == 200 assert Map.objects.count() == 2 - clone = Map.objects.latest('pk') + clone = Map.objects.latest("pk") assert clone.pk != map.pk - assert clone.name == u"Clone of " + map.name + assert clone.name == "Clone of " + map.name assert clone.owner == user def test_map_creation_should_allow_unicode_names(client, map, post_data): - url = reverse('map_create') + url = reverse("map_create") # POST only mendatory fields - name = u'Академический' - post_data['name'] = name + name = "Академический" + post_data["name"] = name client.login(username=map.owner.username, password="123123") response = client.post(url, post_data) assert response.status_code == 200 j = json.loads(response.content.decode()) - created_map = Map.objects.latest('pk') - assert j['id'] == created_map.pk + created_map = Map.objects.latest("pk") + assert j["id"] == created_map.pk assert created_map.name == name # Lower case of the russian original name # self.assertEqual(created_map.slug, u"академический") # for now we fallback to "map", see unicode_name branch - assert created_map.slug == 'map' + assert created_map.slug == "map" def test_anonymous_can_access_map_with_share_status_public(client, map): - url = reverse('map', args=(map.slug, map.pk)) + url = reverse("map", args=(map.slug, map.pk)) map.share_status = map.PUBLIC map.save() response = client.get(url) @@ -232,7 +225,7 @@ def test_anonymous_can_access_map_with_share_status_public(client, map): def test_anonymous_can_access_map_with_share_status_open(client, map): - url = reverse('map', args=(map.slug, map.pk)) + url = reverse("map", args=(map.slug, map.pk)) map.share_status = map.OPEN map.save() response = client.get(url) @@ -240,7 +233,7 @@ def test_anonymous_can_access_map_with_share_status_open(client, map): def test_anonymous_cannot_access_map_with_share_status_private(client, map): - url = reverse('map', args=(map.slug, map.pk)) + url = reverse("map", args=(map.slug, map.pk)) map.share_status = map.PRIVATE map.save() response = client.get(url) @@ -248,7 +241,7 @@ def test_anonymous_cannot_access_map_with_share_status_private(client, map): def test_owner_can_access_map_with_share_status_private(client, map): - url = reverse('map', args=(map.slug, map.pk)) + url = reverse("map", args=(map.slug, map.pk)) map.share_status = map.PRIVATE map.save() client.login(username=map.owner.username, password="123123") @@ -257,7 +250,7 @@ def test_owner_can_access_map_with_share_status_private(client, map): def test_editors_can_access_map_with_share_status_private(client, map, user): - url = reverse('map', args=(map.slug, map.pk)) + url = reverse("map", args=(map.slug, map.pk)) map.share_status = map.PRIVATE map.editors.add(user) map.save() @@ -267,7 +260,7 @@ def test_editors_can_access_map_with_share_status_private(client, map, user): 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.BLOCKED map.save() response = client.get(url) @@ -275,7 +268,7 @@ def test_anonymous_cannot_access_map_with_share_status_blocked(client, map): def test_owner_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.BLOCKED map.save() client.login(username=map.owner.username, password="123123") @@ -283,8 +276,10 @@ def test_owner_cannot_access_map_with_share_status_blocked(client, map): assert response.status_code == 403 -def test_non_editor_cannot_access_map_if_share_status_private(client, map, user): # noqa - url = reverse('map', args=(map.slug, map.pk)) +def test_non_editor_cannot_access_map_if_share_status_private( + client, map, user +): # noqa + url = reverse("map", args=(map.slug, map.pk)) map.share_status = map.PRIVATE map.save() client.login(username=user.username, password="123123") @@ -293,16 +288,16 @@ def test_non_editor_cannot_access_map_if_share_status_private(client, map, user) def test_map_geojson_view(client, map): - url = reverse('map_geojson', args=(map.pk, )) + url = reverse("map_geojson", args=(map.pk,)) response = client.get(url) j = json.loads(response.content.decode()) - assert 'json' in response['content-type'] - assert 'type' in j + assert "json" in response["content-type"] + assert "type" in j def test_only_owner_can_delete(client, map, user): map.editors.add(user) - url = reverse('map_delete', kwargs={'map_id': map.pk}) + url = reverse("map_delete", kwargs={"map_id": map.pk}) client.login(username=user.username, password="123123") response = client.post(url, {}, follow=True) assert response.status_code == 403 @@ -312,10 +307,10 @@ def test_map_editors_do_not_see_owner_change_input(client, map, user): map.editors.add(user) map.edit_status = map.EDITORS map.save() - url = reverse('map_update_permissions', kwargs={'map_id': map.pk}) + url = reverse("map_update_permissions", kwargs={"map_id": map.pk}) client.login(username=user.username, password="123123") response = client.get(url) - assert 'id_owner' not in response + assert "id_owner" not in response def test_logged_in_user_can_edit_map_editable_by_anonymous(client, map, user): @@ -323,114 +318,116 @@ def test_logged_in_user_can_edit_map_editable_by_anonymous(client, map, user): map.edit_status = map.ANONYMOUS map.save() client.login(username=user.username, password="123123") - url = reverse('map_update', kwargs={'map_id': map.pk}) - new_name = 'this is my new name' + url = reverse("map_update", kwargs={"map_id": map.pk}) + new_name = "this is my new name" data = { - 'center': '{"type":"Point","coordinates":[13.447265624999998,48.94415123418794]}', # noqa - 'name': new_name + "center": '{"type":"Point","coordinates":[13.447265624999998,48.94415123418794]}', # noqa + "name": new_name, } response = client.post(url, data) assert response.status_code == 200 assert Map.objects.get(pk=map.pk).name == new_name -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_anonymous_create(cookieclient, post_data): - url = reverse('map_create') + url = reverse("map_create") # POST only mendatory fields - name = 'test-map-with-new-name' - post_data['name'] = name + name = "test-map-with-new-name" + post_data["name"] = name response = cookieclient.post(url, post_data) assert response.status_code == 200 j = json.loads(response.content.decode()) - created_map = Map.objects.latest('pk') - assert j['id'] == created_map.pk - assert (created_map.get_anonymous_edit_url() - in j['permissions']['anonymous_edit_url']) + created_map = Map.objects.latest("pk") + assert j["id"] == created_map.pk + assert ( + created_map.get_anonymous_edit_url() in j["permissions"]["anonymous_edit_url"] + ) assert created_map.name == name key, value = created_map.signed_cookie_elements assert key in cookieclient.cookies -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_anonymous_update_without_cookie_fails(client, anonymap, post_data): # noqa - url = reverse('map_update', kwargs={'map_id': anonymap.pk}) + url = reverse("map_update", kwargs={"map_id": anonymap.pk}) response = client.post(url, post_data) assert response.status_code == 403 -@pytest.mark.usefixtures('allow_anonymous') -def test_anonymous_update_with_cookie_should_work(cookieclient, anonymap, post_data): # noqa - url = reverse('map_update', kwargs={'map_id': anonymap.pk}) +@pytest.mark.usefixtures("allow_anonymous") +def test_anonymous_update_with_cookie_should_work( + cookieclient, anonymap, post_data +): # noqa + url = reverse("map_update", kwargs={"map_id": anonymap.pk}) # POST only mendatory fields - name = 'new map name' - post_data['name'] = name + name = "new map name" + post_data["name"] = name response = cookieclient.post(url, post_data) assert response.status_code == 200 j = json.loads(response.content.decode()) updated_map = Map.objects.get(pk=anonymap.pk) - assert j['id'] == updated_map.pk + assert j["id"] == updated_map.pk -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_anonymous_delete(cookieclient, anonymap): - url = reverse('map_delete', args=(anonymap.pk, )) + url = reverse("map_delete", args=(anonymap.pk,)) response = cookieclient.post(url, {}, follow=True) assert response.status_code == 200 assert not Map.objects.filter(pk=anonymap.pk).count() # Test response is a json j = json.loads(response.content.decode()) - assert 'redirect' in j + assert "redirect" in j -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_no_cookie_cant_delete(client, anonymap): - url = reverse('map_delete', args=(anonymap.pk, )) + url = reverse("map_delete", args=(anonymap.pk,)) response = client.post(url, {}, follow=True) assert response.status_code == 403 -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_anonymous_edit_url(cookieclient, anonymap): url = anonymap.get_anonymous_edit_url() - canonical = reverse('map', kwargs={'pk': anonymap.pk, - 'slug': anonymap.slug}) + canonical = reverse("map", kwargs={"pk": anonymap.pk, "slug": anonymap.slug}) response = cookieclient.get(url) assert response.status_code == 302 - assert response['Location'] == canonical + assert response["Location"] == canonical key, value = anonymap.signed_cookie_elements assert key in cookieclient.cookies -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_sha1_anonymous_edit_url(cookieclient, anonymap): - signer = Signer(algorithm='sha1') + signer = Signer(algorithm="sha1") signature = signer.sign(anonymap.pk) - url = reverse('map_anonymous_edit_url', kwargs={'signature': signature}) - canonical = reverse('map', kwargs={'pk': anonymap.pk, - 'slug': anonymap.slug}) + url = reverse("map_anonymous_edit_url", kwargs={"signature": signature}) + canonical = reverse("map", kwargs={"pk": anonymap.pk, "slug": anonymap.slug}) response = cookieclient.get(url) assert response.status_code == 302 - assert response['Location'] == canonical + assert response["Location"] == canonical key, value = anonymap.signed_cookie_elements assert key in cookieclient.cookies -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_bad_anonymous_edit_url_should_return_403(cookieclient, anonymap): url = anonymap.get_anonymous_edit_url() url = reverse( - 'map_anonymous_edit_url', - kwargs={'signature': "%s:badsignature" % anonymap.pk} + "map_anonymous_edit_url", kwargs={"signature": "%s:badsignature" % anonymap.pk} ) response = cookieclient.get(url) assert response.status_code == 403 -@pytest.mark.usefixtures('allow_anonymous') -def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed(client, anonymap, user): # noqa +@pytest.mark.usefixtures("allow_anonymous") +def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed( + client, anonymap, user +): # noqa assert Map.objects.count() == 1 - url = reverse('map_clone', kwargs={'map_id': anonymap.pk}) + url = reverse("map_clone", kwargs={"map_id": anonymap.pk}) anonymap.edit_status = anonymap.OWNER anonymap.save() response = client.post(url) @@ -441,24 +438,26 @@ def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed(clien assert Map.objects.count() == 1 -@pytest.mark.usefixtures('allow_anonymous') -def test_clone_map_should_be_possible_if_edit_status_is_anonymous(client, anonymap): # noqa +@pytest.mark.usefixtures("allow_anonymous") +def test_clone_map_should_be_possible_if_edit_status_is_anonymous( + client, anonymap +): # noqa assert Map.objects.count() == 1 - url = reverse('map_clone', kwargs={'map_id': anonymap.pk}) + url = reverse("map_clone", kwargs={"map_id": anonymap.pk}) anonymap.edit_status = anonymap.ANONYMOUS anonymap.save() response = client.post(url) assert response.status_code == 200 assert Map.objects.count() == 2 - clone = Map.objects.latest('pk') + clone = Map.objects.latest("pk") assert clone.pk != anonymap.pk - assert clone.name == 'Clone of ' + anonymap.name + assert clone.name == "Clone of " + anonymap.name assert clone.owner is None -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_anyone_can_access_anonymous_map(cookieclient, anonymap): - url = reverse('map', args=(anonymap.slug, anonymap.pk)) + url = reverse("map", args=(anonymap.slug, anonymap.pk)) anonymap.share_status = anonymap.PUBLIC response = cookieclient.get(url) assert response.status_code == 200 @@ -470,9 +469,9 @@ def test_anyone_can_access_anonymous_map(cookieclient, anonymap): assert response.status_code == 200 -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_map_attach_owner(cookieclient, anonymap, user): - url = reverse('map_attach_owner', kwargs={'map_id': anonymap.pk}) + url = reverse("map_attach_owner", kwargs={"map_id": anonymap.pk}) cookieclient.login(username=user.username, password="123123") assert anonymap.owner is None response = cookieclient.post(url) @@ -481,17 +480,17 @@ def test_map_attach_owner(cookieclient, anonymap, user): assert map.owner == user -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_map_attach_owner_not_logged_in(cookieclient, anonymap, user): - url = reverse('map_attach_owner', kwargs={'map_id': anonymap.pk}) + url = reverse("map_attach_owner", kwargs={"map_id": anonymap.pk}) assert anonymap.owner is None response = cookieclient.post(url) assert response.status_code == 403 -@pytest.mark.usefixtures('allow_anonymous') +@pytest.mark.usefixtures("allow_anonymous") def test_map_attach_owner_with_already_an_owner(cookieclient, map, user): - url = reverse('map_attach_owner', kwargs={'map_id': map.pk}) + url = reverse("map_attach_owner", kwargs={"map_id": map.pk}) cookieclient.login(username=user.username, password="123123") assert map.owner assert map.owner != user @@ -500,7 +499,7 @@ def test_map_attach_owner_with_already_an_owner(cookieclient, map, user): def test_map_attach_owner_anonymous_not_allowed(cookieclient, anonymap, user): - url = reverse('map_attach_owner', kwargs={'map_id': anonymap.pk}) + url = reverse("map_attach_owner", kwargs={"map_id": anonymap.pk}) cookieclient.login(username=user.username, password="123123") assert anonymap.owner is None response = cookieclient.post(url) @@ -524,11 +523,11 @@ def test_map_attach_owner_anonymous_not_allowed(cookieclient, anonymap, user): def test_create_readonly(client, user, post_data, settings): settings.UMAP_READONLY = True - url = reverse('map_create') + url = reverse("map_create") client.login(username=user.username, password="123123") response = client.post(url, post_data) assert response.status_code == 403 - assert response.content == b'Site is readonly for maintenance' + assert response.content == b"Site is readonly for maintenance" def test_search(client, map): @@ -542,7 +541,7 @@ def test_search(client, map): 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") assert Star.objects.filter(by=user).count() == 0 response = client.post(url) @@ -551,7 +550,7 @@ def test_authenticated_user_can_star_map(client, map, user): def test_anonymous_cannot_star_map(client, map): - url = reverse('map_star', args=(map.pk,)) + url = reverse("map_star", args=(map.pk,)) assert Star.objects.count() == 0 response = client.post(url) assert response.status_code == 302 @@ -560,12 +559,41 @@ def test_anonymous_cannot_star_map(client, map): def test_user_can_see_their_star(client, map, user): - url = reverse('map_star', args=(map.pk,)) + url = reverse("map_star", args=(map.pk,)) client.login(username=user.username, password="123123") assert Star.objects.filter(by=user).count() == 0 response = client.post(url) assert response.status_code == 200 - url = reverse('user_stars', args=(user.username,)) + url = reverse("user_stars", args=(user.username,)) response = client.get(url) assert response.status_code == 200 assert map.name in response.content.decode() + + +@pytest.mark.usefixtures("allow_anonymous") +def test_cannot_send_link_on_owned_map(client, map): + assert len(mail.outbox) == 0 + url = reverse("map_send_edit_link", args=(map.pk,)) + resp = client.post(url, {"email": "foo@bar.org"}) + assert resp.status_code == 200 + assert json.loads(resp.content.decode()) == {"login_required": "/en/login/"} + assert len(mail.outbox) == 0 + + +@pytest.mark.usefixtures("allow_anonymous") +def test_cannot_send_link_on_anonymous_map_without_cookie(client, anonymap): + assert len(mail.outbox) == 0 + url = reverse("map_send_edit_link", args=(anonymap.pk,)) + resp = client.post(url, {"email": "foo@bar.org"}) + assert resp.status_code == 403 + assert len(mail.outbox) == 0 + + +@pytest.mark.usefixtures("allow_anonymous") +def test_can_send_link_on_anonymous_map_with_cookie(cookieclient, anonymap): + assert len(mail.outbox) == 0 + url = reverse("map_send_edit_link", args=(anonymap.pk,)) + resp = cookieclient.post(url, {"email": "foo@bar.org"}) + assert resp.status_code == 200 + assert len(mail.outbox) == 1 + assert mail.outbox[0].subject == "The uMap edit link for your map: test map" diff --git a/umap/urls.py b/umap/urls.py index a6bcfab4..e4d71291 100644 --- a/umap/urls.py +++ b/umap/urls.py @@ -99,8 +99,7 @@ i18n_urls += decorated_patterns( name="map_star", ), ) -i18n_urls += decorated_patterns( - [map_permissions_check, never_cache], +map_urls = [ re_path( r"^map/(?P[\d]+)/update/settings/$", views.MapUpdate.as_view(), @@ -141,7 +140,16 @@ i18n_urls += decorated_patterns( views.DataLayerDelete.as_view(), name="datalayer_delete", ), -) +] +if settings.FROM_EMAIL: + map_urls.append( + re_path( + r"^map/(?P[\d]+)/send-edit-link/$", + views.SendEditLink.as_view(), + name="map_send_edit_link", + ) + ) +i18n_urls += decorated_patterns([map_permissions_check, never_cache], *map_urls) urlpatterns += i18n_patterns( re_path(r"^$", views.home, name="home"), re_path( diff --git a/umap/views.py b/umap/views.py index e65d26c5..2465a498 100644 --- a/umap/views.py +++ b/umap/views.py @@ -14,6 +14,7 @@ from django.contrib.auth import logout as do_logout from django.contrib.auth import get_user_model from django.contrib.gis.measure import D from django.contrib.postgres.search import SearchQuery, SearchVector +from django.core.mail import send_mail from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.signing import BadSignature, Signer from django.core.validators import URLValidator, ValidationError @@ -35,7 +36,7 @@ from django.utils.translation import to_locale from django.views.generic import DetailView, TemplateView, View from django.views.generic.base import RedirectView from django.views.generic.detail import BaseDetailView -from django.views.generic.edit import CreateView, DeleteView, UpdateView +from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.list import ListView from .forms import ( @@ -46,6 +47,7 @@ from .forms import ( DataLayerForm, FlatErrorList, MapSettingsForm, + SendLinkForm, UpdateMapPermissionsForm, ) from .models import DataLayer, Licence, Map, Pictogram, Star, TileLayer @@ -472,13 +474,9 @@ class PermissionsMixin: for editor in self.object.editors.all() ] if not self.object.owner and self.object.is_anonymous_owner(self.request): - permissions["anonymous_edit_url"] = self.get_anonymous_edit_url() + permissions["anonymous_edit_url"] = self.object.get_anonymous_edit_url() return permissions - def get_anonymous_edit_url(self): - anonymous_url = self.object.get_anonymous_edit_url() - return settings.SITE_URL + anonymous_url - class MapView(MapDetailMixin, PermissionsMixin, DetailView): def get(self, request, *args, **kwargs): @@ -547,15 +545,7 @@ class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView): if self.request.user.is_authenticated: form.instance.owner = self.request.user self.object = form.save() - anonymous_url = self.get_anonymous_edit_url() - if not self.request.user.is_authenticated: - msg = _( - "Your map has been created! If you want to edit this map from " - "another computer, please use this link: %(anonymous_url)s" - % {"anonymous_url": anonymous_url} - ) - else: - msg = _("Congratulations, your map has been created!") + anonymous_url = self.object.get_anonymous_edit_url() permissions = self.get_permissions() # User does not have the cookie yet. permissions["anonymous_edit_url"] = anonymous_url @@ -563,7 +553,6 @@ class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView): id=self.object.pk, url=self.object.get_absolute_url(), permissions=permissions, - info=msg, ) if not self.request.user.is_authenticated: key, value = self.object.signed_cookie_elements @@ -628,6 +617,36 @@ class AttachAnonymousMap(View): return simple_json_response() +class SendEditLink(FormLessEditMixin, FormView): + form_class = SendLinkForm + + def post(self, form, **kwargs): + self.object = kwargs["map_inst"] + if ( + self.object.owner + or not self.object.is_anonymous_owner(self.request) + or not self.object.can_edit(self.request.user, self.request) + ): + return HttpResponseForbidden() + form = self.get_form() + if form.is_valid(): + email = form.cleaned_data["email"] + else: + return HttpResponseBadRequest("Invalid") + link = self.object.get_anonymous_edit_url() + + send_mail( + _("The uMap edit link for your map: %(map_name)s" % {"map_name": self.object.name}), + _("Here is your secret edit link: %(link)s" % {"link": link}), + settings.FROM_EMAIL, + [email], + fail_silently=False, + ) + return simple_json_response( + info=_("Email sent to %(email)s" % {"email": email}) + ) + + class MapDelete(DeleteView): model = Map pk_url_kwarg = "map_id" @@ -660,7 +679,7 @@ class MapClone(PermissionsMixin, View): msg = _( "Your map has been cloned! If you want to edit this map from " "another computer, please use this link: %(anonymous_url)s" - % {"anonymous_url": self.get_anonymous_edit_url()} + % {"anonymous_url": self.object.get_anonymous_edit_url()} ) else: msg = _("Congratulations, your map has been cloned!")