Merge pull request #1102 from umap-project/mail-link

Add a button to send edit link by email in anonymous mode
This commit is contained in:
Yohan Boniface 2023-06-02 23:32:34 +02:00 committed by GitHub
commit 6e0c4723a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 345 additions and 179 deletions

View file

@ -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
```

View file

@ -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 = (

View file

@ -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())

View file

@ -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:

View file

@ -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
# =============================================================================

View file

@ -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 */

View file

@ -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:'
) + `<br>${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,

View file

@ -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) {

View file

@ -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 = {

View file

@ -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"

View file

@ -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<map_id>[\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<map_id>[\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(

View file

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