diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..6aef9e75 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "plugins": ["compat"], + "extends": ["plugin:compat/recommended"], + "env": { + "es6": true + }, + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..6e7ead18 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "chore:" diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 0d263682..c6b392a4 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -2,9 +2,9 @@ name: Test & Docs on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: tests: @@ -24,61 +24,59 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - dependencies: [normal, minimal] + python-version: ['3.10', '3.12'] database: [postgresql] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: '**/pyproject.toml' - - name: Change dependencies to minimal supported versions - run: sed -i -e '/requires-python/!s/>=/==/g; /requires-python/!s/~=.*==\(.*\)/==\1/g; /requires-python/!s/~=/==/g;' pyproject.toml - if: matrix.dependencies == 'minimal' - - name: Install dependencies - run: | - sudo apt update - sudo apt install libgdal-dev - python -m pip install --upgrade pip - make develop installjs vendors - - name: run tests - run: make test - env: - DJANGO_SETTINGS_MODULE: 'umap.tests.settings' - UMAP_SETTINGS: 'umap/tests/settings.py' + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: '**/pyproject.toml' + - name: Install dependencies + run: | + sudo apt update + sudo apt install libgdal-dev + python -m pip install --upgrade pip + make develop installjs vendors + - name: run tests + run: make test + env: + DJANGO_SETTINGS_MODULE: 'umap.tests.settings' + UMAP_SETTINGS: 'umap/tests/settings.py' + PLAYWRIGHT_TIMEOUT: '20000' lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - name: Install dependencies - run: | - python3 -m pip install -e .[test,dev] + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python3 -m pip install -e .[test,dev] + make installjs - - name: Run Lint - run: make lint - - - name: Run Docs - run: make docs + - name: Run Lint + run: make lint + + - name: Run Docs + run: make docs docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - name: Install dependencies - run: | - python3 -m pip install -r docs/requirements.txt + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python3 -m pip install -r docs/requirements.txt - - name: Run Docs - run: mkdocs build + - name: Run Docs + run: mkdocs build diff --git a/.gitignore b/.gitignore index efb609d9..72f673fa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ site/* node_modules umap.conf data +./static ### Python ### # Byte-compiled / optimized / DLL files diff --git a/.travis.yml b/.travis.yml index c8edc1a4..0f904422 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,31 +2,30 @@ os: linux language: python dist: focal python: -- "3.6" -- "3.7" -- "3.8" -- "3.9" + - '3.10' + - '3.11' + - '3.12' services: - postgresql addons: apt: packages: - - libgdal-dev - - postgresql-12-postgis-3 + - libgdal-dev + - postgresql-12-postgis-3 env: global: - - PGPORT=5432 - - UMAP_SETTINGS=umap/tests/settings.py + - PGPORT=5432 + - UMAP_SETTINGS=umap/tests/settings.py install: -- make develop + - make develop script: make test notifications: irc: channels: - - "irc.libera.chat#umap" + - 'irc.libera.chat#umap' on_success: change on_failure: always email: false branches: only: - - master + - master diff --git a/LICENSE b/LICENSE index a6ddbed8..f39f8492 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,16 @@ - DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE - Version 2, December 2004 +uMap lets you create maps with OpenStreetMap layers in a minute. +Copyright (C) 2024 Yohan Boniface & contributors +https://github.com/umap-project/umap/graphs/contributors - Copyright (C) 2013 Yohan Boniface +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. - Everyone is permitted to copy and distribute verbatim or modified - copies of this license document, and changing it is allowed as long - as the name is changed. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. - DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. You just DO WHAT THE FUCK YOU WANT TO. \ No newline at end of file +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/Makefile b/Makefile index 509efe4d..770e8dba 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,5 @@ .DEFAULT_GOAL := help -JS_TEST_URL := http://localhost:8001/umap/static/umap/test/index.html - .PHONY: install install: ## Install the dependencies python3 -m pip install --upgrade pip @@ -14,16 +12,17 @@ develop: ## Install the test and dev dependencies .PHONY: format format: ## Format the code and templates files - djlint umap/templates --reformat &&\ - isort --profile black . &&\ - ruff format --target-version=py38 . + -djlint umap/templates --reformat + -isort --profile black umap/ + -ruff format --target-version=py310 umap/ .PHONY: lint lint: ## Lint the code and template files - djlint umap/templates --lint &&\ - isort --check --profile black . &&\ - ruff format --check --target-version=py38 . &&\ - vermin --no-tips --violations -t=3.8- . + npx eslint umap/static/umap/ + djlint umap/templates --lint + isort --check --profile black umap/ + ruff format --check --target-version=py310 umap/ + vermin --no-tips --violations -t=3.10- umap/ docs: ## Compile the docs mkdocs build @@ -48,7 +47,7 @@ docker: ## Create a new Docker image and publish it docker push umap/umap:${VERSION} .PHONY: build -build: test compilemessages ## Build the Python package before release +build: ## Build the Python package before release @hatch build --clean .PHONY: publish @@ -56,7 +55,9 @@ publish: ## Publish the Python package to Pypi @hatch publish make clean -test: +test: testpy testjs + +testpy: pytest -xv umap/tests/ test-integration: @@ -70,25 +71,13 @@ compilemessages: umap generate_js_locale messages: cd umap && umap makemessages -l en - node node_modules/leaflet-i18n/bin/i18n.js --dir_path=umap/static/umap/js/ --dir_path=umap/static/umap/vendors/measurable/ --locale_dir_path=umap/static/umap/locale/ --locale_codes=en --mode=json --clean --default_values + node node_modules/leaflet-i18n/bin/i18n.js --dir_path=umap/static/umap/js/ --dir_path=umap/static/umap/vendors/measurable/ --locale_dir_path=umap/static/umap/locale/ --locale_codes=en --mode=json --clean --default_values --expressions=_,translate vendors: npm run vendors installjs: npm install testjs: node_modules - @{ \ - trap 'kill $$PID; exit' INT; \ - python -m http.server 8001 & \ - PID=$$!; \ - sleep 1; \ - echo "Opening $(JS_TEST_URL)"; \ - if command -v python -m webbrowser > /dev/null 2>&1; then \ - python -m webbrowser "$(JS_TEST_URL)"; \ - else \ - echo "Please open $(JS_TEST_URL) in your web browser"; \ - fi; \ - wait $$PID; \ - } + node_modules/mocha/bin/mocha.js umap/static/umap/unittests/ tx_push: tx push -s tx_pull: diff --git a/README.md b/README.md index b0b1f11a..d89613d3 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,9 @@ - # uMap project -[![Requirements Status](https://requires.io/github/umap-project/umap/requirements.svg?branch=master)](https://requires.io/github/umap-project/umap/requirements/?branch=master) -[![Join the chat at https://gitter.im/umap-project/umap](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/umap-project/umap?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Documentation Status](https://readthedocs.org/projects/umap-project/badge/?version=latest)](http://umap-project.readthedocs.io/en/master/?badge=latest)[![Build Status](https://travis-ci.org/umap-project/umap.svg?branch=master)](https://travis-ci.org/umap-project/umap) - -## About - uMap lets you create maps with OpenStreetMap layers in a minute and embed them in your site. *Because we think that the more OSM will be used, the more OSM will be improved.* Built on top of Django and Leaflet. - -## Installation and configuration - -See [developer documentation](https://umap-project.readthedocs.io/en/master/install/). +- Have a look at [our website](https://umap-project.org) for an introduction +- See [our docs](https://docs.umap-project.org/) for technical information +- Come [chat with us on matrix.org](https://app.element.io/#/room/#umap:matrix.org), or join [the mailing-list](https://lists.openstreetmap.org/listinfo/umap) diff --git a/RELEASE.md b/RELEASE.md deleted file mode 100644 index eba7ff5e..00000000 --- a/RELEASE.md +++ /dev/null @@ -1,28 +0,0 @@ -# How to make a release - -1. I18N - - `make messages` look for new strings within the code - - `make tx_push` to publish new strings [to transifex](https://app.transifex.com/openstreetmap/umap/dashboard/) - - translators at work - - `make tx_pull` to retrieve new translations from transifex - - `make compilemessages` to create regular `.mo` + `umap/static/umap/locale/*.js` - - commit new translations `git commit -am "i18n"` -2. Bump version: `make patch|minor` -3. `git commit -am "1.X.Y"` -4. `git tag 1.X.Y` -5. `git push && git push --tag` -6. Go to [Github release page](https://github.com/umap-project/umap/releases/new) and Generate release notes + paste it in `docs/changelog.md` + finish Github process for a new release -7. Commit the changelog `git commit -am "changelog"` -8. `make build` -9. `make publish` -10. `make docker` - -## Deploying instances - -### OSMfr - -Makefile on @yohanboniface computer. TODO: share it :) - -### 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. diff --git a/docker-compose.yml b/docker-compose.yml index 8c6d5f1a..e77ad38d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: depends_on: db: condition: service_healthy - image: umap/umap:1.3.2 + image: umap/umap:2.0.2 ports: - "${PORT-8000}:8000" environment: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0151897c..8ed7fdfb 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,13 +3,11 @@ set -eo pipefail source /venv/bin/activate -# first wait for the database +# collect static files +umap collectstatic --noinput +# now wait for the database umap wait_for_database # then migrate the database umap migrate -# then collect static files -umap collectstatic --noinput -# compress static files -umap compress # run uWSGI exec uwsgi --ini docker/uwsgi.ini diff --git a/docs/changelog.md b/docs/changelog.md index 05c5cb29..fdb970d7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,11 +1,210 @@ # Changelog +## 2.1.3 - 2024-03-27 + +* refactor initCenter and controls ordering by @yohanboniface in #1716 +* honour old_id in datalayers= query string parameter by @yohanboniface in #1717 + +## 2.1.2 - 2024-03-25 + +- fix datalayer data file removed on save by mistake (this happened after + switching to UUID, when a datalayer had more than UMAP_KEEP_VERSIONS, due to + a sorting issue on purge old files after save) + +## 2.1.1 - 2024-03-25 + +- fix Path.replace called instead of str.replace + +## 2.1.0 - 2024-03-25 + +### Bug fixes + +* deal with i18n in oembed URLs #1688 +* set CORS-related header for oEmbed and map views #1689 +* only use location bias in search for close zoom #1690 +* catch click event on "See all" buttons #1705 + +### Internal changes + +* replace datalayer ids with uuids #1630 +* replace Last-Modified with custom headers #1666 + + +## 2.0.4 - 2024-03-01 + +* fix zoom and fullscreen not shown by default + +## 2.0.3 - 2024-03-01 + +### Bug fixes +* fix: picto category title was added after the related pictograms by @yohanboniface in #1637 +* fix: path was doubled when importing pictograms from command line by @datendelphin in #1653 +* fix: zoomControl rendered twice by @yohanboniface in #1645 +* fix: allow empty datalayers reference on merges. by @almet in #1665 +* fix: make sure to reset feature query string parameter by @yohanboniface in #1667 +* fix: read id and @id as osm id in osm template by @yohanboniface in #1668 +* fix: catch SMTPException when sending secret edit link by @yohanboniface in #1658 + +### Internal changes +* chore: raise error if any in storage post_process by @yohanboniface in #1624 +* chore: generate messages following map creation by @davidbgk in #1631 +* chore: attempt to fix randomly failing test by @yohanboniface in #1639 +* chore: Use CSS variables by @davidbgk in #1589 + +### Documentation +* docs: add a note for Docker install and SECRET_KEY by @davidbgk in #1633 +* docs: update namespace of uMap objects by @davidbgk in #1632 + +## 2.0.2 - 2024-02-19 + +* fix: run collectstatic first in Docker entrypoint + +## 2.0.1 - 2024-02-18 + +* Do not use the `compress` command anymore for the Docker image (#1620) + +## 2.0.0 - 2024-02-16 + +This release is inauguring a new era in versionning uMap: in the future, we'll take care of better documenting breaking changes, so expect more major releases from now on. More details on [how we version](https://docs.umap-project.org/en/master/release/#when-to-make-a-release). + +The main changes are: + +* on the front-end side, we now use native ESM modules, so this may break on old browsers (see our [ESlint configuration](https://github.com/umap-project/umap/blob/a0634e5f55179fb52f7c00e39236b6339a7714b9/package.json#L68)) +* on the back-end, we upgraded to Django 5.x, which drops support for Python 3.8 and Python 3.9. +* the OpenStreetMap OAuth1 client is not supported anymore (now deprecated by OpenStreetMap.org) +* license switched from WTFPL to AGPLv3: having an OSI valid licence was a request from our partners and sponsors (#1605) + +More details below! + +### Breaking changes + +* updrade to Django 5.x drops support for Python < 3.10 +* `django-compressor` has been removed, so `umap compress` is not a valid command anymore (compress is now done in the `collectstatic` process itself) (#1544, #1539) +* removed support for settings starting with `LEAFLET_STORAGE_` (deprecated since 1.0.0) +* removed support for deprecated OpenStreetMap OAuth1 backend in favour of OAuth2 (see below) +* `FROM_EMAIL` setting is replaced by `DEFAULT_FROM_EMAIL`, which is [Django standard](https://docs.djangoproject.com/en/5.0/ref/settings/#default-from-email) + +#### Migrate to OpenStreetMap OAuth2 + +* create a new app on OSM.org: https://www.openstreetmap.org/oauth2/applications/ +* add the key and secret in your settings (or as env vars): + * `SOCIAL_AUTH_OPENSTREETMAP_OAUTH2_KEY=xxxx` + * `SOCIAL_AUTH_OPENSTREETMAP_OAUTH2_SECRET=xxxx` +* if you changed `AUTHENTICATION_BACKENDS`, you need to now use `"social_core.backends.openstreetmap_oauth2.OpenStreetMapOAuth2"` +* run the migration command, that will migrate all accounts from OAuth1 to Oauth2: + `umap migrate` + +### New features + +* Ability to clone, delete and download all maps from user’s dashboard (#1430) +* Add experimental "map preview" in `/map/` endpoint (#1573) +* Adapt features counter in the databrowser to the currently displayed features (#1572) +* Create an oEmbed endpoint for maps `/map/oembed/` (#1526) +* introduce `UMAP_HOME_FEED` to control which maps are shown on the home page (#1531) +* better algorithm (WCAG 21 based) to manage text and picto contrast (#1593) +* show last used pictograms in a separate tab (#1595) + +### Bug fixes + +* Use variable for color in browser if any (#1584) +* Non loaded layers should still be visible in legend and data browser (#1581) +* Do not try to reset tooltip of feature not on map (#1576) +* Empty file input when closing the importer panel (#1535) +* Honour datalayersControl=expanded in querystring (#1538) +* Fix icons for mailto and tel (#1547) +* Do not ask more classes than available values in choropleth mode (#1550) +* Build browser once features are on the map, not before (#1551) +* Replace `list.delete` call by the proper `remove` method +* Prevent datalayer to resetting to an old version on save (#1558) +* Messages coming from Django where never displayed in map view (#1588) +* Browser `inBbox` setting was not persistent (#1586) +* Popup was not opening on click on browser when `inBbox` was active (#1586) +* reset table editor properties after creating a new one (#1610) +* do not try to animate the panel (#1608) + +### Internal changes + +* Move XHR management to a module and use fetch (#1555) +* Use https://umap-project.org link in map footer (#1541) +* Add support for JS modules (+module for URLs handling) (#1463) +* Pin versions in pyproject.toml (#1514) +* Set a umap-fragment web component for lists (#1516) +* Load Leaflet as a module +* Replaced `L.U` global by `U` +* Use SVG for default icon (circle) (#1562) +* Set preconnect link for tilelayer (#1552) + +### Documentation + +* Define an explicit release stragegy (#1567) + +### Changed templates + +* added `header.html` to add extra code in `` +* added `branding.html` with site logo +* `registration/login.html`, which is not loaded in ajax anymore (and include `branding.html`) +* `umap/content.html` the JS call to load more have changed +* `umap/navigation.html`: it now includes `branding.html` +* `umap/map_table.html`: total revamp +* `umap/user_dashboard.html`: improved table header (search + download all) + inline JS changed + +## 1.13.2 - 2024-01-25 + +### Bug fixes + +- prevent datalayer to resetting to an old version on save (#1558) +- replace list.delete call by the proper remove method (#1559) + + +## 1.13.1 - 2024-01-08 + +### Bug fix +* icon element is undefined when clustered by @yohanboniface in #1512 + +## 1.13.0 - 2024-01-08 + +### New features +* Preview map only on click in user’s dashboard by @davidbgk in #1478 +* feat(browser): add counter in datalayer headline by @yohanboniface in #1509 +* Allow to type a latlng in the search box by @yohanboniface in #1480 +* Add a popup template to showcase OpenStreetMap data by @yohanboniface in #1479 +* Refactor Share & Download UI for better usability by @jschleic in #1454 +* Move layer specific settings to a dedicated fieldset by @yohanboniface in #1499 + +### Bug fixes +* fix dirty flags when re-ordering layers by @jschleic in #1497 +* Be more explicit on changed fields when updating choropleth form by @yohanboniface in #1490 + +### Documentation +* docs: Update the links in the README, remove the badges by @almet in #1501 + +### Internal Changes +* Create dependabot.yml by @almet in #1502 + +### Updated templates + +- `umap/templates/auth/user_form.html` +- `umap/templates/umap/content.html` +- `umap/templates/umap/js.html` +- `umap/templates/umap/map_list.html` +- `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#files_bucket). + +## 1.12.2 - 2023-12-29 + +### Bug fixes +* Fix preview of TMS TileLayer by @yohanboniface in #1492 +* Add a small box-shadow to tilelayer preview by @yohanboniface in #1493 + + ## 1.12.1 - 2023-12-23 ### New features * Allow to edit pictogram categories from admin list by @yohanboniface in #1477 -### Bug fixes +### Bug fixes * Increase iconlayers titles on hover by @yohanboniface in #1476 * Remove zoom/moeveend events when deleting datalayer by @yohanboniface in #1484 * Better way of handling escape while drawing by @yohanboniface in #1483 diff --git a/docs/config/customize.md b/docs/config/customize.md index 9c2eb202..c36af4e1 100644 --- a/docs/config/customize.md +++ b/docs/config/customize.md @@ -99,4 +99,16 @@ There are three settings you can play with to control that: # Which field to use in the URL, may also be for example "pk" to use the # primary key and not expose the username (which may be private or may change too # often for URL persistance) - USER_URL_FIELD = "username" \ No newline at end of file + USER_URL_FIELD = "username" + + +## Custom header and/or footer scripts + +You can populate the content of you own `umap/header.html` and `umap/footer.html` +templates with ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + @@ -66,6 +74,10 @@ + - - - - - - - - - - - - - - + + + + + + + +
diff --git a/umap/static/umap/unittests/URLs.js b/umap/static/umap/unittests/URLs.js new file mode 100644 index 00000000..53c7448f --- /dev/null +++ b/umap/static/umap/unittests/URLs.js @@ -0,0 +1,59 @@ +import { describe, it } from 'mocha' + +import pkg from 'chai' +const { expect } = pkg + +import URLs from '../js/modules/urls.js' + +describe('URLs', () => { + // Mock server URLs that will be used for testing + const mockServerUrls = { + map_create: '/maps/create', + map_update: '/maps/{map_id}/update', + datalayer_create: '/maps/{map_id}/datalayers/create', + datalayer_update: '/maps/{map_id}/datalayers/{pk}/update', + } + + let urls = new URLs(mockServerUrls) + + describe('get()', () => { + it('should throw an error if the urlName does not exist', () => { + expect(() => urls.get('non_existent')).to.throw() + }) + + it('should return the correct templated URL for known urlNames', () => { + expect(urls.get('map_create')).to.be.equal('/maps/create') + expect(urls.get('map_update', { map_id: '123' })).to.be.equal('/maps/123/update') + }) + + it('should return the correct templated URL when provided with parameters', () => { + expect(urls.get('datalayer_update', { map_id: '123', pk: '456' })).to.be.equal( + '/maps/123/datalayers/456/update' + ) + }) + }) + + describe('map_save()', () => { + it('should return the create URL if no map_id is provided', () => { + expect(urls.map_save({})).to.be.equal('/maps/create') + }) + + it('should return the update URL if a map_id is provided', () => { + expect(urls.map_save({ map_id: '123' })).to.be.equal('/maps/123/update') + }) + }) + + describe('datalayer_save()', () => { + it('should return the create URL if no pk is provided', () => { + expect(urls.datalayer_save({ map_id: '123' })).to.be.equal( + '/maps/123/datalayers/create' + ) + }) + + it('should return the update URL if a pk is provided', () => { + expect(urls.datalayer_save({ map_id: '123', pk: '456' })).to.be.equal( + '/maps/123/datalayers/456/update' + ) + }) + }) +}) diff --git a/umap/static/umap/unittests/utils.js b/umap/static/umap/unittests/utils.js new file mode 100644 index 00000000..68148c56 --- /dev/null +++ b/umap/static/umap/unittests/utils.js @@ -0,0 +1,593 @@ +import { describe, it } from 'mocha' +import * as Utils from '../js/modules/utils.js' +import pkg from 'chai' +const { assert, expect } = pkg + +// Export JSDOM to the global namespace, to be able to check for its presence +// in the actual implementation. Avoiding monkeypatching the implementations here. +import { JSDOM } from 'jsdom' +global.JSDOM = JSDOM + +describe('Utils', function () { + describe('#toHTML()', function () { + it('should handle title', function () { + assert.equal(Utils.toHTML('# A title'), '

A title

') + }) + + it('should handle title in the middle of the content', function () { + assert.equal( + Utils.toHTML('A phrase\n## A title'), + 'A phrase
\n

A title

' + ) + }) + + it('should handle hr', function () { + assert.equal(Utils.toHTML('---'), '
') + }) + + it('should handle bold', function () { + assert.equal(Utils.toHTML('Some **bold**'), 'Some bold') + }) + + it('should handle italic', function () { + assert.equal(Utils.toHTML('Some *italic*'), 'Some italic') + }) + + it('should handle newlines', function () { + assert.equal(Utils.toHTML('two\nlines'), 'two
\nlines') + }) + + it('should not change last newline', function () { + assert.equal(Utils.toHTML('two\nlines\n'), 'two
\nlines\n') + }) + + it('should handle two successive newlines', function () { + assert.equal(Utils.toHTML('two\n\nlines\n'), 'two
\n
\nlines\n') + }) + + it('should handle links without formatting', function () { + assert.equal( + Utils.toHTML('A simple http://osm.org link'), + 'A simple http://osm.org link' + ) + }) + + it('should handle simple link in title', function () { + assert.equal( + Utils.toHTML('# http://osm.org'), + '

http://osm.org

' + ) + }) + + it('should handle links with url parameter', function () { + assert.equal( + Utils.toHTML('A simple https://osm.org/?url=https%3A//anotherurl.com link'), + 'A simple https://osm.org/?url=https%3A//anotherurl.com link' + ) + }) + + it('should handle simple link inside parenthesis', function () { + assert.equal( + Utils.toHTML('A simple link (http://osm.org)'), + 'A simple link (http://osm.org)' + ) + }) + + it('should handle simple link with formatting', function () { + assert.equal( + Utils.toHTML('A simple [[http://osm.org]] link'), + 'A simple http://osm.org link' + ) + }) + + it('should handle simple link with formatting and content', function () { + assert.equal( + Utils.toHTML('A simple [[http://osm.org|link]]'), + 'A simple link' + ) + }) + + it('should handle simple link followed by a carriage return', function () { + assert.equal( + Utils.toHTML('A simple link http://osm.org\nAnother line'), + 'A simple link http://osm.org
\nAnother line' + ) + }) + + it('should handle target option', function () { + assert.equal( + Utils.toHTML('A simple http://osm.org link', { target: 'self' }), + 'A simple http://osm.org link' + ) + }) + + it('should handle image', function () { + assert.equal( + Utils.toHTML('A simple image: {{http://osm.org/pouet.png}}'), + 'A simple image: ' + ) + }) + + it('should handle image without text', function () { + assert.equal( + Utils.toHTML('{{http://osm.org/pouet.png}}'), + '' + ) + }) + + it('should handle image with width', function () { + assert.equal( + Utils.toHTML('A simple image: {{http://osm.org/pouet.png|100}}'), + 'A simple image: ' + ) + }) + + it('should handle iframe', function () { + assert.equal( + Utils.toHTML('A simple iframe: {{{http://osm.org/pouet.html}}}'), + 'A simple iframe:
' + ) + }) + + it('should handle iframe with height', function () { + assert.equal( + Utils.toHTML('A simple iframe: {{{http://osm.org/pouet.html|200}}}'), + 'A simple iframe:
' + ) + }) + + it('should handle iframe with height and width', function () { + assert.equal( + Utils.toHTML('A simple iframe: {{{http://osm.org/pouet.html|200*400}}}'), + 'A simple iframe:
' + ) + }) + + it('should handle iframe with height with px', function () { + assert.equal( + Utils.toHTML('A simple iframe: {{{http://osm.org/pouet.html|200px}}}'), + 'A simple iframe:
' + ) + }) + + it('should handle iframe with url parameter', function () { + assert.equal( + Utils.toHTML( + 'A simple iframe: {{{https://osm.org/?url=https%3A//anotherurl.com}}}' + ), + 'A simple iframe:
' + ) + }) + + it('should handle iframe with height with px', function () { + assert.equal( + Utils.toHTML( + 'A double iframe: {{{https://osm.org/pouet}}}{{{https://osm.org/boudin}}}' + ), + 'A double iframe:
' + ) + }) + + it('http link with http link as parameter as variable', function () { + assert.equal( + Utils.toHTML('A phrase with a [[http://iframeurl.com?to=http://another.com]].'), + 'A phrase with a http://iframeurl.com?to=http://another.com.' + ) + }) + }) + + describe('#escapeHTML', function () { + it('should escape HTML tags', function () { + assert.equal(Utils.escapeHTML(''), '') + }) + + it('should not escape geo: links', function () { + assert.equal(Utils.escapeHTML(''), '') + }) + + it('should not fail with int value', function () { + assert.equal(Utils.escapeHTML(25), '25') + }) + + it('should not fail with null value', function () { + assert.equal(Utils.escapeHTML(null), '') + }) + }) + + describe('#greedyTemplate', function () { + it('should replace simple props', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {variable}.', { variable: 'thing' }), + 'A phrase with a thing.' + ) + }) + + it('should not fail when missing key', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {missing}', {}), + 'A phrase with a ' + ) + }) + + it('should process brakets in brakets', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {{{variable}}}.', { variable: 'value' }), + 'A phrase with a {{value}}.' + ) + }) + + it('should not process http links', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {{{http://iframeurl.com}}}.', { + 'http://iframeurl.com': 'value', + }), + 'A phrase with a {{{http://iframeurl.com}}}.' + ) + }) + + it('should not accept dash', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {var-iable}.', { 'var-iable': 'value' }), + 'A phrase with a {var-iable}.' + ) + }) + + it('should accept colon', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {variable:fr}.', { + 'variable:fr': 'value', + }), + 'A phrase with a value.' + ) + }) + + it('should accept arobase', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {@variable}.', { + '@variable': 'value', + }), + 'A phrase with a value.' + ) + }) + + it('should accept space', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {var iable}.', { + 'var iable': 'value', + }), + 'A phrase with a value.' + ) + }) + + it('should accept non ascii chars', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {Accessibilité} and {переменная}.', { + Accessibilité: 'value', + переменная: 'another', + }), + 'A phrase with a value and another.' + ) + }) + + it('should replace even with ignore if key is found', function () { + assert.equal( + Utils.greedyTemplate( + 'A phrase with a {variable:fr}.', + { 'variable:fr': 'value' }, + true + ), + 'A phrase with a value.' + ) + }) + + it('should keep string when using ignore if key is not found', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {variable:fr}.', {}, true), + 'A phrase with a {variable:fr}.' + ) + }) + + it('should replace nested variables', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var}.', { fr: { var: 'value' } }), + 'A phrase with a value.' + ) + }) + + it('should not fail if nested variable is missing', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var.foo}.', { + fr: { var: 'value' }, + }), + 'A phrase with a .' + ) + }) + + it('should not fail with nested variables and no data', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var.foo}.', {}), + 'A phrase with a .' + ) + }) + + it('should handle fallback value if any', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var.bar|"default"}.', {}), + 'A phrase with a default.' + ) + }) + + it('should handle fallback var if any', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var.bar|fallback}.', { + fallback: 'default', + }), + 'A phrase with a default.' + ) + }) + + it('should handle multiple fallbacks', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var.bar|try.again|"default"}.', {}), + 'A phrase with a default.' + ) + }) + + it('should use the first defined value', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var.bar|try.again|"default"}.', { + try: { again: 'please' }, + }), + 'A phrase with a please.' + ) + }) + + it('should use the first defined value', function () { + assert.equal( + Utils.greedyTemplate('A phrase with a {fr.var.bar|try.again|"default"}.', { + try: { again: 'again' }, + fr: { var: { bar: 'value' } }, + }), + 'A phrase with a value.' + ) + }) + + it('should support the first example from #820 when translated to final syntax', function () { + assert.equal( + Utils.greedyTemplate('# {name} ({ele|"-"} m ü. M.)', { name: 'Portalet' }), + '# Portalet (- m ü. M.)' + ) + }) + + it('should support the first example from #820 when translated to final syntax when no fallback required', function () { + assert.equal( + Utils.greedyTemplate('# {name} ({ele|"-"} m ü. M.)', { + name: 'Portalet', + ele: 3344, + }), + '# Portalet (3344 m ü. M.)' + ) + }) + + it('should support white space in fallback', function () { + assert.equal( + Utils.greedyTemplate('A phrase with {var|"white space in the fallback."}', {}), + 'A phrase with white space in the fallback.' + ) + }) + + it('should support empty string as fallback', function () { + assert.equal( + Utils.greedyTemplate( + 'A phrase with empty string ("{var|""}") in the fallback.', + {} + ), + 'A phrase with empty string ("") in the fallback.' + ) + }) + + it('should support e.g. links as fallback', function () { + assert.equal( + Utils.greedyTemplate( + 'A phrase with {var|"[[https://osm.org|link]]"} as fallback.', + {} + ), + 'A phrase with [[https://osm.org|link]] as fallback.' + ) + }) + }) + + describe('#flattenCoordinates()', function () { + it('should not alter already flat coords', function () { + var coords = [ + [1, 2], + [3, 4], + ] + assert.deepEqual(Utils.flattenCoordinates(coords), coords) + }) + + it('should flatten nested coords', function () { + var coords = [ + [ + [1, 2], + [3, 4], + ], + ] + assert.deepEqual(Utils.flattenCoordinates(coords), coords[0]) + coords = [ + [ + [ + [1, 2], + [3, 4], + ], + ], + ] + assert.deepEqual(Utils.flattenCoordinates(coords), coords[0][0]) + }) + + it('should not fail on empty coords', function () { + var coords = [] + assert.deepEqual(Utils.flattenCoordinates(coords), coords) + }) + }) + + describe('#usableOption()', function () { + it('should consider false', function () { + assert.ok(Utils.usableOption({ key: false }, 'key')) + }) + + it('should consider 0', function () { + assert.ok(Utils.usableOption({ key: 0 }, 'key')) + }) + + it('should not consider undefined', function () { + assert.notOk(Utils.usableOption({}, 'key')) + }) + + it('should not consider empty string', function () { + assert.notOk(Utils.usableOption({ key: '' }, 'key')) + }) + + it('should consider null', function () { + assert.ok(Utils.usableOption({ key: null }, 'key')) + }) + }) + + describe('#normalize()', function () { + it('should remove accents', + function () { + // French é + assert.equal(Utils.normalize('aéroport'), 'aeroport') + // American é + assert.equal(Utils.normalize('aéroport'), 'aeroport') + }) + }) + + describe('#sortFeatures()', function () { + let feat1, feat2, feat3 + before(function () { + feat1 = { properties: {} } + feat2 = { properties: {} } + feat3 = { properties: {} } + }) + it('should sort feature from custom key', function () { + feat1.properties.mykey = '13. foo' + feat2.properties.mykey = '7. foo' + feat3.properties.mykey = '111. foo' + let features = Utils.sortFeatures([feat1, feat2, feat3], 'mykey') + assert.equal(features[0], feat2) + assert.equal(features[1], feat1) + assert.equal(features[2], feat3) + }) + it('should sort feature from multiple keys', function () { + feat1.properties.mykey = '13. foo' + feat2.properties.mykey = '111. foo' + feat3.properties.mykey = '111. foo' + feat1.properties.otherkey = 'C' + feat2.properties.otherkey = 'B' + feat3.properties.otherkey = 'A' + let features = Utils.sortFeatures([feat1, feat2, feat3], 'mykey,otherkey') + assert.equal(features[0], feat1) + assert.equal(features[1], feat3) + assert.equal(features[2], feat2) + }) + it('should sort feature from custom key reverse', function () { + feat1.properties.mykey = '13. foo' + feat2.properties.mykey = '7. foo' + feat3.properties.mykey = '111. foo' + let features = Utils.sortFeatures([feat1, feat2, feat3], '-mykey') + assert.equal(features[0], feat3) + assert.equal(features[1], feat1) + assert.equal(features[2], feat2) + }) + it('should sort feature from multiple keys with reverse', function () { + feat1.properties.mykey = '13. foo' + feat2.properties.mykey = '111. foo' + feat3.properties.mykey = '111. foo' + feat1.properties.otherkey = 'C' + feat2.properties.otherkey = 'B' + feat3.properties.otherkey = 'A' + let features = Utils.sortFeatures([feat1, feat2, feat3], 'mykey,-otherkey') + assert.equal(features[0], feat1) + assert.equal(features[1], feat2) + assert.equal(features[2], feat3) + }) + it('should sort feature with space first', function () { + feat1.properties.mykey = '1 foo' + feat2.properties.mykey = '2 foo' + feat3.properties.mykey = '1a foo' + let features = Utils.sortFeatures([feat1, feat2, feat3], 'mykey') + assert.equal(features[0], feat1) + assert.equal(features[1], feat3) + assert.equal(features[2], feat2) + }) + }) + + describe("#copyJSON", function () { + it('should actually copy the JSON', function () { + let originalJSON = { "some": "json" } + let returned = Utils.CopyJSON(originalJSON) + + // Change the original JSON + originalJSON["anotherKey"] = "value" + + // ensure the two aren't the same object + assert.notEqual(returned, originalJSON) + assert.deepEqual(returned, { "some": "json" }) + }) + }) + + describe('#getImpactsFromSchema()', function () { + let getImpactsFromSchema = Utils.getImpactsFromSchema + it('should return an array', function () { + expect(getImpactsFromSchema(['foo'], {})).to.be.an('array') + expect(getImpactsFromSchema(['foo'], { foo: {} })).to.be.an('array') + expect(getImpactsFromSchema(['foo'], { foo: { impacts: [] } })).to.be.an('array') + expect(getImpactsFromSchema(['foo'], { foo: { impacts: ['A'] } })).to.be.an( + 'array' + ) + }) + + it('should return a list of unique impacted values', function () { + let schema = { + foo: { impacts: ['A'] }, + bar: { impacts: ['A', 'B'] }, + baz: { impacts: ['B', 'C'] }, + } + + assert.deepEqual(getImpactsFromSchema(['foo'], schema), ['A']) + assert.deepEqual(getImpactsFromSchema(['foo', 'bar'], schema), ['A', 'B']) + assert.deepEqual(getImpactsFromSchema(['foo', 'bar', 'baz'], schema), [ + 'A', + 'B', + 'C', + ]) + }) + it('should return an empty list if nothing is found', function () { + let schema = { + foo: { impacts: ['A'] }, + bar: { impacts: ['A', 'B'] }, + baz: { impacts: ['B', 'C'] }, + } + + assert.deepEqual(getImpactsFromSchema(['bad'], schema), []) + }) + + it('should return an empty list if the schema key does not exist', function () { + let schema = { + foo: { impacts: ['A'] }, + } + + assert.deepEqual(getImpactsFromSchema(['bad'], schema), []) + }) + it('should work if the "impacts" key is not defined', function () { + let schema = { + foo: {}, + bar: { impacts: ['A'] }, + baz: { impacts: ['B'] }, + } + + assert.deepEqual(getImpactsFromSchema(['foo', 'bar', 'baz'], schema), ['A', 'B']) + }) + }) +}) diff --git a/umap/static/umap/vars.css b/umap/static/umap/vars.css new file mode 100644 index 00000000..c63a1669 --- /dev/null +++ b/umap/static/umap/vars.css @@ -0,0 +1,22 @@ +:root { + /* Colors. */ + --color-waterMint: #B9F5D2; + --color-darkBlue: #263B58; + --color-lightGray: #ddd; + --color-darkGray: #323737; + + /* Buttons. */ + --button-primary-background: var(--color-waterMint); + --button-primary-color: var(--color-darkBlue); + --button-neutral-background: var(--color-lightGray); + --button-neutral-color: var(--color-darkGray); + + /* Sizes and spaces */ + --panel-gutter: 10px; + --panel-bottom: 40px; + --panel-header-height: 36px; + --panel-width: 400px; + --header-height: 46px; + --footer-height: 46px; + --control-size: 36px; +} diff --git a/umap/storage.py b/umap/storage.py new file mode 100644 index 00000000..3ece91dc --- /dev/null +++ b/umap/storage.py @@ -0,0 +1,59 @@ +from pathlib import Path + +from django.conf import settings +from django.contrib.staticfiles.storage import ManifestStaticFilesStorage +from rcssmin import cssmin +from rjsmin import jsmin + + +class UmapManifestStaticFilesStorage(ManifestStaticFilesStorage): + support_js_module_import_aggregation = True + + # We remove `;` at the end of all regexps to match our prettier config. + _js_module_import_aggregation_patterns = ( + "*.js", + ( + ( + ( + r"""(?Pimport(?s:(?P[\s\{].*?))""" + r"""\s*from\s*['"](?P[\.\/].*?)["']\s*)""" + ), + 'import%(import)s from "%(url)s"\n', + ), + ( + ( + r"""(?Pexport(?s:(?P[\s\{].*?))""" + r"""\s*from\s*["'](?P[\.\/].*?)["']\s*)""" + ), + 'export%(exports)s from "%(url)s"\n', + ), + ( + r"""(?Pimport\s*['"](?P[\.\/].*?)["']\s*)""", + 'import"%(url)s"\n', + ), + ( + r"""(?Pimport\(["'](?P.*?)["']\))""", + """import("%(url)s")""", + ), + ), + ) + + def post_process(self, paths, **options): + collected = super().post_process(paths, **options) + for original_path, processed_path, processed in collected: + if isinstance(processed, Exception): + print("Error with file", original_path) + raise processed + if processed_path.endswith(".js"): + path = Path(settings.STATIC_ROOT) / processed_path + initial = path.read_text() + if "sourceMappingURL" not in initial: # Already minified. + minified = jsmin(initial) + path.write_text(minified) + if processed_path.endswith(".css"): + path = Path(settings.STATIC_ROOT) / processed_path + initial = path.read_text() + if "sourceMappingURL" not in initial: # Already minified. + minified = cssmin(initial) + path.write_text(minified) + yield original_path, processed_path, True diff --git a/umap/templates/auth/user_form.html b/umap/templates/auth/user_form.html index 67933eea..88f98328 100644 --- a/umap/templates/auth/user_form.html +++ b/umap/templates/auth/user_form.html @@ -1,9 +1,10 @@ {% extends "umap/content.html" %} {% load i18n %} {% block maincontent %} -
+
diff --git a/umap/templates/base.html b/umap/templates/base.html index 97007244..35be9099 100644 --- a/umap/templates/base.html +++ b/umap/templates/base.html @@ -1,4 +1,4 @@ -{% load compress umap_tags i18n %} +{% load umap_tags i18n static %} @@ -12,18 +12,19 @@ content="{% trans "uMap lets you create maps with OpenStreetMap layers in a minute and embed them in your site." %}"> {% block extra_head %} + {% include "umap/header.html" %} {% endblock extra_head %} {# See https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs #} + href="{% static 'umap/favicons/apple-touch-icon.png' %}"> diff --git a/umap/templates/registration/login.html b/umap/templates/registration/login.html index c9edc372..1429ff5a 100644 --- a/umap/templates/registration/login.html +++ b/umap/templates/registration/login.html @@ -1,37 +1,52 @@ -{% load i18n %} -{% if ENABLE_ACCOUNT_LOGIN %} -
{% trans "Please log in with your account" %}
-
- {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %}
  • {{ error }}
  • {% endfor %} -
+{% extends "base.html" %} +{% load umap_tags i18n %} +{% block extra_head %} + {% umap_css %} + {{ block.super }} +{% endblock extra_head %} +{% block body_class %} + login +{% endblock body_class %} +{% block content %} +
+
+ {% include "umap/branding.html" %} +
+ {% if ENABLE_ACCOUNT_LOGIN %} +

{% trans "Please log in with your account" %}

+
+ {% if form.non_field_errors %} +
    + {% for error in form.non_field_errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} + {{ form.username.errors }} + + {{ form.password.errors }} + + +
+
{% endif %} -
- {% csrf_token %} - {{ form.username.errors }} - - {{ form.password.errors }} - - -
-
-{% endif %} -{% if backends.backends|length %} -
{% trans "Please choose a provider" %}
-
- -
-{% endif %} + {% if backends.backends|length %} +

{% trans "Please choose a provider" %}

+
+ +
+ {% endif %} + +{% endblock content %} diff --git a/umap/templates/umap/about_summary.html b/umap/templates/umap/about_summary.html index 870047eb..846205e8 100644 --- a/umap/templates/umap/about_summary.html +++ b/umap/templates/umap/about_summary.html @@ -1,9 +1,9 @@ -{% load i18n %} +{% load i18n static %}
@@ -13,7 +13,7 @@
@@ -29,7 +29,7 @@
@@ -45,7 +45,7 @@ {% spaceless %}
{% if not UMAP_READONLY %} - {% trans "Create a map" %} + {% trans "Create a map" %} {% endif %} {% if demo_map %} {% trans "Play with the demo" %} diff --git a/umap/templates/umap/branding.html b/umap/templates/umap/branding.html new file mode 100644 index 00000000..b6fb9931 --- /dev/null +++ b/umap/templates/umap/branding.html @@ -0,0 +1,3 @@ +

+ {{ SITE_NAME }} +

diff --git a/umap/templates/umap/content.html b/umap/templates/umap/content.html index 7ae6c00c..f061d8eb 100644 --- a/umap/templates/umap/content.html +++ b/umap/templates/umap/content.html @@ -1,12 +1,11 @@ {% extends "base.html" %} -{% load umap_tags compress i18n %} +{% load umap_tags i18n %} {% block body_class %} content {% endblock body_class %} {% block extra_head %} - {% compress css %} - {% umap_css %} - {% endcompress css %} + {% umap_css %} + {{ block.super }} {% umap_js %} {% endblock extra_head %} {% block header %} @@ -38,53 +37,26 @@ {% block bottom_js %} {{ block.super }} {% endblock bottom_js %} {% block footer %} diff --git a/umap/templates/umap/css.html b/umap/templates/umap/css.html index 120ff967..c121ccd1 100644 --- a/umap/templates/umap/css.html +++ b/umap/templates/umap/css.html @@ -1,28 +1,32 @@ +{% load static %} + href="{% static 'umap/vendors/leaflet/leaflet.css' %}" /> + href="{% static 'umap/vendors/markercluster/MarkerCluster.css' %}" /> + href="{% static 'umap/vendors/markercluster/MarkerCluster.Default.css' %}" /> + href="{% static 'umap/vendors/editinosm/Leaflet.EditInOSM.css' %}" /> + href="{% static 'umap/vendors/minimap/Control.MiniMap.min.css' %}" /> + href="{% static 'umap/vendors/contextmenu/leaflet.contextmenu.min.css' %}" /> + href="{% static 'umap/vendors/toolbar/leaflet.toolbar.css' %}" /> + href="{% static 'umap/vendors/measurable/Leaflet.Measurable.css' %}" /> + href="{% static 'umap/vendors/fullscreen/leaflet.fullscreen.css' %}" /> + href="{% static 'umap/vendors/locatecontrol/L.Control.Locate.min.css' %}" /> - - - - - - + href="{% static 'umap/vendors/iconlayers/iconLayers.css' %}" /> + + + + + + + + + diff --git a/umap/templates/umap/header.html b/umap/templates/umap/header.html new file mode 100644 index 00000000..e69de29b diff --git a/umap/templates/umap/home.html b/umap/templates/umap/home.html index cfd9fce0..47b2b880 100644 --- a/umap/templates/umap/home.html +++ b/umap/templates/umap/home.html @@ -10,7 +10,9 @@
{% endif %}
-

{% blocktrans %}Get inspired, browse maps{% endblocktrans %}

-
{% include "umap/map_list.html" %}
+ {% if maps %} +

{% blocktrans %}Get inspired, browse maps{% endblocktrans %}

+
{% include "umap/map_list.html" %}
+ {% endif %}
{% endblock maincontent %} diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html index 131c1a02..8c8c206e 100644 --- a/umap/templates/umap/js.html +++ b/umap/templates/umap/js.html @@ -1,50 +1,64 @@ -{% load compress %} -{% compress js %} - - - - - - - - - - - - - - - - - - - - - - - - - - - -{% endcompress %} -{% if locale %}{% endif %} -{% compress js %} - - - - - - - - - - - - - - - - - -{% endcompress %} +{% load static %} + + +{% if locale %} + {% with "umap/locale/"|add:locale|add:".js" as path %} + + {% endwith %} +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/umap/templates/umap/map_detail.html b/umap/templates/umap/map_detail.html index 25f927e7..f854e4e7 100644 --- a/umap/templates/umap/map_detail.html +++ b/umap/templates/umap/map_detail.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load umap_tags compress i18n %} +{% load umap_tags i18n %} {% block head_title %} {{ map.name }} - {{ SITE_NAME }} {% endblock head_title %} @@ -7,15 +7,24 @@ map_detail {% endblock body_class %} {% block extra_head %} - {% compress css %} - {% umap_css %} - {% endcompress %} + {% if preconnect_domains %} + {% for domain in preconnect_domains %}{% endfor %} + {% endif %} + {% umap_css %} + {{ block.super }} {% umap_js locale=locale %} {% if object.share_status != object.PUBLIC %}{% endif %} + + + + + {% endblock extra_head %} {% block content %} {% block map_init %} {% include "umap/map_init.html" %} {% endblock map_init %} - {% include "umap/map_messages.html" %} {% endblock content %} diff --git a/umap/templates/umap/map_fragment.html b/umap/templates/umap/map_fragment.html index a05afda3..6bd17562 100644 --- a/umap/templates/umap/map_fragment.html +++ b/umap/templates/umap/map_fragment.html @@ -1,7 +1,4 @@ {% load umap_tags %} +
- - - +
diff --git a/umap/templates/umap/map_init.html b/umap/templates/umap/map_init.html index 66d036b4..f3701593 100644 --- a/umap/templates/umap/map_init.html +++ b/umap/templates/umap/map_init.html @@ -1,7 +1,17 @@ {% load umap_tags %}
- diff --git a/umap/templates/umap/map_list.html b/umap/templates/umap/map_list.html index b0c74325..473c7a42 100644 --- a/umap/templates/umap/map_list.html +++ b/umap/templates/umap/map_list.html @@ -1,4 +1,4 @@ -{% load umap_tags umap_tags i18n %} +{% load umap_tags i18n %} {% for map_inst in maps %}
diff --git a/umap/templates/umap/map_messages.html b/umap/templates/umap/map_messages.html deleted file mode 100644 index da4655a8..00000000 --- a/umap/templates/umap/map_messages.html +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/umap/templates/umap/map_table.html b/umap/templates/umap/map_table.html index e4df76b7..4d2d20b2 100644 --- a/umap/templates/umap/map_table.html +++ b/umap/templates/umap/map_table.html @@ -1,41 +1,137 @@ -{% load umap_tags umap_tags i18n %} - - {% if not is_ajax %} +{% load umap_tags i18n %} +
+
- - + + + + + {% for map_inst in maps %} + {% with unique_id="map_"|addstr:map_inst.pk %} + + + + + + + + + + {% endwith %} + {% endfor %} + +
{% blocktrans %}Map{% endblocktrans %} {% blocktrans %}Name{% endblocktrans %}{% blocktrans %}Who can see / edit{% endblocktrans %}{% blocktrans %}Preview{% endblocktrans %}{% blocktrans %}Who can see{% endblocktrans %}{% blocktrans %}Who can edit{% endblocktrans %} {% blocktrans %}Last save{% endblocktrans %} {% blocktrans %}Owner{% endblocktrans %} {% blocktrans %}Actions{% endblocktrans %}
+ {{ map_inst.name }} + + {{ map_inst.preview_settings|json_script:unique_id }} + + +
+
+

