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!")