Merge branch 'master' into dependabot/pip/django-5.0.1

This commit is contained in:
David Larlet 2024-01-30 14:01:55 -05:00 committed by GitHub
commit 5da9b67b2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2143 additions and 78 deletions

11
.eslintrc.json Normal file
View file

@ -0,0 +1,11 @@
{
"plugins": ["compat"],
"extends": ["plugin:compat/recommended"],
"env": {
"es6": true
},
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
}
}

View file

@ -57,6 +57,7 @@ jobs:
- name: Install dependencies
run: |
python3 -m pip install -e .[test,dev]
make installjs
- name: Run Lint
run: make lint

View file

@ -20,9 +20,10 @@ format: ## Format the code and templates files
.PHONY: lint
lint: ## Lint the code and template files
npx eslint umap/static/umap/ &&\
djlint umap/templates --lint &&\
isort --check --profile black umap/ &&\
ruff format --check --target-version=py310 umap/ &&\
ruff format --check --target-version=py310 umap/ &&\
vermin --no-tips --violations -t=3.10- umap/
docs: ## Compile the docs

View file

@ -42,7 +42,7 @@
- `umap/templates/umap/map_table.html`
- `umap/templates/umap/user_dashboard.html`
[See the diff](https://github.com/umap-project/umap/compare/1.12.2...1.13.0#diff-1311890945256dbddf0e59928c2e9d4f59fd6bcc6b1fd33719ef35f03e5168b4).
[See the diff](https://github.com/umap-project/umap/compare/1.12.2...1.13.0#files_bucket).
## 1.12.2 - 2023-12-29

View file

@ -203,6 +203,13 @@ ready for production use (no backup, etc.)
Link to show on the header under the "Feedback and help" label.
#### UMAP_HOME_FEED
Which feed to display on the home page. Three valid values:
- `"latest"`, which shows the latest maps (default)
- `"highlighted"`, which shows the maps that have been starred by a staff member
- `None`, which does not show any map on the home page
#### UMAP_MAPS_PER_PAGE
How many maps to show in maps list, like search or home page.

View file

@ -1,4 +1,6 @@
# How to make a release
# Releases
## How to make a release
1. Run tests:
- `make test`
@ -20,12 +22,35 @@
9. `make publish`
10. `make docker`
## Deploying instances
### Deploying instances
### OSMfr
#### OSMfr
The process is manual for now, Yohan has one Makefile on his computer.
### ANCT
#### ANCT
Update the [Dockerfile](https://gitlab.com/incubateur-territoires/startups/donnees-et-territoires/umap-dsfr-moncomptepro/-/blob/main/Dockerfile?ref_type=heads) with correct version and put a tag `YYYY.MM.DD` in order to deploy it to production.
## When to make a release
We aim to support [Baseline](https://developer.mozilla.org/en-US/blog/baseline-evolution-on-mdn/) “Widely available” (implemented in major browsers within the last 30 months).
### Major (2.Y.Z)
* when we bump Django to a major version
* when we change how we store data (both in database and filesystem)
### Minor (X.3.Z)
* when we add new features
* when we improve an existing feature
* when we improve the usability
* when we change templates
If it's not a major nor a patch, it's a minor.
### Patch (X.Y.12)
* when there are bugfixes

1715
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,8 @@
},
"devDependencies": {
"chai": "^3.3.0",
"eslint": "^8.56.0",
"eslint-plugin-compat": "^4.2.0",
"happen": "~0.1.3",
"lebab": "^3.2.1",
"mocha": "^10.2.0",
@ -60,5 +62,8 @@
"simple-statistics": "^7.8.3",
"togpx": "^0.5.4",
"tokml": "0.4.0"
}
},
"browserslist": [
"> 0.5%, last 2 versions, Firefox ESR, not dead, not op_mini all"
]
}

View file

@ -48,7 +48,7 @@ dev = [
"djlint==1.34.1",
"mkdocs==1.5.3",
"mkdocs-material==9.4.14",
"vermin==1.5.2",
"vermin==1.6.0",
"pymdown-extensions==10.4",
"isort==5.12",
]

View file

@ -216,7 +216,7 @@ class Map(NamedModel):
"umap_id": self.pk,
"onLoadPanel": "none",
"captionBar": False,
"default_iconUrl": "%sumap/img/marker.png" % settings.STATIC_URL,
"default_iconUrl": "%sumap/img/marker.svg" % settings.STATIC_URL,
"slideshow": {},
}
)

