Compare commits

...

8 commits

Author SHA1 Message Date
Yohan Boniface
45a0ec5d42 fix: add default saved message
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
Now that we do not always do a save on the map itself, we may have
no feedback message in case we only save layers.
2024-12-13 10:05:20 +01:00
Yohan Boniface
cbab6e03f8 fix: properly close the translate parentesis 2024-12-13 09:48:03 +01:00
Yohan Boniface
54aa687ba1 2.8.0a2 2024-12-13 09:33:24 +01:00
Yohan Boniface
8346459f28 i18n 2024-12-13 09:31:02 +01:00
Yohan Boniface
cd24bf0b3d
feat: refactor importer feedback (#2363)
We basically make the all import chain return the results as promise, so
the importer can act at the end of the process and:
- zoom only to the imported data (in case the layer already as some)
- display a counter of imported data when import is successfull
- display an alert when nothing has been imported

cf #564
2024-12-13 09:18:08 +01:00
Yohan Boniface
30b685ae43
chore: more logs in the ajax proxy validate url (#2362)
This should only be used in localhost, but there are a bunch of check
and it's often that we need to add print to understand why the URL does
not validate, which is usually an issue with the SITE_URL or this kind.
So let's make those print permanent.
2024-12-13 09:12:12 +01:00
Yohan Boniface
de921660c9 feat: refactor importer feedback
We basically make the all import chain return the results as promise, so
the importer can act at the end of the process and:
- zoom only to the imported data (in case the layer already as some)
- display a counter of imported data when import is successfull
- display an alert when nothing has been imported

cf #564
2024-12-12 21:52:54 +01:00
Yohan Boniface
8db974b96a chore: more logs in the ajax proxy validate url
This should only be used in localhost, but there are a bunch of
check and it's often that we need to add print to understand why
the URL does not validate, which is usually an issue with the SITE_URL
or this kind. So let's make those print permanent.
2024-12-12 21:06:09 +01:00
11 changed files with 156 additions and 70 deletions

View file

@ -1,5 +1,11 @@
# Changelog # Changelog
## 2.8.0a2 - 2024-12-13
## Bug fixes
* make sure we set X-DataLayer-Version even when using X-Accel-Redirect by @yohanboniface in #2361
* refactor importer feedback by @yohanboniface in #2363
## 2.8.0a1 - 2024-12-11 ## 2.8.0a1 - 2024-12-11
### Internal changes ### Internal changes

View file

@ -1 +1 @@
VERSION = "2.8.0a1" VERSION = "2.8.0a2"

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-11 17:05+0000\n" "POT-Creation-Date: 2024-12-13 08:26+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -617,57 +617,57 @@ msgstr ""
msgid "View the map" msgid "View the map"
msgstr "" msgstr ""
#: views.py:820 #: views.py:821
msgid "See full screen" msgid "See full screen"
msgstr "" msgstr ""
#: views.py:963 #: views.py:964
msgid "Map editors updated with success!" msgid "Map editors updated with success!"
msgstr "" msgstr ""
#: views.py:999 #: views.py:1000
#, python-format #, python-format
msgid "The uMap edit link for your map: %(map_name)s" msgid "The uMap edit link for your map: %(map_name)s"
msgstr "" msgstr ""
#: views.py:1002 #: views.py:1003
#, python-format #, python-format
msgid "Here is your secret edit link: %(link)s" msgid "Here is your secret edit link: %(link)s"
msgstr "" msgstr ""
#: views.py:1009 #: views.py:1010
#, python-format #, python-format
msgid "Can't send email to %(email)s" msgid "Can't send email to %(email)s"
msgstr "" msgstr ""
#: views.py:1012 #: views.py:1013
#, python-format #, python-format
msgid "Email sent to %(email)s" msgid "Email sent to %(email)s"
msgstr "" msgstr ""
#: views.py:1023 #: views.py:1024
msgid "Only its owner can delete the map." msgid "Only its owner can delete the map."
msgstr "" msgstr ""
#: views.py:1026 #: views.py:1027
msgid "Map successfully deleted." msgid "Map successfully deleted."
msgstr "" msgstr ""
#: views.py:1052 #: views.py:1053
#, python-format #, python-format
msgid "" msgid ""
"Your map has been cloned! If you want to edit this map from another " "Your map has been cloned! If you want to edit this map from another "
"computer, please use this link: %(anonymous_url)s" "computer, please use this link: %(anonymous_url)s"
msgstr "" msgstr ""
#: views.py:1057 #: views.py:1058
msgid "Congratulations, your map has been cloned!" msgid "Congratulations, your map has been cloned!"
msgstr "" msgstr ""
#: views.py:1308 #: views.py:1309
msgid "Layer successfully deleted." msgid "Layer successfully deleted."
msgstr "" msgstr ""
#: views.py:1330 #: views.py:1331
msgid "Permissions updated with success!" msgid "Permissions updated with success!"
msgstr "" msgstr ""

View file

@ -252,10 +252,11 @@ export class DataLayer extends ServerStored {
} }
fromGeoJSON(geojson, sync = true) { fromGeoJSON(geojson, sync = true) {
this.addData(geojson, sync) const features = this.addData(geojson, sync)
this._geojson = geojson this._geojson = geojson
this.onDataLoaded() this.onDataLoaded()
this.dataChanged() this.dataChanged()
return features
} }
onDataLoaded() { onDataLoaded() {
@ -315,7 +316,7 @@ export class DataLayer extends ServerStored {
const response = await this._umap.request.get(url) const response = await this._umap.request.get(url)
if (response?.ok) { if (response?.ok) {
this.clear() this.clear()
this._umap.formatter return this._umap.formatter
.parse(await response.text(), this.options.remoteData.format) .parse(await response.text(), this.options.remoteData.format)
.then((geojson) => this.fromGeoJSON(geojson)) .then((geojson) => this.fromGeoJSON(geojson))
} }
@ -443,10 +444,11 @@ export class DataLayer extends ServerStored {
try { try {
// Do not fail if remote data is somehow invalid, // Do not fail if remote data is somehow invalid,
// otherwise the layer becomes uneditable. // otherwise the layer becomes uneditable.
this.makeFeatures(geojson, sync) return this.makeFeatures(geojson, sync)
} catch (err) { } catch (err) {
console.log('Error with DataLayer', this.id) console.log('Error with DataLayer', this.id)
console.error(err) console.error(err)
return []
} }
} }
@ -463,10 +465,13 @@ export class DataLayer extends ServerStored {
? geojson ? geojson
: geojson.features || geojson.geometries : geojson.features || geojson.geometries
if (!collection) return if (!collection) return
const features = []
this.sortFeatures(collection) this.sortFeatures(collection)
for (const feature of collection) { for (const featureJson of collection) {
this.makeFeature(feature, sync) const feature = this.makeFeature(featureJson, sync)
if (feature) features.push(feature)
} }
return features
} }
makeFeature(geojson = {}, sync = true, id = null) { makeFeature(geojson = {}, sync = true, id = null) {
@ -503,31 +508,47 @@ export class DataLayer extends ServerStored {
} }
async importRaw(raw, format) { async importRaw(raw, format) {
this._umap.formatter return this._umap.formatter
.parse(raw, format) .parse(raw, format)
.then((geojson) => this.addData(geojson)) .then((geojson) => this.addData(geojson))
.then(() => this.zoomTo()) .then((data) => {
this.isDirty = true if (data?.length) this.isDirty = true
return data
})
} }
importFromFiles(files, type) { readFile(f) {
for (const f of files) { return new Promise((resolve) => {
this.importFromFile(f, type)
}
}
importFromFile(f, type) {
const reader = new FileReader() const reader = new FileReader()
type = type || Utils.detectFileType(f) reader.onloadend = () => resolve(reader.result)
reader.readAsText(f) reader.readAsText(f)
reader.onload = (e) => this.importRaw(e.target.result, type) })
}
async importFromFiles(files, type) {
let all = []
for (const file of files) {
const features = await this.importFromFile(file, type)
if (features) {
all = all.concat(features)
}
}
return new Promise((resolve) => {
resolve(all)
})
}
async importFromFile(file, type) {
type = type || Utils.detectFileType(f)
const raw = await this.readFile(file)
return this.importRaw(raw, type)
} }
async importFromUrl(uri, type) { async importFromUrl(uri, type) {
uri = this._umap.renderUrl(uri) uri = this._umap.renderUrl(uri)
const response = await this._umap.request.get(uri) const response = await this._umap.request.get(uri)
if (response?.ok) { if (response?.ok) {
this.importRaw(await response.text(), type) return this.importRaw(await response.text(), type)
} }
} }
@ -930,9 +951,9 @@ export class DataLayer extends ServerStored {
else this.hide() else this.hide()
} }
zoomTo() { zoomTo(bounds) {
if (!this.isVisible()) return if (!this.isVisible()) return
const bounds = this.layer.getBounds() bounds = bounds || this.layer.getBounds()
if (bounds.isValid()) { if (bounds.isValid()) {
const options = { maxZoom: this.getOption('zoomTo') } const options = { maxZoom: this.getOption('zoomTo') }
this._leafletMap.fitBounds(bounds, options) this._leafletMap.fitBounds(bounds, options)

View file

@ -1,4 +1,8 @@
import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import {
DomEvent,
DomUtil,
LatLngBounds,
} from '../../vendors/leaflet/leaflet-src.esm.js'
import { uMapAlert as Alert } from '../components/alerts/alert.js' import { uMapAlert as Alert } from '../components/alerts/alert.js'
import { translate } from './i18n.js' import { translate } from './i18n.js'
import { SCHEMA } from './schema.js' import { SCHEMA } from './schema.js'
@ -270,16 +274,12 @@ export default class Importer extends Utils.WithTemplate {
} }
submit() { submit() {
let hasErrors
if (this.format === 'umap') { if (this.format === 'umap') {
hasErrors = !this.full() this.full()
} else if (!this.url) { } else if (!this.url) {
hasErrors = !this.copy() this.copy()
} else if (this.action) { } else if (this.action) {
hasErrors = !this[this.action]() this[this.action]()
}
if (hasErrors === false) {
Alert.info(translate('Data successfully imported!'))
} }
} }
@ -294,8 +294,9 @@ export default class Importer extends Utils.WithTemplate {
} else if (this.url) { } else if (this.url) {
this._umap.importFromUrl(this.url, this.format) this._umap.importFromUrl(this.url, this.format)
} }
this.onSuccess()
} catch (e) { } catch (e) {
Alert.error(translate('Invalid umap data')) this.onError(translate('Invalid umap data'))
console.error(e) console.error(e)
return false return false
} }
@ -306,7 +307,7 @@ export default class Importer extends Utils.WithTemplate {
return false return false
} }
if (!this.format) { if (!this.format) {
Alert.error(translate('Please choose a format')) this.onError(translate('Please choose a format'))
return false return false
} }
const layer = this.layer const layer = this.layer
@ -318,26 +319,67 @@ export default class Importer extends Utils.WithTemplate {
layer.options.remoteData.proxy = true layer.options.remoteData.proxy = true
layer.options.remoteData.ttl = SCHEMA.ttl.default layer.options.remoteData.ttl = SCHEMA.ttl.default
} }
layer.fetchRemoteData(true) layer.fetchRemoteData(true).then((features) => {
if (features?.length) {
layer.zoomTo()
this.onSuccess()
} else {
this.onError()
}
})
} }
copy() { async copy() {
// Format may be guessed from file later. // Format may be guessed from file later.
// Usefull in case of multiple files with different formats. // Usefull in case of multiple files with different formats.
if (!this.format && !this.files.length) { if (!this.format && !this.files.length) {
Alert.error(translate('Please choose a format')) this.onError(translate('Please choose a format'))
return false return false
} }
let promise
const layer = this.layer const layer = this.layer
if (this.clear) layer.empty() if (this.clear) layer.empty()
if (this.files.length) { if (this.files.length) {
for (const file of this.files) { promise = layer.importFromFiles(this.files, this.format)
this._umap.processFileToImport(file, layer, this.format)
}
} else if (this.raw) { } else if (this.raw) {
layer.importRaw(this.raw, this.format) promise = layer.importRaw(this.raw, this.format)
} else if (this.url) { } else if (this.url) {
layer.importFromUrl(this.url, this.format) promise = layer.importFromUrl(this.url, this.format)
}
if (promise) promise.then((data) => this.onCopyFinished(layer, data))
}
onError(message = translate('No data has been found for import')) {
Alert.error(message)
}
onSuccess(count) {
if (count) {
Alert.success(
translate('Successfully imported {count} feature(s)', {
count: count,
})
)
} else {
Alert.success(translate('Data successfully imported!'))
}
}
onCopyFinished(layer, features) {
// undefined features means error, let original error message pop
if (!features) return
if (!features.length) {
this.onError()
} else {
const bounds = new LatLngBounds()
for (const feature of features) {
const featureBounds = feature.ui.getBounds
? feature.ui.getBounds()
: feature.ui.getCenter()
bounds.extend(featureBounds)
}
this.onSuccess(features.length)
layer.zoomTo(bounds)
} }
} }
} }

