Merge pull request #1573 from umap-project/map-preview
feat: add experimental "map preview" in /map/ endpoint
This commit is contained in:
commit
10efc5d103
5 changed files with 219 additions and 108 deletions
|
@ -69,6 +69,65 @@ L.U.Map.include({
|
||||||
'tilelayers',
|
'tilelayers',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
editableOptions: {
|
||||||
|
'zoom': undefined,
|
||||||
|
'scrollWheelZoom': Boolean,
|
||||||
|
'scaleControl': Boolean,
|
||||||
|
'moreControl': Boolean,
|
||||||
|
'miniMap': Boolean,
|
||||||
|
'displayPopupFooter': undefined,
|
||||||
|
'onLoadPanel': String,
|
||||||
|
'defaultView': String,
|
||||||
|
'name': String,
|
||||||
|
'description': String,
|
||||||
|
'licence': undefined,
|
||||||
|
'tilelayer': undefined,
|
||||||
|
'overlay': undefined,
|
||||||
|
'limitBounds': undefined,
|
||||||
|
'color': String,
|
||||||
|
'iconClass': String,
|
||||||
|
'iconUrl': String,
|
||||||
|
'smoothFactor': undefined,
|
||||||
|
'iconOpacity': undefined,
|
||||||
|
'opacity': undefined,
|
||||||
|
'weight': undefined,
|
||||||
|
'fill': undefined,
|
||||||
|
'fillColor': undefined,
|
||||||
|
'fillOpacity': undefined,
|
||||||
|
'dashArray': undefined,
|
||||||
|
'popupShape': String,
|
||||||
|
'popupTemplate': String,
|
||||||
|
'popupContentTemplate': String,
|
||||||
|
'zoomTo': undefined,
|
||||||
|
'captionBar': Boolean,
|
||||||
|
'captionMenus': Boolean,
|
||||||
|
'slideshow': undefined,
|
||||||
|
'sortKey': undefined,
|
||||||
|
'labelKey': undefined,
|
||||||
|
'filterKey': undefined,
|
||||||
|
'facetKey': undefined,
|
||||||
|
'slugKey': undefined,
|
||||||
|
'showLabel': undefined,
|
||||||
|
'labelDirection': undefined,
|
||||||
|
'labelInteractive': undefined,
|
||||||
|
'outlinkTarget': undefined,
|
||||||
|
'shortCredit': undefined,
|
||||||
|
'longCredit': undefined,
|
||||||
|
'permanentCredit': undefined,
|
||||||
|
'permanentCreditBackground': undefined,
|
||||||
|
'zoomControl': 'NullableBoolean',
|
||||||
|
'datalayersControl': 'NullableBoolean',
|
||||||
|
'searchControl': 'NullableBoolean',
|
||||||
|
'locateControl': 'NullableBoolean',
|
||||||
|
'fullscreenControl': 'NullableBoolean',
|
||||||
|
'editinosmControl': 'NullableBoolean',
|
||||||
|
'embedControl': 'NullableBoolean',
|
||||||
|
'measureControl': 'NullableBoolean',
|
||||||
|
'tilelayersControl': 'NullableBoolean',
|
||||||
|
'starControl': 'NullableBoolean',
|
||||||
|
'easing': undefined,
|
||||||
|
},
|
||||||
|
|
||||||
initialize: function (el, geojson) {
|
initialize: function (el, geojson) {
|
||||||
// Locale name (pt_PT, en_US…)
|
// Locale name (pt_PT, en_US…)
|
||||||
// To be used for Django localization
|
// To be used for Django localization
|
||||||
|
@ -89,7 +148,7 @@ L.U.Map.include({
|
||||||
? geojson.properties.fullscreenControl
|
? geojson.properties.fullscreenControl
|
||||||
: true
|
: true
|
||||||
geojson.properties.fullscreenControl = false
|
geojson.properties.fullscreenControl = false
|
||||||
L.Util.setBooleanFromQueryString(geojson.properties, 'scrollWheelZoom')
|
this.setOptionsFromQueryString(geojson.properties)
|
||||||
|
|
||||||
L.Map.prototype.initialize.call(this, el, geojson.properties)
|
L.Map.prototype.initialize.call(this, el, geojson.properties)
|
||||||
|
|
||||||
|
@ -109,32 +168,10 @@ L.U.Map.include({
|
||||||
this.demoTileInfos = this.options.demoTileInfos
|
this.demoTileInfos = this.options.demoTileInfos
|
||||||
this.options.zoomControl = zoomControl
|
this.options.zoomControl = zoomControl
|
||||||
this.options.fullscreenControl = fullscreenControl
|
this.options.fullscreenControl = fullscreenControl
|
||||||
L.Util.setBooleanFromQueryString(this.options, 'moreControl')
|
|
||||||
L.Util.setBooleanFromQueryString(this.options, 'scaleControl')
|
|
||||||
L.Util.setBooleanFromQueryString(this.options, 'miniMap')
|
|
||||||
L.Util.setFromQueryString(this.options, 'editMode')
|
|
||||||
L.Util.setBooleanFromQueryString(this.options, 'displayDataBrowserOnLoad')
|
|
||||||
L.Util.setBooleanFromQueryString(this.options, 'displayCaptionOnLoad')
|
|
||||||
L.Util.setBooleanFromQueryString(this.options, 'captionBar')
|
|
||||||
L.Util.setBooleanFromQueryString(this.options, 'captionMenus')
|
|
||||||
for (let i = 0; i < this.HIDDABLE_CONTROLS.length; i++) {
|
|
||||||
L.Util.setNullableBooleanFromQueryString(
|
|
||||||
this.options,
|
|
||||||
`${this.HIDDABLE_CONTROLS[i]}Control`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Specific case for datalayersControl
|
|
||||||
// which accept "expanded" value, on top of true/false/null
|
|
||||||
if (L.Util.queryString('datalayersControl') === 'expanded') {
|
|
||||||
L.Util.setFromQueryString(this.options, 'datalayersControl')
|
|
||||||
}
|
|
||||||
this.datalayersOnLoad = L.Util.queryString('datalayers')
|
this.datalayersOnLoad = L.Util.queryString('datalayers')
|
||||||
this.options.onLoadPanel = L.Util.queryString(
|
if (this.datalayersOnLoad) {
|
||||||
'onLoadPanel',
|
|
||||||
this.options.onLoadPanel
|
|
||||||
)
|
|
||||||
if (this.datalayersOnLoad)
|
|
||||||
this.datalayersOnLoad = this.datalayersOnLoad.toString().split(',')
|
this.datalayersOnLoad = this.datalayersOnLoad.toString().split(',')
|
||||||
|
}
|
||||||
|
|
||||||
if (L.Browser.ielt9) this.options.editMode = 'disabled' // TODO include ie9
|
if (L.Browser.ielt9) this.options.editMode = 'disabled' // TODO include ie9
|
||||||
|
|
||||||
|
@ -233,18 +270,25 @@ L.U.Map.include({
|
||||||
|
|
||||||
// Creation mode
|
// Creation mode
|
||||||
if (!this.options.umap_id) {
|
if (!this.options.umap_id) {
|
||||||
this.isDirty = true
|
if (!this.options.preview) {
|
||||||
|
this.isDirty = true
|
||||||
|
this.enableEdit()
|
||||||
|
}
|
||||||
this._default_extent = true
|
this._default_extent = true
|
||||||
this.options.name = L._('Untitled map')
|
this.options.name = L._('Untitled map')
|
||||||
this.options.editMode = 'advanced'
|
let data = L.Util.queryString('data', null)
|
||||||
this.enableEdit()
|
|
||||||
let dataUrl = L.Util.queryString('dataUrl', null)
|
let dataUrl = L.Util.queryString('dataUrl', null)
|
||||||
const dataFormat = L.Util.queryString('dataFormat', 'geojson')
|
const dataFormat = L.Util.queryString('dataFormat', 'geojson')
|
||||||
if (dataUrl) {
|
if (dataUrl) {
|
||||||
dataUrl = decodeURIComponent(dataUrl)
|
dataUrl = decodeURIComponent(dataUrl)
|
||||||
dataUrl = this.localizeUrl(dataUrl)
|
dataUrl = this.localizeUrl(dataUrl)
|
||||||
dataUrl = this.proxyUrl(dataUrl)
|
dataUrl = this.proxyUrl(dataUrl)
|
||||||
|
const datalayer = this.createDataLayer()
|
||||||
datalayer.importFromUrl(dataUrl, dataFormat)
|
datalayer.importFromUrl(dataUrl, dataFormat)
|
||||||
|
} else if (data) {
|
||||||
|
data = decodeURIComponent(data)
|
||||||
|
const datalayer = this.createDataLayer()
|
||||||
|
datalayer.importRaw(data, dataFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -290,12 +334,37 @@ L.U.Map.include({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.onbeforeunload = () => this.isDirty || null
|
window.onbeforeunload = () => this.editEnabled && this.isDirty || null
|
||||||
this.backup()
|
this.backup()
|
||||||
this.initContextMenu()
|
this.initContextMenu()
|
||||||
this.on('click contextmenu.show', this.closeInplaceToolbar)
|
this.on('click contextmenu.show', this.closeInplaceToolbar)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setOptionsFromQueryString: function (options) {
|
||||||
|
// This is not an editable option
|
||||||
|
L.Util.setFromQueryString(options, 'editMode')
|
||||||
|
// FIXME retrocompat
|
||||||
|
L.Util.setBooleanFromQueryString(options, 'displayDataBrowserOnLoad')
|
||||||
|
L.Util.setBooleanFromQueryString(options, 'displayCaptionOnLoad')
|
||||||
|
for (const [key, type] of Object.entries(this.editableOptions)) {
|
||||||
|
switch (type) {
|
||||||
|
case Boolean:
|
||||||
|
L.Util.setBooleanFromQueryString(options, key)
|
||||||
|
break
|
||||||
|
case 'NullableBoolean':
|
||||||
|
L.Util.setNullableBooleanFromQueryString(options, key)
|
||||||
|
break
|
||||||
|
case String:
|
||||||
|
L.Util.setFromQueryString(options, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Specific case for datalayersControl
|
||||||
|
// which accepts "expanded" value, on top of true/false/null
|
||||||
|
if (L.Util.queryString('datalayersControl') === 'expanded') {
|
||||||
|
L.Util.setFromQueryString(options, 'datalayersControl')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
initControls: function () {
|
initControls: function () {
|
||||||
this.helpMenuActions = {}
|
this.helpMenuActions = {}
|
||||||
this._controls = {}
|
this._controls = {}
|
||||||
|
@ -852,8 +921,7 @@ L.U.Map.include({
|
||||||
|
|
||||||
let mustReindex = false
|
let mustReindex = false
|
||||||
|
|
||||||
for (let i = 0; i < this.editableOptions.length; i++) {
|
for (const option of Object.keys(this.editableOptions)) {
|
||||||
const option = this.editableOptions[i]
|
|
||||||
if (typeof importedData.properties[option] !== 'undefined') {
|
if (typeof importedData.properties[option] !== 'undefined') {
|
||||||
this.options[option] = importedData.properties[option]
|
this.options[option] = importedData.properties[option]
|
||||||
if (option === 'sortKey') mustReindex = true
|
if (option === 'sortKey') mustReindex = true
|
||||||
|
@ -980,70 +1048,11 @@ L.U.Map.include({
|
||||||
else this.fire('saved')
|
else this.fire('saved')
|
||||||
},
|
},
|
||||||
|
|
||||||
editableOptions: [
|
|
||||||
'zoom',
|
|
||||||
'scrollWheelZoom',
|
|
||||||
'scaleControl',
|
|
||||||
'moreControl',
|
|
||||||
'miniMap',
|
|
||||||
'displayPopupFooter',
|
|
||||||
'onLoadPanel',
|
|
||||||
'defaultView',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'licence',
|
|
||||||
'tilelayer',
|
|
||||||
'overlay',
|
|
||||||
'limitBounds',
|
|
||||||
'color',
|
|
||||||
'iconClass',
|
|
||||||
'iconUrl',
|
|
||||||
'smoothFactor',
|
|
||||||
'iconOpacity',
|
|
||||||
'opacity',
|
|
||||||
'weight',
|
|
||||||
'fill',
|
|
||||||
'fillColor',
|
|
||||||
'fillOpacity',
|
|
||||||
'dashArray',
|
|
||||||
'popupShape',
|
|
||||||
'popupTemplate',
|
|
||||||
'popupContentTemplate',
|
|
||||||
'zoomTo',
|
|
||||||
'captionBar',
|
|
||||||
'captionMenus',
|
|
||||||
'slideshow',
|
|
||||||
'sortKey',
|
|
||||||
'labelKey',
|
|
||||||
'filterKey',
|
|
||||||
'facetKey',
|
|
||||||
'slugKey',
|
|
||||||
'showLabel',
|
|
||||||
'labelDirection',
|
|
||||||
'labelInteractive',
|
|
||||||
'outlinkTarget',
|
|
||||||
'shortCredit',
|
|
||||||
'longCredit',
|
|
||||||
'permanentCredit',
|
|
||||||
'permanentCreditBackground',
|
|
||||||
'zoomControl',
|
|
||||||
'datalayersControl',
|
|
||||||
'searchControl',
|
|
||||||
'locateControl',
|
|
||||||
'fullscreenControl',
|
|
||||||
'editinosmControl',
|
|
||||||
'embedControl',
|
|
||||||
'measureControl',
|
|
||||||
'tilelayersControl',
|
|
||||||
'starControl',
|
|
||||||
'easing',
|
|
||||||
],
|
|
||||||
|
|
||||||
exportOptions: function () {
|
exportOptions: function () {
|
||||||
const properties = {}
|
const properties = {}
|
||||||
for (let i = this.editableOptions.length - 1; i >= 0; i--) {
|
for (const option of Object.keys(this.editableOptions)) {
|
||||||
if (typeof this.options[this.editableOptions[i]] !== 'undefined') {
|
if (typeof this.options[option] !== 'undefined') {
|
||||||
properties[this.editableOptions[i]] = this.options[this.editableOptions[i]]
|
properties[option] = this.options[option]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return properties
|
return properties
|
||||||
|
|
75
umap/tests/integration/test_map_preview.py
Normal file
75
umap/tests/integration/test_map_preview.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
GEOJSON = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {
|
||||||
|
"name": "Niagara Falls",
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [-79.04, 43.08],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
CSV = "name,latitude,longitude\nNiagara Falls,43.08,-79.04"
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_preview(page, live_server, tilelayer):
|
||||||
|
page.goto(f"{live_server.url}/map/")
|
||||||
|
# Edit mode is not enabled
|
||||||
|
edit_button = page.get_by_role("button", name="Edit")
|
||||||
|
expect(edit_button).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_preview_can_load_remote_geojson(page, live_server, tilelayer):
|
||||||
|
def handle(route):
|
||||||
|
route.fulfill(json=GEOJSON)
|
||||||
|
|
||||||
|
# Intercept the route to the proxy
|
||||||
|
page.route("*/**/ajax-proxy/**", handle)
|
||||||
|
|
||||||
|
page.goto(f"{live_server.url}/map/?dataUrl=http://some.org/geo.json")
|
||||||
|
markers = page.locator(".leaflet-marker-icon")
|
||||||
|
expect(markers).to_have_count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_preview_can_load_remote_csv(page, live_server, tilelayer):
|
||||||
|
def handle(route):
|
||||||
|
csv = """name,latitude,longitude\nNiagara Falls,43.08,-79.04"""
|
||||||
|
route.fulfill(body=csv)
|
||||||
|
|
||||||
|
# Intercept the route to the proxy
|
||||||
|
page.route("*/**/ajax-proxy/**", handle)
|
||||||
|
|
||||||
|
page.goto(f"{live_server.url}/map/?dataUrl=http://some.org/geo.csv&dataFormat=csv")
|
||||||
|
markers = page.locator(".leaflet-marker-icon")
|
||||||
|
expect(markers).to_have_count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_preview_can_load_geojson_in_querystring(page, live_server, tilelayer):
|
||||||
|
page.goto(f"{live_server.url}/map/?data={quote(json.dumps(GEOJSON))}")
|
||||||
|
markers = page.locator(".leaflet-marker-icon")
|
||||||
|
expect(markers).to_have_count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_preview_can_load_csv_in_querystring(page, live_server, tilelayer):
|
||||||
|
page.goto(f"{live_server.url}/map/?data={quote(CSV)}&dataFormat=csv")
|
||||||
|
markers = page.locator(".leaflet-marker-icon")
|
||||||
|
expect(markers).to_have_count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_preview_can_change_styling_from_querystring(page, live_server, tilelayer):
|
||||||
|
page.goto(f"{live_server.url}/map/?data={quote(json.dumps(GEOJSON))}&color=DarkRed")
|
||||||
|
markers = page.locator(".leaflet-marker-icon .icon_container")
|
||||||
|
expect(markers).to_have_count(1)
|
||||||
|
expect(markers).to_have_css("background-color", "rgb(139, 0, 0)")
|
|
@ -1,3 +1,5 @@
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
@ -37,3 +39,14 @@ def test_datalayers_control(map, live_server, datalayer, page):
|
||||||
page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=expanded")
|
page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=expanded")
|
||||||
expect(control).to_be_hidden()
|
expect(control).to_be_hidden()
|
||||||
expect(box).to_be_visible()
|
expect(box).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_deactivate_wheel_from_query_string(map, live_server, page):
|
||||||
|
page.goto(f"{live_server.url}{map.get_absolute_url()}")
|
||||||
|
expect(page).to_have_url(re.compile(r".*#7/.+"))
|
||||||
|
page.mouse.wheel(0, 1)
|
||||||
|
expect(page).to_have_url(re.compile(r".*#6/.+"))
|
||||||
|
page.goto(f"{live_server.url}{map.get_absolute_url()}?scrollWheelZoom=false")
|
||||||
|
expect(page).to_have_url(re.compile(r".*#7/.+"))
|
||||||
|
page.mouse.wheel(0, 1)
|
||||||
|
expect(page).to_have_url(re.compile(r".*#7/.+"))
|
||||||
|
|
|
@ -96,6 +96,7 @@ i18n_urls += decorated_patterns(
|
||||||
)
|
)
|
||||||
i18n_urls += decorated_patterns(
|
i18n_urls += decorated_patterns(
|
||||||
[ensure_csrf_cookie],
|
[ensure_csrf_cookie],
|
||||||
|
re_path(r"^map/$", views.MapPreview.as_view(), name="map_preview"),
|
||||||
re_path(r"^map/new/$", views.MapNew.as_view(), name="map_new"),
|
re_path(r"^map/new/$", views.MapNew.as_view(), name="map_new"),
|
||||||
)
|
)
|
||||||
i18n_urls += decorated_patterns(
|
i18n_urls += decorated_patterns(
|
||||||
|
|
|
@ -455,8 +455,7 @@ class MapDetailMixin:
|
||||||
if domain and "{" not in domain:
|
if domain and "{" not in domain:
|
||||||
context["preconnect_domains"] = [f"//{domain}"]
|
context["preconnect_domains"] = [f"//{domain}"]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_map_properties(self):
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
properties = {
|
properties = {
|
||||||
"urls": _urls_for_js(),
|
"urls": _urls_for_js(),
|
||||||
|
@ -486,6 +485,17 @@ class MapDetailMixin:
|
||||||
if self.get_short_url():
|
if self.get_short_url():
|
||||||
properties["shortUrl"] = self.get_short_url()
|
properties["shortUrl"] = self.get_short_url()
|
||||||
|
|
||||||
|
if not user.is_anonymous:
|
||||||
|
properties["user"] = {
|
||||||
|
"id": user.pk,
|
||||||
|
"name": str(user),
|
||||||
|
"url": reverse("user_dashboard"),
|
||||||
|
}
|
||||||
|
return properties
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
properties = self.get_map_properties()
|
||||||
if settings.USE_I18N:
|
if settings.USE_I18N:
|
||||||
lang = settings.LANGUAGE_CODE
|
lang = settings.LANGUAGE_CODE
|
||||||
# Check attr in case the middleware is not active
|
# Check attr in case the middleware is not active
|
||||||
|
@ -495,19 +505,13 @@ class MapDetailMixin:
|
||||||
locale = to_locale(lang)
|
locale = to_locale(lang)
|
||||||
properties["locale"] = locale
|
properties["locale"] = locale
|
||||||
context["locale"] = locale
|
context["locale"] = locale
|
||||||
if not user.is_anonymous:
|
geojson = self.get_geojson()
|
||||||
properties["user"] = {
|
if "properties" not in geojson:
|
||||||
"id": user.pk,
|
geojson["properties"] = {}
|
||||||
"name": str(user),
|
geojson["properties"].update(properties)
|
||||||
"url": reverse("user_dashboard"),
|
geojson["properties"]["datalayers"] = self.get_datalayers()
|
||||||
}
|
context["map_settings"] = json.dumps(geojson, indent=settings.DEBUG)
|
||||||
map_settings = self.get_geojson()
|
self.set_preconnect(geojson["properties"], context)
|
||||||
if "properties" not in map_settings:
|
|
||||||
map_settings["properties"] = {}
|
|
||||||
map_settings["properties"].update(properties)
|
|
||||||
map_settings["properties"]["datalayers"] = self.get_datalayers()
|
|
||||||
context["map_settings"] = json.dumps(map_settings, indent=settings.DEBUG)
|
|
||||||
self.set_preconnect(map_settings["properties"], context)
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_datalayers(self):
|
def get_datalayers(self):
|
||||||
|
@ -709,6 +713,15 @@ class MapNew(MapDetailMixin, TemplateView):
|
||||||
template_name = "umap/map_detail.html"
|
template_name = "umap/map_detail.html"
|
||||||
|
|
||||||
|
|
||||||
|
class MapPreview(MapDetailMixin, TemplateView):
|
||||||
|
template_name = "umap/map_detail.html"
|
||||||
|
|
||||||
|
def get_map_properties(self):
|
||||||
|
properties = super().get_map_properties()
|
||||||
|
properties["preview"] = True
|
||||||
|
return properties
|
||||||
|
|
||||||
|
|
||||||
class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView):
|
class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView):
|
||||||
model = Map
|
model = Map
|
||||||
form_class = MapSettingsForm
|
form_class = MapSettingsForm
|
||||||
|
|
Loading…
Reference in a new issue