View file

@ -34,10 +34,7 @@ if path:
for key in dir(d):
if key.isupper():
value = getattr(d, key)
if key.startswith("LEAFLET_STORAGE"):
# Retrocompat pre 1.0, remove me in 1.1.
globals()["UMAP" + key[15:]] = value
elif key == "UMAP_CUSTOM_TEMPLATES":
if key == "UMAP_CUSTOM_TEMPLATES":
if "DIRS" in globals()["TEMPLATES"][0]:
globals()["TEMPLATES"][0]["DIRS"].insert(0, value)
else:

View file

@ -255,6 +255,7 @@ DATABASES = {"default": env.db(default="postgis://localhost:5432/umap")}
UMAP_DEFAULT_SHARE_STATUS = None
UMAP_DEFAULT_EDIT_STATUS = None
UMAP_DEFAULT_FEATURES_HAVE_OWNERS = False
UMAP_HOME_FEED = "latest"
UMAP_READONLY = env("UMAP_READONLY", default=False)
UMAP_GZIP = True

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" id="circle" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
<path d="M14,7.5c0,3.5899-2.9101,6.5-6.5,6.5S1,11.0899,1,7.5S3.9101,1,7.5,1S14,3.9101,14,7.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -110,13 +110,13 @@ L.U.Browser = L.Class.extend({
const formContainer = L.DomUtil.create('div', '', container)
const dataContainer = L.DomUtil.create('div', 'umap-browse-features', container)
const appendAll = () => {
const rebuildHTML = () => {
dataContainer.innerHTML = ''
this.map.eachBrowsableDataLayer((datalayer) => {
this.addDatalayer(datalayer, dataContainer)
})
}
const resetLayers = () => {
const redrawDataLayers = () => {
this.map.eachBrowsableDataLayer((datalayer) => {
datalayer.resetLayer(true)
})
@ -129,16 +129,16 @@ L.U.Browser = L.Class.extend({
makeDirty: false,
callback: (e) => {
if (e.helper.field === 'options.inBbox') {
if (this.options.inBbox) this.map.on('moveend', appendAll)
else this.map.off('moveend', appendAll)
if (this.options.inBbox) this.map.on('moveend', rebuildHTML)
else this.map.off('moveend', rebuildHTML)
}
appendAll()
resetLayers()
redrawDataLayers()
rebuildHTML()
},
})
formContainer.appendChild(builder.build())
appendAll()
rebuildHTML()
this.map.ui.openPanel({
data: { html: container },

View file

@ -1192,7 +1192,7 @@ L.U.AttributionControl = L.Control.Attribution.extend({
'',
container,
`${L._('Powered by uMap')}`,
'https://github.com/umap-project/umap/'
'https://umap-project.org/'
)
}
L.DomUtil.createLink('attribution-toggle', this._container, '')

View file

@ -548,6 +548,7 @@ L.U.Help = L.Class.extend({
label.title = label.textContent = L._('Close')
this.content = L.DomUtil.create('div', 'umap-help-content', this.box)
this.isMacOS = /mac/i.test(
// eslint-disable-next-line compat/compat -- Fallback available.
navigator.userAgentData ? navigator.userAgentData.platform : navigator.platform
)
},

View file

@ -16,6 +16,7 @@ L.U.Importer = L.Class.extend({
{ type: 'file', multiple: 'multiple', autofocus: true },
this.fileBox
)
this.map.ui.once('panel:closed', () => (this.fileInput.value = null))
this.urlInput = L.DomUtil.element(
'input',
{ type: 'text', placeholder: L._('Provide an URL here') },

View file

@ -122,6 +122,11 @@ L.U.Map.include({
`${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.options.onLoadPanel = L.Util.queryString(
'onLoadPanel',

View file

@ -194,6 +194,7 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({
let mode = this.datalayer.options.choropleth.mode,
classes = +this.datalayer.options.choropleth.classes || 5,
breaks
classes = Math.min(classes, values.length)
if (mode === 'manual') {
const manualBreaks = this.datalayer.options.choropleth.breaks
if (manualBreaks) {

View file

@ -1322,10 +1322,10 @@ a.add-datalayer:hover,
margin: 5px;
}
a[href^='mailto']::before {
content: '🖃 ';
content: '✉︎ ';
}
a[href^='tel']::before {
content: '🕿 ';
content: '☎︎ ';
}
address span {
padding-right: 5px;

View file

@ -7,7 +7,6 @@
"after": true,
"it": true,
"sinon": true,
"qs": true,
"enableEdit": true,
"disableEdit": true,
"changeInputValue": true,

View file

@ -132,7 +132,7 @@ function initMap(options) {
map_update_permissions: '/map/{map_id}/update/permissions/',
map_download: '/map/{map_id}/download/',
},
default_iconUrl: '../src/img/marker.png',
default_iconUrl: '../src/img/marker.svg',
zoom: 6,
share_statuses: [
[1, 'Tout le monde (public)'],

View file

@ -10,7 +10,9 @@
</div>
{% endif %}
<div class="wrapper">
<h2 class="section">{% blocktrans %}Get inspired, browse maps{% endblocktrans %}</h2>
<div class="map_list row">{% include "umap/map_list.html" %}</div>
{% if maps %}
<h2 class="section">{% blocktrans %}Get inspired, browse maps{% endblocktrans %}</h2>
<div class="map_list row">{% include "umap/map_list.html" %}</div>
{% endif %}
</div>
{% endblock maincontent %}

View file

@ -7,9 +7,17 @@
map_detail
{% endblock body_class %}
{% block extra_head %}
{% if preconnect_domains %}
{% for domain in preconnect_domains %}
<link rel="preconnect" href="{{ domain }}" />
{% endfor %}
{% endif %}
{% umap_css %}
{% umap_js locale=locale %}
{% if object.share_status != object.PUBLIC %}<meta name="robots" content="noindex">{% endif %}
<link rel="alternate" type="application/json+oembed"
href="{{ oembed_absolute_uri }}?url={{ absolute_uri|urlencode }}&format=json"
title="{{ map.name }} oEmbed URL" />
{% endblock extra_head %}
{% block content %}
{% block map_init %}

View file

@ -81,7 +81,7 @@ class MapFactory(factory.django.DjangoModelFactory):
"attribution": "\xa9 OSM Contributors",
"maxZoom": 18,
"minZoom": 0,
"url_template": "https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
"url_template": "https://a.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
},
"tilelayersControl": True,
"zoom": 7,

View file

@ -13,12 +13,12 @@ DATALAYER_DATA = {
"features": [
{
"type": "Feature",
"properties": {"name": "one point in france"},
"properties": {"name": "one point in france", "foo": "point"},
"geometry": {"type": "Point", "coordinates": [3.339844, 46.920255]},
},
{
"type": "Feature",
"properties": {"name": "one polygon in greenland"},
"properties": {"name": "one polygon in greenland", "foo": "polygon"},
"geometry": {
"type": "Polygon",
"coordinates": [
@ -34,7 +34,7 @@ DATALAYER_DATA = {
},
{
"type": "Feature",
"properties": {"name": "one line in new zeland"},
"properties": {"name": "one line in new zeland", "foo": "line"},
"geometry": {
"type": "LineString",
"coordinates": [
@ -72,14 +72,28 @@ def test_data_browser_should_be_open(live_server, page, bootstrap, map):
def test_data_browser_should_be_filterable(live_server, page, bootstrap, map):
page.goto(f"{live_server.url}{map.get_absolute_url()}")
markers = page.locator(".leaflet-marker-icon")
paths = page.locator(".leaflet-overlay-pane path")
expect(markers).to_have_count(1)
el = page.locator("input[name='filter']")
expect(el).to_be_visible()
el.type("poly")
expect(paths).to_have_count(2)
filter_ = page.locator("input[name='filter']")
expect(filter_).to_be_visible()
filter_.type("poly")
expect(page.get_by_text("one point in france")).to_be_hidden()
expect(page.get_by_text("one line in new zeland")).to_be_hidden()
expect(page.get_by_text("one polygon in greenland")).to_be_visible()
expect(markers).to_have_count(0) # Hidden by filter
expect(paths).to_have_count(1) # Only polygon
# Empty the filter
filter_.fill("")
filter_.blur()
expect(markers).to_have_count(1)
expect(paths).to_have_count(2)
filter_.type("point")
expect(page.get_by_text("one point in france")).to_be_visible()
expect(page.get_by_text("one line in new zeland")).to_be_hidden()
expect(page.get_by_text("one polygon in greenland")).to_be_hidden()
expect(markers).to_have_count(1)
expect(paths).to_have_count(0)
def test_data_browser_can_show_only_visible_features(live_server, page, bootstrap, map):
@ -131,3 +145,25 @@ def test_data_browser_bbox_limit_should_be_dynamic(live_server, page, bootstrap,
expect(page.get_by_text("one point in france")).to_be_visible()
expect(page.get_by_text("one polygon in greenland")).to_be_visible()
expect(page.get_by_text("one line in new zeland")).to_be_hidden()
def test_data_browser_with_variable_in_name(live_server, page, bootstrap, map):
# Include a variable
map.settings["properties"]["labelKey"] = "{name} ({foo})"
map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}")
expect(page.get_by_text("one point in france (point)")).to_be_visible()
expect(page.get_by_text("one line in new zeland (line)")).to_be_visible()
expect(page.get_by_text("one polygon in greenland (polygon)")).to_be_visible()
filter_ = page.locator("input[name='filter']")
expect(filter_).to_be_visible()
filter_.type("foobar") # Hide all
expect(page.get_by_text("one point in france (point)")).to_be_hidden()
expect(page.get_by_text("one line in new zeland (line)")).to_be_hidden()
expect(page.get_by_text("one polygon in greenland (polygon)")).to_be_hidden()
# Empty back the filter
filter_.fill("")
filter_.blur()
expect(page.get_by_text("one point in france (point)")).to_be_visible()
expect(page.get_by_text("one line in new zeland (line)")).to_be_visible()
expect(page.get_by_text("one polygon in greenland (polygon)")).to_be_visible()

View file

@ -1,9 +1,14 @@
import re
from time import sleep
from playwright.sync_api import expect
from umap.models import DataLayer
from ..base import DataLayerFactory, MapFactory
DATALAYER_UPDATE = re.compile(r".*/datalayer/update/.*")
def test_collaborative_editing_create_markers(context, live_server, tilelayer):
# Let's create a new map with an empty datalayer
@ -31,11 +36,17 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
map_el_p1.click(position={"x": 200, "y": 200})
expect(marker_pane_p1).to_have_count(1)
save_p1.click()
with page_one.expect_response(DATALAYER_UPDATE):
save_p1.click()
# Prefent two layers to be saved on the same second, as we compare them based
# on time in case of conflict. FIXME do not use time for comparison.
sleep(1)
assert DataLayer.objects.get(pk=datalayer.pk).settings == {
"browsable": True,
"displayOnLoad": True,
"name": "test datalayer",
"editMode": "advanced",
"inCaption": True,
}
# Now navigate to this map from another tab
@ -60,7 +71,9 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
map_el_p2.click(position={"x": 220, "y": 220})
expect(marker_pane_p2).to_have_count(2)
save_p2.click()
with page_two.expect_response(DATALAYER_UPDATE):
save_p2.click()
sleep(1)
# No change after the save
expect(marker_pane_p2).to_have_count(2)
assert DataLayer.objects.get(pk=datalayer.pk).settings == {
@ -75,7 +88,8 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
create_marker_p1.click()
map_el_p1.click(position={"x": 150, "y": 150})
expect(marker_pane_p1).to_have_count(2)
save_p1.click()
with page_one.expect_response(DATALAYER_UPDATE):
save_p1.click()
# Should now get the other marker too
expect(marker_pane_p1).to_have_count(3)
assert DataLayer.objects.get(pk=datalayer.pk).settings == {
@ -92,7 +106,9 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
create_marker_p1.click()
map_el_p1.click(position={"x": 180, "y": 150})
expect(marker_pane_p1).to_have_count(4)
save_p1.click()
with page_one.expect_response(DATALAYER_UPDATE):
save_p1.click()
sleep(1)
# Should now get the other marker too
assert DataLayer.objects.get(pk=datalayer.pk).settings == {
"browsable": True,
@ -110,7 +126,9 @@ def test_collaborative_editing_create_markers(context, live_server, tilelayer):
create_marker_p2.click()
map_el_p2.click(position={"x": 250, "y": 150})
expect(marker_pane_p2).to_have_count(3)
save_p2.click()
with page_two.expect_response(DATALAYER_UPDATE):
save_p2.click()
sleep(1)
# Should now get the other markers too
assert DataLayer.objects.get(pk=datalayer.pk).settings == {
"browsable": True,

View file

@ -61,7 +61,7 @@ def test_umap_export(map, live_server, datalayer, page):
"attribution": "© OSM Contributors",
"maxZoom": 18,
"minZoom": 0,
"url_template": "https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
"url_template": "https://a.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
},
"tilelayersControl": True,
"zoom": 7,

View file

@ -11,8 +11,9 @@ def test_umap_import_from_file(live_server, datalayer, page):
button = page.get_by_title("Import data")
expect(button).to_be_visible()
button.click()
file_input = page.locator("input[type='file']")
with page.expect_file_chooser() as fc_info:
page.locator("input[type='file']").click()
file_input.click()
file_chooser = fc_info.value
path = Path(__file__).parent.parent / "fixtures/display_on_load.umap"
file_chooser.set_files(path)
@ -23,6 +24,10 @@ def test_umap_import_from_file(live_server, datalayer, page):
expect(layers).to_have_count(3)
nonloaded = page.locator(".umap-browse-datalayers li.off")
expect(nonloaded).to_have_count(1)
assert file_input.input_value()
# Close the import panel
page.keyboard.press("Escape")
assert not file_input.input_value()
def test_umap_import_geojson_from_textarea(live_server, datalayer, page):

View file

@ -12,6 +12,35 @@ from ..base import DataLayerFactory
pytestmark = pytest.mark.django_db
def test_preconnect_for_tilelayer(map, page, live_server, tilelayer):
page.goto(f"{live_server.url}{map.get_absolute_url()}")
meta = page.locator('link[rel="preconnect"]')
expect(meta).to_have_count(1)
expect(meta).to_have_attribute("href", "//a.tile.openstreetmap.fr")
# Add custom tilelayer
map.settings["properties"]["tilelayer"] = {
"name": "OSM Piano FR",
"maxZoom": 20,
"minZoom": 0,
"attribution": "test",
"url_template": "https://a.piano.tiles.quaidorsay.fr/fr{r}/{z}/{x}/{y}.png",
}
map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}")
expect(meta).to_have_attribute("href", "//a.piano.tiles.quaidorsay.fr")
# Add custom tilelayer with variable in domain, should create a preconnect
map.settings["properties"]["tilelayer"] = {
"name": "OSM Piano FR",
"maxZoom": 20,
"minZoom": 0,
"attribution": "test",
"url_template": "https://{s}.piano.tiles.quaidorsay.fr/fr{r}/{z}/{x}/{y}.png",
}
map.save()
page.goto(f"{live_server.url}{map.get_absolute_url()}")
expect(meta).to_have_count(0)
def test_default_view_latest_without_datalayer_should_use_default_center(
map, live_server, datalayer, page
):

View file

@ -46,7 +46,7 @@ def test_can_change_picto_at_map_level(map, live_server, page, pictos):
marker = page.locator(".umap-div-icon img")
expect(marker).to_have_count(1)
# Should have default img
expect(marker).to_have_attribute("src", "/static/umap/img/marker.png")
expect(marker).to_have_attribute("src", "/static/umap/img/marker.svg")
edit_settings = page.get_by_title("Edit map properties")
expect(edit_settings).to_be_visible()
edit_settings.click()
@ -66,7 +66,7 @@ def test_can_change_picto_at_map_level(map, live_server, page, pictos):
symbols.click()
expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg")
undefine.click()
expect(marker).to_have_attribute("src", "/static/umap/img/marker.png")
expect(marker).to_have_attribute("src", "/static/umap/img/marker.svg")
def test_can_change_picto_at_datalayer_level(map, live_server, page, pictos):
@ -147,7 +147,7 @@ def test_can_use_remote_url_as_picto(map, live_server, page, pictos):
marker = page.locator(".umap-div-icon img")
expect(marker).to_have_count(1)
# Should have default img
expect(marker).to_have_attribute("src", "/static/umap/img/marker.png")
expect(marker).to_have_attribute("src", "/static/umap/img/marker.svg")
edit_settings = page.get_by_title("Edit map properties")
expect(edit_settings).to_be_visible()
edit_settings.click()

View file

@ -0,0 +1,39 @@
import pytest
from playwright.sync_api import expect
pytestmark = pytest.mark.django_db
def test_scale_control(map, live_server, datalayer, page):
control = page.locator(".leaflet-control-scale")
page.goto(f"{live_server.url}{map.get_absolute_url()}")
expect(control).to_be_visible()
page.goto(f"{live_server.url}{map.get_absolute_url()}?scaleControl=false")
expect(control).to_be_hidden()
def test_datalayers_control(map, live_server, datalayer, page):
control = page.locator(".umap-browse-toggle")
box = page.locator(".umap-browse-datalayers")
more = page.get_by_title("More controls")
page.goto(f"{live_server.url}{map.get_absolute_url()}")
expect(control).to_be_visible()
expect(box).to_be_hidden()
page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=true")
expect(control).to_be_visible()
expect(box).to_be_hidden()
page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=null")
expect(control).to_be_hidden()
expect(more).to_be_visible()
more.click()
expect(control).to_be_visible()
expect(box).to_be_hidden()
page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=false")
expect(control).to_be_hidden()
expect(more).to_be_visible()
more.click()
expect(control).to_be_hidden()
expect(box).to_be_hidden()
page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=expanded")
expect(control).to_be_hidden()
expect(box).to_be_visible()

View file

@ -19,7 +19,7 @@ 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
"settings": '{"type":"Feature","geometry":{"type":"Point","coordinates":[5.0592041015625,52.05924589011585]},"properties":{"tilelayer":{"maxZoom":20,"url_template":"http://a.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
}
@ -275,7 +275,7 @@ 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
def test_non_editor_cannot_access_map_if_share_status_private(client, map, user):
url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.PRIVATE
map.save()
@ -346,14 +346,14 @@ def test_anonymous_create(cookieclient, post_data):
@pytest.mark.usefixtures("allow_anonymous")
def test_anonymous_update_without_cookie_fails(client, anonymap, post_data): # noqa
def test_anonymous_update_without_cookie_fails(client, anonymap, post_data):
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
def test_anonymous_update_with_cookie_should_work(cookieclient, anonymap, post_data):
url = reverse("map_update", kwargs={"map_id": anonymap.pk})
# POST only mendatory fields
name = "new map name"
@ -420,7 +420,7 @@ def test_bad_anonymous_edit_url_should_return_403(cookieclient, anonymap):
@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})
anonymap.edit_status = anonymap.OWNER
@ -434,7 +434,7 @@ def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed(
@pytest.mark.usefixtures("allow_anonymous")
def test_clone_map_should_be_possible_if_edit_status_is_anonymous(client, anonymap): # noqa
def test_clone_map_should_be_possible_if_edit_status_is_anonymous(client, anonymap):
assert Map.objects.count() == 1
url = reverse("map_clone", kwargs={"map_id": anonymap.pk})
anonymap.edit_status = anonymap.ANONYMOUS
@ -624,7 +624,7 @@ def test_download(client, map, datalayer):
"attribution": "© OSM Contributors",
"maxZoom": 18,
"minZoom": 0,
"url_template": "https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
"url_template": "https://a.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png",
},
"tilelayersControl": True,
"zoom": 7,
@ -675,3 +675,63 @@ def test_download_my_map(client, map, datalayer):
# Test response is a json
j = json.loads(response.content.decode())
assert j["type"] == "umap"
@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED, Map.OPEN])
def test_oembed_shared_status_map(client, map, datalayer, share_status):
map.share_status = share_status
map.save()
url = f"{reverse('map_oembed')}?url=http://testserver{map.get_absolute_url()}"
response = client.get(url)
assert response.status_code == 403
def test_oembed_no_url_map(client, map, datalayer):
url = reverse("map_oembed")
response = client.get(url)
assert response.status_code == 404
def test_oembed_wrong_format_map(client, map, datalayer):
url = (
f"{reverse('map_oembed')}"
f"?url=http://testserver{map.get_absolute_url()}&format=xml"
)
response = client.get(url)
assert response.status_code == 501
def test_oembed_wrong_domain_map(client, map, datalayer):
url = f"{reverse('map_oembed')}?url=http://BADserver{map.get_absolute_url()}"
response = client.get(url)
assert response.status_code == 404
def test_oembed_map(client, map, datalayer):
url = f"{reverse('map_oembed')}?url=http://testserver{map.get_absolute_url()}"
response = client.get(url)
assert response.status_code == 200
j = json.loads(response.content.decode())
assert j["type"] == "rich"
assert j["version"] == "1.0"
assert j["width"] == 800
assert j["height"] == 300
assert j["html"] == (
'<iframe width="100%" height="300px" frameborder="0" allowfullscreen '
f'allow="geolocation" src="//testserver/en/map/test-map_{map.id}"></iframe>'
f'<p><a href="//testserver/en/map/test-map_{map.id}">See full screen</a></p>'
)
def test_oembed_link(client, map, datalayer):
response = client.get(map.get_absolute_url())
assert response.status_code == 200
assert (
'<link rel="alternate" type="application/json+oembed"'
in response.content.decode()
)
assert (
'href="http://testserver/map/oembed/'
f'?url=http%3A//testserver/en/map/test-map_{map.id}&format=json"'
) in response.content.decode()
assert 'title="test map oEmbed URL" />' in response.content.decode()

View file

@ -1,6 +1,6 @@
import json
import socket
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
import pytest
from django.conf import settings
@ -10,6 +10,7 @@ from django.urls import reverse
from django.utils.timezone import make_aware
from umap import VERSION
from umap.models import Map, Star
from umap.views import validate_url
from .base import MapFactory, UserFactory
@ -391,3 +392,51 @@ def test_webmanifest(client):
},
]
}
@pytest.mark.django_db
def test_home_feed(client, settings, user, tilelayer):
settings.UMAP_HOME_FEED = "latest"
staff = UserFactory(username="Staff", is_staff=True)
starred = MapFactory(
owner=user, name="A public map starred by staff", share_status=Map.PUBLIC
)
MapFactory(
owner=user, name="A public map not starred by staff", share_status=Map.PUBLIC
)
non_staff = MapFactory(
owner=user, name="A public map starred by non staff", share_status=Map.PUBLIC
)
private = MapFactory(
owner=user, name="A private map starred by staff", share_status=Map.PRIVATE
)
reserved = MapFactory(
owner=user, name="A reserved map starred by staff", share_status=Map.OPEN
)
Star.objects.create(by=staff, map=starred)
Star.objects.create(by=staff, map=private)
Star.objects.create(by=staff, map=reserved)
Star.objects.create(by=user, map=non_staff)
response = client.get(reverse("home"))
content = response.content.decode()
assert "A public map starred by staff" in content
assert "A public map not starred by staff" in content
assert "A public map starred by non staff" in content
assert "A private map starred by staff" not in content
assert "A reserved map starred by staff" not in content
settings.UMAP_HOME_FEED = "highlighted"
response = client.get(reverse("home"))
content = response.content.decode()
assert "A public map starred by staff" in content
assert "A public map not starred by staff" not in content
assert "A public map starred by non staff" not in content
assert "A private map starred by staff" not in content
assert "A reserved map starred by staff" not in content
settings.UMAP_HOME_FEED = None
response = client.get(reverse("home"))
content = response.content.decode()
assert "A public map starred by staff" not in content
assert "A public map not starred by staff" not in content
assert "A public map starred by non staff" not in content
assert "A private map starred by staff" not in content
assert "A reserved map starred by staff" not in content

View file

@ -41,6 +41,7 @@ urlpatterns = [
),
re_path(r"^i18n/", include("django.conf.urls.i18n")),
re_path(r"^agnocomplete/", include("agnocomplete.urls")),
re_path(r"^map/oembed/", views.MapOEmbed.as_view(), name="map_oembed"),
re_path(
r"^map/(?P<map_id>\d+)/download/",
can_view_map(views.MapDownload.as_view()),

View file

@ -18,20 +18,23 @@ from django.contrib.auth import logout as do_logout
from django.contrib.gis.measure import D
from django.contrib.postgres.search import SearchQuery, SearchVector
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import PermissionDenied
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
from django.http import (
Http404,
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
HttpResponsePermanentRedirect,
HttpResponseRedirect,
HttpResponseServerError,
)
from django.middleware.gzip import re_accepts_gzip
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.urls import resolve, reverse, reverse_lazy
from django.utils.encoding import smart_bytes
from django.utils.http import http_date
from django.utils.timezone import make_aware
@ -118,13 +121,26 @@ class PublicMapsMixin(object):
maps = qs.order_by("-modified_at")
return maps
def get_highlighted_maps(self):
staff = User.objects.filter(is_staff=True)
stars = Star.objects.filter(by__in=staff).values("map")
qs = Map.public.filter(pk__in=stars)
maps = qs.order_by("-modified_at")
return maps
class Home(PaginatorMixin, TemplateView, PublicMapsMixin):
template_name = "umap/home.html"
list_template_name = "umap/map_list.html"
def get_context_data(self, **kwargs):
maps = self.get_public_maps()
if settings.UMAP_HOME_FEED is None:
maps = []
elif settings.UMAP_HOME_FEED == "highlighted":
maps = self.get_highlighted_maps()
else:
maps = self.get_public_maps()
maps = self.paginate(maps, settings.UMAP_MAPS_PER_PAGE)
demo_map = None
if hasattr(settings, "UMAP_DEMO_PK"):
@ -140,8 +156,6 @@ class Home(PaginatorMixin, TemplateView, PublicMapsMixin):
except Map.DoesNotExist:
pass
maps = self.paginate(maps, settings.UMAP_MAPS_PER_PAGE)
return {
"maps": maps,
"demo_map": demo_map,
@ -421,6 +435,21 @@ class MapDetailMixin:
model = Map
pk_url_kwarg = "map_id"
def set_preconnect(self, properties, context):
# Try to extract the tilelayer domain, in order to but a preconnect meta.
url_template = properties.get("tilelayer", {}).get("url_template")
# Not explicit tilelayer set, take the first of the list, which will be
# used by frontend too.
if not url_template:
tilelayers = properties.get("tilelayers")
if tilelayers:
url_template = tilelayers[0].get("url_template")
if url_template:
domain = urlparse(url_template).netloc
# Do not try to preconnect on domains with variables
if domain and "{" not in domain:
context["preconnect_domains"] = [f"//{domain}"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
@ -428,7 +457,7 @@ class MapDetailMixin:
"urls": _urls_for_js(),
"tilelayers": TileLayer.get_list(),
"editMode": self.edit_mode,
"default_iconUrl": "%sumap/img/marker.png" % settings.STATIC_URL, # noqa
"default_iconUrl": "%sumap/img/marker.svg" % settings.STATIC_URL, # noqa
"umap_id": self.get_umap_id(),
"starred": self.is_starred(),
"licences": dict((l.name, l.json) for l in Licence.objects.all()),
@ -473,6 +502,7 @@ class MapDetailMixin:
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
def get_datalayers(self):
@ -525,6 +555,16 @@ class PermissionsMixin:
class MapView(MapDetailMixin, PermissionsMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["oembed_absolute_uri"] = self.request.build_absolute_uri(
reverse("map_oembed")
)
context["absolute_uri"] = self.request.build_absolute_uri(
self.object.get_absolute_url()
)
return context
def get(self, request, *args, **kwargs):
self.object = self.get_object()
canonical = self.get_canonical_url()
@ -606,6 +646,52 @@ class MapDownload(DetailView):
return response
class MapOEmbed(View):
def get(self, request, *args, **kwargs):
data = {"type": "rich", "version": "1.0"}
format_ = request.GET.get("format", "json")
if format_ != "json":
response = HttpResponseServerError("Only `json` format is implemented.")
response.status_code = 501
return response
url = request.GET.get("url")
if not url:
raise Http404("Missing `url` parameter.")
parsed_url = urlparse(url)
netloc = parsed_url.netloc
allowed_hosts = settings.ALLOWED_HOSTS
if parsed_url.hostname not in allowed_hosts and allowed_hosts != ["*"]:
raise Http404("Host not allowed.")
url_path = parsed_url.path
view, args, kwargs = resolve(url_path)
if "slug" not in kwargs or "map_id" not in kwargs:
raise Http404("Invalid URL path.")
map_ = Map.objects.get(id=kwargs["map_id"], slug=kwargs["slug"])
if map_.share_status != Map.PUBLIC:
raise PermissionDenied("This map is not public.")
map_url = map_.get_absolute_url()
label = _("See full screen")
height = 300
data["height"] = height
width = 800
data["width"] = width
# TODISCUSS: do we keep width=100% by default for the iframe?
html = (
f'<iframe width="100%" height="{height}px" '
f'frameborder="0" allowfullscreen allow="geolocation" '
f'src="//{netloc}{map_url}"></iframe>'
f'<p><a href="//{netloc}{map_url}">{label}</a></p>'
)
data["html"] = html
return simple_json_response(**data)
class MapViewGeoJSON(MapView):
def get_canonical_url(self):
return reverse("map_geojson", args=(self.object.pk,))