View file

@ -316,12 +316,14 @@ export default class Umap extends ServerStored {
dataUrl = this.renderUrl(dataUrl) dataUrl = this.renderUrl(dataUrl)
dataUrl = this.proxyUrl(dataUrl) dataUrl = this.proxyUrl(dataUrl)
const datalayer = this.createDataLayer() const datalayer = this.createDataLayer()
await datalayer.importFromUrl(dataUrl, dataFormat) await datalayer
.importFromUrl(dataUrl, dataFormat)
.then(() => datalayer.zoomTo())
} }
} else if (data) { } else if (data) {
data = decodeURIComponent(data) data = decodeURIComponent(data)
const datalayer = this.createDataLayer() const datalayer = this.createDataLayer()
await datalayer.importRaw(data, dataFormat) await datalayer.importRaw(data, dataFormat).then(() => datalayer.zoomTo())
} }
} }
@ -641,6 +643,12 @@ export default class Umap extends ServerStored {
// have changed, we'll be more subtil when we'll remove the // have changed, we'll be more subtil when we'll remove the
// save action // save action
this.render(['name', 'user', 'permissions']) this.render(['name', 'user', 'permissions'])
if (!this._leafletMap.listens('saved')) {
// When we save only layers, we don't have the map feedback message
this._leafletMap.on('saved', () => {
Alert.success(translate('Map has been saved!'))
})
}
this.fire('saved') this.fire('saved')
} }
@ -1514,7 +1522,7 @@ export default class Umap extends ServerStored {
processFileToImport(file, layer, type) { processFileToImport(file, layer, type) {
type = type || Utils.detectFileType(file) type = type || Utils.detectFileType(file)
if (!type) { if (!type) {
U.Alert.error( Alert.error(
translate('Unable to detect format of file {filename}', { translate('Unable to detect format of file {filename}', {
filename: file.name, filename: file.name,
}) })

View file

@ -520,7 +520,9 @@ const locale = {
"Import helpers": "Import helpers", "Import helpers": "Import helpers",
"Import helpers will fill the URL field for you.": "Import helpers will fill the URL field for you.", "Import helpers will fill the URL field for you.": "Import helpers will fill the URL field for you.",
"Wikipedia": "Wikipedia", "Wikipedia": "Wikipedia",
"Save draft": "Save draft" "Save draft": "Save draft",
"No data has been found for import": "No data has been found for import",
"Successfully imported {count} feature(s)": "Successfully imported {count} feature(s)"
} }
L.registerLocale("en", locale) L.registerLocale("en", locale)
L.setLocale("en") L.setLocale("en")

View file

@ -520,5 +520,7 @@
"Import helpers": "Import helpers", "Import helpers": "Import helpers",
"Import helpers will fill the URL field for you.": "Import helpers will fill the URL field for you.", "Import helpers will fill the URL field for you.": "Import helpers will fill the URL field for you.",
"Wikipedia": "Wikipedia", "Wikipedia": "Wikipedia",
"Save draft": "Save draft" "Save draft": "Save draft",
"No data has been found for import": "No data has been found for import",
"Successfully imported {count} feature(s)": "Successfully imported {count} feature(s)"
} }

View file

@ -520,7 +520,9 @@ const locale = {
"Import helpers": "Assistants d'import", "Import helpers": "Assistants d'import",
"Import helpers will fill the URL field for you.": "Les assistants d'import vont renseigner le champ URL pour vous.", "Import helpers will fill the URL field for you.": "Les assistants d'import vont renseigner le champ URL pour vous.",
"Wikipedia": "Wikipedia", "Wikipedia": "Wikipedia",
"Save draft": "Enregistrer le brouillon" "Save draft": "Enregistrer le brouillon",
"No data has been found for import": "Aucunes données à importer",
"Successfully imported {count} feature(s)": "{count} élément(s) ajouté(s) à la carte"
} }
L.registerLocale("fr", locale) L.registerLocale("fr", locale)
L.setLocale("fr") L.setLocale("fr")

View file

@ -520,5 +520,7 @@
"Import helpers": "Assistants d'import", "Import helpers": "Assistants d'import",
"Import helpers will fill the URL field for you.": "Les assistants d'import vont renseigner le champ URL pour vous.", "Import helpers will fill the URL field for you.": "Les assistants d'import vont renseigner le champ URL pour vous.",
"Wikipedia": "Wikipedia", "Wikipedia": "Wikipedia",
"Save draft": "Enregistrer le brouillon" "Save draft": "Enregistrer le brouillon",
"No data has been found for import": "Aucunes données à importer",
"Successfully imported {count} feature(s)": "{count} élément(s) ajouté(s) à la carte"
} }

View file

@ -452,27 +452,27 @@ showcase = MapsShowCase.as_view()
def validate_url(request): def validate_url(request):
assert request.method == "GET" assert request.method == "GET", "Wrong HTTP method"
url = request.GET.get("url") url = request.GET.get("url")
assert url assert url, "Missing URL"
try: try:
URLValidator(url) URLValidator(url)
except ValidationError: except ValidationError as err:
raise AssertionError() raise AssertionError(err)
assert "HTTP_REFERER" in request.META assert "HTTP_REFERER" in request.META, "Missing HTTP_REFERER"
referer = urlparse(request.META.get("HTTP_REFERER")) referer = urlparse(request.META.get("HTTP_REFERER"))
toproxy = urlparse(url) toproxy = urlparse(url)
local = urlparse(settings.SITE_URL) local = urlparse(settings.SITE_URL)
assert toproxy.hostname assert toproxy.hostname, "No hostname"
assert referer.hostname == local.hostname assert referer.hostname == local.hostname, f"{referer.hostname} != {local.hostname}"
assert toproxy.hostname != "localhost" assert toproxy.hostname != "localhost", "Invalid localhost target"
assert toproxy.netloc != local.netloc assert toproxy.netloc != local.netloc, "Invalid netloc"
try: try:
# clean this when in python 3.4 # clean this when in python 3.4
ipaddress = socket.gethostbyname(toproxy.hostname) ipaddress = socket.gethostbyname(toproxy.hostname)
except: except Exception as err:
raise AssertionError() raise AssertionError(err)
assert not PRIVATE_IP.match(ipaddress) assert not PRIVATE_IP.match(ipaddress), "Private IP"
return url return url
@ -480,7 +480,8 @@ class AjaxProxy(View):
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
try: try:
url = validate_url(self.request) url = validate_url(self.request)
except AssertionError: except AssertionError as err:
print(f"AjaxProxy: {err}")
return HttpResponseBadRequest() return HttpResponseBadRequest()
try: try:
ttl = int(self.request.GET.get("ttl")) ttl = int(self.request.GET.get("ttl"))