Create an API for uMap with DRF

Refs #1333
This commit is contained in:
David Larlet 2023-09-25 17:48:57 -04:00
parent 87814f0362
commit 6e9e46dbbd
No known key found for this signature in database
GPG key ID: 3E2953A359E7E7BD
14 changed files with 174 additions and 154 deletions

View file

@ -33,6 +33,7 @@ classifiers = [
]
dependencies = [
"Django>=4.1",
"djangorestframework==3.14.0",
"django-agnocomplete==2.2.0",
"django-compressor==4.3.1",
"django-environ==0.10.0",

59
umap/api_views.py Normal file
View file

@ -0,0 +1,59 @@
from rest_framework import generics, status
from rest_framework.response import Response
from .models import Map
from .serializers import MapSerializer
from .views import ANONYMOUS_COOKIE_MAX_AGE
class MapList(generics.ListCreateAPIView):
queryset = Map.public.all().order_by("-modified_at")
serializer_class = MapSerializer
def perform_create(self, serializer):
if self.request.user.is_authenticated:
serializer.save(owner=self.request.user)
else:
serializer.save()
def get_map_permissions(self, map_):
permissions = {}
permissions["edit_status"] = map_.edit_status
permissions["share_status"] = map_.share_status
if map_.owner:
permissions["owner"] = {
"id": map_.owner.pk,
"name": str(map_.owner),
"url": map_.owner.get_url(),
}
permissions["editors"] = [
{"id": editor.pk, "name": str(editor)} for editor in map_.editors.all()
]
if not map_.owner and map_.is_anonymous_owner(self.request):
permissions["anonymous_edit_url"] = map_.get_anonymous_edit_url()
return permissions
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
map_ = serializer.instance
headers = self.get_success_headers(serializer.data)
data = serializer.data
permissions = self.get_map_permissions(map_)
if not map_.owner:
anonymous_url = map_.get_anonymous_edit_url()
permissions["anonymous_edit_url"] = anonymous_url
data["permissions"] = permissions
response = Response(data, status=status.HTTP_201_CREATED, headers=headers)
if not self.request.user.is_authenticated:
key, value = map_.signed_cookie_elements
response.set_signed_cookie(
key=key, value=value, max_age=ANONYMOUS_COOKIE_MAX_AGE
)
return response
class MapDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Map.public.all().order_by("-modified_at")
serializer_class = MapSerializer

View file

@ -33,7 +33,7 @@ def can_edit_map(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
map_inst = get_object_or_404(Map, pk=kwargs["map_id"])
map_inst = get_object_or_404(Map, pk=kwargs.get("map_id", kwargs.get("pk")))
user = request.user
kwargs["map_inst"] = map_inst # Avoid rerequesting the map in the view
if map_inst.edit_status >= map_inst.EDITORS:
@ -54,7 +54,7 @@ def can_view_map(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
map_inst = get_object_or_404(Map, pk=kwargs["map_id"])
map_inst = get_object_or_404(Map, pk=kwargs.get("map_id", kwargs.get("pk")))
kwargs["map_inst"] = map_inst # Avoid rerequesting the map in the view
if not map_inst.can_view(request):
return HttpResponseForbidden()

View file

@ -2,7 +2,6 @@ from django import forms
from django.contrib.gis.geos import Point
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from django.template.defaultfilters import slugify
from django.conf import settings
from django.forms.utils import ErrorList
@ -78,34 +77,6 @@ class AnonymousDataLayerPermissionsForm(forms.ModelForm):
fields = ("edit_status",)
class MapSettingsForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(MapSettingsForm, self).__init__(*args, **kwargs)
self.fields["slug"].required = False
self.fields["center"].widget.map_srid = 4326
def clean_slug(self):
slug = self.cleaned_data.get("slug", None)
name = self.cleaned_data.get("name", None)
if not slug and name:
# If name is empty, don't do nothing, validation will raise
# later on the process because name is required
self.cleaned_data["slug"] = slugify(name) or "map"
return self.cleaned_data["slug"][:50]
else:
return ""
def clean_center(self):
if not self.cleaned_data["center"]:
point = DEFAULT_CENTER
self.cleaned_data["center"] = point
return self.cleaned_data["center"]
class Meta:
fields = ("settings", "name", "center", "slug")
model = Map
class UserProfileForm(forms.ModelForm):
class Meta:
model = User

32
umap/serializers.py Normal file
View file

@ -0,0 +1,32 @@
from django.contrib.gis.geos import Point
from django.template.defaultfilters import slugify
from django.conf import settings
from rest_framework import serializers
from .models import Map
DEFAULT_LATITUDE = (
settings.LEAFLET_LATITUDE if hasattr(settings, "LEAFLET_LATITUDE") else 51
)
DEFAULT_LONGITUDE = (
settings.LEAFLET_LONGITUDE if hasattr(settings, "LEAFLET_LONGITUDE") else 2
)
DEFAULT_CENTER = Point(DEFAULT_LONGITUDE, DEFAULT_LATITUDE)
class MapSerializer(serializers.HyperlinkedModelSerializer):
slug = serializers.CharField(required=False)
class Meta:
model = Map
fields = ["id", "name", "slug", "center", "settings"]
def validate(self, data):
slug = data.get("slug")
name = data.get("name")
if not slug and name:
data["slug"] = slugify(name)[:50] or "map"
return data
def validate_center(self, value):
return value or DEFAULT_CENTER

View file

@ -122,6 +122,7 @@ INSTALLED_APPS = (
"umap",
"compressor",
"social_django",
"rest_framework",
# See https://github.com/peopledoc/django-agnocomplete/commit/26eda2dfa4a2f8a805ca2ea19a0c504b9d773a1c
# Django does not find the app config in the default place, so the app is not loaded
# so the "autodiscover" is not run.
@ -281,6 +282,11 @@ if SOCIAL_AUTH_OPENSTREETMAP_KEY and SOCIAL_AUTH_OPENSTREETMAP_SECRET:
)
AUTHENTICATION_BACKENDS += ("django.contrib.auth.backends.ModelBackend",)
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"]
}
LOGGING = {
"version": 1,

View file

@ -1102,7 +1102,8 @@ L.U.Map.include({
formData.append('name', this.options.name)
formData.append('center', JSON.stringify(this.geometry()))
formData.append('settings', JSON.stringify(geojson))
this.post(this.getSaveUrl(), {
const method = this.options.umap_id ? this.put.bind(this) : this.post.bind(this)
method(this.getSaveUrl(), {
data: formData,
context: this,
callback: function (data) {
@ -1192,13 +1193,13 @@ L.U.Map.include({
},
getEditUrl: function () {
return L.Util.template(this.options.urls.map_update, {
map_id: this.options.umap_id,
return L.Util.template(this.options.urls.map_detail, {
pk: this.options.umap_id,
})
},
getCreateUrl: function () {
return L.Util.template(this.options.urls.map_create)
return L.Util.template(this.options.urls.map_list)
},
getSaveUrl: function () {
@ -1855,6 +1856,12 @@ L.U.Map.include({
this.xhr.post(url, options)
},
put: function (url, options) {
options = options || {}
options.listener = this
this.xhr.put(url, options)
},
get: function (url, options) {
options = options || {}
options.listener = this

View file

@ -60,7 +60,7 @@ L.U.Xhr = L.Evented.extend({
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status == 200) {
if (xhr.status == 200 || xhr.status == 201) {
settings.callback.call(settings.context || xhr, xhr.responseText, xhr)
} else if (xhr.status === 403) {
self.ui.alert({
@ -130,7 +130,7 @@ L.U.Xhr = L.Evented.extend({
}
const settings = L.Util.extend({}, default_options, options)
if (verb === 'POST') {
if (verb === 'POST' || verb === 'PUT') {
// find a way not to make this django specific
const token = document.cookie.replace(
/(?:(?:^|.*;\s*)csrftoken\s*\=\s*([^;]*).*$)|^.*$/,
@ -186,6 +186,10 @@ L.U.Xhr = L.Evented.extend({
this._json('POST', uri, options)
},
put: function (uri, options) {
this._json('PUT', uri, options)
},
submit_form: function (form_id, options) {
if (typeof options === 'undefined') options = {}
const form = L.DomUtil.get(form_id)

View file

@ -76,11 +76,7 @@ describe('L.U.DataLayer', function () {
it('should call datalayer.save on save button click', function (done) {
sinon.spy(this.datalayer, 'save')
this.server.flush()
this.server.respondWith(
'POST',
'/map/99/update/settings/',
JSON.stringify({ id: 99 })
)
this.server.respondWith('PUT', '/map/99/', JSON.stringify({ id: 99 }))
this.server.respondWith(
'POST',
'/map/99/datalayer/update/62/',
@ -97,11 +93,7 @@ describe('L.U.DataLayer', function () {
it('should show alert if server respond 412', function () {
cleanAlert()
this.server.flush()
this.server.respondWith(
'POST',
'/map/99/update/settings/',
JSON.stringify({ id: 99 })
)
this.server.respondWith('PUT', '/map/99/', JSON.stringify({ id: 99 }))
this.server.respondWith('POST', '/map/99/datalayer/update/62/', [412, {}, ''])
happen.click(editButton)
input = qs('form.umap-form input[name="name"]')
@ -178,11 +170,7 @@ describe('L.U.DataLayer', function () {
it('should set umap_id on save callback', function () {
assert.notOk(newDatalayer.umap_id)
this.server.flush()
this.server.respondWith(
'POST',
'/map/99/update/settings/',
JSON.stringify({ id: 99 })
)
this.server.respondWith('PUT', '/map/99/', JSON.stringify({ id: 99 }))
this.server.respondWith(
'POST',
'/map/99/datalayer/create/',
@ -219,11 +207,7 @@ describe('L.U.DataLayer', function () {
}
var spy = sinon.spy(response)
this.server.flush()
this.server.respondWith(
'POST',
'/map/99/update/settings/',
JSON.stringify({ id: 99 })
)
this.server.respondWith('PUT', '/map/99/', JSON.stringify({ id: 99 }))
this.server.respondWith('POST', '/map/99/datalayer/update/63/', spy)
clickSave()
this.server.respond()
@ -404,8 +388,7 @@ describe('L.U.DataLayer', function () {
})
})
describe("#displayOnLoad", function () {
describe('#displayOnLoad', function () {
beforeEach(function () {
this.server.respondWith(
/\/datalayer\/64\/\?.*/,
@ -415,27 +398,26 @@ describe('L.U.DataLayer', function () {
// Force fetching the data, so to deal here with fake server
this.datalayer.fetchData()
this.server.respond()
this.map.setZoom(10, {animate: false})
});
this.map.setZoom(10, { animate: false })
})
afterEach(function () {
this.datalayer._delete()
})
it("should not display layer at load", function () {
it('should not display layer at load', function () {
assert.notOk(qs('path[fill="AliceBlue"]'))
})
it("should display on click", function () {
it('should display on click', function () {
happen.click(qs(`[data-id='${L.stamp(this.datalayer)}'] .layer-toggle`))
assert.ok(qs('path[fill="AliceBlue"]'))
})
it("should not display on zoom", function () {
this.map.setZoom(9, {animate: false})
it('should not display on zoom', function () {
this.map.setZoom(9, { animate: false })
assert.notOk(qs('path[fill="AliceBlue"]'))
})
})
describe('#facet-search()', function () {
@ -474,22 +456,22 @@ describe('L.U.DataLayer', function () {
})
describe('#zoomEnd', function () {
it('should honour the fromZoom option', function () {
this.map.setZoom(6, {animate: false})
this.map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
this.datalayer.options.fromZoom = 6
this.map.setZoom(5, {animate: false})
this.map.setZoom(5, { animate: false })
assert.notOk(qs('path[fill="none"]'))
this.map.setZoom(6, {animate: false})
this.map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
})
it('should honour the toZoom option', function () {
this.map.setZoom(6, {animate: false})
this.map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
this.datalayer.options.toZoom = 6
this.map.setZoom(7, {animate: false})
this.map.setZoom(7, { animate: false })
assert.notOk(qs('path[fill="none"]'))
this.map.setZoom(6, {animate: false})
this.map.setZoom(6, { animate: false })
assert.ok(qs('path[fill="none"]'))
})
})

View file

@ -114,7 +114,8 @@ function initMap(options) {
urls: {
map: '/map/{slug}_{pk}',
datalayer_view: '/datalayer/{pk}/',
map_update: '/map/{map_id}/update/settings/',
map_list: '/api/maps/',
map_detail: '/api/maps/{map_id}/',
map_old_url: '/map/{username}/{slug}/',
map_clone: '/map/{map_id}/update/clone/',
map_short_url: '/m/{pk}/',
@ -122,7 +123,6 @@ function initMap(options) {
map_new: '/map/new/',
datalayer_update: '/map/{map_id}/datalayer/update/{pk}/',
map_delete: '/map/{map_id}/update/delete/',
map_create: '/map/create/',
logout: '/logout/',
datalayer_create: '/map/{map_id}/datalayer/create/',
login_popup_end: '/login/popupd/',

View file

@ -50,11 +50,13 @@ def test_editors_can_edit_if_status_editors(map, user):
assert map.can_edit(user)
def test_logged_in_user_should_be_allowed_for_anonymous_map_with_anonymous_edit_status(map, user, rf): # noqa
def test_logged_in_user_should_be_allowed_for_anonymous_map_with_anonymous_edit_status(
map, user, rf
): # noqa
map.owner = None
map.edit_status = map.ANONYMOUS
map.save()
url = reverse('map_update', kwargs={'map_id': map.pk})
url = reverse("map_detail", kwargs={"pk": map.pk})
request = rf.get(url)
request.user = user
assert map.can_edit(user, request)
@ -70,7 +72,7 @@ def test_anonymous_user_should_not_be_allowed_for_anonymous_map(map, user, rf):
def test_clone_should_return_new_instance(map, user):
clone = map.clone()
assert map.pk != clone.pk
assert u"Clone of " + map.name == clone.name
assert "Clone of " + map.name == clone.name
assert map.settings == clone.settings
assert map.center == clone.center
assert map.zoom == clone.zoom
@ -108,8 +110,7 @@ def test_clone_should_clone_datalayers_and_features_too(map, user, datalayer):
def test_publicmanager_should_get_only_public_maps(map, user, licence):
map.share_status = map.PUBLIC
open_map = MapFactory(owner=user, licence=licence, share_status=Map.OPEN)
private_map = MapFactory(owner=user, licence=licence,
share_status=Map.PRIVATE)
private_map = MapFactory(owner=user, licence=licence, share_status=Map.PRIVATE)
assert map in Map.public.all()
assert open_map not in Map.public.all()
assert private_map not in Map.public.all()

View file

@ -24,13 +24,13 @@ def post_data():
def test_create(client, user, post_data):
url = reverse("map_create")
# POST only mendatory fields
url = reverse("map_list")
# POST only mandatory fields
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
assert response.status_code == 201
j = json.loads(response.content.decode())
created_map = Map.objects.latest("pk")
assert j["id"] == created_map.pk
@ -45,21 +45,22 @@ def test_create(client, user, post_data):
}
def test_map_create_permissions(client, settings):
def test_map_list_permissions(client, settings):
settings.UMAP_ALLOW_ANONYMOUS = False
url = reverse("map_create")
url = reverse("map_list")
# POST anonymous
response = client.post(url, {})
assert response.status_code == 200
assert login_required(response)
def test_map_update_access(client, map, user):
url = reverse("map_update", kwargs={"map_id": map.pk})
url = reverse("map_detail", kwargs={"pk": map.pk})
# GET anonymous
response = client.get(url)
assert login_required(response)
# POST anonymous
response = client.post(url, {})
response = client.put(url, {}, content_type="application/json")
assert login_required(response)
# GET with wrong permissions
client.login(username=user.username, password="123123")
@ -67,7 +68,7 @@ def test_map_update_access(client, map, user):
assert response.status_code == 403
# POST with wrong permissions
client.login(username=user.username, password="123123")
response = client.post(url, {})
response = client.put(url, {}, content_type="application/json")
assert response.status_code == 403
@ -90,12 +91,12 @@ 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})
# POST only mendatory fields
url = reverse("map_detail", kwargs={"pk": map.pk})
# PUT only mandatory fields
name = "new map name"
post_data["name"] = name
client.login(username=map.owner.username, password="123123")
response = client.post(url, post_data)
response = client.put(url, post_data, content_type="application/json")
assert response.status_code == 200
j = json.loads(response.content.decode())
assert "html" not in j
@ -198,13 +199,13 @@ def test_clone_should_set_cloner_as_owner(client, map, user):
def test_map_creation_should_allow_unicode_names(client, map, post_data):
url = reverse("map_create")
# POST only mendatory fields
url = reverse("map_list")
# POST only mandatory fields
name = "Академический"
post_data["name"] = name
client.login(username=map.owner.username, password="123123")
response = client.post(url, post_data)
assert response.status_code == 200
assert response.status_code == 201
j = json.loads(response.content.decode())
created_map = Map.objects.latest("pk")
assert j["id"] == created_map.pk
@ -317,25 +318,25 @@ 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})
url = reverse("map_detail", kwargs={"pk": map.pk})
new_name = "this is my new name"
data = {
"center": '{"type":"Point","coordinates":[13.447265624999998,48.94415123418794]}', # noqa
"name": new_name,
}
response = client.post(url, data)
response = client.put(url, data, content_type="application/json")
assert response.status_code == 200
assert Map.objects.get(pk=map.pk).name == new_name
@pytest.mark.usefixtures("allow_anonymous")
def test_anonymous_create(cookieclient, post_data):
url = reverse("map_create")
# POST only mendatory fields
url = reverse("map_list")
# POST only mandatory fields
name = "test-map-with-new-name"
post_data["name"] = name
response = cookieclient.post(url, post_data)
assert response.status_code == 200
assert response.status_code == 201
j = json.loads(response.content.decode())
created_map = Map.objects.latest("pk")
assert j["id"] == created_map.pk
@ -349,8 +350,8 @@ def test_anonymous_create(cookieclient, post_data):
@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})
response = client.post(url, post_data)
url = reverse("map_detail", kwargs={"pk": anonymap.pk})
response = client.put(url, post_data, content_type="application/json")
assert response.status_code == 403
@ -358,11 +359,11 @@ def test_anonymous_update_without_cookie_fails(client, anonymap, post_data): #
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
url = reverse("map_detail", kwargs={"pk": anonymap.pk})
# POST only mandatory fields
name = "new map name"
post_data["name"] = name
response = cookieclient.post(url, post_data)
response = cookieclient.put(url, post_data, content_type="application/json")
assert response.status_code == 200
j = json.loads(response.content.decode())
updated_map = Map.objects.get(pk=anonymap.pk)
@ -522,7 +523,7 @@ 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_list")
client.login(username=user.username, password="123123")
response = client.post(url, post_data)
assert response.status_code == 403

View file

@ -9,6 +9,7 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.views.decorators.cache import cache_control, cache_page, never_cache
from django.views.decorators.csrf import ensure_csrf_cookie
from . import api_views
from . import views
from .decorators import (
jsonize_view,
@ -93,7 +94,7 @@ i18n_urls += decorated_patterns(
)
i18n_urls += decorated_patterns(
[login_required_if_not_anonymous_allowed, never_cache],
re_path(r"^map/create/$", views.MapCreate.as_view(), name="map_create"),
path("api/maps/", api_views.MapList.as_view(), name="map_list"),
)
i18n_urls += decorated_patterns(
[login_required],
@ -115,9 +116,9 @@ i18n_urls += decorated_patterns(
)
map_urls = [
re_path(
r"^map/(?P<map_id>[\d]+)/update/settings/$",
views.MapUpdate.as_view(),
name="map_update",
r"^api/map/(?P<pk>[\d]+)/$",
api_views.MapDetail.as_view(),
name="map_detail",
),
re_path(
r"^map/(?P<map_id>[\d]+)/update/permissions/$",

View file

@ -50,7 +50,6 @@ from .forms import (
AnonymousDataLayerPermissionsForm,
AnonymousMapPermissionsForm,
FlatErrorList,
MapSettingsForm,
SendLinkForm,
UpdateMapPermissionsForm,
UserProfileForm,
@ -469,9 +468,7 @@ class MapDetailMixin:
else:
map_statuses = AnonymousMapPermissionsForm.STATUS
datalayer_statuses = AnonymousDataLayerPermissionsForm.STATUS
properties["edit_statuses"] = [
(i, str(label)) for i, label in map_statuses
]
properties["edit_statuses"] = [(i, str(label)) for i, label in map_statuses]
properties["datalayer_edit_statuses"] = [
(i, str(label)) for i, label in datalayer_statuses
]
@ -618,48 +615,6 @@ class MapNew(MapDetailMixin, TemplateView):
template_name = "umap/map_detail.html"
class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView):
model = Map
form_class = MapSettingsForm
def form_valid(self, form):
if self.request.user.is_authenticated:
form.instance.owner = self.request.user
self.object = form.save()
permissions = self.get_permissions()
# User does not have the cookie yet.
if not self.object.owner:
anonymous_url = self.object.get_anonymous_edit_url()
permissions["anonymous_edit_url"] = anonymous_url
response = simple_json_response(
id=self.object.pk,
url=self.object.get_absolute_url(),
permissions=permissions,
)
if not self.request.user.is_authenticated:
key, value = self.object.signed_cookie_elements
response.set_signed_cookie(
key=key, value=value, max_age=ANONYMOUS_COOKIE_MAX_AGE
)
return response
class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView):
model = Map
form_class = MapSettingsForm
pk_url_kwarg = "map_id"
def form_valid(self, form):
self.object.settings = form.cleaned_data["settings"]
self.object.save()
return simple_json_response(
id=self.object.pk,
url=self.object.get_absolute_url(),
permissions=self.get_permissions(),
info=_("Map has been updated!"),
)
class UpdateMapPermissions(FormLessEditMixin, UpdateView):
model = Map
pk_url_kwarg = "map_id"