+ +

+
+
+
{{ map_inst.get_share_status_display }}{{ map_inst.get_edit_status_display }}{{ map_inst.modified_at }} + {{ map_inst.owner }} + + + + {% translate "Share" %} + + + + {% translate "Edit" %} + + + + {% translate "Download" %} + +
+ {% csrf_token %} + +
+ {% if map_inst|can_delete_map:request %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+ + diff --git a/umap/templates/umap/navigation.html b/umap/templates/umap/navigation.html index fcc41fe0..349ad252 100644 --- a/umap/templates/umap/navigation.html +++ b/umap/templates/umap/navigation.html @@ -1,9 +1,7 @@ {% load i18n %} diff --git a/umap/templates/umap/user_dashboard.html b/umap/templates/umap/user_dashboard.html index 8cbc2710..855a79be 100644 --- a/umap/templates/umap/user_dashboard.html +++ b/umap/templates/umap/user_dashboard.html @@ -5,15 +5,37 @@ {% endblock head_title %} {% block maincontent %} {% trans "Search my maps" as placeholder %} -
+

- {% trans "My Dashboard" %} | {% trans "My profile" %} + {% blocktranslate with count=maps.paginator.count %}My Maps ({{ count }}){% endblocktranslate %} + + {% trans "My profile" %}

- {% include "umap/search_bar.html" with action=request.get_full_path placeholder=placeholder %}
- {% if maps %} +
+
+ + + + + +
+ {% if maps.object_list|length > 1 %} + + {% blocktranslate with count=maps.object_list|length trimmed %} + Download {{ count }} maps + {% endblocktranslate %} + + {% endif %} +
+ {% if maps or request.GET.q %} {% include "umap/map_table.html" %} {% else %}
@@ -23,3 +45,25 @@
{% endblock maincontent %} +{% block bottom_js %} + {{ block.super }} + +{% endblock bottom_js %} diff --git a/umap/templatetags/umap_tags.py b/umap/templatetags/umap_tags.py index 349e426e..9e776637 100644 --- a/umap/templatetags/umap_tags.py +++ b/umap/templatetags/umap_tags.py @@ -1,11 +1,9 @@ -import json from copy import copy from django import template from django.conf import settings -from ..models import DataLayer, TileLayer -from ..views import _urls_for_js +from umap.utils import json_dumps register = template.Library() @@ -22,36 +20,13 @@ def umap_js(locale=None): @register.inclusion_tag("umap/map_fragment.html") def map_fragment(map_instance, **kwargs): - layers = DataLayer.objects.filter(map=map_instance) - datalayer_data = [c.metadata() for c in layers] - map_settings = map_instance.settings - if "properties" not in map_settings: - map_settings["properties"] = {} - map_settings["properties"].update( - { - "tilelayers": [TileLayer.get_default().json], - "datalayers": datalayer_data, - "urls": _urls_for_js(), - "STATIC_URL": settings.STATIC_URL, - "editMode": "disabled", - "hash": False, - "attributionControl": False, - "scrollWheelZoom": False, - "umapAttributionControl": False, - "noControl": True, - "umap_id": map_instance.pk, - "onLoadPanel": "none", - "captionBar": False, - "default_iconUrl": "%sumap/img/marker.png" % settings.STATIC_URL, - "slideshow": {}, - } - ) + map_settings = map_instance.preview_settings map_settings["properties"].update(kwargs) prefix = kwargs.pop("prefix", None) or "map" page = kwargs.pop("page", None) or "" unique_id = prefix + str(page) + "_" + str(map_instance.pk) return { - "map_settings": json.dumps(map_settings), + "map_settings": json_dumps(map_settings), "map": map_instance, "unique_id": unique_id, } @@ -68,6 +43,11 @@ def tilelayer_preview(tilelayer): return output +@register.filter +def can_delete_map(map, request): + return map.can_delete(request.user, request) + + @register.filter def notag(s): return s.replace("<", "<") @@ -86,3 +66,9 @@ def ipdb(what): ipdb.set_trace() return "" + + +@register.filter +def addstr(arg1, arg2): + # Necessity: https://stackoverflow.com/a/23783666 + return str(arg1) + str(arg2) diff --git a/umap/tests/base.py b/umap/tests/base.py index 687793e6..62f948eb 100644 --- a/umap/tests/base.py +++ b/umap/tests/base.py @@ -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, diff --git a/umap/tests/conftest.py b/umap/tests/conftest.py index 7e0b034e..4dafa3dd 100644 --- a/umap/tests/conftest.py +++ b/umap/tests/conftest.py @@ -50,6 +50,13 @@ def map(licence, tilelayer): return MapFactory(owner=user, licence=licence) +@pytest.fixture +def openmap(map): + map.edit_status = Map.ANONYMOUS + map.save() + return map + + @pytest.fixture def anonymap(map): map.owner = None diff --git a/umap/tests/fixtures/test_upload_data.csv b/umap/tests/fixtures/test_upload_data.csv index 107f2510..c6266599 100644 --- a/umap/tests/fixtures/test_upload_data.csv +++ b/umap/tests/fixtures/test_upload_data.csv @@ -1,2 +1,3 @@ Foo,Latitude,geo_Longitude,title,description -bar,41.34,122.86,a point somewhere,the description of this point \ No newline at end of file +bar,41.34,122.86,a point somewhere,the description of this point +bar,43.34,121.86,a point somewhere else,the description of this other point diff --git a/umap/tests/fixtures/test_upload_data.umap b/umap/tests/fixtures/test_upload_data.umap new file mode 100644 index 00000000..0392e2c2 --- /dev/null +++ b/umap/tests/fixtures/test_upload_data.umap @@ -0,0 +1,171 @@ +{ + "type": "umap", + "geometry": { + "type": "Point", + "coordinates": [ + 3.0528, + 50.6269 + ] + }, + "properties": { + "umap_id": 666, + "longCredit": "the illustrious mapmaker", + "shortCredit": "the mapmaker", + "slideshow": {}, + "captionBar": true, + "dashArray": "5,5", + "fillOpacity": "0.5", + "fillColor": "Crimson", + "fill": true, + "weight": "2", + "opacity": "0.9", + "smoothFactor": "1", + "iconClass": "Drop", + "color": "Red", + "limitBounds": {}, + "tilelayer": { + "maxZoom": 20, + "url_template": "https://tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", + "minZoom": 0, + "attribution": "map data © [[https://osm.org/copyright|OpenStreetMap contributors]] under ODbL - Tiles © HOT", + "name": "OSM Humanitarian (OSM-FR)" + }, + "licence": { + "url": "", + "name": "No licence set" + }, + "description": "Map description", + "name": "Imported map", + "tilelayersControl": true, + "onLoadPanel": "caption", + "displayPopupFooter": true, + "miniMap": true, + "moreControl": true, + "scaleControl": true, + "zoomControl": true, + "scrollWheelZoom": true, + "datalayersControl": true, + "zoom": 6 + }, + "layers": [ + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 4.2939, + 50.8893 + ], + [ + 4.2441, + 50.8196 + ], + [ + 4.3869, + 50.7642 + ], + [ + 4.4813, + 50.7929 + ], + [ + 4.413, + 50.9119 + ], + [ + 4.2939, + 50.8893 + ] + ] + ] + }, + "properties": { + "name": "Bruxelles", + "description": "polygon" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 3.0528, + 50.6269 + ] + }, + "properties": { + "_umap_options": { + "color": "Orange" + }, + "name": "Lille", + "description": "une ville" + } + } + ], + "_umap_options": { + "displayOnLoad": true, + "name": "Cities", + "id": 108, + "remoteData": {}, + "description": "A layer with some cities", + "color": "Navy", + "iconClass": "Drop", + "smoothFactor": "1", + "dashArray": "5,1", + "fillOpacity": "0.5", + "fillColor": "Blue", + "fill": true + } + }, + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 1.7715, + 50.9255 + ], + [ + 1.6589, + 50.9696 + ], + [ + 1.4941, + 51.0128 + ], + [ + 1.4199, + 51.0638 + ], + [ + 1.2881, + 51.1104 + ] + ] + }, + "properties": { + "_umap_options": { + "weight": "4" + }, + "name": "tunnel sous la Manche" + } + } + ], + "_umap_options": { + "displayOnLoad": true, + "name": "Tunnels", + "id": 109, + "remoteData": {} + } + } + ] +} diff --git a/umap/tests/fixtures/test_upload_data_osm.json b/umap/tests/fixtures/test_upload_data_osm.json new file mode 100644 index 00000000..dff71593 --- /dev/null +++ b/umap/tests/fixtures/test_upload_data_osm.json @@ -0,0 +1,33 @@ +{ + "version": 0.6, + "generator": "Overpass API 0.7.55.4 3079d8ea", + "osm3s": { + "timestamp_osm_base": "2018-09-22T05:26:02Z", + "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL." + }, + "elements": [ + { + "type": "node", + "id": 3619112991, + "lat": 48.9352995, + "lon": 2.3570684, + "tags": { + "information": "map", + "map_size": "city", + "map_type": "scheme", + "tourism": "information" + } + }, + { + "type": "node", + "id": 3682500756, + "lat": 48.9804426, + "lon": 2.2719725, + "tags": { + "information": "map", + "level": "0", + "tourism": "information" + } + } + ] +} diff --git a/umap/tests/integration/conftest.py b/umap/tests/integration/conftest.py new file mode 100644 index 00000000..89a115e9 --- /dev/null +++ b/umap/tests/integration/conftest.py @@ -0,0 +1,25 @@ +import os + +import pytest + + +@pytest.fixture(autouse=True) +def set_timeout(context): + context.set_default_timeout(os.environ.get("PLAYWRIGHT_TIMEOUT", 7500)) + + +@pytest.fixture +def login(context, settings, live_server): + def do_login(user): + # TODO use storage state to do login only once per session + # https://playwright.dev/python/docs/auth + settings.ENABLE_ACCOUNT_LOGIN = True + page = context.new_page() + page.goto(f"{live_server.url}/en/") + page.locator(".login").click() + page.get_by_placeholder("Username").fill(user.username) + page.get_by_placeholder("Password").fill("123123") + page.locator('#login_form input[type="submit"]').click() + return page + + return do_login diff --git a/umap/tests/integration/test_anonymous_owned_map.py b/umap/tests/integration/test_anonymous_owned_map.py index 19090454..74f3f0e7 100644 --- a/umap/tests/integration/test_anonymous_owned_map.py +++ b/umap/tests/integration/test_anonymous_owned_map.py @@ -1,11 +1,12 @@ import re -from time import sleep +from smtplib import SMTPException +from unittest.mock import patch import pytest from django.core.signing import get_cookie_signer from playwright.sync_api import expect -from umap.models import DataLayer +from umap.models import DataLayer, Map from ..base import DataLayerFactory @@ -33,7 +34,7 @@ def test_map_load_with_owner(anonymap, live_server, owner_session): expect(save).to_be_visible() add_marker = owner_session.get_by_title("Draw a marker") expect(add_marker).to_be_visible() - edit_settings = owner_session.get_by_title("Edit map properties") + edit_settings = owner_session.get_by_title("Map advanced properties") expect(edit_settings).to_be_visible() edit_permissions = owner_session.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() @@ -64,7 +65,7 @@ def test_map_load_with_anonymous_but_editable_layer( expect(save).to_be_visible() add_marker = page.get_by_title("Draw a marker") expect(add_marker).to_be_visible() - edit_settings = page.get_by_title("Edit map properties") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_hidden() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_hidden() @@ -91,6 +92,9 @@ def test_owner_permissions_form(map, datalayer, live_server, owner_session): ".datalayer-permissions select[name='edit_status'] option:checked" ) expect(option).to_have_text("Inherit") + # Those fields should not be present in anonymous maps + expect(owner_session.locator(".umap-field-share_status select")).to_be_hidden() + expect(owner_session.locator(".umap-field-owner")).to_be_hidden() def test_anonymous_can_add_marker_on_editable_layer( @@ -110,12 +114,13 @@ def test_anonymous_can_add_marker_on_editable_layer( marker = page.locator(".leaflet-marker-icon") map_el = page.locator("#map") expect(marker).to_have_count(2) - expect(map_el).not_to_have_class(re.compile("umap-ui")) + panel = page.locator(".panel.right.on") + expect(panel).to_be_hidden() add_marker.click() map_el.click(position={"x": 100, "y": 100}) expect(marker).to_have_count(3) # Edit panel is open - expect(map_el).to_have_class(re.compile("umap-ui")) + expect(panel).to_be_visible() datalayer_select = page.locator("select[name='datalayer']") expect(datalayer_select).to_be_visible() options = page.locator("select[name='datalayer'] option") @@ -126,10 +131,14 @@ def test_anonymous_can_add_marker_on_editable_layer( def test_can_change_perms_after_create(tilelayer, live_server, page): page.goto(f"{live_server.url}/en/map/new") + # Create a layer + page.get_by_title("Manage layers").click() + page.get_by_title("Add a layer").click() + page.locator("input[name=name]").fill("Layer 1") save = page.get_by_role("button", name="Save") expect(save).to_be_visible() - save.click() - sleep(1) # Let save ajax go back + with page.expect_response(re.compile(r".*/datalayer/create/.*")): + save.click() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() edit_permissions.click() @@ -147,3 +156,50 @@ def test_can_change_perms_after_create(tilelayer, live_server, page): ".datalayer-permissions select[name='edit_status'] option:checked" ) expect(option).to_have_text("Inherit") + + +def test_alert_message_after_create( + tilelayer, live_server, page, monkeypatch, settings +): + page.goto(f"{live_server.url}/en/map/new") + save = page.get_by_role("button", name="Save") + expect(save).to_be_visible() + alert = page.locator(".umap-alert") + expect(alert).to_be_hidden() + with page.expect_response(re.compile(r".*/map/create/")): + save.click() + new_map = Map.objects.last() + expect(alert).to_be_visible() + expect( + alert.get_by_text( + "Your map has been created! As you are not logged in, here is your secret " + "link to edit the map, please keep it safe:" + ) + ).to_be_visible() + expect(alert.get_by_role("button", name="Copy")).to_be_visible() + expect(alert.get_by_role("button", name="Send me the link")).to_be_visible() + alert.get_by_placeholder("Email").fill("foo@bar.com") + with patch("umap.views.send_mail") as patched: + with page.expect_response(re.compile("/en/map/.*/send-edit-link/")): + alert.get_by_role("button", name="Send me the link").click() + assert patched.called + patched.assert_called_with( + "The uMap edit link for your map: Untitled map", + f"Here is your secret edit link: {new_map.get_anonymous_edit_url()}", + "test@test.org", + ["foo@bar.com"], + fail_silently=False, + ) + + +def test_email_sending_error_are_catched(tilelayer, page, live_server): + page.goto(f"{live_server.url}/en/map/new") + alert = page.locator(".umap-alert") + with page.expect_response(re.compile(r".*/map/create/")): + page.get_by_role("button", name="Save").click() + alert.get_by_placeholder("Email").fill("foo@bar.com") + with patch("umap.views.send_mail", side_effect=SMTPException) as patched: + with page.expect_response(re.compile("/en/map/.*/send-edit-link/")): + alert.get_by_role("button", name="Send me the link").click() + assert patched.called + expect(alert.get_by_text("Can't send email to foo@bar.com")).to_be_visible() diff --git a/umap/tests/integration/test_browser.py b/umap/tests/integration/test_browser.py index bde3acd7..11a31460 100644 --- a/umap/tests/integration/test_browser.py +++ b/umap/tests/integration/test_browser.py @@ -1,3 +1,4 @@ +from copy import deepcopy from time import sleep import pytest @@ -13,12 +14,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 +35,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": [ @@ -62,7 +63,7 @@ def bootstrap(map, live_server): def test_data_browser_should_be_open(live_server, page, bootstrap, map): page.goto(f"{live_server.url}{map.get_absolute_url()}") - el = page.locator(".umap-browse-data") + el = page.locator(".umap-browser") expect(el).to_be_visible() expect(page.get_by_text("one point in france")).to_be_visible() expect(page.get_by_text("one line in new zeland")).to_be_visible() @@ -71,15 +72,32 @@ 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()}") + expect(page.get_by_title("Features in this layer: 3")).to_be_visible() 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_title("Features in this layer: 1/3")).to_be_visible() + expect(page.get_by_title("Features in this layer: 1/3")).to_have_text("(1/3)") 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 +149,165 @@ 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_bbox_filter_should_be_persistent( + live_server, page, bootstrap, map +): + # Zoom on Europe + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/51.000/2.000") + el = page.get_by_text("Current map view") + expect(el).to_be_visible() + el.click() + browser = page.locator(".panel.left.on") + expect(browser.get_by_text("one point in france")).to_be_visible() + expect(browser.get_by_text("one line in new zeland")).to_be_hidden() + expect(browser.get_by_text("one polygon in greenland")).to_be_hidden() + # Close and reopen the browser to make sure this settings is persistent + close = browser.get_by_title("Close") + close.click() + expect(browser).to_be_hidden() + sleep(0.5) + expect(browser.get_by_text("one point in france")).to_be_hidden() + expect(browser.get_by_text("one line in new zeland")).to_be_hidden() + expect(browser.get_by_text("one polygon in greenland")).to_be_hidden() + page.get_by_title("See layers").click() + expect(browser.get_by_text("one point in france")).to_be_visible() + expect(browser.get_by_text("one line in new zeland")).to_be_hidden() + expect(browser.get_by_text("one polygon in greenland")).to_be_hidden() + + +def test_data_browser_bbox_filtered_is_clickable(live_server, page, bootstrap, map): + popup = page.locator(".leaflet-popup") + # Zoom on Europe + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/51.000/2.000") + el = page.get_by_text("Current map view") + expect(el).to_be_visible() + el.click() + browser = page.locator(".panel.left.on") + expect(browser.get_by_text("one point in france")).to_be_visible() + expect(browser.get_by_text("one line in new zeland")).to_be_hidden() + expect(browser.get_by_text("one polygon in greenland")).to_be_hidden() + expect(popup).to_be_hidden() + browser.get_by_text("one point in france").click() + expect(popup).to_be_visible() + expect(popup.get_by_text("one point in france")).to_be_visible() + + +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() + + +def test_should_sort_features_in_natural_order(live_server, map, page): + map.settings["properties"]["onLoadPanel"] = "databrowser" + map.save() + datalayer_data = deepcopy(DATALAYER_DATA) + datalayer_data["features"][0]["properties"]["name"] = "9. a marker" + datalayer_data["features"][1]["properties"]["name"] = "1. a poly" + datalayer_data["features"][2]["properties"]["name"] = "100. a line" + DataLayerFactory(map=map, data=datalayer_data) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + features = page.locator(".umap-browser .datalayer li") + expect(features).to_have_count(3) + expect(features.nth(0)).to_have_text("1. a poly") + expect(features.nth(1)).to_have_text("9. a marker") + expect(features.nth(2)).to_have_text("100. a line") + + +def test_should_redraw_list_on_feature_delete(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") + # Enable edit + page.get_by_role("button", name="Edit").click() + buttons = page.locator(".umap-browser .datalayer li .icon-delete") + expect(buttons).to_have_count(3) + page.on("dialog", lambda dialog: dialog.accept()) + buttons.nth(0).click() + expect(buttons).to_have_count(2) + page.get_by_role("button", name="Cancel edits").click() + expect(buttons).to_have_count(3) + + +def test_should_show_header_for_display_on_load_false( + live_server, page, bootstrap, map, datalayer +): + datalayer.settings["displayOnLoad"] = False + datalayer.settings["name"] = "This layer is not loaded" + datalayer.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + browser = page.locator(".umap-browser") + expect(browser).to_be_visible() + expect(browser.get_by_text("This layer is not loaded")).to_be_visible() + + +def test_should_use_color_variable(live_server, map, page): + map.settings["properties"]["onLoadPanel"] = "databrowser" + map.settings["properties"]["color"] = "{mycolor}" + map.save() + datalayer_data = deepcopy(DATALAYER_DATA) + datalayer_data["features"][0]["properties"]["mycolor"] = "DarkRed" + datalayer_data["features"][2]["properties"]["mycolor"] = "DarkGreen" + DataLayerFactory(map=map, data=datalayer_data) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + features = page.locator(".umap-browser .datalayer li .feature-color") + expect(features).to_have_count(3) + # DarkGreen + expect(features.nth(0)).to_have_css("background-color", "rgb(0, 100, 0)") + # DarkRed + expect(features.nth(1)).to_have_css("background-color", "rgb(139, 0, 0)") + # DarkBlue (default color) + expect(features.nth(2)).to_have_css("background-color", "rgb(0, 0, 139)") + + +def test_should_allow_to_toggle_datalayer_visibility(live_server, map, page, bootstrap): + 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) + expect(paths).to_have_count(2) + toggle = page.locator(".umap-browser").get_by_title("Show/hide layer") + toggle.click() + expect(markers).to_have_count(0) + expect(paths).to_have_count(0) + + +def test_should_have_edit_buttons_in_edit_mode(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") + browser = page.locator(".umap-browser") + edit_layer = browser.get_by_title("Edit", exact=True) + in_table = browser.get_by_title("Edit properties in a table") + delete_layer = browser.get_by_title("Delete layer") + edit_feature = browser.get_by_title("Edit this feature") + delete_feature = browser.get_by_title("Delete this feature") + expect(edit_layer).to_be_hidden() + expect(in_table).to_be_hidden() + expect(delete_layer).to_be_hidden() + # Does not work + # to_have_count does not seem to case about the elements being visible or not + # and to_be_hidden is not happy if the selector resolve to more than on element + # expect(edit_feature).to_have_count(0) + # expect(delete_feature).to_be_hidden() + # Switch to edit mode + page.get_by_role("button", name="Edit").click() + expect(edit_layer).to_be_visible() + expect(in_table).to_be_visible() + expect(delete_layer).to_be_visible() + expect(edit_feature).to_have_count(3) + expect(delete_feature).to_have_count(3) diff --git a/umap/tests/integration/test_choropleth.py b/umap/tests/integration/test_choropleth.py new file mode 100644 index 00000000..e896dba1 --- /dev/null +++ b/umap/tests/integration/test_choropleth.py @@ -0,0 +1,89 @@ +import json +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +def test_basic_choropleth_map_with_default_color(map, live_server, page): + path = Path(__file__).parent.parent / "fixtures/choropleth_region_chomage.geojson" + data = json.loads(path.read_text()) + DataLayerFactory(data=data, map=map) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + # Hauts-de-France + expect(page.locator("path[fill='#08519c']")).to_have_count(1) + # Occitanie + expect(page.locator("path[fill='#3182bd']")).to_have_count(1) + # Grand-Est, PACA + expect(page.locator("path[fill='#6baed6']")).to_have_count(2) + # Bourgogne-Franche-Comté, Centre-Val-de-Loire, IdF, Normandie, Corse, Nouvelle-Aquitaine + expect(page.locator("path[fill='#bdd7e7']")).to_have_count(6) + # Bretagne, Pays de la Loire, AURA + expect(page.locator("path[fill='#eff3ff']")).to_have_count(3) + + +def test_basic_choropleth_map_with_custom_brewer(openmap, live_server, page): + path = Path(__file__).parent.parent / "fixtures/choropleth_region_chomage.geojson" + data = json.loads(path.read_text()) + + # Change brewer at load + data["_umap_options"]["choropleth"]["brewer"] = "Reds" + DataLayerFactory(data=data, map=openmap) + + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") + # Hauts-de-France + expect(page.locator("path[fill='#a50f15']")).to_have_count(1) + # Occitanie + expect(page.locator("path[fill='#de2d26']")).to_have_count(1) + # Grand-Est, PACA + expect(page.locator("path[fill='#fb6a4a']")).to_have_count(2) + # Bourgogne-Franche-Comté, Centre-Val-de-Loire, IdF, Normandie, Corse, Nouvelle-Aquitaine + expect(page.locator("path[fill='#fcae91']")).to_have_count(6) + # Bretagne, Pays de la Loire, AURA + expect(page.locator("path[fill='#fee5d9']")).to_have_count(3) + + # Now change brewer from UI + page.get_by_role("button", name="Edit").click() + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Choropleth: settings").click() + page.locator('select[name="brewer"]').select_option("Greens") + + # Hauts-de-France + expect(page.locator("path[fill='#006d2c']")).to_have_count(1) + # Occitanie + expect(page.locator("path[fill='#31a354']")).to_have_count(1) + # Grand-Est, PACA + expect(page.locator("path[fill='#74c476']")).to_have_count(2) + # Bourgogne-Franche-Comté, Centre-Val-de-Loire, IdF, Normandie, Corse, Nouvelle-Aquitaine + expect(page.locator("path[fill='#bae4b3']")).to_have_count(6) + # Bretagne, Pays de la Loire, AURA + expect(page.locator("path[fill='#edf8e9']")).to_have_count(3) + + +def test_basic_choropleth_map_with_custom_classes(openmap, live_server, page): + path = Path(__file__).parent.parent / "fixtures/choropleth_region_chomage.geojson" + data = json.loads(path.read_text()) + + # Change brewer at load + data["_umap_options"]["choropleth"]["classes"] = 6 + DataLayerFactory(data=data, map=openmap) + + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") + + # Hauts-de-France + expect(page.locator("path[fill='#08519c']")).to_have_count(1) + # Occitanie + expect(page.locator("path[fill='#3182bd']")).to_have_count(1) + # PACA + expect(page.locator("path[fill='#6baed6']")).to_have_count(1) + # Grand-Est + expect(page.locator("path[fill='#9ecae1']")).to_have_count(1) + # Bourgogne-Franche-Comté, Centre-Val-de-Loire, IdF, Normandie, Corse, Nouvelle-Aquitaine + expect(page.locator("path[fill='#c6dbef']")).to_have_count(6) + # Bretagne, Pays de la Loire, AURA + expect(page.locator("path[fill='#eff3ff']")).to_have_count(3) diff --git a/umap/tests/integration/test_collaborative_editing.py b/umap/tests/integration/test_collaborative_editing.py new file mode 100644 index 00000000..d441c68e --- /dev/null +++ b/umap/tests/integration/test_collaborative_editing.py @@ -0,0 +1,296 @@ +import json +import re +from pathlib import Path +from time import sleep + +from playwright.sync_api import expect + +from umap.models import DataLayer, Map + +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 + map = MapFactory(name="collaborative editing") + datalayer = DataLayerFactory(map=map, edit_status=DataLayer.ANONYMOUS, data={}) + + # Now navigate to this map and create marker + page_one = context.new_page() + page_one.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + save_p1 = page_one.get_by_role("button", name="Save") + expect(save_p1).to_be_visible() + + # Click on the Draw a marker button on a new map. + create_marker_p1 = page_one.get_by_title("Draw a marker") + expect(create_marker_p1).to_be_visible() + create_marker_p1.click() + + # Check no marker is present by default. + marker_pane_p1 = page_one.locator(".leaflet-marker-pane > div") + expect(marker_pane_p1).to_have_count(0) + + # Click on the map, it will place a marker at the given position. + map_el_p1 = page_one.locator("#map") + map_el_p1.click(position={"x": 200, "y": 200}) + expect(marker_pane_p1).to_have_count(1) + + with page_one.expect_response(DATALAYER_UPDATE): + save_p1.click() + # Prevent 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 + page_two = context.new_page() + + page_two.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + save_p2 = page_two.get_by_role("button", name="Save") + expect(save_p2).to_be_visible() + + # Click on the Draw a marker button on a new map. + create_marker_p2 = page_two.get_by_title("Draw a marker") + expect(create_marker_p2).to_be_visible() + create_marker_p2.click() + + # Check that the marker created in the orther tab is present. + marker_pane_p2 = page_two.locator(".leaflet-marker-pane > div") + expect(marker_pane_p2).to_have_count(1) + + # Click on the map, it will place a marker at the given position. + map_el_p2 = page_two.locator("#map") + map_el_p2.click(position={"x": 220, "y": 220}) + expect(marker_pane_p2).to_have_count(2) + + 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 == { + "browsable": True, + "displayOnLoad": True, + "name": "test datalayer", + "inCaption": True, + "editMode": "advanced", + } + + # Now create another marker in the first tab + create_marker_p1.click() + map_el_p1.click(position={"x": 150, "y": 150}) + expect(marker_pane_p1).to_have_count(2) + 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 == { + "browsable": True, + "displayOnLoad": True, + "name": "test datalayer", + "inCaption": True, + "editMode": "advanced", + "id": str(datalayer.pk), + "permissions": {"edit_status": 1}, + } + + # And again + create_marker_p1.click() + map_el_p1.click(position={"x": 180, "y": 150}) + expect(marker_pane_p1).to_have_count(4) + 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, + "displayOnLoad": True, + "name": "test datalayer", + "inCaption": True, + "editMode": "advanced", + "id": str(datalayer.pk), + "permissions": {"edit_status": 1}, + } + expect(marker_pane_p1).to_have_count(4) + + # And again from the second tab + expect(marker_pane_p2).to_have_count(2) + create_marker_p2.click() + map_el_p2.click(position={"x": 250, "y": 150}) + expect(marker_pane_p2).to_have_count(3) + 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, + "displayOnLoad": True, + "name": "test datalayer", + "inCaption": True, + "editMode": "advanced", + "id": str(datalayer.pk), + "permissions": {"edit_status": 1}, + } + expect(marker_pane_p2).to_have_count(5) + + +def test_empty_datalayers_can_be_merged(context, live_server, tilelayer): + # Let's create a new map with an empty datalayer + map = MapFactory(name="collaborative editing") + DataLayerFactory(map=map, edit_status=DataLayer.ANONYMOUS, data={}) + + # Open two tabs at the same time, on the same empty map + page_one = context.new_page() + page_one.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + page_two = context.new_page() + page_two.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + save_p1 = page_one.get_by_role("button", name="Save") + expect(save_p1).to_be_visible() + + # Click on the Draw a marker button on a new map. + create_marker_p1 = page_one.get_by_title("Draw a marker") + expect(create_marker_p1).to_be_visible() + create_marker_p1.click() + + # Check no marker is present by default. + marker_pane_p1 = page_one.locator(".leaflet-marker-pane > div") + expect(marker_pane_p1).to_have_count(0) + + # Click on the map, it will place a marker at the given position. + map_el_p1 = page_one.locator("#map") + map_el_p1.click(position={"x": 200, "y": 200}) + expect(marker_pane_p1).to_have_count(1) + + with page_one.expect_response(DATALAYER_UPDATE): + save_p1.click() + sleep(1) + + save_p2 = page_two.get_by_role("button", name="Save") + expect(save_p2).to_be_visible() + + # Click on the Draw a marker button on a new map. + create_marker_p2 = page_two.get_by_title("Draw a marker") + expect(create_marker_p2).to_be_visible() + create_marker_p2.click() + + marker_pane_p2 = page_two.locator(".leaflet-marker-pane > div") + + # Click on the map, it will place a marker at the given position. + map_el_p2 = page_two.locator("#map") + map_el_p2.click(position={"x": 220, "y": 220}) + expect(marker_pane_p2).to_have_count(1) + + # Save p1 and p2 at the same time + with page_two.expect_response(DATALAYER_UPDATE): + save_p2.click() + sleep(1) + + expect(marker_pane_p2).to_have_count(2) + + +def test_same_second_edit_doesnt_conflict(context, live_server, tilelayer): + # Let's create a new map with an empty datalayer + map = MapFactory(name="collaborative editing") + datalayer = DataLayerFactory(map=map, edit_status=DataLayer.ANONYMOUS, data={}) + + # Open the created map on two pages. + page_one = context.new_page() + page_one.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + page_two = context.new_page() + page_two.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + + save_p1 = page_one.get_by_role("button", name="Save") + expect(save_p1).to_be_visible() + + save_p2 = page_two.get_by_role("button", name="Save") + expect(save_p2).to_be_visible() + + # Create a point on the first map + create_marker_p1 = page_one.get_by_title("Draw a marker") + expect(create_marker_p1).to_be_visible() + create_marker_p1.click() + + # Check no marker is present by default. + marker_pane_p1 = page_one.locator(".leaflet-marker-pane > div") + expect(marker_pane_p1).to_have_count(0) + + # Click on the map, it will place a marker at the given position. + map_el_p1 = page_one.locator("#map") + map_el_p1.click(position={"x": 200, "y": 200}) + expect(marker_pane_p1).to_have_count(1) + + # And add one on the second map as well. + create_marker_p2 = page_two.get_by_title("Draw a marker") + expect(create_marker_p2).to_be_visible() + create_marker_p2.click() + + marker_pane_p2 = page_two.locator(".leaflet-marker-pane > div") + + # Click on the map, it will place a marker at the given position. + map_el_p2 = page_two.locator("#map") + map_el_p2.click(position={"x": 220, "y": 220}) + expect(marker_pane_p2).to_have_count(1) + + # Save the two tabs at the same time + with page_one.expect_response(DATALAYER_UPDATE): + save_p1.click() + sleep(0.2) # Needed to avoid having multiple requests coming at the same time. + save_p2.click() + + # Now create another marker in the first tab + create_marker_p1.click() + map_el_p1.click(position={"x": 150, "y": 150}) + expect(marker_pane_p1).to_have_count(2) + 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 == { + "browsable": True, + "displayOnLoad": True, + "name": "test datalayer", + "inCaption": True, + "editMode": "advanced", + "id": str(datalayer.pk), + "permissions": {"edit_status": 1}, + } + + +def test_should_display_alert_on_conflict(context, live_server, datalayer, openmap): + # Open the map on two pages. + page_one = context.new_page() + page_one.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page_two = context.new_page() + page_two.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + + page_one.locator(".leaflet-marker-icon").click(modifiers=["Shift"]) + page_one.locator('input[name="name"]').fill("new name") + with page_one.expect_response(re.compile(r".*/datalayer/update/.*")): + page_one.get_by_role("button", name="Save").click() + + page_two.locator(".leaflet-marker-icon").click(modifiers=["Shift"]) + page_two.locator('input[name="name"]').fill("custom name") + with page_two.expect_response(re.compile(r".*/datalayer/update/.*")): + page_two.get_by_role("button", name="Save").click() + saved = DataLayer.objects.last() + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"]["name"] == "new name" + expect(page_two.get_by_text("Woops! Someone else seems to")).to_be_visible() + with page_two.expect_response(re.compile(r".*/datalayer/update/.*")): + page_two.get_by_role("button", name="Save anyway").click() + saved = DataLayer.objects.last() + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"]["name"] == "custom name" diff --git a/umap/tests/integration/test_dashboard.py b/umap/tests/integration/test_dashboard.py new file mode 100644 index 00000000..86e8ff78 --- /dev/null +++ b/umap/tests/integration/test_dashboard.py @@ -0,0 +1,48 @@ +import pytest +from playwright.sync_api import expect + +from umap.models import Map + +pytestmark = pytest.mark.django_db + + +def test_owner_can_delete_map_after_confirmation(map, live_server, login): + dialog_shown = False + + def handle_dialog(dialog): + dialog.accept() + nonlocal dialog_shown + dialog_shown = True + + page = login(map.owner) + page.goto(f"{live_server.url}/en/me") + delete_button = page.get_by_title("Delete") + expect(delete_button).to_be_visible() + page.on("dialog", handle_dialog) + with page.expect_navigation(): + delete_button.click() + assert dialog_shown + assert Map.objects.all().count() == 0 + + +def test_dashboard_map_preview(map, live_server, datalayer, login): + page = login(map.owner) + page.goto(f"{live_server.url}/en/me") + dialog = page.locator("dialog") + expect(dialog).to_be_hidden() + button = page.get_by_role("button", name="Open preview") + expect(button).to_be_visible() + button.click() + expect(dialog).to_be_visible() + # Let's check we have a marker on it, so we can guess the map loaded correctly + expect(dialog.locator(".leaflet-marker-icon")).to_be_visible() + + +def test_no_delete_button_for_editors(map, live_server, datalayer, login, user): + map.name = "Map I cannot delete" + map.editors.add(user) + map.save() + page = login(user) + page.goto(f"{live_server.url}/en/me") + expect(page.get_by_text("Map I cannot delete")).to_be_visible() + expect(page.get_by_title("Delete")).to_be_hidden() diff --git a/umap/tests/integration/test_datalayer.py b/umap/tests/integration/test_datalayer.py new file mode 100644 index 00000000..ed3ffc52 --- /dev/null +++ b/umap/tests/integration/test_datalayer.py @@ -0,0 +1,132 @@ +import json + +import pytest +from django.core.files.base import ContentFile +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +def set_options(datalayer, **options): + # For now we need to change both the DB and the FS… + datalayer.settings.update(options) + data = json.load(datalayer.geojson.file) + data["_umap_options"].update(**options) + datalayer.geojson = ContentFile(json.dumps(data), "foo.json") + datalayer.save() + + +def test_honour_displayOnLoad_false(map, live_server, datalayer, page): + set_options(datalayer, displayOnLoad=False) + page.goto(f"{live_server.url}{map.get_absolute_url()}?onLoadPanel=datalayers") + expect(page.locator(".leaflet-marker-icon")).to_be_hidden() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + layers_off = page.locator(".umap-browser .datalayer.off") + expect(layers).to_have_count(1) + expect(layers_off).to_have_count(1) + page.get_by_role("button", name="See layers").click() + page.get_by_label("Zoom in").click() + expect(markers).to_be_hidden() + page.get_by_title("Show/hide layer").click() + expect(layers_off).to_have_count(0) + expect(markers).to_be_visible() + + +def test_should_honour_fromZoom(live_server, map, datalayer, page): + set_options(datalayer, displayOnLoad=True, fromZoom=6) + page.goto(f"{live_server.url}{map.get_absolute_url()}#5/48.55/14.68") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_be_hidden() + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.55/14.68") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_be_visible() + page.get_by_label("Zoom out").click() + expect(markers).to_be_hidden() + page.get_by_label("Zoom in").click() + expect(markers).to_be_visible() + page.get_by_label("Zoom in").click() + expect(markers).to_be_visible() + + +def test_should_honour_toZoom(live_server, map, datalayer, page): + set_options(datalayer, displayOnLoad=True, toZoom=6) + page.goto(f"{live_server.url}{map.get_absolute_url()}#7/48.55/14.68") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_be_hidden() + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.55/14.68") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_be_visible() + page.get_by_label("Zoom out").click() + expect(markers).to_be_visible() + page.get_by_label("Zoom in").click() + expect(markers).to_be_visible() + page.get_by_label("Zoom in").click() + # FIXME does not work (but works when using PWDEBUG=1), not sure why + # may be a race condition related to css transition + # expect(markers).to_be_hidden() + + +def test_should_honour_color_variable(live_server, map, page): + data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"mycolor": "aliceblue", "name": "Point 4"}, + "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, + }, + { + "type": "Feature", + "properties": {"name": "a polygon", "mycolor": "tomato"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [2.12, 49.57], + [1.08, 49.02], + [2.51, 47.55], + [3.19, 48.77], + [2.12, 49.57], + ] + ], + }, + }, + ], + "_umap_options": { + "name": "Calque 2", + "color": "{mycolor}", + "fillColor": "{mycolor}", + }, + } + DataLayerFactory(map=map, data=data) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.locator(".leaflet-overlay-pane path[fill='tomato']")) + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_css("background-color", "rgb(240, 248, 255)") + + +def test_datalayers_in_query_string(live_server, datalayer, map, page): + map.settings["properties"]["onLoadPanel"] = "datalayers" + map.save() + with_old_id = DataLayerFactory(old_id=134, map=map, name="with old id") + set_options(with_old_id, name="with old id") + visible = page.locator(".umap-browser .datalayer:not(.off) .datalayer-name") + hidden = page.locator(".umap-browser .datalayer.off .datalayer-name") + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(visible).to_have_count(2) + expect(hidden).to_have_count(0) + page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayers={datalayer.pk}") + expect(visible).to_have_count(1) + expect(visible).to_have_text(datalayer.name) + expect(hidden).to_have_count(1) + expect(hidden).to_have_text(with_old_id.name) + page.goto( + f"{live_server.url}{map.get_absolute_url()}?datalayers={with_old_id.old_id}" + ) + expect(visible).to_have_count(1) + expect(visible).to_have_text(with_old_id.name) + expect(hidden).to_have_count(1) + expect(hidden).to_have_text(datalayer.name) diff --git a/umap/tests/integration/test_draw_polygon.py b/umap/tests/integration/test_draw_polygon.py new file mode 100644 index 00000000..44cb3c7c --- /dev/null +++ b/umap/tests/integration/test_draw_polygon.py @@ -0,0 +1,363 @@ +import json +import re +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from umap.models import DataLayer + +pytestmark = pytest.mark.django_db + + +def save_and_get_json(page): + with page.expect_response(re.compile(r".*/datalayer/create/.*")): + page.get_by_role("button", name="Save").click() + datalayer = DataLayer.objects.last() + return json.loads(Path(datalayer.geojson.path).read_text()) + + +def test_draw_polygon(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a polygon button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polygon" + ) + create_line.click() + + # Check no polygon is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + # Click on the map, it will create a polygon. + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(2) + map.click(position={"x": 100, "y": 100}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(2) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(0) + + +def test_clicking_esc_should_finish_polygon(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a polygon button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polygon" + ) + create_line.click() + + # Check no polygon is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + # Click on the map, it will create a polygon. + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(2) + map.click(position={"x": 100, "y": 100}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(2) + # Click ESC to finish + page.keyboard.press("Escape") + expect(lines).to_have_count(1) + expect(guide).to_have_count(0) + + +def test_clicking_esc_should_delete_polygon_if_empty(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a polygon button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polygon" + ) + create_line.click() + + # Check no polygon is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + # Click ESC to finish, no polygon should have been created + page.keyboard.press("Escape") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + +def test_clicking_esc_should_delete_polygon_if_invalid(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a polygon button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polygon" + ) + create_line.click() + + # Check no polygon is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + # Click on the map twice, it will start a polygon. + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(2) + # Click ESC to finish, the polygon is invalid, it should not be persisted + page.keyboard.press("Escape") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + +def test_can_draw_multi(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + polygons = page.locator(".leaflet-overlay-pane path") + expect(polygons).to_have_count(0) + multi_button = page.get_by_title("Add a polygon to the current multi") + expect(multi_button).to_be_hidden() + page.get_by_title("Draw a polygon").click() + map = page.locator("#map") + map.click(position={"x": 150, "y": 100}) + map.click(position={"x": 150, "y": 150}) + map.click(position={"x": 100, "y": 150}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(multi_button).to_be_visible() + expect(polygons).to_have_count(1) + multi_button.click() + map.click(position={"x": 250, "y": 200}) + map.click(position={"x": 250, "y": 250}) + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(polygons).to_have_count(1) + page.keyboard.press("Escape") + expect(multi_button).to_be_hidden() + polygons.first.click(button="right", position={"x": 10, "y": 10}) + expect(page.get_by_role("link", name="Transform to lines")).to_be_hidden() + expect(page.get_by_role("link", name="Remove shape from the multi")).to_be_visible() + + +def test_can_draw_hole(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + page.get_by_title("Draw a polygon").click() + + polygons = page.locator(".leaflet-overlay-pane path") + vertices = page.locator(".leaflet-vertex-icon") + + # Click on the map, it will create a polygon. + map = page.locator("#map") + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 200, "y": 200}) + map.click(position={"x": 100, "y": 200}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(polygons).to_have_count(1) + expect(vertices).to_have_count(4) + + # First vertex of the hole will be created here + map.click(position={"x": 180, "y": 120}) + page.get_by_role("link", name="Start a hole here").click() + map.click(position={"x": 180, "y": 180}) + map.click(position={"x": 120, "y": 180}) + map.click(position={"x": 120, "y": 120}) + # Click again to finish + map.click(position={"x": 120, "y": 120}) + expect(polygons).to_have_count(1) + expect(vertices).to_have_count(8) + # Click on the polygon but not in the hole + polygons.first.click(button="right", position={"x": 10, "y": 10}) + expect(page.get_by_role("link", name="Transform to lines")).to_be_hidden() + + +def test_can_transfer_shape_from_simple_polygon(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + polygons = page.locator(".leaflet-overlay-pane path") + expect(polygons).to_have_count(0) + page.get_by_title("Draw a polygon").click() + map = page.locator("#map") + + # Draw a first polygon + map.click(position={"x": 150, "y": 100}) + map.click(position={"x": 150, "y": 150}) + map.click(position={"x": 100, "y": 150}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(polygons).to_have_count(1) + + # Draw another polygon + page.get_by_title("Draw a polygon").click() + map.click(position={"x": 250, "y": 200}) + map.click(position={"x": 250, "y": 250}) + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(polygons).to_have_count(2) + + # Now that polygon 2 is selected, right click on first one + # and transfer shape + polygons.first.click(position={"x": 20, "y": 20}, button="right") + page.get_by_role("link", name="Transfer shape to edited feature").click() + expect(polygons).to_have_count(1) + + +def test_can_extract_shape(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + polygons = page.locator(".leaflet-overlay-pane path") + expect(polygons).to_have_count(0) + page.get_by_title("Draw a polygon").click() + map = page.locator("#map") + map.click(position={"x": 150, "y": 100}) + map.click(position={"x": 150, "y": 150}) + map.click(position={"x": 100, "y": 150}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(polygons).to_have_count(1) + extract_button = page.get_by_role("link", name="Extract shape to separate feature") + expect(extract_button).to_be_hidden() + page.get_by_title("Add a polygon to the current multi").click() + map.click(position={"x": 250, "y": 200}) + map.click(position={"x": 250, "y": 250}) + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(polygons).to_have_count(1) + polygons.first.click(position={"x": 20, "y": 20}, button="right") + extract_button.click() + expect(polygons).to_have_count(2) + + +def test_cannot_transfer_shape_to_line(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + polygons = page.locator(".leaflet-overlay-pane path") + expect(polygons).to_have_count(0) + page.get_by_title("Draw a polygon").click() + map = page.locator("#map") + map.click(position={"x": 150, "y": 100}) + map.click(position={"x": 150, "y": 150}) + map.click(position={"x": 100, "y": 150}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(polygons).to_have_count(1) + extract_button = page.get_by_role("link", name="Extract shape to separate feature") + polygons.first.click(position={"x": 20, "y": 20}, button="right") + expect(extract_button).to_be_hidden() + page.get_by_title("Draw a polyline").click() + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(polygons).to_have_count(2) + polygons.first.click(position={"x": 20, "y": 20}, button="right") + expect(extract_button).to_be_hidden() + + +def test_cannot_transfer_shape_to_marker(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + polygons = page.locator(".leaflet-overlay-pane path") + expect(polygons).to_have_count(0) + page.get_by_title("Draw a polygon").click() + map = page.locator("#map") + map.click(position={"x": 150, "y": 100}) + map.click(position={"x": 150, "y": 150}) + map.click(position={"x": 100, "y": 150}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(polygons).to_have_count(1) + extract_button = page.get_by_role("link", name="Extract shape to separate feature") + polygons.first.click(position={"x": 20, "y": 20}, button="right") + expect(extract_button).to_be_hidden() + page.get_by_title("Draw a marker").click() + map.click(position={"x": 250, "y": 200}) + expect(polygons).to_have_count(1) + polygons.first.click(position={"x": 20, "y": 20}, button="right") + expect(extract_button).to_be_hidden() + + +def test_can_clone_polygon(live_server, page, tilelayer, settings): + settings.UMAP_ALLOW_ANONYMOUS = True + page.goto(f"{live_server.url}/en/map/new/") + polygons = page.locator(".leaflet-overlay-pane path") + expect(polygons).to_have_count(0) + page.get_by_title("Draw a polygon").click() + map = page.locator("#map") + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 200, "y": 200}) + map.click(position={"x": 100, "y": 200}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(polygons).to_have_count(1) + polygons.first.click(button="right") + page.get_by_role("link", name="Clone this feature").click() + expect(polygons).to_have_count(2) + data = save_and_get_json(page) + assert len(data["features"]) == 2 + assert data["features"][0]["geometry"]["type"] == "Polygon" + assert data["features"][0]["geometry"] == data["features"][1]["geometry"] + + +def test_can_transform_polygon_to_line(live_server, page, tilelayer, settings): + settings.UMAP_ALLOW_ANONYMOUS = True + page.goto(f"{live_server.url}/en/map/new/") + paths = page.locator(".leaflet-overlay-pane path") + polygons = page.locator(".leaflet-overlay-pane path[fill='DarkBlue']") + expect(polygons).to_have_count(0) + page.get_by_title("Draw a polygon").click() + map = page.locator("#map") + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 200, "y": 200}) + map.click(position={"x": 100, "y": 200}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(polygons).to_have_count(1) + expect(paths).to_have_count(1) + polygons.first.click(button="right") + page.get_by_role("link", name="Transform to lines").click() + # No more polygons (will fill), but one path, it must be a line + expect(polygons).to_have_count(0) + expect(paths).to_have_count(1) + data = save_and_get_json(page) + assert len(data["features"]) == 1 + assert data["features"][0]["geometry"]["type"] == "LineString" diff --git a/umap/tests/integration/test_draw_polyline.py b/umap/tests/integration/test_draw_polyline.py new file mode 100644 index 00000000..fe76d4b8 --- /dev/null +++ b/umap/tests/integration/test_draw_polyline.py @@ -0,0 +1,325 @@ +import json +import re +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from umap.models import DataLayer + +pytestmark = pytest.mark.django_db + + +def save_and_get_json(page): + with page.expect_response(re.compile(r".*/datalayer/create/.*")): + page.get_by_role("button", name="Save").click() + datalayer = DataLayer.objects.last() + return json.loads(Path(datalayer.geojson.path).read_text()) + + +def test_draw_polyline(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a line button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polyline" + ) + create_line.click() + + # Check no line is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + # Click on the map, it will create a line. + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + map.click(position={"x": 100, "y": 100}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(0) + + +def test_clicking_esc_should_finish_line(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a line button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polyline" + ) + create_line.click() + + # Check no line is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + # Click on the map, it will create a line. + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + map.click(position={"x": 100, "y": 100}) + expect(lines).to_have_count(1) + expect(guide).to_have_count(1) + # Click ESC to finish + page.keyboard.press("Escape") + expect(lines).to_have_count(1) + expect(guide).to_have_count(0) + + +def test_clicking_esc_should_delete_line_if_empty(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a line button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polyline" + ) + create_line.click() + + # Check no line is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + # At this stage, the line as one element, it should not be created + # on pressing esc, as invalid + # Click ESC to finish + page.keyboard.press("Escape") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + +def test_clicking_esc_should_delete_line_if_invalid(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a line button on a new map. + create_line = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polyline" + ) + create_line.click() + + # Check no line is present by default. + # We target with the color, because there is also the drawing line guide (dash-array) + # around + lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") + guide = page.locator(".leaflet-overlay-pane > svg > g > path") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + # At this stage, the line as no element, it should not be created + # on pressing esc + # Click ESC to finish + page.keyboard.press("Escape") + expect(lines).to_have_count(0) + expect(guide).to_have_count(0) + + +def test_can_draw_multi(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + lines = page.locator(".leaflet-overlay-pane path") + expect(lines).to_have_count(0) + add_shape = page.get_by_title("Add a line to the current multi") + expect(add_shape).to_be_hidden() + page.get_by_title("Draw a polyline").click() + map = page.locator("#map") + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 200}) + # Click again to finish + map.click(position={"x": 100, "y": 200}) + expect(add_shape).to_be_visible() + expect(lines).to_have_count(1) + add_shape.click() + map.click(position={"x": 250, "y": 250}) + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + page.keyboard.press("Escape") + expect(add_shape).to_be_hidden() + lines.first.click(button="right", position={"x": 10, "y": 1}) + expect(page.get_by_role("link", name="Transform to polygon")).to_be_hidden() + expect(page.get_by_role("link", name="Remove shape from the multi")).to_be_visible() + + +def test_can_transfer_shape_from_simple_polyline(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + lines = page.locator(".leaflet-overlay-pane path") + expect(lines).to_have_count(0) + page.get_by_title("Draw a polyline").click() + map = page.locator("#map") + + # Draw a first line + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 200}) + # Click again to finish + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + + # Draw another polygon + page.get_by_title("Draw a polyline").click() + map.click(position={"x": 250, "y": 250}) + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(2) + + # Now that polygon 2 is selected, right click on first one + # and transfer shape + lines.first.click(position={"x": 10, "y": 1}, button="right") + page.get_by_role("link", name="Transfer shape to edited feature").click() + expect(lines).to_have_count(1) + + +def test_can_transfer_shape_from_multi(live_server, page, tilelayer, settings): + settings.UMAP_ALLOW_ANONYMOUS = True + page.goto(f"{live_server.url}/en/map/new/") + lines = page.locator(".leaflet-overlay-pane path") + expect(lines).to_have_count(0) + page.get_by_title("Draw a polyline").click() + map = page.locator("#map") + + # Draw a multi line + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 200}) + # Click again to finish + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + page.get_by_title("Add a line to the current multi").click() + map.click(position={"x": 250, "y": 250}) + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + + # Draw another line + page.get_by_title("Draw a polyline").click() + map.click(position={"x": 350, "y": 350}) + map.click(position={"x": 300, "y": 350}) + map.click(position={"x": 300, "y": 300}) + # Click again to finish + map.click(position={"x": 300, "y": 300}) + expect(lines).to_have_count(2) + + # Now that polygon 2 is selected, right click on first one + # and transfer shape + lines.first.click(position={"x": 10, "y": 1}, button="right") + page.get_by_role("link", name="Transfer shape to edited feature").click() + expect(lines).to_have_count(2) + data = save_and_get_json(page) + # FIXME this should be a LineString, not MultiLineString + assert data["features"][0]["geometry"] == { + "coordinates": [ + [[-6.569824, 52.49616], [-7.668457, 52.49616], [-7.668457, 53.159947]] + ], + "type": "MultiLineString", + } + assert data["features"][1]["geometry"] == { + "coordinates": [ + [[-4.372559, 51.138001], [-5.471191, 51.138001], [-5.471191, 51.822198]], + [[-7.668457, 54.457267], [-9.865723, 54.457267], [-9.865723, 53.159947]], + ], + "type": "MultiLineString", + } + + +def test_can_extract_shape(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + lines = page.locator(".leaflet-overlay-pane path") + expect(lines).to_have_count(0) + page.get_by_title("Draw a polylin").click() + map = page.locator("#map") + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 200}) + # Click again to finish + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + extract_button = page.get_by_role("link", name="Extract shape to separate feature") + expect(extract_button).to_be_hidden() + page.get_by_title("Add a line to the current multi").click() + map.click(position={"x": 250, "y": 250}) + map.click(position={"x": 200, "y": 250}) + map.click(position={"x": 200, "y": 200}) + # Click again to finish + map.click(position={"x": 200, "y": 200}) + expect(lines).to_have_count(1) + lines.first.click(position={"x": 10, "y": 1}, button="right") + extract_button.click() + expect(lines).to_have_count(2) + + +def test_can_clone_polyline(live_server, page, tilelayer, settings): + settings.UMAP_ALLOW_ANONYMOUS = True + page.goto(f"{live_server.url}/en/map/new/") + lines = page.locator(".leaflet-overlay-pane path") + expect(lines).to_have_count(0) + page.get_by_title("Draw a polyline").click() + map = page.locator("#map") + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 200}) + # Click again to finish + map.click(position={"x": 100, "y": 200}) + expect(lines).to_have_count(1) + lines.first.click(position={"x": 10, "y": 1}, button="right") + page.get_by_role("link", name="Clone this feature").click() + expect(lines).to_have_count(2) + data = save_and_get_json(page) + assert len(data["features"]) == 2 + assert data["features"][0]["geometry"]["type"] == "LineString" + assert data["features"][0]["geometry"] == data["features"][1]["geometry"] + assert data["features"][0]["properties"] == data["features"][1]["properties"] + + +def test_can_transform_polyline_to_polygon(live_server, page, tilelayer, settings): + settings.UMAP_ALLOW_ANONYMOUS = True + page.goto(f"{live_server.url}/en/map/new/") + paths = page.locator(".leaflet-overlay-pane path") + # Paths with fill + polygons = page.locator(".leaflet-overlay-pane path[fill='DarkBlue']") + expect(paths).to_have_count(0) + page.get_by_title("Draw a polyline").click() + map = page.locator("#map") + map.click(position={"x": 200, "y": 100}) + map.click(position={"x": 100, "y": 100}) + map.click(position={"x": 100, "y": 200}) + # Click again to finish + map.click(position={"x": 100, "y": 200}) + expect(paths).to_have_count(1) + expect(polygons).to_have_count(0) + paths.first.click(position={"x": 10, "y": 1}, button="right") + page.get_by_role("link", name="Transform to polygon").click() + expect(polygons).to_have_count(1) + expect(paths).to_have_count(1) + data = save_and_get_json(page) + assert len(data["features"]) == 1 + assert data["features"][0]["geometry"]["type"] == "Polygon" diff --git a/umap/tests/integration/test_drawing.py b/umap/tests/integration/test_drawing.py deleted file mode 100644 index 27a11036..00000000 --- a/umap/tests/integration/test_drawing.py +++ /dev/null @@ -1,243 +0,0 @@ -from playwright.sync_api import expect - - -def test_draw_polyline(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a line button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polyline (Ctrl+L)" - ) - create_line.click() - - # Check no line is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - # Click on the map, it will create a line. - map = page.locator("#map") - map.click(position={"x": 200, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - map.click(position={"x": 100, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - map.click(position={"x": 100, "y": 100}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - # Click again to finish - map.click(position={"x": 100, "y": 100}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(0) - - -def test_clicking_esc_should_finish_line(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a line button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polyline (Ctrl+L)" - ) - create_line.click() - - # Check no line is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - # Click on the map, it will create a line. - map = page.locator("#map") - map.click(position={"x": 200, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - map.click(position={"x": 100, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - map.click(position={"x": 100, "y": 100}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - # Click ESC to finish - page.keyboard.press("Escape") - expect(lines).to_have_count(1) - expect(guide).to_have_count(0) - - -def test_clicking_esc_should_delete_line_if_empty(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a line button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polyline (Ctrl+L)" - ) - create_line.click() - - # Check no line is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - map = page.locator("#map") - map.click(position={"x": 200, "y": 200}) - # At this stage, the line as one element, it should not be created - # on pressing esc, as invalid - # Click ESC to finish - page.keyboard.press("Escape") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - -def test_clicking_esc_should_delete_line_if_invalid(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a line button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polyline (Ctrl+L)" - ) - create_line.click() - - # Check no line is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - # At this stage, the line as no element, it should not be created - # on pressing esc - # Click ESC to finish - page.keyboard.press("Escape") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - -def test_draw_polygon(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a polygon button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polygon (Ctrl+P)" - ) - create_line.click() - - # Check no polygon is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - # Click on the map, it will create a polygon. - map = page.locator("#map") - map.click(position={"x": 200, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - map.click(position={"x": 100, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(2) - map.click(position={"x": 100, "y": 100}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(2) - # Click again to finish - map.click(position={"x": 100, "y": 100}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(0) - - -def test_clicking_esc_should_finish_polygon(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a polygon button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polygon (Ctrl+P)" - ) - create_line.click() - - # Check no polygon is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - # Click on the map, it will create a polygon. - map = page.locator("#map") - map.click(position={"x": 200, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - map.click(position={"x": 100, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(2) - map.click(position={"x": 100, "y": 100}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(2) - # Click ESC to finish - page.keyboard.press("Escape") - expect(lines).to_have_count(1) - expect(guide).to_have_count(0) - - -def test_clicking_esc_should_delete_polygon_if_empty(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a polygon button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polygon (Ctrl+P)" - ) - create_line.click() - - # Check no polygon is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - # Click ESC to finish, no polygon should have been created - page.keyboard.press("Escape") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - -def test_clicking_esc_should_delete_polygon_if_invalid(page, live_server, tilelayer): - page.goto(f"{live_server.url}/en/map/new/") - - # Click on the Draw a polygon button on a new map. - create_line = page.locator(".leaflet-control-toolbar ").get_by_title( - "Draw a polygon (Ctrl+P)" - ) - create_line.click() - - # Check no polygon is present by default. - # We target with the color, because there is also the drawing line guide (dash-array) - # around - lines = page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']") - guide = page.locator(".leaflet-overlay-pane > svg > g > path") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) - - # Click on the map twice, it will start a polygon. - map = page.locator("#map") - map.click(position={"x": 200, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(1) - map.click(position={"x": 100, "y": 200}) - expect(lines).to_have_count(1) - expect(guide).to_have_count(2) - # Click ESC to finish, the polygon is invalid, it should not be persisted - page.keyboard.press("Escape") - expect(lines).to_have_count(0) - expect(guide).to_have_count(0) diff --git a/umap/tests/integration/test_edit_datalayer.py b/umap/tests/integration/test_edit_datalayer.py new file mode 100644 index 00000000..ecfba705 --- /dev/null +++ b/umap/tests/integration/test_edit_datalayer.py @@ -0,0 +1,185 @@ +import platform +import re + +from playwright.sync_api import expect + +from umap.models import DataLayer + +from ..base import DataLayerFactory + + +def test_should_have_fieldset_for_layer_type_properties(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Open DataLayers list + page.get_by_title("Manage layers").click() + + # Create a layer + page.get_by_title("Add a layer").click() + page.locator("input[name=name]").fill("Layer 1") + + select = page.locator(".panel.on .umap-field-type select") + expect(select).to_be_visible() + + choropleth_header = page.get_by_text("Choropleth: settings") + heat_header = page.get_by_text("Heatmap: settings") + cluster_header = page.get_by_text("Clustered: settings") + expect(choropleth_header).to_be_hidden() + expect(heat_header).to_be_hidden() + expect(cluster_header).to_be_hidden() + + # Switching to Choropleth should add a dedicated fieldset + select.select_option("Choropleth") + expect(choropleth_header).to_be_visible() + expect(heat_header).to_be_hidden() + expect(cluster_header).to_be_hidden() + + select.select_option("Heat") + expect(heat_header).to_be_visible() + expect(choropleth_header).to_be_hidden() + expect(cluster_header).to_be_hidden() + + select.select_option("Cluster") + expect(cluster_header).to_be_visible() + expect(choropleth_header).to_be_hidden() + expect(heat_header).to_be_hidden() + + select.select_option("Default") + expect(choropleth_header).to_be_hidden() + expect(heat_header).to_be_hidden() + expect(cluster_header).to_be_hidden() + + +def test_cancel_deleting_datalayer_should_restore( + live_server, openmap, datalayer, page +): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(layers).to_have_count(1) + expect(markers).to_have_count(1) + page.get_by_role("link", name="Manage layers").click() + page.once("dialog", lambda dialog: dialog.accept()) + page.locator(".panel.right").get_by_title("Delete layer").click() + expect(markers).to_have_count(0) + page.get_by_role("button", name="See layers").click() + expect(page.get_by_text("test datalayer")).to_be_hidden() + page.once("dialog", lambda dialog: dialog.accept()) + page.get_by_role("button", name="Cancel edits").click() + expect(markers).to_have_count(1) + expect(page.locator(".umap-browser").get_by_text("test datalayer")).to_be_visible() + + +def test_can_clone_datalayer(live_server, openmap, login, datalayer, page): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(layers).to_have_count(1) + expect(markers).to_have_count(1) + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel.right").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Advanced actions").click() + page.get_by_role("button", name="Clone").click() + expect(layers).to_have_count(2) + expect(markers).to_have_count(2) + + +def test_can_change_icon_class(live_server, openmap, page): + # Faster than doing a login + data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": "Point 4"}, + "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, + }, + ], + } + DataLayerFactory(map=openmap, data=data) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + expect(page.locator(".umap-div-icon")).to_be_visible() + page.get_by_role("link", name="Manage layers").click() + expect(page.locator(".umap-circle-icon")).to_be_hidden() + page.locator(".panel.right").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Shape properties").click() + page.locator(".umap-field-iconClass a.define").click() + page.get_by_text("Circle").click() + expect(page.locator(".umap-circle-icon")).to_be_visible() + expect(page.locator(".umap-div-icon")).to_be_hidden() + + +def test_can_change_name(live_server, openmap, page, datalayer): + page.goto( + f"{live_server.url}{openmap.get_absolute_url()}?edit&datalayersControl=expanded" + ) + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel.right").get_by_title("Edit", exact=True).click() + expect(page.locator(".umap-is-dirty")).to_be_hidden() + page.locator('input[name="name"]').click() + page.locator('input[name="name"]').press("Control+a") + page.locator('input[name="name"]').fill("new name") + expect(page.locator(".umap-browser .datalayer")).to_contain_text("new name") + expect(page.locator(".umap-is-dirty")).to_be_visible() + with page.expect_response(re.compile(".*/datalayer/update/.*")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.last() + assert saved.name == "new name" + expect(page.locator(".umap-is-dirty")).to_be_hidden() + + +def test_can_create_new_datalayer(live_server, openmap, page, datalayer): + page.goto( + f"{live_server.url}{openmap.get_absolute_url()}?edit&datalayersControl=expanded" + ) + page.get_by_role("link", name="Manage layers").click() + page.get_by_role("button", name="Add a layer").click() + page.locator('input[name="name"]').click() + page.locator('input[name="name"]').fill("my new layer") + expect(page.get_by_text("my new layer")).to_be_visible() + with page.expect_response(re.compile(".*/datalayer/create/.*")): + page.get_by_role("button", name="Save").click() + assert DataLayer.objects.count() == 2 + saved = DataLayer.objects.last() + assert saved.name == "my new layer" + expect(page.locator(".umap-is-dirty")).to_be_hidden() + # Edit again, it should not create a new datalayer + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel.right").get_by_title("Edit", exact=True).first.click() + page.locator('input[name="name"]').click() + page.locator('input[name="name"]').fill("my new layer with a new name") + expect(page.get_by_text("my new layer with a new name")).to_be_visible() + page.get_by_role("button", name="Save").click() + with page.expect_response(re.compile(".*/datalayer/update/.*")): + page.get_by_role("button", name="Save").click() + assert DataLayer.objects.count() == 2 + saved = DataLayer.objects.last() + assert saved.name == "my new layer with a new name" + expect(page.locator(".umap-is-dirty")).to_be_hidden() + + +def test_can_restore_version(live_server, openmap, page, datalayer): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + marker = page.locator(".leaflet-marker-icon") + expect(marker).to_have_class(re.compile(".*umap-ball-icon.*")) + marker.click(modifiers=["Shift"]) + page.get_by_role("heading", name="Shape properties").click() + page.locator("#umap-feature-shape-properties").get_by_text("Default").click() + with page.expect_response(re.compile(".*/datalayer/update/.*")): + page.get_by_role("button", name="Save").click() + expect(marker).to_have_class(re.compile(".*umap-div-icon.*")) + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel.right").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Versions").click() + page.once("dialog", lambda dialog: dialog.accept()) + page.get_by_role("button", name="Restore this version").last.click() + expect(marker).to_have_class(re.compile(".*umap-ball-icon.*")) + + +def test_can_edit_layer_on_ctrl_shift_click(live_server, openmap, page, datalayer): + modifier = "Meta" if platform.system() == "Darwin" else "Control" + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.locator(".leaflet-marker-icon").click(modifiers=[modifier, "Shift"]) + expect(page.get_by_role("heading", name="Layer properties")).to_be_visible() diff --git a/umap/tests/integration/test_edit_map.py b/umap/tests/integration/test_edit_map.py new file mode 100644 index 00000000..4d397532 --- /dev/null +++ b/umap/tests/integration/test_edit_map.py @@ -0,0 +1,195 @@ +import re + +from playwright.sync_api import expect + +from umap.models import DataLayer, Map + +from ..base import DataLayerFactory + + +def test_can_edit_name(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + page.get_by_title("Edit map name and caption").click() + name_input = page.locator('.map-metadata input[name="name"]') + expect(name_input).to_be_visible() + name_input.click() + name_input.press("Control+a") + name_input.fill("New map name") + expect(page.locator(".umap-main-edit-toolbox .map-name").nth(0)).to_have_text( + "New map name" + ) + + +def test_map_name_impacts_ui(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + gear_icon = page.get_by_title("Edit map name and caption") + expect(gear_icon).to_be_visible() + gear_icon.click() + + name_input = page.locator("form").locator('input[name="name"]').first + expect(name_input).to_be_visible() + + name_input.fill("something else") + + expect(page.get_by_role("button", name="something else").nth(1)).to_be_visible() + + +def test_zoomcontrol_impacts_ui(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + gear_icon = page.get_by_title("Map advanced properties") + expect(gear_icon).to_be_visible() + gear_icon.click() + + # Should be visible by default + zoom_in = page.get_by_label("Zoom in") + zoom_out = page.get_by_label("Zoom out") + + expect(zoom_in).to_be_visible() + expect(zoom_out).to_be_visible() + + # Hide them + page.get_by_role("heading", name="User interface options").click() + hide_zoom_controls = ( + page.locator("div") + .filter(has_text=re.compile(r"^Display the zoom control")) + .locator("label") + .nth(2) + ) + hide_zoom_controls.click() + + expect(zoom_in).to_be_hidden() + expect(zoom_out).to_be_hidden() + + +def test_map_color_impacts_data(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + gear_icon = page.get_by_title("Map advanced properties") + expect(gear_icon).to_be_visible() + gear_icon.click() + + # Click on the Draw a marker button on a new map. + create_marker_p1 = page.get_by_title("Draw a marker") + expect(create_marker_p1).to_be_visible() + create_marker_p1.click() + + # Add a new marker + marker_pane_p1 = page.locator(".leaflet-marker-pane > div") + map_el = page.locator("#map") + map_el.click(position={"x": 200, "y": 200}) + expect(marker_pane_p1).to_have_count(1) + + # Change the default color + page.get_by_role("heading", name="Shape properties").click() + page.locator("#umap-feature-shape-properties").get_by_text("define").first.click() + page.get_by_title("Lime", exact=True).click() + + # Assert the new color was used + marker_style = page.locator(".leaflet-marker-icon .icon_container").get_attribute( + "style" + ) + assert "lime" in marker_style + + +def test_limitbounds_impacts_ui(live_server, page, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + gear_icon = page.get_by_title("Map advanced properties") + expect(gear_icon).to_be_visible() + gear_icon.click() + + page.get_by_role("heading", name="Limit bounds").click() + default_zoom_url = f"{live_server.url}/en/map/new/#5/51.110/7.053" + page.goto(default_zoom_url) + page.get_by_role("button", name="Use current bounds").click() + + zoom_in = page.get_by_label("Zoom in") + zoom_out = page.get_by_label("Zoom out") + + # It should be possible to zoom in + zoom_in.click() + page.wait_for_timeout(500) + assert page.url != default_zoom_url + + # But not to zoom out of the window + zoom_out.click() # back to normal + page.wait_for_timeout(500) + assert "leaflet-disabled" in zoom_out.get_attribute("class") + + +def test_sortkey_impacts_datalayerindex(map, live_server, page): + # Create points with a "key" property. + # But we want them to sort by key (First, Second, Third) + DataLayerFactory( + map=map, + data={ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.6, 48.5], + }, + "properties": {"name": "Z First", "key": "1st Point"}, + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.7, 48.4], + }, + "properties": {"name": "Y Second", "key": "2d Point"}, + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.5, 48.6], + }, + "properties": {"name": "X Third", "key": "3rd Point"}, + }, + ], + }, + ) + map.edit_status = Map.ANONYMOUS + datalayer = map.datalayer_set.first() + datalayer.edit_status = DataLayer.ANONYMOUS + datalayer.save() + map.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + + # By default, features are sorted by name (Third, Second, First) + page.get_by_role("button", name="See layers").click() + page.get_by_role("heading", name="Show/hide layer").locator("i").click() + + first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0) + second_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(1) + third_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(2) + assert "X Third" == first_listed_feature.text_content() + assert "Y Second" == second_listed_feature.text_content() + assert "Z First" == third_listed_feature.text_content() + + # Change the default sortkey to be "key" + page.get_by_role("button", name="Edit").click() + page.get_by_role("link", name="Map advanced properties").click() + page.get_by_role("heading", name="Default properties").click() + + # Click "define" + page.locator(".panel .umap-field-sortKey .define").click() + page.locator('input[name="sortKey"]').click() + page.locator('input[name="sortKey"]').fill("key") + + # Click the checkmark to apply the changes + page.locator(".panel .umap-field-sortKey .blur-button").click() + + # Features should be sorted by key (First, Second, Third) + first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0) + second_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(1) + third_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(2) + assert "Z First" == first_listed_feature.text_content() + assert "Y Second" == second_listed_feature.text_content() + assert "X Third" == third_listed_feature.text_content() diff --git a/umap/tests/integration/test_edit_marker.py b/umap/tests/integration/test_edit_marker.py new file mode 100644 index 00000000..fb3b2d1a --- /dev/null +++ b/umap/tests/integration/test_edit_marker.py @@ -0,0 +1,107 @@ +import platform +from copy import deepcopy + +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "name": "test marker", + "description": "Some description", + }, + "id": "QwNjg", + "geometry": { + "type": "Point", + "coordinates": [14.6889, 48.5529], + }, + }, + ], +} + + +@pytest.fixture +def bootstrap(map, live_server): + DataLayerFactory(map=map, data=DATALAYER_DATA) + + +def test_can_edit_on_shift_click(live_server, openmap, page, datalayer): + modifier = "Meta" if platform.system() == "Darwin" else "Control" + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.locator(".leaflet-marker-icon").click(modifiers=[modifier, "Shift"]) + expect(page.get_by_role("heading", name="Layer properties")).to_be_visible() + + +def test_marker_style_should_have_precedence(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + + # Change colour at layer level + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Shape properties").click() + page.locator(".umap-field-color .define").click() + expect(page.locator(".leaflet-marker-icon .icon_container")).to_have_css( + "background-color", "rgb(0, 0, 139)" + ) + page.get_by_title("DarkRed").first.click() + expect(page.locator(".leaflet-marker-icon .icon_container")).to_have_css( + "background-color", "rgb(139, 0, 0)" + ) + + # Now change at marker level, it should take precedence + page.locator(".leaflet-marker-icon").click(modifiers=["Shift"]) + page.get_by_role("heading", name="Shape properties").click() + page.locator("#umap-feature-shape-properties").get_by_text("define").first.click() + page.get_by_title("GoldenRod", exact=True).click() + expect(page.locator(".leaflet-marker-icon .icon_container")).to_have_css( + "background-color", "rgb(218, 165, 32)" + ) + + # Now change again at layer level again, it should not change the marker color + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Shape properties").click() + page.locator(".umap-field-color input").click() + page.get_by_title("DarkViolet").first.click() + expect(page.locator(".leaflet-marker-icon .icon_container")).to_have_css( + "background-color", "rgb(218, 165, 32)" + ) + + +def test_should_open_an_edit_toolbar_on_click(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.locator(".leaflet-marker-icon").click() + expect(page.get_by_role("link", name="Toggle edit mode")).to_be_visible() + expect(page.get_by_role("link", name="Delete this feature")).to_be_visible() + + +def test_should_follow_datalayer_style_when_changing_datalayer( + live_server, openmap, page +): + data = deepcopy(DATALAYER_DATA) + data["_umap_options"] = {"color": "DarkCyan"} + DataLayerFactory(map=openmap, data=data) + DataLayerFactory( + map=openmap, + name="other datalayer", + data={ + "type": "FeatureCollection", + "features": [], + "_umap_options": {"color": "DarkViolet"}, + }, + ) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + marker = page.locator(".leaflet-marker-icon .icon_container") + expect(marker).to_have_css("background-color", "rgb(0, 139, 139)") + # Change datalayer + marker.click() + page.get_by_role("link", name="Toggle edit mode (⇧+Click)").click() + page.locator(".umap-field-datalayer select").select_option(label="other datalayer") + expect(marker).to_have_css("background-color", "rgb(148, 0, 211)") diff --git a/umap/tests/integration/test_edit_polygon.py b/umap/tests/integration/test_edit_polygon.py new file mode 100644 index 00000000..b812e181 --- /dev/null +++ b/umap/tests/integration/test_edit_polygon.py @@ -0,0 +1,122 @@ +import platform + +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "name": "name poly", + }, + "id": "gyNzM", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [11.25, 53.585984], + [10.151367, 52.975108], + [12.689209, 52.167194], + [14.084473, 53.199452], + [12.634277, 53.618579], + [11.25, 53.585984], + [11.25, 53.585984], + ], + ], + }, + }, + ], +} + + +@pytest.fixture +def bootstrap(map, live_server): + map.settings["properties"]["zoom"] = 6 + map.settings["geometry"] = { + "type": "Point", + "coordinates": [8.429, 53.239], + } + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + + +def test_can_edit_on_shift_click(live_server, openmap, page, datalayer): + modifier = "Meta" if platform.system() == "Darwin" else "Control" + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.locator(".leaflet-marker-icon").click(modifiers=[modifier, "Shift"]) + expect(page.get_by_role("heading", name="Layer properties")).to_be_visible() + + +def test_marker_style_should_have_precedence(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + + # Change colour at layer level + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Shape properties").click() + page.locator(".umap-field-color .define").click() + expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1) + page.get_by_title("DarkRed").first.click() + expect(page.locator(".leaflet-overlay-pane path[fill='DarkRed']")).to_have_count(1) + + # Now change at polygon level, it should take precedence + page.locator("path").click(modifiers=["Shift"]) + page.get_by_role("heading", name="Shape properties").click() + page.locator("#umap-feature-shape-properties").get_by_text("define").first.click() + page.get_by_title("GoldenRod", exact=True).first.click() + expect(page.locator(".leaflet-overlay-pane path[fill='GoldenRod']")).to_have_count( + 1 + ) + + # Now change again at layer level again, it should not change the marker color + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit", exact=True).click() + page.get_by_role("heading", name="Shape properties").click() + page.locator(".umap-field-color input").click() + page.get_by_title("DarkViolet").first.click() + expect(page.locator(".leaflet-overlay-pane path[fill='GoldenRod']")).to_have_count( + 1 + ) + + +def test_should_open_an_edit_toolbar_on_click(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.locator("path").click() + expect(page.get_by_role("link", name="Toggle edit mode")).to_be_visible() + expect(page.get_by_role("link", name="Delete this feature")).to_be_visible() + + +def test_can_remove_stroke(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + expect(page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']")).to_have_count( + 1 + ) + page.locator("path").click() + page.get_by_role("link", name="Toggle edit mode").click() + page.get_by_role("heading", name="Shape properties").click() + page.locator(".umap-field-stroke .define").first.click() + page.locator(".umap-field-stroke label").first.click() + expect(page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']")).to_have_count( + 0 + ) + expect(page.locator(".leaflet-overlay-pane path[stroke='none']")).to_have_count(1) + + +def test_should_reset_style_on_cancel(live_server, openmap, page, bootstrap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.locator("path").click(modifiers=["Shift"]) + page.get_by_role("heading", name="Shape properties").click() + page.locator("#umap-feature-shape-properties").get_by_text("define").first.click() + page.get_by_title("GoldenRod", exact=True).first.click() + expect(page.locator(".leaflet-overlay-pane path[fill='GoldenRod']")).to_have_count( + 1 + ) + page.once("dialog", lambda dialog: dialog.accept()) + page.get_by_role("button", name="Cancel edits").click() + expect(page.locator(".leaflet-overlay-pane path[fill='DarkBlue']")).to_have_count(1) diff --git a/umap/tests/integration/test_export_map.py b/umap/tests/integration/test_export_map.py index 28b215a1..1f65d46f 100644 --- a/umap/tests/integration/test_export_map.py +++ b/umap/tests/integration/test_export_map.py @@ -4,12 +4,86 @@ from pathlib import Path import pytest from playwright.sync_api import expect +from ..base import DataLayerFactory + pytestmark = pytest.mark.django_db +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "name": "name poly", + }, + "id": "gyNzM", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [11.25, 53.585984], + [10.151367, 52.975108], + [12.689209, 52.167194], + [14.084473, 53.199452], + [12.634277, 53.618579], + [11.25, 53.585984], + [11.25, 53.585984], + ], + ], + }, + }, + { + "type": "Feature", + "properties": { + "_umap_options": { + "color": "OliveDrab", + }, + "name": "test", + "description": "Some description", + }, + "id": "QwNjg", + "geometry": { + "type": "Point", + "coordinates": [-0.274658, 52.57635], + }, + }, + { + "type": "Feature", + "properties": { + "_umap_options": { + "fill": False, + "opacity": 0.6, + }, + "name": "test", + }, + "id": "YwMTM", + "geometry": { + "type": "LineString", + "coordinates": [ + [-0.571289, 54.476422], + [0.439453, 54.610255], + [1.724854, 53.448807], + [4.163818, 53.988395], + [5.306396, 53.533778], + [6.591797, 53.709714], + [7.042236, 53.350551], + ], + }, + }, + ], +} -def test_umap_export(map, live_server, datalayer, page): + +@pytest.fixture +def bootstrap(map, live_server): + map.settings["properties"]["onLoadPanel"] = "databrowser" + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + + +def test_umap_export(map, live_server, bootstrap, page): page.goto(f"{live_server.url}{map.get_absolute_url()}?share") - link = page.get_by_role("link", name="Download full data") + link = page.get_by_role("link", name="full backup") expect(link).to_be_visible() with page.expect_download() as download_info: link.click() @@ -34,16 +108,56 @@ def test_umap_export(map, live_server, datalayer, page): "features": [ { "geometry": { - "coordinates": [14.68896484375, 48.55297816440071], + "coordinates": [ + [ + [11.25, 53.585984], + [10.151367, 52.975108], + [12.689209, 52.167194], + [14.084473, 53.199452], + [12.634277, 53.618579], + [11.25, 53.585984], + [11.25, 53.585984], + ] + ], + "type": "Polygon", + }, + "id": "gyNzM", + "properties": {"name": "name poly"}, + "type": "Feature", + }, + { + "geometry": { + "coordinates": [-0.274658, 52.57635], "type": "Point", }, + "id": "QwNjg", "properties": { - "_umap_options": {"color": "DarkCyan", "iconClass": "Ball"}, - "description": "Da place anonymous " "again 755", - "name": "Here", + "_umap_options": {"color": "OliveDrab"}, + "name": "test", + "description": "Some description", }, "type": "Feature", - } + }, + { + "geometry": { + "coordinates": [ + [-0.571289, 54.476422], + [0.439453, 54.610255], + [1.724854, 53.448807], + [4.163818, 53.988395], + [5.306396, 53.533778], + [6.591797, 53.709714], + [7.042236, 53.350551], + ], + "type": "LineString", + }, + "id": "YwMTM", + "properties": { + "_umap_options": {"fill": False, "opacity": 0.6}, + "name": "test", + }, + "type": "Feature", + }, ], "type": "FeatureCollection", } @@ -61,21 +175,21 @@ 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, "zoomControl": True, + "onLoadPanel": "databrowser", }, "type": "umap", } -def test_csv_export(map, live_server, datalayer, page): +def test_csv_export(map, live_server, bootstrap, page): page.goto(f"{live_server.url}{map.get_absolute_url()}?share") - button = page.get_by_role("button", name="Download data") + button = page.get_by_role("button", name="csv") expect(button).to_be_visible() - page.locator('select[name="format"]').select_option("csv") with page.expect_download() as download_info: button.click() download = download_info.value @@ -84,6 +198,108 @@ def test_csv_export(map, live_server, datalayer, page): download.save_as(path) assert ( path.read_text() - == """name,description,Latitude,Longitude -Here,Da place anonymous again 755,48.55297816440071,14.68896484375""" + == """name,Latitude,Longitude,description +name poly,53.0072070131872,12.182431646910137, +test,52.57635,-0.274658,Some description +test,53.725145179688646,2.9700064980570517,""" ) + + +def test_gpx_export(map, live_server, bootstrap, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}?share") + button = page.get_by_role("button", name="gpx") + expect(button).to_be_visible() + with page.expect_download() as download_info: + button.click() + download = download_info.value + # FIXME assert mimetype (find no way to access it throught PW) + assert download.suggested_filename == "test_map.gpx" + path = Path("/tmp/") / download.suggested_filename + download.save_as(path) + assert ( + path.read_text() + == """testname=test +description=Some descriptionname polyname=name polytestname=test""" + ) + + +def test_kml_export(map, live_server, bootstrap, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}?share") + button = page.get_by_role("button", name="kml") + expect(button).to_be_visible() + with page.expect_download() as download_info: + button.click() + download = download_info.value + assert download.suggested_filename == "test_map.kml" + path = Path("/tmp/") / download.suggested_filename + download.save_as(path) + assert ( + path.read_text() + == """name polyname poly11.25,53.585984 10.151367,52.975108 12.689209,52.167194 14.084473,53.199452 12.634277,53.618579 11.25,53.585984 11.25,53.585984testSome description[object Object]testSome description-0.274658,52.57635test[object Object]test-0.571289,54.476422 0.439453,54.610255 1.724854,53.448807 4.163818,53.988395 5.306396,53.533778 6.591797,53.709714 7.042236,53.350551""" + ) + + +def test_geojson_export(map, live_server, bootstrap, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}?share") + button = page.get_by_role("button", name="geojson") + expect(button).to_be_visible() + with page.expect_download() as download_info: + button.click() + download = download_info.value + assert download.suggested_filename == "test_map.geojson" + path = Path("/tmp/") / download.suggested_filename + download.save_as(path) + assert json.loads(path.read_text()) == { + "features": [ + { + "geometry": { + "coordinates": [ + [ + [11.25, 53.585984], + [10.151367, 52.975108], + [12.689209, 52.167194], + [14.084473, 53.199452], + [12.634277, 53.618579], + [11.25, 53.585984], + [11.25, 53.585984], + ] + ], + "type": "Polygon", + }, + "id": "gyNzM", + "properties": {"name": "name poly"}, + "type": "Feature", + }, + { + "geometry": {"coordinates": [-0.274658, 52.57635], "type": "Point"}, + "id": "QwNjg", + "properties": { + "_umap_options": {"color": "OliveDrab"}, + "name": "test", + "description": "Some description", + }, + "type": "Feature", + }, + { + "geometry": { + "coordinates": [ + [-0.571289, 54.476422], + [0.439453, 54.610255], + [1.724854, 53.448807], + [4.163818, 53.988395], + [5.306396, 53.533778], + [6.591797, 53.709714], + [7.042236, 53.350551], + ], + "type": "LineString", + }, + "id": "YwMTM", + "properties": { + "_umap_options": {"fill": False, "opacity": 0.6}, + "name": "test", + }, + "type": "Feature", + }, + ], + "type": "FeatureCollection", + } diff --git a/umap/tests/integration/test_facets_browser.py b/umap/tests/integration/test_facets_browser.py new file mode 100644 index 00000000..68e64ee3 --- /dev/null +++ b/umap/tests/integration/test_facets_browser.py @@ -0,0 +1,117 @@ +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +DATALAYER_DATA1 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"mytype": "even", "name": "Point 2"}, + "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, + }, + { + "type": "Feature", + "properties": {"mytype": "odd", "name": "Point 1"}, + "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, + }, + ], + "_umap_options": { + "name": "Calque 1", + }, +} + + +DATALAYER_DATA2 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"mytype": "even", "name": "Point 4"}, + "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, + }, + { + "type": "Feature", + "properties": {"mytype": "odd", "name": "Point 3"}, + "geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]}, + }, + ], + "_umap_options": { + "name": "Calque 2", + }, +} + + +DATALAYER_DATA3 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": "a polygon"}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [2.12, 49.57], + [1.08, 49.02], + [2.51, 47.55], + [3.19, 48.77], + [2.12, 49.57], + ] + ], + }, + }, + ], + "_umap_options": {"name": "Calque 2", "browsable": False}, +} + + +@pytest.fixture +def bootstrap(map, live_server): + map.settings["properties"]["onLoadPanel"] = "facet" + map.settings["properties"]["facetKey"] = "mytype|My type" + map.settings["properties"]["showLabel"] = True + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + DataLayerFactory(map=map, data=DATALAYER_DATA3) + + +def test_simple_facet_search(live_server, page, bootstrap, map): + page.goto(f"{live_server.url}{map.get_absolute_url()}") + panel = page.locator(".umap-facet-search") + # From a non browsable datalayer, should not be impacted + paths = page.locator(".leaflet-overlay-pane path") + expect(paths).to_be_visible + expect(panel).to_be_visible() + # Facet name + expect(page.get_by_text("My type")).to_be_visible() + # Facet values + oven = page.get_by_text("even") + odd = page.get_by_text("odd") + expect(oven).to_be_visible() + expect(odd).to_be_visible() + expect(paths).to_be_visible + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(4) + # Tooltips + expect(page.get_by_text("Point 1")).to_be_visible() + expect(page.get_by_text("Point 2")).to_be_visible() + expect(page.get_by_text("Point 3")).to_be_visible() + expect(page.get_by_text("Point 4")).to_be_visible() + # Now let's filter + odd.click() + expect(markers).to_have_count(2) + expect(page.get_by_text("Point 2")).to_be_hidden() + expect(page.get_by_text("Point 4")).to_be_hidden() + expect(page.get_by_text("Point 1")).to_be_visible() + expect(page.get_by_text("Point 3")).to_be_visible() + expect(paths).to_be_visible + # Now let's filter + odd.click() + expect(markers).to_have_count(4) + expect(paths).to_be_visible diff --git a/umap/tests/integration/test_features_id_generation.py b/umap/tests/integration/test_features_id_generation.py new file mode 100644 index 00000000..ca4558a5 --- /dev/null +++ b/umap/tests/integration/test_features_id_generation.py @@ -0,0 +1,51 @@ +import json +from pathlib import Path + + +def test_ids_generation(page, live_server, tilelayer): + page.goto(f"{live_server.url}/en/map/new/") + + # Click on the Draw a line button on a new map. + create_polyline = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polyline" + ) + create_polyline.click() + + map = page.locator("#map") + map.click(position={"x": 200, "y": 200}) + map.click(position={"x": 100, "y": 100}) + # Click again to finish + map.click(position={"x": 100, "y": 100}) + + # Click on the Draw a polygon button on a new map. + create_polygon = page.locator(".leaflet-control-toolbar ").get_by_title( + "Draw a polygon" + ) + create_polygon.click() + + map = page.locator("#map") + map.click(position={"x": 300, "y": 300}) + map.click(position={"x": 300, "y": 400}) + map.click(position={"x": 350, "y": 450}) + # Click again to finish + map.click(position={"x": 350, "y": 450}) + + download_panel = page.get_by_title("Share and download") + download_panel.click() + + button = page.get_by_role("button", name="geojson") + + with page.expect_download() as download_info: + button.click() + + download = download_info.value + + path = Path("/tmp/") / download.suggested_filename + download.save_as(path) + downloaded = json.loads(path.read_text()) + + assert "features" in downloaded + features = downloaded["features"] + assert len(features) == 2 + assert "id" in features[0] + assert "id" in features[1] diff --git a/umap/tests/integration/test_import.py b/umap/tests/integration/test_import.py index fbc21418..1be5ffd6 100644 --- a/umap/tests/integration/test_import.py +++ b/umap/tests/integration/test_import.py @@ -1,39 +1,103 @@ +import json +import re from pathlib import Path +from time import sleep import pytest from playwright.sync_api import expect +from umap.models import DataLayer + pytestmark = pytest.mark.django_db -def test_umap_import_from_file(live_server, datalayer, page): +def test_layers_list_is_updated(live_server, tilelayer, page): page.goto(f"{live_server.url}/map/new/") - button = page.get_by_title("Import data (Ctrl+I)") - expect(button).to_be_visible() - button.click() + page.get_by_role("link", name="Import data (Ctrl+I)").click() + # Should work + page.get_by_label("Choose the layer to import").select_option( + label="Import in a new layer" + ) + page.get_by_role("link", name="Manage layers").click() + page.get_by_role("button", name="Add a layer").click() + page.locator('input[name="name"]').click() + page.locator('input[name="name"]').fill("foobar") + page.get_by_role("link", name="Import data (Ctrl+I)").click() + # Should still work + page.get_by_label("Choose the layer to import").select_option( + label="Import in a new layer" + ) + # Now layer should be visible in the options + page.get_by_label("Choose the layer to import").select_option(label="foobar") + + +def test_umap_import_from_file(live_server, tilelayer, page): + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("Import data").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) button = page.get_by_role("button", name="Import", exact=True) expect(button).to_be_visible() button.click() - layers = page.locator(".umap-browse-datalayers li") - expect(layers).to_have_count(3) - nonloaded = page.locator(".umap-browse-datalayers li.off") + assert file_input.input_value() + # Close the import panel + page.keyboard.press("Escape") + # Reopen + page.get_by_title("Import data").click() + sleep(1) # Wait for CSS transition to happen + assert not file_input.input_value() + expect(page.locator(".umap-main-edit-toolbox .map-name")).to_have_text( + "Carte sans nom" + ) + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + expect(layers).to_have_count(2) + nonloaded = page.locator(".umap-browser .datalayer.off") expect(nonloaded).to_have_count(1) -def test_umap_import_geojson_from_textarea(live_server, datalayer, page): +def test_umap_import_from_textarea(live_server, tilelayer, page, settings): + settings.UMAP_ALLOW_ANONYMOUS = True page.goto(f"{live_server.url}/map/new/") - layers = page.locator(".umap-browse-datalayers li") + page.get_by_role("button", name="See layers").click() + page.get_by_title("Import data").click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data.umap" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("umap") + page.get_by_role("button", name="Import", exact=True).click() + layers = page.locator(".umap-browser .datalayer") + expect(layers).to_have_count(2) + expect(page.locator(".umap-main-edit-toolbox .map-name")).to_have_text( + "Imported map" + ) + expect(page.get_by_text("Tunnels")).to_be_visible() + expect(page.get_by_text("Cities")).to_be_visible() + expect(page.locator(".leaflet-control-minimap")).to_be_visible() + expect( + page.locator('img[src="https://tile.openstreetmap.fr/hot/6/32/21.png"]') + ).to_be_visible() + # Should not have imported umap_id, while in the file options + assert not page.evaluate("U.MAP.options.umap_id") + with page.expect_response(re.compile(r".*/datalayer/create/.*")): + page.get_by_role("button", name="Save").click() + assert page.evaluate("U.MAP.options.umap_id") + + +def test_import_geojson_from_textarea(tilelayer, live_server, page): + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") markers = page.locator(".leaflet-marker-icon") paths = page.locator("path") expect(markers).to_have_count(0) expect(paths).to_have_count(0) - expect(layers).to_have_count(1) - button = page.get_by_title("Import data (Ctrl+I)") + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") expect(button).to_be_visible() button.click() textarea = page.locator(".umap-upload textarea") @@ -43,7 +107,327 @@ def test_umap_import_geojson_from_textarea(live_server, datalayer, page): button = page.get_by_role("button", name="Import", exact=True) expect(button).to_be_visible() button.click() - # No layer has been created + # A layer has been created expect(layers).to_have_count(1) expect(markers).to_have_count(2) expect(paths).to_have_count(3) + + +def test_import_kml_from_textarea(tilelayer, live_server, page): + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + paths = page.locator("path") + expect(markers).to_have_count(0) + expect(paths).to_have_count(0) + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") + expect(button).to_be_visible() + button.click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data.kml" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("kml") + button = page.get_by_role("button", name="Import", exact=True) + expect(button).to_be_visible() + button.click() + # A layer has been created + expect(layers).to_have_count(1) + expect(markers).to_have_count(1) + expect(paths).to_have_count(2) + + +def test_import_gpx_from_textarea(tilelayer, live_server, page): + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + paths = page.locator("path") + expect(markers).to_have_count(0) + expect(paths).to_have_count(0) + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") + expect(button).to_be_visible() + button.click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data.gpx" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("gpx") + button = page.get_by_role("button", name="Import", exact=True) + expect(button).to_be_visible() + button.click() + # A layer has been created + expect(layers).to_have_count(1) + expect(markers).to_have_count(1) + expect(paths).to_have_count(1) + + +def test_import_osm_from_textarea(tilelayer, live_server, page): + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(0) + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") + expect(button).to_be_visible() + button.click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data_osm.json" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("osm") + page.get_by_role("button", name="Import", exact=True).click() + # A layer has been created + expect(layers).to_have_count(1) + expect(markers).to_have_count(2) + + +def test_import_csv_from_textarea(tilelayer, live_server, page): + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(0) + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") + expect(button).to_be_visible() + button.click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("csv") + page.get_by_role("button", name="Import", exact=True).click() + # A layer has been created + expect(layers).to_have_count(1) + expect(markers).to_have_count(2) + + +def test_can_import_in_existing_datalayer(live_server, datalayer, page, openmap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(1) + expect(layers).to_have_count(1) + page.get_by_role("button", name="Edit").click() + page.get_by_title("Import data").click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("csv") + page.get_by_role("button", name="Import", exact=True).click() + # No layer has been created + expect(layers).to_have_count(1) + expect(markers).to_have_count(3) + + +def test_can_replace_datalayer_data(live_server, datalayer, page, openmap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(1) + expect(layers).to_have_count(1) + page.get_by_role("button", name="Edit").click() + page.get_by_title("Import data").click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("csv") + page.get_by_label("Replace layer content").check() + page.get_by_role("button", name="Import", exact=True).click() + # No layer has been created + expect(layers).to_have_count(1) + expect(markers).to_have_count(2) + + +def test_can_import_in_new_datalayer(live_server, datalayer, page, openmap): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(1) + expect(layers).to_have_count(1) + page.get_by_role("button", name="Edit").click() + page.get_by_title("Import data").click() + textarea = page.locator(".umap-upload textarea") + path = Path(__file__).parent.parent / "fixtures/test_upload_data.csv" + textarea.fill(path.read_text()) + page.locator('select[name="format"]').select_option("csv") + page.get_by_label("Choose the layer to import").select_option( + label="Import in a new layer" + ) + page.get_by_role("button", name="Import", exact=True).click() + # A new layer has been created + expect(layers).to_have_count(2) + expect(markers).to_have_count(3) + + +def test_should_remove_dot_in_property_names(live_server, page, settings, tilelayer): + settings.UMAP_ALLOW_ANONYMOUS = True + data = { + "type": "FeatureCollection", + "features": [ + { + "geometry": { + "type": "Point", + "coordinates": [6.922931671142578, 47.481161607175736], + }, + "type": "Feature", + "properties": { + "color": "", + "name": "Chez Rémy", + "A . in the name": "", + }, + }, + { + "geometry": { + "type": "LineString", + "coordinates": [ + [2.4609375, 48.88639177703194], + [2.48291015625, 48.76343113791796], + [2.164306640625, 48.719961222646276], + ], + }, + "type": "Feature", + "properties": {"color": "", "name": "Périf", "with a dot.": ""}, + }, + ], + } + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("Import data").click() + textarea = page.locator(".umap-upload textarea") + textarea.fill(json.dumps(data)) + page.locator('select[name="format"]').select_option("geojson") + page.get_by_role("button", name="Import", exact=True).click() + with page.expect_response(re.compile(r".*/datalayer/create/.*")): + page.get_by_role("button", name="Save").click() + datalayer = DataLayer.objects.last() + saved_data = json.loads(Path(datalayer.geojson.path).read_text()) + assert saved_data["features"][0]["properties"] == { + "color": "", + "name": "Chez Rémy", + "A _ in the name": "", + } + assert saved_data["features"][1]["properties"] == { + "color": "", + "name": "Périf", + "with a dot_": "", + } + + +def test_import_geometry_collection(live_server, page, tilelayer): + data = { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [-80.6608, 35.0493]}, + { + "type": "Polygon", + "coordinates": [ + [ + [-80.6645, 35.0449], + [-80.6634, 35.0460], + [-80.6625, 35.0455], + [-80.6638, 35.0442], + [-80.6645, 35.0449], + ] + ], + }, + { + "type": "LineString", + "coordinates": [ + [-80.66237, 35.05950], + [-80.66269, 35.05926], + [-80.66284, 35.05893], + [-80.66308, 35.05833], + [-80.66385, 35.04387], + [-80.66303, 35.04371], + ], + }, + ], + } + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + paths = page.locator("path") + expect(markers).to_have_count(0) + expect(paths).to_have_count(0) + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") + expect(button).to_be_visible() + button.click() + textarea = page.locator(".umap-upload textarea") + textarea.fill(json.dumps(data)) + page.locator('select[name="format"]').select_option("geojson") + page.get_by_role("button", name="Import", exact=True).click() + # A layer has been created + expect(layers).to_have_count(1) + expect(markers).to_have_count(1) + expect(paths).to_have_count(2) + + +def test_import_multipolygon(live_server, page, tilelayer): + data = { + "type": "Feature", + "properties": {"name": "Some states"}, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [[-109, 36], [-109, 40], [-102, 37], [-109, 36]], + [[-108, 39], [-107, 37], [-104, 37], [-108, 39]], + ], + [[[-119, 42], [-120, 39], [-114, 41], [-119, 42]]], + ], + }, + } + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + paths = page.locator("path") + expect(paths).to_have_count(0) + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") + expect(button).to_be_visible() + button.click() + textarea = page.locator(".umap-upload textarea") + textarea.fill(json.dumps(data)) + page.locator('select[name="format"]').select_option("geojson") + page.get_by_role("button", name="Import", exact=True).click() + # A layer has been created + expect(layers).to_have_count(1) + expect(paths).to_have_count(1) + + +def test_import_multipolyline(live_server, page, tilelayer): + data = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiLineString", + "coordinates": [[[-108, 46], [-113, 43]], [[-112, 45], [-115, 44]]], + }, + } + ], + } + page.goto(f"{live_server.url}/map/new/") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + paths = page.locator("path") + expect(paths).to_have_count(0) + expect(layers).to_have_count(0) + button = page.get_by_title("Import data") + expect(button).to_be_visible() + button.click() + textarea = page.locator(".umap-upload textarea") + textarea.fill(json.dumps(data)) + page.locator('select[name="format"]').select_option("geojson") + page.get_by_role("button", name="Import", exact=True).click() + # A layer has been created + expect(layers).to_have_count(1) + expect(paths).to_have_count(1) diff --git a/umap/tests/integration/test_map.py b/umap/tests/integration/test_map.py index 3bcbb2f8..8ede6547 100644 --- a/umap/tests/integration/test_map.py +++ b/umap/tests/integration/test_map.py @@ -1,17 +1,54 @@ -import json import re -from pathlib import Path import pytest from playwright.sync_api import expect -from umap.models import Map - 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_without_datalayer_should_use_default_center( + map, live_server, datalayer, page +): + datalayer.settings["displayOnLoad"] = False + datalayer.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}?onLoadPanel=datalayers") + # Hash is defined, so map is initialized + expect(page).to_have_url(re.compile(r".*#7/48\..+/13\..+")) + layers = page.locator(".umap-browser .datalayer h5") + expect(layers).to_have_count(1) + + def test_default_view_latest_without_datalayer_should_use_default_center( map, live_server, datalayer, page ): @@ -19,20 +56,34 @@ def test_default_view_latest_without_datalayer_should_use_default_center( datalayer.save() map.settings["properties"]["defaultView"] = "latest" map.save() - page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.goto(f"{live_server.url}{map.get_absolute_url()}?onLoadPanel=datalayers") # Hash is defined, so map is initialized expect(page).to_have_url(re.compile(r".*#7/48\..+/13\..+")) - layers = page.locator(".umap-browse-datalayers li") + layers = page.locator(".umap-browser .datalayer h5") + expect(layers).to_have_count(1) + + +def test_default_view_data_without_datalayer_should_use_default_center( + map, live_server, datalayer, page +): + datalayer.settings["displayOnLoad"] = False + datalayer.save() + map.settings["properties"]["defaultView"] = "data" + map.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}?onLoadPanel=datalayers") + # Hash is defined, so map is initialized + expect(page).to_have_url(re.compile(r".*#7/48\..+/13\..+")) + layers = page.locator(".umap-browser .datalayer h5") expect(layers).to_have_count(1) def test_default_view_latest_with_marker(map, live_server, datalayer, page): map.settings["properties"]["defaultView"] = "latest" map.save() - page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.goto(f"{live_server.url}{map.get_absolute_url()}?onLoadPanel=datalayers") # Hash is defined, so map is initialized expect(page).to_have_url(re.compile(r".*#7/48\..+/14\..+")) - layers = page.locator(".umap-browse-datalayers li") + layers = page.locator(".umap-browser .datalayer h5") expect(layers).to_have_count(1) @@ -58,9 +109,9 @@ def test_default_view_latest_with_line(map, live_server, page): DataLayerFactory(map=map, data=data) map.settings["properties"]["defaultView"] = "latest" map.save() - page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.goto(f"{live_server.url}{map.get_absolute_url()}?onLoadPanel=datalayers") expect(page).to_have_url(re.compile(r".*#8/48\..+/2\..+")) - layers = page.locator(".umap-browse-datalayers li") + layers = page.locator(".umap-browser .datalayer h5") expect(layers).to_have_count(1) @@ -89,29 +140,38 @@ def test_default_view_latest_with_polygon(map, live_server, page): DataLayerFactory(map=map, data=data) map.settings["properties"]["defaultView"] = "latest" map.save() - page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.goto(f"{live_server.url}{map.get_absolute_url()}?onLoadPanel=datalayers") expect(page).to_have_url(re.compile(r".*#8/48\..+/2\..+")) - layers = page.locator(".umap-browse-datalayers li") + layers = page.locator(".umap-browser .datalayer h5") expect(layers).to_have_count(1) -def test_remote_layer_should_not_be_used_as_datalayer_for_created_features( - map, live_server, datalayer, page -): - # Faster than doing a login - map.edit_status = Map.ANONYMOUS +def test_default_view_locate(browser, live_server, map): + context = browser.new_context( + geolocation={"longitude": 8.52967, "latitude": 39.16267}, + permissions=["geolocation"], + ) + map.settings["properties"]["defaultView"] = "locate" map.save() + page = context.new_page() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page).to_have_url(re.compile(r".*#18/39\.16267/8\.52967")) + + +def test_remote_layer_should_not_be_used_as_datalayer_for_created_features( + openmap, live_server, datalayer, page +): datalayer.settings["remoteData"] = { "url": "https://overpass-api.de/api/interpreter?data=[out:xml];node[harbour=yes]({south},{west},{north},{east});out body;", "format": "osm", "from": "10", } datalayer.save() - page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") - toggle = page.get_by_role("button", name="See data layers") + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + toggle = page.get_by_role("button", name="See layers") expect(toggle).to_be_visible() toggle.click() - layers = page.locator(".umap-browse-datalayers li") + layers = page.locator(".umap-browser .datalayer h5") expect(layers).to_have_count(1) map_el = page.locator("#map") add_marker = page.get_by_title("Draw a marker") @@ -119,48 +179,42 @@ def test_remote_layer_should_not_be_used_as_datalayer_for_created_features( marker = page.locator(".leaflet-marker-icon") expect(marker).to_have_count(0) add_marker.click() - map_el.click(position={"x": 100, "y": 100}) + map_el.click(position={"x": 500, "y": 100}) expect(marker).to_have_count(1) # A new datalayer has been created to host this created feature # given the remote one cannot accept new features + page.get_by_title("See layers").click() expect(layers).to_have_count(2) -def test_can_hide_datalayer_from_caption(map, live_server, datalayer, page): - # Faster than doing a login - map.edit_status = Map.ANONYMOUS - map.save() +def test_can_hide_datalayer_from_caption(openmap, live_server, datalayer, page): # Add another DataLayer - other = DataLayerFactory(map=map, name="Hidden", settings={"inCaption": False}) - page.goto(f"{live_server.url}{map.get_absolute_url()}") + other = DataLayerFactory(map=openmap, name="Hidden", settings={"inCaption": False}) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}") toggle = page.get_by_text("About").first expect(toggle).to_be_visible() toggle.click() layers = page.locator(".umap-caption .datalayer-legend") expect(layers).to_have_count(1) - found = page.locator("#umap-ui-container").get_by_text(datalayer.name) + found = page.locator(".panel.left.on").get_by_text(datalayer.name) expect(found).to_be_visible() - hidden = page.locator("#umap-ui-container").get_by_text(other.name) + hidden = page.locator(".panel.left.on").get_by_text(other.name) expect(hidden).to_be_hidden() -def test_basic_choropleth_map(map, live_server, page): - path = Path(__file__).parent.parent / "fixtures/choropleth_region_chomage.geojson" - data = json.loads(path.read_text()) - DataLayerFactory(data=data, map=map) +def test_minimap_on_load(map, live_server, datalayer, page): page.goto(f"{live_server.url}{map.get_absolute_url()}") - # Hauts-de-France - paths = page.locator("path[fill='#08519c']") - expect(paths).to_have_count(1) - # Occitanie - paths = page.locator("path[fill='#3182bd']") - expect(paths).to_have_count(1) - # Grand-Est, PACA - paths = page.locator("path[fill='#6baed6']") - expect(paths).to_have_count(2) - # Bourgogne-Franche-Comté, Centre-Val-de-Loire, IdF, Normandie, Corse, Nouvelle-Aquitaine - paths = page.locator("path[fill='#bdd7e7']") - expect(paths).to_have_count(6) - # Bretagne, Pays de la Loire, AURA - paths = page.locator("path[fill='#eff3ff']") - expect(paths).to_have_count(3) + expect(page.locator(".leaflet-control-minimap")).to_be_hidden() + map.settings["properties"]["miniMap"] = True + map.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.locator(".leaflet-control-minimap")).to_be_visible() + + +def test_zoom_control_on_load(map, live_server, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.locator(".leaflet-control-zoom")).to_be_visible() + map.settings["properties"]["zoomControl"] = False + map.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.locator(".leaflet-control-zoom")).to_be_hidden() diff --git a/umap/tests/integration/test_map_preview.py b/umap/tests/integration/test_map_preview.py new file mode 100644 index 00000000..ca338956 --- /dev/null +++ b/umap/tests/integration/test_map_preview.py @@ -0,0 +1,83 @@ +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)") + + +def test_can_open_feature_on_load(page, live_server, tilelayer): + page.goto( + f"{live_server.url}/map/?data={quote(json.dumps(GEOJSON))}&feature=Niagara Falls" + ) + # Popup is open. + expect(page.get_by_text("Niagara Falls")).to_be_visible() diff --git a/umap/tests/integration/test_owned_map.py b/umap/tests/integration/test_owned_map.py index 3f10204b..d197ff0d 100644 --- a/umap/tests/integration/test_owned_map.py +++ b/umap/tests/integration/test_owned_map.py @@ -1,4 +1,4 @@ -from time import sleep +import re import pytest from playwright.sync_api import expect @@ -8,24 +8,6 @@ from umap.models import DataLayer, Map pytestmark = pytest.mark.django_db -@pytest.fixture -def login(context, settings, live_server): - def do_login(user): - # TODO use storage state to do login only once per session - # https://playwright.dev/python/docs/auth - settings.ENABLE_ACCOUNT_LOGIN = True - page = context.new_page() - page.goto(f"{live_server.url}/en/") - page.locator(".login").click() - page.get_by_placeholder("Username").fill(user.username) - page.get_by_placeholder("Password").fill("123123") - page.locator('#login_form input[type="submit"]').click() - sleep(1) # Time for ajax login POST to proceed - return page - - return do_login - - def test_map_update_with_owner(map, live_server, login): page = login(map.owner) page.goto(f"{live_server.url}{map.get_absolute_url()}") @@ -40,7 +22,7 @@ def test_map_update_with_owner(map, live_server, login): expect(save).to_be_visible() add_marker = page.get_by_title("Draw a marker") expect(add_marker).to_be_visible() - edit_settings = page.get_by_title("Edit map properties") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_visible() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() @@ -67,7 +49,7 @@ def test_map_update_with_anonymous_but_editable_datalayer( enable.click() add_marker = page.get_by_title("Draw a marker") expect(add_marker).to_be_visible() - edit_settings = page.get_by_title("Edit map properties") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_hidden() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_hidden() @@ -115,7 +97,7 @@ def test_map_update_with_editor(map, live_server, login, user): expect(save).to_be_visible() add_marker = page.get_by_title("Draw a marker") expect(add_marker).to_be_visible() - edit_settings = page.get_by_title("Edit map properties") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_visible() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() @@ -144,7 +126,7 @@ def test_permissions_form_with_editor(map, datalayer, live_server, login, user): def test_owner_has_delete_map_button(map, live_server, login): page = login(map.owner) page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") - settings = page.get_by_title("Edit map properties") + settings = page.get_by_title("Map advanced properties") expect(settings).to_be_visible() settings.click() advanced = page.get_by_text("Advanced actions") @@ -152,6 +134,18 @@ def test_owner_has_delete_map_button(map, live_server, login): advanced.click() delete = page.get_by_role("button", name="Delete", exact=True) expect(delete).to_be_visible() + dialog_shown = False + + def handle_dialog(dialog): + dialog.accept() + nonlocal dialog_shown + dialog_shown = True + + page.on("dialog", handle_dialog) + with page.expect_navigation(): + delete.click() + assert dialog_shown + assert Map.objects.all().count() == 0 def test_editor_do_not_have_delete_map_button(map, live_server, login, user): @@ -160,7 +154,7 @@ def test_editor_do_not_have_delete_map_button(map, live_server, login, user): map.save() page = login(user) page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") - settings = page.get_by_title("Edit map properties") + settings = page.get_by_title("Map advanced properties") expect(settings).to_be_visible() settings.click() advanced = page.get_by_text("Advanced actions") @@ -183,18 +177,22 @@ def test_create(tilelayer, live_server, login, user): expect(marker).to_have_count(1) save = page.get_by_role("button", name="Save") expect(save).to_be_visible() - save.click() - sleep(1) # Let save ajax go back + with page.expect_response(re.compile(r".*/datalayer/create/")): + save.click() expect(marker).to_have_count(1) def test_can_change_perms_after_create(tilelayer, live_server, login, user): page = login(user) page.goto(f"{live_server.url}/en/map/new") + # Create a layer + page.get_by_title("Manage layers").click() + page.get_by_title("Add a layer").click() + page.locator("input[name=name]").fill("Layer 1") save = page.get_by_role("button", name="Save") expect(save).to_be_visible() - save.click() - sleep(1) # Let save ajax go back + with page.expect_response(re.compile(r".*/map/create/")): + save.click() edit_permissions = page.get_by_title("Update permissions and editors") expect(edit_permissions).to_be_visible() edit_permissions.click() @@ -224,11 +222,30 @@ def test_can_change_owner(map, live_server, login, user): close = page.locator(".umap-field-owner .close") close.click() input = page.locator("input.edit-owner") - input.type(user.username) + with page.expect_response(re.compile(r".*/agnocomplete/.*")): + input.type(user.username) input.press("Tab") save = page.get_by_role("button", name="Save") expect(save).to_be_visible() - save.click() - sleep(1) # Let save ajax go + with page.expect_response(re.compile(r".*/update/permissions/.*")): + save.click() modified = Map.objects.get(pk=map.pk) assert modified.owner == user + + +def test_can_delete_datalayer(live_server, map, login, datalayer): + page = login(map.owner) + page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") + page.get_by_title("See layers").click() + layers = page.locator(".umap-browser .datalayer") + markers = page.locator(".leaflet-marker-icon") + expect(layers).to_have_count(1) + expect(markers).to_have_count(1) + page.get_by_role("link", name="Manage layers").click() + page.once("dialog", lambda dialog: dialog.accept()) + page.locator(".panel.right").get_by_title("Delete layer").click() + with page.expect_response(re.compile(r".*/datalayer/delete/.*")): + page.get_by_role("button", name="Save").click() + expect(markers).to_have_count(0) + # FIXME does not work, resolve to 1 element, even if this command is empty: + expect(layers).to_have_count(0) diff --git a/umap/tests/integration/test_picto.py b/umap/tests/integration/test_picto.py index 049e379d..b8541f8b 100644 --- a/umap/tests/integration/test_picto.py +++ b/umap/tests/integration/test_picto.py @@ -1,10 +1,11 @@ +import platform from pathlib import Path import pytest from django.core.files.base import ContentFile from playwright.sync_api import expect -from umap.models import Map, Pictogram +from umap.models import Pictogram from ..base import DataLayerFactory @@ -36,17 +37,14 @@ def pictos(): Pictogram(name="circle", pictogram=ContentFile(path.read_text(), path.name)).save() -def test_can_change_picto_at_map_level(map, live_server, page, pictos): - # Faster than doing a login - map.edit_status = Map.ANONYMOUS - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA) - page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") +def test_can_change_picto_at_map_level(openmap, live_server, page, pictos): + DataLayerFactory(map=openmap, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") 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") - edit_settings = page.get_by_title("Edit map properties") + expect(marker).to_have_attribute("src", "/static/umap/img/marker.svg") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_visible() edit_settings.click() shape_settings = page.get_by_text("Default shape properties") @@ -57,6 +55,8 @@ def test_can_change_picto_at_map_level(map, live_server, page, pictos): expect(define).to_be_visible() expect(undefine).to_be_hidden() define.click() + # No picto defined yet, so recent should not be visible + expect(page.get_by_text("Recent")).to_be_hidden() symbols = page.locator(".umap-pictogram-choice") expect(symbols).to_have_count(2) search = page.locator(".umap-pictogram-body input") @@ -65,22 +65,21 @@ 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): - # Faster than doing a login - map.edit_status = Map.ANONYMOUS - map.settings["properties"]["iconUrl"] = "/uploads/pictogram/star.svg" - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA) - page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") +def test_can_change_picto_at_datalayer_level(openmap, live_server, page, pictos): + openmap.settings["properties"]["iconUrl"] = "/uploads/pictogram/star.svg" + openmap.save() + DataLayerFactory(map=openmap, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") marker = page.locator(".umap-div-icon img") expect(marker).to_have_count(1) # Should have default img expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") # Edit datalayer - marker.click(modifiers=["Control", "Shift"]) + modifier = "Meta" if platform.system() == "Darwin" else "Control" + marker.click(modifiers=[modifier, "Shift"]) settings = page.get_by_text("Layer properties") expect(settings).to_be_visible() shape_settings = page.get_by_text("Shape properties") @@ -91,7 +90,13 @@ def test_can_change_picto_at_datalayer_level(map, live_server, page, pictos): expect(define).to_be_visible() expect(undefine).to_be_hidden() define.click() + # Map has an icon defined, so it shold open on Recent tab symbols = page.locator(".umap-pictogram-choice") + expect(page.get_by_text("Recent")).to_be_visible() + expect(symbols).to_have_count(1) + symbol_tab = page.get_by_role("button", name="Symbol") + expect(symbol_tab).to_be_visible() + symbol_tab.click() expect(symbols).to_have_count(2) search = page.locator(".umap-pictogram-body input") search.type("circle") @@ -102,13 +107,11 @@ def test_can_change_picto_at_datalayer_level(map, live_server, page, pictos): expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") -def test_can_change_picto_at_marker_level(map, live_server, page, pictos): - # Faster than doing a login - map.edit_status = Map.ANONYMOUS - map.settings["properties"]["iconUrl"] = "/uploads/pictogram/star.svg" - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA) - page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") +def test_can_change_picto_at_marker_level(openmap, live_server, page, pictos): + openmap.settings["properties"]["iconUrl"] = "/uploads/pictogram/star.svg" + openmap.save() + DataLayerFactory(map=openmap, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") marker = page.locator(".umap-div-icon img") expect(marker).to_have_count(1) # Should have default img @@ -125,7 +128,13 @@ def test_can_change_picto_at_marker_level(map, live_server, page, pictos): expect(define).to_be_visible() expect(undefine).to_be_hidden() define.click() + # Map has an icon defined, so it shold open on Recent tab symbols = page.locator(".umap-pictogram-choice") + expect(page.get_by_text("Recent")).to_be_visible() + expect(symbols).to_have_count(1) + symbol_tab = page.get_by_role("button", name="Symbol") + expect(symbol_tab).to_be_visible() + symbol_tab.click() expect(symbols).to_have_count(2) search = page.locator(".umap-pictogram-body input") search.type("circle") @@ -136,17 +145,14 @@ def test_can_change_picto_at_marker_level(map, live_server, page, pictos): expect(marker).to_have_attribute("src", "/uploads/pictogram/star.svg") -def test_can_use_remote_url_as_picto(map, live_server, page, pictos): - # Faster than doing a login - map.edit_status = Map.ANONYMOUS - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA) - page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") +def test_can_use_remote_url_as_picto(openmap, live_server, page, pictos): + DataLayerFactory(map=openmap, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") 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") - edit_settings = page.get_by_title("Edit map properties") + expect(marker).to_have_attribute("src", "/static/umap/img/marker.svg") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_visible() edit_settings.click() shape_settings = page.get_by_text("Default shape properties") @@ -165,7 +171,7 @@ def test_can_use_remote_url_as_picto(map, live_server, page, pictos): input_el.blur() expect(marker).to_have_attribute("src", "https://foo.bar/img.jpg") # Now close and reopen the form, it should still be the URL tab - close = page.locator("#umap-ui-container .toolbox").get_by_title("Close") + close = page.locator(".panel.right.on .toolbox").get_by_title("Close") expect(close).to_be_visible() close.click() edit_settings.click() @@ -173,20 +179,19 @@ def test_can_use_remote_url_as_picto(map, live_server, page, pictos): modify = page.locator(".umap-field-iconUrl").get_by_text("Change") expect(modify).to_be_visible() modify.click() - # Should be on URL tab - expect(input_el).to_be_visible() + # Should be on Recent tab + symbols = page.locator(".umap-pictogram-choice") + expect(page.get_by_text("Recent")).to_be_visible() + expect(symbols).to_have_count(1) -def test_can_use_char_as_picto(map, live_server, page, pictos): - # Faster than doing a login - map.edit_status = Map.ANONYMOUS - map.save() - DataLayerFactory(map=map, data=DATALAYER_DATA) - page.goto(f"{live_server.url}{map.get_absolute_url()}?edit") +def test_can_use_char_as_picto(openmap, live_server, page, pictos): + DataLayerFactory(map=openmap, data=DATALAYER_DATA) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") marker = page.locator(".umap-div-icon span") # Should have default img, so not a span expect(marker).to_have_count(0) - edit_settings = page.get_by_title("Edit map properties") + edit_settings = page.get_by_title("Map advanced properties") expect(edit_settings).to_be_visible() edit_settings.click() shape_settings = page.get_by_text("Default shape properties") @@ -205,7 +210,7 @@ def test_can_use_char_as_picto(map, live_server, page, pictos): expect(marker).to_have_count(1) expect(marker).to_have_text("♩") # Now close and reopen the form, it should still be the URL tab - close = page.locator("#umap-ui-container .toolbox").get_by_title("Close") + close = page.locator(".panel.right.on .toolbox").get_by_title("Close") expect(close).to_be_visible() close.click() edit_settings.click() @@ -214,4 +219,6 @@ def test_can_use_char_as_picto(map, live_server, page, pictos): expect(preview).to_be_visible() preview.click() # Should be on URL tab - expect(input_el).to_be_visible() + symbols = page.locator(".umap-pictogram-choice") + expect(page.get_by_text("Recent")).to_be_visible() + expect(symbols).to_have_count(1) diff --git a/umap/tests/integration/test_querystring.py b/umap/tests/integration/test_querystring.py new file mode 100644 index 00000000..d4199e90 --- /dev/null +++ b/umap/tests/integration/test_querystring.py @@ -0,0 +1,60 @@ +import re + +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-control-browse") + browser = page.locator(".umap-browser") + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(control).to_be_visible() + expect(browser).to_be_hidden() + page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=true") + expect(control).to_be_visible() + expect(browser).to_be_hidden() + page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=null") + expect(control).to_be_hidden() + expect(browser).to_be_hidden() + page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=false") + expect(control).to_be_hidden() + expect(browser).to_be_hidden() + # Retrocompat + page.goto(f"{live_server.url}{map.get_absolute_url()}?datalayersControl=expanded") + expect(control).to_be_visible() + expect(browser).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/.+")) + + +def test_zoom_control(map, live_server, datalayer, page): + control = page.locator(".leaflet-control-zoom") + 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()}?zoomControl=false") + expect(control).to_be_hidden() + page.goto(f"{live_server.url}{map.get_absolute_url()}?zoomControl=true") + expect(control).to_be_visible() + page.goto(f"{live_server.url}{map.get_absolute_url()}?zoomControl=null") + expect(control).to_be_hidden() + page.get_by_title("More controls").click() + expect(control).to_be_visible() diff --git a/umap/tests/integration/test_share.py b/umap/tests/integration/test_share.py new file mode 100644 index 00000000..2b3c83c6 --- /dev/null +++ b/umap/tests/integration/test_share.py @@ -0,0 +1,40 @@ +import re + +import pytest +from playwright.sync_api import expect + +pytestmark = pytest.mark.django_db + + +def test_iframe_code_can_contain_datalayers(map, live_server, datalayer, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}?share") + textarea = page.locator(".umap-share-iframe") + expect(textarea).to_be_visible() + expect(textarea).to_have_text(re.compile('src="')) + expect(textarea).to_have_text(re.compile('href="')) + # We should ave both, once for iframe link, once for full screen + expect(textarea).to_have_text(re.compile("scrollWheelZoom=true")) + expect(textarea).to_have_text(re.compile("scrollWheelZoom=false")) + expect(textarea).not_to_have_text(re.compile(f"datalayers={datalayer.pk}")) + # Open options + page.get_by_text("Embed and link options").click() + page.get_by_title("Keep current visible layers").click() + expect(textarea).to_have_text(re.compile(f"datalayers={datalayer.pk}")) + # Now click again + page.get_by_title("Keep current visible layers").click() + expect(textarea).not_to_have_text(re.compile(f"datalayers={datalayer.pk}")) + + +def test_iframe_code_can_contain_feature(map, live_server, datalayer, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}?share") + page.locator(".icon_container").click() + textarea = page.locator(".umap-share-iframe") + expect(textarea).to_be_visible() + expect(textarea).not_to_have_text(re.compile("feature=Here")) + # Open options + page.get_by_text("Embed and link options").click() + page.get_by_title("Open current feature on load").click() + expect(textarea).to_have_text(re.compile("feature=Here")) + # Click again to deactivate it + page.get_by_title("Open current feature on load").click() + expect(textarea).not_to_have_text(re.compile("feature=Here")) diff --git a/umap/tests/integration/test_slideshow.py b/umap/tests/integration/test_slideshow.py index 01f79e2f..d206a942 100644 --- a/umap/tests/integration/test_slideshow.py +++ b/umap/tests/integration/test_slideshow.py @@ -1,11 +1,6 @@ -from pathlib import Path - import pytest -from django.core.files.base import ContentFile from playwright.sync_api import expect -from umap.models import Map, Pictogram - from ..base import DataLayerFactory pytestmark = pytest.mark.django_db diff --git a/umap/tests/integration/test_star.py b/umap/tests/integration/test_star.py new file mode 100644 index 00000000..3984fb91 --- /dev/null +++ b/umap/tests/integration/test_star.py @@ -0,0 +1,27 @@ +import re + +import pytest +from playwright.sync_api import expect + +from umap.models import Star + +pytestmark = pytest.mark.django_db + + +def test_star_control_is_visible_if_logged_in(map, live_server, page, login, user): + login(user) + assert not Star.objects.count() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.get_by_title("More controls").click() + control = page.locator(".leaflet-control-star") + expect(control).to_be_visible() + with page.expect_response(re.compile(".*/star/")): + control.click() + assert Star.objects.count() == 1 + + +def test_no_star_control_if_not_logged_in(map, live_server, page): + page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.get_by_title("More controls").click() + control = page.locator(".leaflet-control-star") + expect(control).to_be_hidden() diff --git a/umap/tests/integration/test_statics.py b/umap/tests/integration/test_statics.py new file mode 100644 index 00000000..3b92fb7d --- /dev/null +++ b/umap/tests/integration/test_statics.py @@ -0,0 +1,47 @@ +import re +import shutil +import tempfile +from copy import deepcopy + +import pytest +from django.core.management import call_command +from django.utils.translation import override +from playwright.sync_api import expect + + +@pytest.fixture +def staticfiles(settings): + static_root = tempfile.mkdtemp(prefix="test_static") + settings.STATIC_ROOT = static_root + # Make sure settings are properly reset after the test + settings.STORAGES = deepcopy(settings.STORAGES) + settings.STORAGES["staticfiles"]["BACKEND"] = ( + "umap.storage.UmapManifestStaticFilesStorage" + ) + try: + call_command("collectstatic", "--noinput") + yield + finally: + shutil.rmtree(static_root) + + +def test_javascript_have_been_loaded( + map, live_server, datalayer, page, settings, staticfiles +): + datalayer.settings["displayOnLoad"] = False + datalayer.save() + map.settings["properties"]["defaultView"] = "latest" + map.save() + with override("fr"): + url = f"{live_server.url}{map.get_absolute_url()}" + assert "/fr/" in url + page.goto(url) + # Hash is defined, so map is initialized + expect(page).to_have_url(re.compile(r".*#7/48\..+/13\..+")) + expect(page).to_have_url(re.compile(r".*/fr/")) + # Should be in French, so hashed locale file has been loaded correctly + button = page.get_by_text("Voir les calques") + expect(button).to_be_visible() + button.click() + layers = page.locator(".umap-browser .datalayer") + expect(layers).to_have_count(1) diff --git a/umap/tests/integration/test_tableeditor.py b/umap/tests/integration/test_tableeditor.py new file mode 100644 index 00000000..b2d3cc89 --- /dev/null +++ b/umap/tests/integration/test_tableeditor.py @@ -0,0 +1,23 @@ +import json +import re +from pathlib import Path + +from umap.models import DataLayer + + +def test_table_editor(live_server, openmap, datalayer, page): + page.goto(f"{live_server.url}{openmap.get_absolute_url()}?edit") + page.get_by_role("link", name="Manage layers").click() + page.locator(".panel").get_by_title("Edit properties in a table").click() + page.once("dialog", lambda dialog: dialog.accept(prompt_text="newprop")) + page.get_by_text("Add a new property").click() + page.locator('input[name="newprop"]').fill("newvalue") + page.once("dialog", lambda dialog: dialog.accept()) + page.hover(".umap-table-editor .tcell") + page.get_by_title("Delete this property on all").first.click() + with page.expect_response(re.compile(r".*/datalayer/update/.*")): + page.get_by_role("button", name="Save").click() + saved = DataLayer.objects.last() + data = json.loads(Path(saved.geojson.path).read_text()) + assert data["features"][0]["properties"]["newprop"] == "newvalue" + assert "name" not in data["features"][0]["properties"] diff --git a/umap/tests/integration/test_tilelayer.py b/umap/tests/integration/test_tilelayer.py index b2922b6f..c6897935 100644 --- a/umap/tests/integration/test_tilelayer.py +++ b/umap/tests/integration/test_tilelayer.py @@ -101,9 +101,9 @@ def test_map_should_display_custom_tilelayer(map, live_server, tilelayers, page) url_pattern = re.compile( r"https://[abc]{1}.basemaps.cartocdn.com/rastertiles/voyager/\d+/\d+/\d+.png" ) - map.settings["properties"]["tilelayer"][ - "url_template" - ] = "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png" + map.settings["properties"]["tilelayer"]["url_template"] = ( + "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png" + ) map.settings["properties"]["tilelayersControl"] = True map.save() page.goto(f"{live_server.url}{map.get_absolute_url()}") diff --git a/umap/tests/integration/test_view_marker.py b/umap/tests/integration/test_view_marker.py new file mode 100644 index 00000000..c0c5002a --- /dev/null +++ b/umap/tests/integration/test_view_marker.py @@ -0,0 +1,64 @@ +from copy import deepcopy + +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "name": "test marker", + "description": "Some description", + }, + "geometry": { + "type": "Point", + "coordinates": [14.6889, 48.5529], + }, + }, + ], +} + + +@pytest.fixture +def bootstrap(map, live_server): + DataLayerFactory(map=map, data=DATALAYER_DATA) + + +def test_should_open_popup_on_click(live_server, map, page, bootstrap): + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.locator(".umap-icon-active")).to_be_hidden() + page.locator(".leaflet-marker-icon").click() + expect(page.locator(".umap-icon-active")).to_be_visible() + expect(page.locator(".leaflet-popup-content-wrapper")).to_be_visible() + expect(page.get_by_role("heading", name="test marker")).to_be_visible() + expect(page.get_by_text("Some description")).to_be_visible() + # Close popup + page.locator("#map").click() + expect(page.locator(".umap-icon-active")).to_be_hidden() + + +def test_should_handle_locale_var_in_description(live_server, map, page): + data = deepcopy(DATALAYER_DATA) + data["features"][0]["properties"]["description"] = ( + "this is a link to [[https://domain.org/?locale={locale}|Wikipedia]]" + ) + DataLayerFactory(map=map, data=data) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.locator(".leaflet-marker-icon").click() + link = page.get_by_role("link", name="Wikipedia") + expect(link).to_be_visible() + expect(link).to_have_attribute("href", "https://domain.org/?locale=en") + + +def test_should_display_tooltip_with_variable(live_server, map, page, bootstrap): + map.settings["properties"]["showLabel"] = True + map.settings["properties"]["labelKey"] = "Foo {name}" + map.save() + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.get_by_text("Foo test marker")).to_be_visible() diff --git a/umap/tests/integration/test_view_polygon.py b/umap/tests/integration/test_view_polygon.py new file mode 100644 index 00000000..f7462667 --- /dev/null +++ b/umap/tests/integration/test_view_polygon.py @@ -0,0 +1,59 @@ +import re + +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": "name poly", "description": "poly description"}, + "id": "gyNzM", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [11.25, 53.585984], + [10.151367, 52.975108], + [12.689209, 52.167194], + [14.084473, 53.199452], + [12.634277, 53.618579], + [11.25, 53.585984], + [11.25, 53.585984], + ], + ], + }, + }, + ], +} + + +@pytest.fixture +def bootstrap(map, live_server): + map.settings["properties"]["zoom"] = 6 + map.settings["geometry"] = { + "type": "Point", + "coordinates": [8.429, 53.239], + } + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + + +def test_should_open_popup_on_click(live_server, map, page, bootstrap): + page.goto(f"{live_server.url}{map.get_absolute_url()}") + polygon = page.locator("path").first + expect(polygon).to_have_attribute("fill-opacity", "0.3") + polygon.click() + expect(page.locator(".leaflet-popup-content-wrapper")).to_be_visible() + expect(page.get_by_role("heading", name="name poly")).to_be_visible() + expect(page.get_by_text("poly description")).to_be_visible() + # It's not a round value + expect(polygon).to_have_attribute("fill-opacity", re.compile(r"0.5\d+")) + # Close popup + page.locator("#map").click() + expect(polygon).to_have_attribute("fill-opacity", "0.3") diff --git a/umap/tests/integration/test_view_polyline.py b/umap/tests/integration/test_view_polyline.py new file mode 100644 index 00000000..9ed949be --- /dev/null +++ b/umap/tests/integration/test_view_polyline.py @@ -0,0 +1,51 @@ +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + +DATALAYER_DATA = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": "name line", "description": "line description"}, + "geometry": { + "type": "LineString", + "coordinates": [ + # Flat line so PW will click on it + # (it compute the center of the element) + [11.25, 53.585984], + [10.151367, 52.975108], + ], + }, + }, + ], +} + + +@pytest.fixture +def bootstrap(map, live_server): + map.settings["properties"]["zoom"] = 6 + map.settings["geometry"] = { + "type": "Point", + "coordinates": [8.429, 53.239], + } + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA) + + +def test_should_open_popup_on_click(live_server, map, page, bootstrap): + page.goto(f"{live_server.url}{map.get_absolute_url()}") + line = page.locator("path").first + expect(line).to_have_attribute("stroke-opacity", "0.5") + line.click() + expect(page.locator(".leaflet-popup-content-wrapper")).to_be_visible() + expect(page.get_by_role("heading", name="name line")).to_be_visible() + expect(page.get_by_text("line description")).to_be_visible() + # It's not a round value + expect(line).to_have_attribute("stroke-opacity", "1") + # Close popup + page.locator("#map").click() + expect(line).to_have_attribute("stroke-opacity", "0.5") diff --git a/umap/tests/settings.py b/umap/tests/settings.py index 37a72c5f..479737a9 100644 --- a/umap/tests/settings.py +++ b/umap/tests/settings.py @@ -3,9 +3,11 @@ import os from umap.settings.base import * # pylint: disable=W0614,W0401 SECRET_KEY = "justfortests" -COMPRESS_ENABLED = False -FROM_EMAIL = "test@test.org" +DEFAULT_FROM_EMAIL = "test@test.org" EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +STORAGES["staticfiles"]["BACKEND"] = ( + "django.contrib.staticfiles.storage.StaticFilesStorage" +) if os.environ.get("GITHUB_ACTIONS", False) == "true": DATABASES = { diff --git a/umap/tests/test_datalayer.py b/umap/tests/test_datalayer.py index c5c12f89..ee564bc8 100644 --- a/umap/tests/test_datalayer.py +++ b/umap/tests/test_datalayer.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import pytest from django.core.files.base import ContentFile @@ -60,30 +61,43 @@ def test_clone_should_clone_geojson_too(datalayer): assert clone.geojson.path != datalayer.geojson.path -def test_should_remove_old_versions_on_save(datalayer, map, settings): +def test_should_remove_old_versions_on_save(map, settings): + datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17) settings.UMAP_KEEP_VERSIONS = 3 - root = datalayer.storage_root() + root = Path(datalayer.storage_root()) before = len(datalayer.geojson.storage.listdir(root)[1]) - newer = f"{root}/{datalayer.pk}_1440924889.geojson" - medium = f"{root}/{datalayer.pk}_1440923687.geojson" - older = f"{root}/{datalayer.pk}_1440918637.geojson" - other = f"{root}/123456_1440918637.geojson" - for path in [medium, newer, older, other]: - datalayer.geojson.storage.save(path, ContentFile("{}")) - datalayer.geojson.storage.save(path + ".gz", ContentFile("{}")) - assert len(datalayer.geojson.storage.listdir(root)[1]) == 8 + before + newer = f"{datalayer.pk}_1440924889.geojson" + medium = f"{datalayer.pk}_1440923687.geojson" + older = f"{datalayer.pk}_1440918637.geojson" + with_old_id = f"{datalayer.old_id}_1440918537.geojson" + other = "123456_1440918637.geojson" + for path in [medium, newer, older, with_old_id, other]: + datalayer.geojson.storage.save(root / path, ContentFile("{}")) + datalayer.geojson.storage.save(root / f"{path}.gz", ContentFile("{}")) + assert len(datalayer.geojson.storage.listdir(root)[1]) == 10 + before + files = datalayer.geojson.storage.listdir(root)[1] + # Those files should be present before save, which will purge them + assert older in files + assert older + ".gz" in files + assert with_old_id in files + assert with_old_id + ".gz" in files datalayer.save() files = datalayer.geojson.storage.listdir(root)[1] # Flat + gz files, but not latest gz, which is created at first datalayer read. + # older and with_old_id should have been removed assert len(files) == 5 - assert os.path.basename(newer) in files - assert os.path.basename(medium) in files - assert os.path.basename(datalayer.geojson.path) in files + assert newer in files + assert medium in files + assert Path(datalayer.geojson.path).name in files # File from another datalayer, purge should have impacted it. - assert os.path.basename(other) in files - assert os.path.basename(other + ".gz") in files - assert os.path.basename(older) not in files - assert os.path.basename(older + ".gz") not in files + assert other in files + assert other + ".gz" in files + assert older not in files + assert older + ".gz" not in files + assert with_old_id not in files + assert with_old_id + ".gz" not in files + names = [v["name"] for v in datalayer.versions] + assert names == [Path(datalayer.geojson.name).name, newer, medium] def test_anonymous_cannot_edit_in_editors_mode(datalayer): diff --git a/umap/tests/test_datalayer_views.py b/umap/tests/test_datalayer_views.py index 3fe8a99d..6055e47e 100644 --- a/umap/tests/test_datalayer_views.py +++ b/umap/tests/test_datalayer_views.py @@ -35,7 +35,7 @@ def test_get_with_public_mode(client, settings, datalayer, map): url = reverse("datalayer_view", args=(map.pk, datalayer.pk)) response = client.get(url) assert response.status_code == 200 - assert response["Last-Modified"] is not None + assert response["X-Datalayer-Version"] is not None assert response["Cache-Control"] is not None assert "Content-Encoding" not in response j = json.loads(response.content.decode()) @@ -44,6 +44,16 @@ def test_get_with_public_mode(client, settings, datalayer, map): assert j["type"] == "FeatureCollection" +def test_get_with_x_accel_redirect(client, settings, datalayer, map): + settings.UMAP_XSENDFILE_HEADER = "X-Accel-Redirect" + url = reverse("datalayer_view", args=(map.pk, datalayer.pk)) + response = client.get(url) + assert response.status_code == 200 + assert "X-Accel-Redirect" in response.headers + assert response["X-Accel-Redirect"].startswith("/internal/datalayer/") + assert response["X-Accel-Redirect"].endswith(".geojson") + + def test_get_with_open_mode(client, settings, datalayer, map): map.share_status = Map.PUBLIC map.save() @@ -111,7 +121,7 @@ def test_update(client, datalayer, map, post_data): # Test response is a json j = json.loads(response.content.decode()) assert "id" in j - assert datalayer.pk == j["id"] + assert str(datalayer.pk) == j["id"] assert j["browsable"] is True assert Path(modified_datalayer.geojson.path).exists() @@ -154,48 +164,50 @@ def test_should_not_be_possible_to_delete_with_wrong_map_id_in_url( assert DataLayer.objects.filter(pk=datalayer.pk).exists() -def test_optimistic_concurrency_control_with_good_last_modified( +def test_optimistic_concurrency_control_with_good_version( client, datalayer, map, post_data ): map.share_status = Map.PUBLIC map.save() - # Get Last-Modified + # Get reference version url = reverse("datalayer_view", args=(map.pk, datalayer.pk)) response = client.get(url) - last_modified = response["Last-Modified"] + reference_version = response["X-Datalayer-Version"] url = reverse("datalayer_update", args=(map.pk, datalayer.pk)) client.login(username=map.owner.username, password="123123") name = "new name" post_data["name"] = "new name" response = client.post( - url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=last_modified + url, post_data, follow=True, HTTP_X_DATALAYER_REFERENCE=reference_version ) assert response.status_code == 200 modified_datalayer = DataLayer.objects.get(pk=datalayer.pk) assert modified_datalayer.name == name -def test_optimistic_concurrency_control_with_bad_last_modified( +def test_optimistic_concurrency_control_with_bad_version( client, datalayer, map, post_data ): url = reverse("datalayer_update", args=(map.pk, datalayer.pk)) client.login(username=map.owner.username, password="123123") name = "new name" post_data["name"] = name - response = client.post(url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE="xxx") + response = client.post( + url, post_data, follow=True, HTTP_X_DATALAYER_REFERENCE="xxx" + ) assert response.status_code == 412 modified_datalayer = DataLayer.objects.get(pk=datalayer.pk) assert modified_datalayer.name != name -def test_optimistic_concurrency_control_with_empty_last_modified( +def test_optimistic_concurrency_control_with_empty_version( client, datalayer, map, post_data ): url = reverse("datalayer_update", args=(map.pk, datalayer.pk)) client.login(username=map.owner.username, password="123123") name = "new name" post_data["name"] = name - response = client.post(url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=None) + response = client.post(url, post_data, follow=True, X_DATALAYER_REFERENCE=None) assert response.status_code == 200 modified_datalayer = DataLayer.objects.get(pk=datalayer.pk) assert modified_datalayer.name == name @@ -225,6 +237,41 @@ def test_versions_should_return_versions(client, datalayer, map, settings): assert version in versions["versions"] +def test_versions_can_return_old_format(client, datalayer, map, settings): + map.share_status = Map.PUBLIC + map.save() + root = datalayer.storage_root() + datalayer.old_id = 123 # old datalayer id (now replaced by uuid) + datalayer.save() + + datalayer.geojson.storage.save( + "%s/%s_1440924889.geojson" % (root, datalayer.pk), ContentFile("{}") + ) + datalayer.geojson.storage.save( + "%s/%s_1440923687.geojson" % (root, datalayer.pk), ContentFile("{}") + ) + + # store with the id prefix (rather than the uuid) + old_format_version = "%s_1440918637.geojson" % datalayer.old_id + datalayer.geojson.storage.save( + ("%s/" % root) + old_format_version, ContentFile("{}") + ) + + url = reverse("datalayer_versions", args=(map.pk, datalayer.pk)) + versions = json.loads(client.get(url).content.decode()) + assert len(versions["versions"]) == 4 + version = { + "name": old_format_version, + "size": 2, + "at": "1440918637", + } + assert version in versions["versions"] + + client.get( + reverse("datalayer_version", args=(map.pk, datalayer.pk, old_format_version)) + ) + + def test_version_should_return_one_version_geojson(client, datalayer, map): map.share_status = Map.PUBLIC map.save() @@ -444,7 +491,7 @@ def test_optimistic_merge_both_added(client, datalayer, map, reference_data): assert response.status_code == 200 response = client.get(reverse("datalayer_view", args=(map.pk, datalayer.pk))) - reference_timestamp = response["Last-Modified"] + reference_version = response.headers.get("X-Datalayer-Version") # Client 1 adds "Point 5, 6" to the existing data client1_feature = { @@ -454,14 +501,16 @@ def test_optimistic_merge_both_added(client, datalayer, map, reference_data): } client1_data = deepcopy(reference_data) client1_data["features"].append(client1_feature) - # Sleep to change the current timestamp (used in the If-Unmodified-Since header) - time.sleep(1) + post_data["geojson"] = SimpleUploadedFile( "foo.json", json.dumps(client1_data).encode("utf-8"), ) response = client.post( - url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=reference_timestamp + url, + post_data, + follow=True, + headers={"X-Datalayer-Reference": reference_version}, ) assert response.status_code == 200 @@ -479,7 +528,10 @@ def test_optimistic_merge_both_added(client, datalayer, map, reference_data): json.dumps(client2_data).encode("utf-8"), ) response = client.post( - url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=reference_timestamp + url, + post_data, + follow=True, + headers={"X-Datalayer-Reference": reference_version}, ) assert response.status_code == 200 modified_datalayer = DataLayer.objects.get(pk=datalayer.pk) @@ -513,20 +565,22 @@ def test_optimistic_merge_conflicting_change_raises( assert response.status_code == 200 response = client.get(reverse("datalayer_view", args=(map.pk, datalayer.pk))) - reference_timestamp = response["Last-Modified"] + + reference_version = response.headers.get("X-Datalayer-Version") # First client changes the first feature. client1_data = deepcopy(reference_data) client1_data["features"][0]["geometry"] = {"type": "Point", "coordinates": [5, 6]} - # Sleep to change the current timestamp (used in the If-Unmodified-Since header) - time.sleep(1) post_data["geojson"] = SimpleUploadedFile( "foo.json", json.dumps(client1_data).encode("utf-8"), ) response = client.post( - url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=reference_timestamp + url, + post_data, + follow=True, + headers={"X-Datalayer-Reference": reference_version}, ) assert response.status_code == 200 @@ -539,7 +593,10 @@ def test_optimistic_merge_conflicting_change_raises( json.dumps(client2_data).encode("utf-8"), ) response = client.post( - url, post_data, follow=True, HTTP_IF_UNMODIFIED_SINCE=reference_timestamp + url, + post_data, + follow=True, + headers={"X-Datalayer-Reference": reference_version}, ) assert response.status_code == 412 diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index 6ef798e2..aeb65e5a 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -1,14 +1,17 @@ import json +import zipfile +from io import BytesIO import pytest from django.contrib.auth import get_user_model from django.core import mail from django.core.signing import Signer from django.urls import reverse +from django.utils import translation from umap.models import DataLayer, Map, Star -from .base import login_required +from .base import MapFactory, UserFactory, login_required pytestmark = pytest.mark.django_db User = get_user_model() @@ -19,7 +22,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 } @@ -107,7 +110,9 @@ def test_update(client, map, post_data): def test_delete(client, map, datalayer): url = reverse("map_delete", args=(map.pk,)) client.login(username=map.owner.username, password="123123") - response = client.post(url, {}, follow=True) + response = client.post( + url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True + ) assert response.status_code == 200 assert not Map.objects.filter(pk=map.pk).exists() assert not DataLayer.objects.filter(pk=datalayer.pk).exists() @@ -143,6 +148,13 @@ def test_should_not_consider_the_query_string_for_canonical_check(client, map): assert response.status_code == 200 +def test_map_headers(client, map): + url = reverse("map", kwargs={"map_id": map.pk, "slug": map.slug}) + response = client.get(url) + assert response.status_code == 200 + assert response.headers["Access-Control-Allow-Origin"] == "*" + + def test_short_url_should_redirect_to_canonical(client, map): url = reverse("map_short_url", kwargs={"pk": map.pk}) canonical = reverse("map", kwargs={"map_id": map.pk, "slug": map.slug}) @@ -156,9 +168,23 @@ def test_clone_map_should_create_a_new_instance(client, map): url = reverse("map_clone", kwargs={"map_id": map.pk}) client.login(username=map.owner.username, password="123123") response = client.post(url) + assert response.status_code == 302 + assert Map.objects.count() == 2 + clone = Map.objects.latest("pk") + assert response["Location"] == clone.get_absolute_url() + assert clone.pk != map.pk + assert clone.name == "Clone of " + map.name + + +def test_clone_map_should_be_possible_via_ajax(client, map): + assert Map.objects.count() == 1 + url = reverse("map_clone", kwargs={"map_id": map.pk}) + client.login(username=map.owner.username, password="123123") + response = client.post(url, headers={"X-Requested-With": "XMLHttpRequest"}) assert response.status_code == 200 assert Map.objects.count() == 2 clone = Map.objects.latest("pk") + assert response.json() == {"redirect": clone.get_absolute_url()} assert clone.pk != map.pk assert clone.name == "Clone of " + map.name @@ -189,7 +215,7 @@ def test_clone_should_set_cloner_as_owner(client, map, user): map.save() client.login(username=user.username, password="123123") response = client.post(url) - assert response.status_code == 200 + assert response.status_code == 302 assert Map.objects.count() == 2 clone = Map.objects.latest("pk") assert clone.pk != map.pk @@ -275,7 +301,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() @@ -296,7 +322,9 @@ def test_only_owner_can_delete(client, map, user): map.editors.add(user) url = reverse("map_delete", kwargs={"map_id": map.pk}) client.login(username=user.username, password="123123") - response = client.post(url, {}, follow=True) + response = client.post( + url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True + ) assert response.status_code == 403 @@ -346,14 +374,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" @@ -368,7 +396,9 @@ def test_anonymous_update_with_cookie_should_work(cookieclient, anonymap, post_d @pytest.mark.usefixtures("allow_anonymous") def test_anonymous_delete(cookieclient, anonymap): url = reverse("map_delete", args=(anonymap.pk,)) - response = cookieclient.post(url, {}, follow=True) + response = cookieclient.post( + url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True + ) assert response.status_code == 200 assert not Map.objects.filter(pk=anonymap.pk).count() # Test response is a json @@ -379,7 +409,9 @@ def test_anonymous_delete(cookieclient, anonymap): @pytest.mark.usefixtures("allow_anonymous") def test_no_cookie_cant_delete(client, anonymap): url = reverse("map_delete", args=(anonymap.pk,)) - response = client.post(url, {}, follow=True) + response = client.post( + url, headers={"X-Requested-With": "XMLHttpRequest"}, follow=True + ) assert response.status_code == 403 @@ -420,7 +452,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,15 +466,16 @@ 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 anonymap.save() response = client.post(url) - assert response.status_code == 200 + assert response.status_code == 302 assert Map.objects.count() == 2 clone = Map.objects.latest("pk") + assert response["Location"] == clone.get_absolute_url() assert clone.pk != anonymap.pk assert clone.name == "Clone of " + anonymap.name assert clone.owner is None @@ -624,7 +657,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, @@ -656,6 +689,64 @@ def test_download(client, map, datalayer): ] +def test_download_multiple_maps(client, map, datalayer): + map.share_status = Map.PRIVATE + map.save() + another_map = MapFactory( + owner=map.owner, name="Another map", share_status=Map.PUBLIC + ) + client.login(username=map.owner.username, password="123123") + url = reverse("user_download") + response = client.get(f"{url}?map_id={map.id}&map_id={another_map.id}") + assert response.status_code == 200 + with zipfile.ZipFile(file=BytesIO(response.content), mode="r") as f: + assert len(f.infolist()) == 2 + assert f.infolist()[0].filename == f"umap_backup_test-map_{another_map.id}.umap" + assert f.infolist()[1].filename == f"umap_backup_test-map_{map.id}.umap" + with f.open(f.infolist()[1]) as umap_file: + umapjson = json.loads(umap_file.read().decode()) + assert list(umapjson.keys()) == [ + "type", + "geometry", + "properties", + "uri", + "layers", + ] + assert umapjson["type"] == "umap" + assert umapjson["uri"] == f"http://testserver/en/map/test-map_{map.id}" + + +def test_download_multiple_maps_unauthorized(client, map, datalayer): + map.share_status = Map.PRIVATE + map.save() + user1 = UserFactory(username="user1") + another_map = MapFactory(owner=user1, name="Another map", share_status=Map.PUBLIC) + client.login(username=map.owner.username, password="123123") + url = reverse("user_download") + response = client.get(f"{url}?map_id={map.id}&map_id={another_map.id}") + assert response.status_code == 200 + with zipfile.ZipFile(file=BytesIO(response.content), mode="r") as f: + assert len(f.infolist()) == 1 + assert f.infolist()[0].filename == f"umap_backup_test-map_{map.id}.umap" + + +def test_download_multiple_maps_editor(client, map, datalayer): + map.share_status = Map.PRIVATE + map.save() + user1 = UserFactory(username="user1") + another_map = MapFactory(owner=user1, name="Another map", share_status=Map.PUBLIC) + another_map.editors.add(map.owner) + another_map.save() + client.login(username=map.owner.username, password="123123") + url = reverse("user_download") + response = client.get(f"{url}?map_id={map.id}&map_id={another_map.id}") + assert response.status_code == 200 + with zipfile.ZipFile(file=BytesIO(response.content), mode="r") as f: + assert len(f.infolist()) == 2 + assert f.infolist()[0].filename == f"umap_backup_test-map_{another_map.id}.umap" + assert f.infolist()[1].filename == f"umap_backup_test-map_{map.id}.umap" + + @pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED]) def test_download_shared_status_map(client, map, datalayer, share_status): map.share_status = share_status @@ -675,3 +766,97 @@ 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_unknown_url_map(client, map, datalayer): + map_url = f"http://testserver{map.get_absolute_url()}" + # We change to an unknown id prefix to keep URL structure. + map_url = map_url.replace("map_", "_111") + url = f"{reverse('map_oembed')}?url={map_url}" + 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 + assert response.headers["Access-Control-Allow-Origin"] == "*" + 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"] == ( + '' + f'

See full screen

' + ) + + +def test_oembed_map_with_non_default_language(client, map, datalayer): + translation.activate("en") + path = map.get_absolute_url() + assert path.startswith("/en/") + path = path.replace("/en/", "/fr/") + url = f"{reverse('map_oembed')}?url=http://testserver{path}" + response = client.get(url) + assert response.status_code == 200 + translation.activate("en") + + +def test_oembed_link(client, map, datalayer): + response = client.get(map.get_absolute_url()) + assert response.status_code == 200 + + assert ( + '' in response.content.decode() + + +def test_ogp_links(client, map, datalayer): + response = client.get(map.get_absolute_url()) + assert response.status_code == 200 + content = response.content.decode() + assert ( + f'' + in content + ) + assert f'' in content + assert f'' in content + assert '' in content diff --git a/umap/tests/test_merge_features.py b/umap/tests/test_merge_features.py index d67e1e43..f17d6402 100644 --- a/umap/tests/test_merge_features.py +++ b/umap/tests/test_merge_features.py @@ -1,6 +1,6 @@ import pytest -from umap.utils import merge_features +from umap.utils import ConflictError, merge_features def test_adding_one_element(): @@ -33,11 +33,11 @@ def test_adding_elements(): def test_adding_one_removing_one(): - assert merge_features(["A", "B"], ["A", "C"], ["A", "B", "D"]) == [ - "A", - "C", - "D", - ] + assert merge_features(["A", "B"], ["A", "C"], ["A", "B", "D"]) == ["A", "C", "D"] + + +def test_removing_one(): + assert merge_features(["A", "B"], ["A", "B", "C"], ["A", "D"]) == ["A", "C", "D"] def test_removing_same_element(): @@ -50,18 +50,38 @@ def test_removing_same_element(): def test_removing_changed_element(): - with pytest.raises(ValueError): + with pytest.raises(ConflictError): merge_features(["A", "B"], ["A", "C"], ["A"]) def test_changing_removed_element(): - with pytest.raises(ValueError): + with pytest.raises(ConflictError): merge_features(["A", "B"], ["A"], ["A", "C"]) def test_changing_same_element(): - with pytest.raises(ValueError): + with pytest.raises(ConflictError): merge_features(["A", "B"], ["A", "D"], ["A", "C"]) # Order does not count - with pytest.raises(ValueError): + with pytest.raises(ConflictError): merge_features(["A", "B", "C"], ["B", "D", "A"], ["A", "E", "B"]) + + +def test_merge_with_ids_raises(): + # If reference doesn't have ids, but latest and incoming has + # We need to raise as a conflict + reference = [ + {"properties": {}, "geometry": "A"}, + {"properties": {}, "geometry": "B"}, + ] + latest = [ + {"properties": {id: "100"}, "geometry": "A"}, + {"properties": {id: "101"}, "geometry": "B"}, + ] + incoming = [ + {"properties": {id: "200"}, "geometry": "A"}, + {"properties": {id: "201"}, "geometry": "B"}, + ] + + with pytest.raises(ConflictError): + merge_features(reference, latest, incoming) diff --git a/umap/tests/test_views.py b/umap/tests/test_views.py index 1c865357..86904cdc 100644 --- a/umap/tests/test_views.py +++ b/umap/tests/test_views.py @@ -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 @@ -303,16 +304,6 @@ def test_user_dashboard_display_user_maps_distinct(client, map): assert body.count(anonymap.name) == 0 -@pytest.mark.django_db -def test_logout_should_return_json_in_ajax(client, user, settings): - client.login(username=user.username, password="123123") - response = client.get( - reverse("logout"), headers={"X_REQUESTED_WITH": "XMLHttpRequest"} - ) - data = json.loads(response.content.decode()) - assert data["redirect"] == "/" - - @pytest.mark.django_db def test_logout_should_return_redirect(client, user, settings): client.login(username=user.username, password="123123") @@ -391,3 +382,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 diff --git a/umap/urls.py b/umap/urls.py index f2905025..28564b55 100644 --- a/umap/urls.py +++ b/umap/urls.py @@ -15,7 +15,6 @@ from . import views from .decorators import ( can_edit_map, can_view_map, - jsonize_view, login_required_if_not_anonymous_allowed, ) from .utils import decorated_patterns @@ -41,6 +40,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\d+)/download/", can_view_map(views.MapDownload.as_view()), @@ -49,7 +49,7 @@ urlpatterns = [ ] i18n_urls = [ - re_path(r"^login/$", jsonize_view(auth_views.LoginView.as_view()), name="login"), + re_path(r"^login/$", auth_views.LoginView.as_view(), name="login"), re_path( r"^login/popup/end/$", views.LoginPopupEnd.as_view(), name="login_popup_end" ), @@ -72,18 +72,18 @@ i18n_urls = [ ] i18n_urls += decorated_patterns( [can_view_map, cache_control(must_revalidate=True)], - re_path( - r"^datalayer/(?P\d+)/(?P[\d]+)/$", + path( + "datalayer///", views.DataLayerView.as_view(), name="datalayer_view", ), - re_path( - r"^datalayer/(?P\d+)/(?P[\d]+)/versions/$", + path( + "datalayer///versions/", views.DataLayerVersions.as_view(), name="datalayer_versions", ), - re_path( - r"^datalayer/(?P\d+)/(?P[\d]+)/(?P[_\w]+.geojson)$", + path( + "datalayer///", views.DataLayerVersion.as_view(), name="datalayer_version", ), @@ -96,6 +96,7 @@ i18n_urls += decorated_patterns( ) i18n_urls += decorated_patterns( [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"), ) i18n_urls += decorated_patterns( @@ -109,16 +110,9 @@ i18n_urls += decorated_patterns( views.ToggleMapStarStatus.as_view(), name="map_star", ), - re_path( - r"^me$", - views.user_dashboard, - name="user_dashboard", - ), - re_path( - r"^me/profile$", - views.user_profile, - name="user_profile", - ), + re_path(r"^me$", views.user_dashboard, name="user_dashboard"), + re_path(r"^me/profile$", views.user_profile, name="user_profile"), + re_path(r"^me/download$", views.user_download, name="user_download"), ) map_urls = [ re_path( @@ -151,18 +145,18 @@ map_urls = [ views.DataLayerCreate.as_view(), name="datalayer_create", ), - re_path( - r"^map/(?P[\d]+)/datalayer/delete/(?P\d+)/$", + path( + "map//datalayer/delete//", views.DataLayerDelete.as_view(), name="datalayer_delete", ), - re_path( - r"^map/(?P[\d]+)/datalayer/permissions/(?P\d+)/$", + path( + "map//datalayer/permissions//", views.UpdateDataLayerPermissions.as_view(), name="datalayer_permissions", ), ] -if settings.FROM_EMAIL: +if settings.DEFAULT_FROM_EMAIL: map_urls.append( re_path( r"^map/(?P[\d]+)/send-edit-link/$", @@ -171,8 +165,8 @@ if settings.FROM_EMAIL: ) ) datalayer_urls = [ - re_path( - r"^map/(?P[\d]+)/datalayer/update/(?P\d+)/$", + path( + "map//datalayer/update//", views.DataLayerUpdate.as_view(), name="datalayer_update", ), diff --git a/umap/utils.py b/umap/utils.py index d7dfa6fb..26cf581d 100644 --- a/umap/utils.py +++ b/umap/utils.py @@ -1,9 +1,28 @@ import gzip +import json import os +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder from django.urls import URLPattern, URLResolver, get_resolver +def _urls_for_js(urls=None): + """ + Return templated URLs prepared for javascript. + """ + if urls is None: + # prevent circular import + from .urls import i18n_urls, urlpatterns + + urls = [ + url.name for url in urlpatterns + i18n_urls if getattr(url, "name", None) + ] + urls = dict(zip(urls, [get_uri_template(url) for url in urls])) + urls.update(getattr(settings, "UMAP_EXTRA_URLS", {})) + return urls + + def get_uri_template(urlname, args=None, prefix=""): """ Utility function to return an URI Template from a named URL in django @@ -139,9 +158,14 @@ def merge_features(reference: list, latest: list, incoming: list): # Reapply the changes on top of the latest. for item in removed: - merged.delete(item) + merged.remove(item) for item in added: merged.append(item) return merged + + +def json_dumps(obj, **kwargs): + """Utility using the Django JSON Encoder when dumping objects""" + return json.dumps(obj, cls=DjangoJSONEncoder, **kwargs) diff --git a/umap/views.py b/umap/views.py index d20b924a..c8806b1c 100644 --- a/umap/views.py +++ b/umap/views.py @@ -1,14 +1,17 @@ +import io import json import mimetypes import os import re import socket +import zipfile from datetime import datetime, timedelta from http.client import InvalidURL from io import BytesIO from pathlib import Path +from smtplib import SMTPException from urllib.error import HTTPError, URLError -from urllib.parse import quote, urlparse +from urllib.parse import quote, quote_plus, urlparse from urllib.request import Request, build_opener from django.conf import settings @@ -18,26 +21,28 @@ 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.db.models import Q 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 import translation from django.utils.encoding import smart_bytes from django.utils.http import http_date from django.utils.timezone import make_aware from django.utils.translation import gettext as _ -from django.utils.translation import to_locale from django.views.decorators.cache import cache_control from django.views.decorators.http import require_GET from django.views.generic import DetailView, TemplateView, View @@ -62,7 +67,14 @@ from .forms import ( UserProfileForm, ) from .models import DataLayer, Licence, Map, Pictogram, Star, TileLayer -from .utils import ConflictError, get_uri_template, gzip_file, is_ajax, merge_features +from .utils import ( + ConflictError, + _urls_for_js, + gzip_file, + is_ajax, + json_dumps, + merge_features, +) User = get_user_model() @@ -106,6 +118,12 @@ class PaginatorMixin: return [self.list_template_name] return super().get_template_names() + def get(self, *args, **kwargs): + response = super().get(*args, **kwargs) + if is_ajax(self.request): + return simple_json_response(html=response.rendered_content) + return response + class PublicMapsMixin(object): def get_public_maps(self): @@ -119,13 +137,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"): @@ -141,8 +172,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, @@ -269,15 +298,44 @@ class UserDashboard(PaginatorMixin, DetailView, SearchMixin): return qs.order_by("-modified_at") def get_context_data(self, **kwargs): - kwargs.update( - {"maps": self.paginate(self.get_maps(), settings.UMAP_MAPS_PER_PAGE_OWNER)} - ) + page = self.paginate(self.get_maps(), settings.UMAP_MAPS_PER_PAGE_OWNER) + kwargs.update({"q": self.request.GET.get("q"), "maps": page}) return super().get_context_data(**kwargs) user_dashboard = UserDashboard.as_view() +class UserDownload(DetailView, SearchMixin): + model = User + + def get_object(self): + return self.get_queryset().get(pk=self.request.user.pk) + + def get_maps(self): + qs = Map.objects.filter(id__in=self.request.GET.getlist("map_id")) + qs = qs.filter(owner=self.object).union(qs.filter(editors=self.object)) + return qs.order_by("-modified_at") + + def render_to_response(self, context, *args, **kwargs): + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: + for map_ in self.get_maps(): + umapjson = map_.generate_umapjson(self.request) + geojson_file = io.StringIO(json_dumps(umapjson)) + file_name = f"umap_backup_{map_.slug}_{map_.pk}.umap" + zip_file.writestr(file_name, geojson_file.getvalue()) + + response = HttpResponse(zip_buffer.getvalue(), content_type="application/zip") + response["Content-Disposition"] = ( + 'attachment; filename="umap_backup_complete.zip"' + ) + return response + + +user_download = UserDownload.as_view() + + class MapsShowCase(View): def get(self, *args, **kwargs): maps = Map.public.filter(center__distance_gt=(DEFAULT_CENTER, D(km=1))) @@ -303,7 +361,7 @@ class MapsShowCase(View): } geojson = {"type": "FeatureCollection", "features": [make(m) for m in maps]} - return HttpResponse(smart_bytes(json.dumps(geojson))) + return HttpResponse(smart_bytes(json_dumps(geojson))) showcase = MapsShowCase.as_view() @@ -311,7 +369,6 @@ showcase = MapsShowCase.as_view() def validate_url(request): assert request.method == "GET" - assert is_ajax(request) url = request.GET.get("url") assert url try: @@ -390,24 +447,8 @@ ajax_proxy = AjaxProxy.as_view() # ############## # -def _urls_for_js(urls=None): - """ - Return templated URLs prepared for javascript. - """ - if urls is None: - # prevent circular import - from .urls import i18n_urls, urlpatterns - - urls = [ - url.name for url in urlpatterns + i18n_urls if getattr(url, "name", None) - ] - urls = dict(zip(urls, [get_uri_template(url) for url in urls])) - urls.update(getattr(settings, "UMAP_EXTRA_URLS", {})) - return urls - - def simple_json_response(**kwargs): - return HttpResponse(json.dumps(kwargs), content_type="application/json") + return HttpResponse(json_dumps(kwargs), content_type="application/json") # ############## # @@ -433,14 +474,28 @@ class MapDetailMixin: model = Map pk_url_kwarg = "map_id" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + 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_map_properties(self): user = self.request.user properties = { "urls": _urls_for_js(), "tilelayers": TileLayer.get_list(), "editMode": self.edit_mode, - "default_iconUrl": "%sumap/img/marker.png" % settings.STATIC_URL, # noqa + "schema": Map.extra_schema, "umap_id": self.get_umap_id(), "starred": self.is_starred(), "licences": dict((l.name, l.json) for l in Licence.objects.all()), @@ -464,27 +519,33 @@ class MapDetailMixin: if self.get_short_url(): properties["shortUrl"] = self.get_short_url() - if settings.USE_I18N: - lang = settings.LANGUAGE_CODE - # Check attr in case the middleware is not active - if hasattr(self.request, "LANGUAGE_CODE"): - lang = self.request.LANGUAGE_CODE - properties["lang"] = lang - locale = to_locale(lang) - properties["locale"] = locale - context["locale"] = locale if not user.is_anonymous: properties["user"] = { "id": user.pk, "name": str(user), "url": reverse("user_dashboard"), } - map_settings = self.get_geojson() - 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) + return properties + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + properties = self.get_map_properties() + if settings.USE_I18N: + lang = settings.LANGUAGE_CODE + # Check attr in case the middleware is not active + if hasattr(self.request, "LANGUAGE_CODE"): + lang = self.request.LANGUAGE_CODE + properties["lang"] = lang + locale = translation.to_locale(lang) + properties["locale"] = locale + context["locale"] = locale + geojson = self.get_geojson() + if "properties" not in geojson: + geojson["properties"] = {} + geojson["properties"].update(properties) + geojson["properties"]["datalayers"] = self.get_datalayers() + context["map_settings"] = json_dumps(geojson, indent=settings.DEBUG) + self.set_preconnect(geojson["properties"], context) return context def get_datalayers(self): @@ -537,6 +598,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["quoted_absolute_uri"] = quote_plus( + 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() @@ -544,7 +615,9 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView): if request.META.get("QUERY_STRING"): canonical = "?".join([canonical, request.META["QUERY_STRING"]]) return HttpResponsePermanentRedirect(canonical) - return super(MapView, self).get(request, *args, **kwargs) + response = super(MapView, self).get(request, *args, **kwargs) + response["Access-Control-Allow-Origin"] = "*" + return response def get_canonical_url(self): return self.object.get_absolute_url() @@ -600,21 +673,61 @@ class MapDownload(DetailView): return reverse("map_download", args=(self.object.pk,)) def render_to_response(self, context, *args, **kwargs): - geojson = self.object.settings - geojson["type"] = "umap" - geojson["uri"] = self.request.build_absolute_uri(self.object.get_absolute_url()) - datalayers = [] - for datalayer in self.object.datalayer_set.all(): - with open(datalayer.geojson.path, "rb") as f: - layer = json.loads(f.read()) - if datalayer.settings: - layer["_umap_options"] = datalayer.settings - datalayers.append(layer) - geojson["layers"] = datalayers - response = simple_json_response(**geojson) - response[ - "Content-Disposition" - ] = f'attachment; filename="umap_backup_{self.object.slug}.umap"' + umapjson = self.object.generate_umapjson(self.request) + response = simple_json_response(**umapjson) + response["Content-Disposition"] = ( + f'attachment; filename="umap_backup_{self.object.slug}.umap"' + ) + 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 + lang = translation.get_language_from_path(url_path) + translation.activate(lang) + view, args, kwargs = resolve(url_path) + if "slug" not in kwargs or "map_id" not in kwargs: + raise Http404("Invalid URL path.") + + map_ = get_object_or_404(Map, id=kwargs["map_id"]) + + 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'' + f'

{label}

' + ) + data["html"] = html + response = simple_json_response(**data) + response["Access-Control-Allow-Origin"] = "*" return response @@ -630,6 +743,15 @@ class MapNew(MapDetailMixin, TemplateView): 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): model = Map form_class = MapSettingsForm @@ -668,7 +790,6 @@ class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView): id=self.object.pk, url=self.object.get_absolute_url(), permissions=self.get_permissions(), - info=_("Map has been updated!"), ) @@ -729,16 +850,19 @@ class SendEditLink(FormLessEditMixin, FormView): return HttpResponseBadRequest("Invalid") link = self.object.get_anonymous_edit_url() - send_mail( - _( - "The uMap edit link for your map: %(map_name)s" - % {"map_name": self.object.name} - ), - _("Here is your secret edit link: %(link)s" % {"link": link}), - settings.FROM_EMAIL, - [email], - fail_silently=False, + subject = _( + "The uMap edit link for your map: %(map_name)s" + % {"map_name": self.object.name} ) + body = _("Here is your secret edit link: %(link)s" % {"link": link}) + try: + send_mail( + subject, body, settings.DEFAULT_FROM_EMAIL, [email], fail_silently=False + ) + except SMTPException: + return simple_json_response( + error=_("Can't send email to %(email)s" % {"email": email}) + ) return simple_json_response( info=_("Email sent to %(email)s" % {"email": email}) ) @@ -750,12 +874,14 @@ class MapDelete(DeleteView): def form_valid(self, form): self.object = self.get_object() - if self.object.owner and self.request.user != self.object.owner: + if not self.object.can_delete(self.request.user, self.request): return HttpResponseForbidden(_("Only its owner can delete the map.")) - if not self.object.owner and not self.object.is_anonymous_owner(self.request): - return HttpResponseForbidden() self.object.delete() - return simple_json_response(redirect="/") + home_url = reverse("home") + if is_ajax(self.request): + return simple_json_response(redirect=home_url) + else: + return HttpResponseRedirect(form.data.get("next") or home_url) class MapClone(PermissionsMixin, View): @@ -767,7 +893,10 @@ class MapClone(PermissionsMixin, View): return HttpResponseForbidden() owner = self.request.user if self.request.user.is_authenticated else None self.object = kwargs["map_inst"].clone(owner=owner) - response = simple_json_response(redirect=self.object.get_absolute_url()) + if is_ajax(self.request): + response = simple_json_response(redirect=self.object.get_absolute_url()) + else: + response = HttpResponseRedirect(self.object.get_absolute_url()) if not self.request.user.is_authenticated: key, value = self.object.signed_cookie_elements response.set_signed_cookie( @@ -846,20 +975,20 @@ class GZipMixin(object): @property def path(self): - return self.object.geojson.path + return Path(self.object.geojson.path) @property def gzip_path(self): return Path(f"{self.path}{self.EXT}") - def compute_last_modified(self, path): - stat = os.stat(path) - return http_date(stat.st_mtime) + def read_version(self, path): + # Remove optional .gz, then .geojson, then return the trailing version from path. + return str(path.with_suffix("").with_suffix("")).split("_")[-1] @property - def last_modified(self): + def version(self): # Prior to 1.3.0 we did not set gzip mtime as geojson mtime, - # but we switched from If-Match header to IF-Unmodified-Since + # but we switched from If-Match header to If-Unmodified-Since # and when users accepts gzip their last modified value is the gzip # (when umap is served by nginx and X-Accel-Redirect) # one, so we need to compare with that value in that case. @@ -869,7 +998,7 @@ class GZipMixin(object): if self.accepts_gzip and self.gzip_path.exists() else self.path ) - return self.compute_last_modified(path) + return self.read_version(path) @property def accepts_gzip(self): @@ -891,8 +1020,8 @@ class DataLayerView(GZipMixin, BaseDetailView): if getattr(settings, "UMAP_XSENDFILE_HEADER", None): response = HttpResponse() - path = path.replace(settings.MEDIA_ROOT, "/internal") - response[settings.UMAP_XSENDFILE_HEADER] = path + internal_path = str(path).replace(settings.MEDIA_ROOT, "/internal") + response[settings.UMAP_XSENDFILE_HEADER] = internal_path else: # Do not use in production # (no gzip/cache-control/If-Modified-Since/If-None-Match) @@ -900,7 +1029,7 @@ class DataLayerView(GZipMixin, BaseDetailView): with open(path, "rb") as f: # Should not be used in production! response = HttpResponse(f.read(), content_type="application/geo+json") - response["Last-Modified"] = self.last_modified + response["X-Datalayer-Version"] = self.version response["Content-Length"] = statobj.st_size return response @@ -908,9 +1037,8 @@ class DataLayerView(GZipMixin, BaseDetailView): class DataLayerVersion(DataLayerView): @property def path(self): - return "{root}/{path}".format( - root=settings.MEDIA_ROOT, - path=self.object.get_version_path(self.kwargs["name"]), + return Path(settings.MEDIA_ROOT) / self.object.get_version_path( + self.kwargs["name"] ) @@ -921,11 +1049,11 @@ class DataLayerCreate(FormLessEditMixin, GZipMixin, CreateView): def form_valid(self, form): form.instance.map = self.kwargs["map_inst"] self.object = form.save() - # Simple response with only metadatas (including new id) + # Simple response with only metadata (including new id) response = simple_json_response( **self.object.metadata(self.request.user, self.request) ) - response["Last-Modified"] = self.last_modified + response["X-Datalayer-Version"] = self.version return response @@ -933,30 +1061,30 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView): model = DataLayer form_class = DataLayerForm - def has_been_modified_since(self, if_unmodified_since): - return if_unmodified_since and self.last_modified != if_unmodified_since + def has_changes_since(self, incoming_version): + return incoming_version and self.version != incoming_version - def merge(self, if_unmodified_since): + def merge(self, reference_version): """ - Attempt to apply the incoming changes to the document the client was using, and - then merge it with the last document we have on storage. + Attempt to apply the incoming changes to the reference, and then merge it + with the last document we have on storage. Returns either None (if the merge failed) or the merged python GeoJSON object. """ - # Use If-Modified-Since to find the correct version in our storage. - for name in self.object.get_versions(): - path = os.path.join(settings.MEDIA_ROOT, self.object.get_version_path(name)) - if if_unmodified_since == self.compute_last_modified(path): + # Use the provided info to find the correct version in our storage. + for version in self.object.versions: + name = version["name"] + path = Path(settings.MEDIA_ROOT) / self.object.get_version_path(name) + if reference_version == self.read_version(path): with open(path) as f: reference = json.loads(f.read()) break else: # If the document is not found, we can't merge. return None - # New data received in the request. - entrant = json.loads(self.request.FILES["geojson"].read()) + incoming = json.loads(self.request.FILES["geojson"].read()) # Latest known version of the data. with open(self.path) as f: @@ -964,7 +1092,9 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView): try: merged_features = merge_features( - reference["features"], latest["features"], entrant["features"] + reference.get("features", []), + latest.get("features", []), + incoming.get("features", []), ) latest["features"] = merged_features return latest @@ -979,16 +1109,15 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView): if not self.object.can_edit(user=self.request.user, request=self.request): return HttpResponseForbidden() - ius_header = self.request.META.get("HTTP_IF_UNMODIFIED_SINCE") - - if self.has_been_modified_since(ius_header): - merged = self.merge(ius_header) + reference_version = self.request.headers.get("X-Datalayer-Reference") + if self.has_changes_since(reference_version): + merged = self.merge(reference_version) if not merged: return HttpResponse(status=412) # Replace the uploaded file by the merged version. self.request.FILES["geojson"].file = BytesIO( - json.dumps(merged).encode("utf-8") + json_dumps(merged).encode("utf-8") ) # Mark the data to be reloaded by form_valid @@ -1002,8 +1131,7 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView): data["geojson"] = json.loads(self.object.geojson.read().decode()) self.request.session["needs_reload"] = False response = simple_json_response(**data) - - response["Last-Modified"] = self.last_modified + response["X-Datalayer-Version"] = self.version return response @@ -1098,8 +1226,6 @@ def webmanifest(request): def logout(request): do_logout(request) - if is_ajax(request): - return simple_json_response(redirect="/") return HttpResponseRedirect("/") diff --git a/umap/wsgi.py b/umap/wsgi.py index ea292782..8e367aa5 100644 --- a/umap/wsgi.py +++ b/umap/wsgi.py @@ -13,6 +13,7 @@ middleware here, or combine a Django application with an application of another framework. """ + import os os.environ.setdefault("DJANGO_SETTINGS_MODULE", "umap.settings")