Compare commits

...

37 commits

Author SHA1 Message Date
Yohan Boniface
78762de8cd chore: do not fail if S3 bucket does not handle versions
And mention the need of activating versioning in the doc.
2024-12-03 18:07:50 +01:00
Yohan Boniface
e3fbe0d55b chore: install s3 dep for develop/CI 2024-12-03 18:07:50 +01:00
Yohan Boniface
5084c11434 chore: add basic tests for S3 storage 2024-12-03 18:07:50 +01:00
Yohan Boniface
250d8c38d6 feat: support storing layer data in S3 like servers
fix #2290
2024-12-03 18:07:50 +01:00
Yohan Boniface
8f7e5c7252 chore: add missing import in cadastrefr
Some checks failed
Test & Docs / docs (push) Has been cancelled
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
2024-12-03 18:07:26 +01:00
Yohan Boniface
c9d532508d
chore: change cadastrefr buttons (#2333)
We remove the cancel button and change the accept label.
2024-12-03 17:56:57 +01:00
Yohan Boniface
6221b709f4
fix: importer.map is undefined in geodatamine importer (#2332)
Broken since the map split I guess.
2024-12-03 17:54:20 +01:00
Yohan Boniface
2b2580fa22
feat: swap import and settings buttons in edit toolbar (#2329)
fix #2297


![image](https://github.com/user-attachments/assets/2c7a98db-03dd-4cc2-afd3-1b5da3ae6964)
2024-12-03 17:53:51 +01:00
Yohan Boniface
26ff82e838 chore: change cadastrefr buttons
We remove the cancel button and change the accept label.
2024-12-03 16:24:30 +01:00
Yohan Boniface
8fa26a02a2 fix: importer.map is undefined
Broken this the map split I guess.
2024-12-03 16:21:37 +01:00
Yohan Boniface
9a900319af feat: swap import and settings buttons in edit toolbar
fix #2297
2024-12-03 15:20:22 +01:00
Yohan Boniface
e01a526935
chore: bump pytest-rerunfailures from 14.0 to 15.0 (#2326)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
2024-12-02 19:51:27 +01:00
Yohan Boniface
8569b827ca
fix: compute length of all shapes for MultiLineString (not only first) (#2310) 2024-12-02 19:46:45 +01:00
dependabot[bot]
2dab2f23b5
chore: bump pytest-rerunfailures from 14.0 to 15.0
Bumps [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) from 14.0 to 15.0.
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/14.0...15.0)

---
updated-dependencies:
- dependency-name: pytest-rerunfailures
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 18:43:58 +00:00
Yohan Boniface
f4ff1048bd
chore: bump pytest from 8.3.3 to 8.3.4 (#2325) 2024-12-02 19:42:49 +01:00
Yohan Boniface
6310cde28b
chore: bump djlint from 1.36.1 to 1.36.3 (#2324) 2024-12-02 19:41:34 +01:00
Yohan Boniface
666a92ec44 fix: compute length of all shapes for MultiLineString not only first 2024-12-02 19:39:57 +01:00
Yohan Boniface
2f776dab59
feat: highlight importer URL field when it is fulfilled (#2323)
Very small step of #2302 


![image](https://github.com/user-attachments/assets/0ff2c056-91ad-4daa-9882-dd44a4c6ef9f)
2024-12-02 19:04:04 +01:00
Yohan Boniface
bba9487847 feat: highlight importer URL field when it is fulfilled
cf #2302
2024-12-02 18:56:24 +01:00
dependabot[bot]
995052d83e
chore: bump pytest from 8.3.3 to 8.3.4
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.3 to 8.3.4.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 17:55:51 +00:00
dependabot[bot]
7e53e50b9c
chore: bump djlint from 1.36.1 to 1.36.3
Bumps [djlint](https://github.com/djlint/djLint) from 1.36.1 to 1.36.3.
- [Release notes](https://github.com/djlint/djLint/releases)
- [Changelog](https://github.com/djlint/djLint/blob/master/CHANGELOG.md)
- [Commits](https://github.com/djlint/djLint/compare/v1.36.1...v1.36.3)

---
updated-dependencies:
- dependency-name: djlint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 17:55:44 +00:00
Yohan Boniface
8e5f46eb95
chore: bump pydantic from 2.9.2 to 2.10.2 (#2321) 2024-12-02 17:20:35 +01:00
Yohan Boniface
0f8ebcdf9a
chore: bump pytest-playwright from 0.5.2 to 0.6.2 (#2320) 2024-12-02 17:20:17 +01:00
Yohan Boniface
177a4edc1d
fix: broken ctrl+i and ctrl+o (#2322) 2024-12-02 17:19:53 +01:00
Yohan Boniface
31c8bf95ba fix: broken ctrl+i and ctrl+o 2024-12-02 17:10:06 +01:00
Yohan Boniface
f6f42f5e6b chore: move form css to a separate file 2024-12-02 17:07:06 +01:00
Yohan Boniface
d9998efc0f
Fix reordering of layers (#2316)
Broken in the map split I guess.

(Only the first line is the fix, the other are just naming, cf the two
commits.)
2024-12-02 15:43:14 +01:00
Yohan Boniface
30d9e43cd4
Add logo for social_core.backends.keycloak.KeycloakOAuth2 (#2258)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
2024-12-02 13:14:06 +01:00
dependabot[bot]
c29df404c8
chore: bump pydantic from 2.9.2 to 2.10.2
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.9.2 to 2.10.2.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.9.2...v2.10.2)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 12:13:48 +00:00
dependabot[bot]
3f24563a05
chore: bump pytest-playwright from 0.5.2 to 0.6.2
Bumps [pytest-playwright](https://github.com/microsoft/playwright-pytest) from 0.5.2 to 0.6.2.
- [Release notes](https://github.com/microsoft/playwright-pytest/releases)
- [Commits](https://github.com/microsoft/playwright-pytest/compare/v0.5.2...v0.6.2)

---
updated-dependencies:
- dependency-name: pytest-playwright
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 12:12:48 +00:00
Yohan Boniface
64f0926e2d
chore: bump mkdocs-material from 9.5.44 to 9.5.47 (#2318) 2024-12-02 13:11:27 +01:00
Yohan Boniface
3aa0c8fc82
chore: bump ruff from 0.7.4 to 0.8.1 (#2319) 2024-12-02 13:11:10 +01:00
dependabot[bot]
9e2b207dfd
chore: bump ruff from 0.7.4 to 0.8.1
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.7.4 to 0.8.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.7.4...0.8.1)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 11:56:38 +00:00
Yohan Boniface
35b541f200 chore: better naming in layer reordering 2024-12-02 12:55:57 +01:00
Yohan Boniface
8624209e1b fix: iter on the right elements after reordering layers 2024-12-02 12:55:57 +01:00
dependabot[bot]
998bf87a0b
chore: bump mkdocs-material from 9.5.44 to 9.5.47
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.44 to 9.5.47.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.44...9.5.47)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 11:55:48 +00:00
Thomas Legay
4bd7bd7d48 Add logo for social_core.backends.keycloak.KeycloakOAuth2 2024-11-12 23:58:30 +01:00
30 changed files with 1198 additions and 833 deletions

View file

@ -7,7 +7,7 @@ install: ## Install the dependencies
.PHONY: develop .PHONY: develop
develop: ## Install the test and dev dependencies develop: ## Install the test and dev dependencies
python3 -m pip install -e .[test,dev,sync] python3 -m pip install -e .[test,dev,sync,s3]
playwright install playwright install
.PHONY: format .PHONY: format

View file

@ -1,5 +1,5 @@
# Force rtfd to use a recent version of mkdocs # Force rtfd to use a recent version of mkdocs
mkdocs==1.6.1 mkdocs==1.6.1
pymdown-extensions==10.12 pymdown-extensions==10.12
mkdocs-material==9.5.44 mkdocs-material==9.5.47
mkdocs-static-i18n==1.2.3 mkdocs-static-i18n==1.2.3

View file

@ -89,13 +89,6 @@ Running uMap / Django with a known SECRET_KEY defeats many of Djangos securit
See [Django documentation for SECRET_KEY](https://docs.djangoproject.com/en/4.2/ref/settings/#secret-key) See [Django documentation for SECRET_KEY](https://docs.djangoproject.com/en/4.2/ref/settings/#secret-key)
#### SITE_URL
The final URL of you instance, including the protocol:
`SITE_URL=http://umap.org`
#### SHORT_SITE_URL #### SHORT_SITE_URL
If you have a short domain for sharing links. If you have a short domain for sharing links.
@ -108,6 +101,13 @@ Eg.: `SHORT_SITE_URL=https://u.umap.org`
The name of the site, to be used in header and HTML title. The name of the site, to be used in header and HTML title.
#### SITE_URL
The final URL of you instance, including the protocol:
`SITE_URL=http://umap.org`
#### STATIC_ROOT #### STATIC_ROOT
Where uMap should store static files (CSS, JS…), must be consistent with your Where uMap should store static files (CSS, JS…), must be consistent with your
@ -115,6 +115,12 @@ Nginx configuration.
See [Django documentation for STATIC_ROOT](https://docs.djangoproject.com/en/4.2/ref/settings/#static-root) See [Django documentation for STATIC_ROOT](https://docs.djangoproject.com/en/4.2/ref/settings/#static-root)
#### STORAGES
See [storage](storage.md).
#### USE_I18N #### USE_I18N
Default is True. Set it to False if you don't want uMap to localize the app. Default is True. Set it to False if you don't want uMap to localize the app.

63
docs/config/storage.md Normal file
View file

@ -0,0 +1,63 @@
# Storage
uMap stores metadata (such as owner, permissions…) in PostgreSQL, and the data itself (the content of a layer)
in geojson format, by default on the local file system, but optionally in a S3 like server.
This can be configured through the `STORAGES` settings. uMap will use three keys:
- `default`, used only for the pictogram files, it can use whatever storage suits your needs
- `staticfiles`, used to store the static files, it can use whatever storage suits your needs,
but by default uses a custom storage that will add hash to the filenames, to be sure they
are not kept in any cache after a release
- `data`, used to store the layers data. This one should follow the uMap needs, and currently
uMap provides only two options: `umap.storage.UmapFileSystem` and `umap.storage.UmapS3`
## Default settings:
This will use the file system for everything, including the data.
```
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"data": {
"BACKEND": "umap.storage.UmapFileSystem",
},
"staticfiles": {
"BACKEND": "umap.storage.UmapManifestStaticFilesStorage",
},
}
```
## Using S3
To use an S3 like server for the layers data, the first thing is to install
the needed dependencies: `pip install umap-project[s3]`.
Then, change the `STORAGES` settings with something like this:
```
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"data": {
"BACKEND": "umap.storage.UmapS3",
"OPTIONS": {
"access_key": "xxx",
"secret_key": "yyy",
"bucket_name": "umap",
"region_name": "eu",
"endpoint_url": "http://127.0.0.1:9000",
},
},
"staticfiles": {
"BACKEND": "umap.storage.UmapManifestStaticFilesStorage",
},
}
```
In order to store old versions of a layer, the versioning should be activated in the bucket.
See more about the configuration on the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html).

View file

@ -1,5 +1,5 @@
# Force rtfd to use a recent version of mkdocs # Force rtfd to use a recent version of mkdocs
mkdocs==1.6.1 mkdocs==1.6.1
pymdown-extensions==10.12 pymdown-extensions==10.12
mkdocs-material==9.5.44 mkdocs-material==9.5.47
mkdocs-static-i18n==1.2.3 mkdocs-static-i18n==1.2.3

View file

@ -15,6 +15,7 @@ nav:
- Configuration: - Configuration:
- Settings: config/settings.md - Settings: config/settings.md
- Customize: config/customize.md - Customize: config/customize.md
- Storage: config/storage.md
- Icon packs: config/icons.md - Icon packs: config/icons.md
- Deployment: - Deployment:
- Docker: deploy/docker.md - Docker: deploy/docker.md

View file

@ -44,10 +44,10 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"hatch==1.13.0", "hatch==1.13.0",
"ruff==0.7.4", "ruff==0.8.1",
"djlint==1.36.1", "djlint==1.36.3",
"mkdocs==1.6.1", "mkdocs==1.6.1",
"mkdocs-material==9.5.44", "mkdocs-material==9.5.47",
"mkdocs-static-i18n==1.2.3", "mkdocs-static-i18n==1.2.3",
"vermin==1.6.0", "vermin==1.6.0",
"pymdown-extensions==10.12", "pymdown-extensions==10.12",
@ -56,19 +56,23 @@ dev = [
test = [ test = [
"factory-boy==3.3.1", "factory-boy==3.3.1",
"playwright>=1.39", "playwright>=1.39",
"pytest==8.3.3", "pytest==8.3.4",
"pytest-django==4.9.0", "pytest-django==4.9.0",
"pytest-playwright==0.5.2", "pytest-playwright==0.6.2",
"pytest-rerunfailures==14.0", "pytest-rerunfailures==15.0",
"pytest-xdist>=3.5.0,<4", "pytest-xdist>=3.5.0,<4",
"moto[s3]==5.0.21"
] ]
docker = [ docker = [
"uwsgi==2.0.28", "uwsgi==2.0.28",
] ]
s3 = [
"django-storages[s3]==1.14.4",
]
sync = [ sync = [
"channels==4.2.0", "channels==4.2.0",
"daphne==4.1.2", "daphne==4.1.2",
"pydantic==2.9.2", "pydantic==2.10.2",
"websockets==13.1", "websockets==13.1",
] ]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.1.2 on 2024-11-29 15:45
from django.db import migrations, models
import umap.models
class Migration(migrations.Migration):
dependencies = [
("umap", "0023_alter_datalayer_uuid"),
]
operations = [
migrations.AlterField(
model_name="datalayer",
name="geojson",
field=models.FileField(
blank=True,
null=True,
storage=umap.models.set_storage,
upload_to=umap.models.upload_to,
),
),
]

View file

@ -1,17 +1,12 @@
import json import json
import operator
import os
import shutil
import time
import uuid import uuid
from pathlib import Path
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.core.files.base import File from django.core.files.base import File
from django.core.files.storage import storages
from django.core.signing import Signer from django.core.signing import Signer
from django.template.defaultfilters import slugify
from django.urls import reverse from django.urls import reverse
from django.utils.functional import classproperty from django.utils.functional import classproperty
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -270,7 +265,7 @@ class Map(NamedModel):
umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url()) umapjson["uri"] = request.build_absolute_uri(self.get_absolute_url())
datalayers = [] datalayers = []
for datalayer in self.datalayer_set.all(): for datalayer in self.datalayer_set.all():
with open(datalayer.geojson.path, "rb") as f: with datalayer.geojson.open("rb") as f:
layer = json.loads(f.read()) layer = json.loads(f.read())
if datalayer.settings: if datalayer.settings:
layer["_umap_options"] = datalayer.settings layer["_umap_options"] = datalayer.settings
@ -423,10 +418,11 @@ class Pictogram(NamedModel):
# Must be out of Datalayer for Django migration to run, because of python 2 # Must be out of Datalayer for Django migration to run, because of python 2
# serialize limitations. # serialize limitations.
def upload_to(instance, filename): def upload_to(instance, filename):
if instance.pk: return instance.geojson.storage.make_filename(instance)
return instance.upload_to()
name = "%s.geojson" % slugify(instance.name)[:50] or "untitled"
return os.path.join(instance.storage_root(), name) def set_storage():
return storages["data"]
class DataLayer(NamedModel): class DataLayer(NamedModel):
@ -448,7 +444,9 @@ class DataLayer(NamedModel):
old_id = models.IntegerField(null=True, blank=True) old_id = models.IntegerField(null=True, blank=True)
map = models.ForeignKey(Map, on_delete=models.CASCADE) map = models.ForeignKey(Map, on_delete=models.CASCADE)
description = models.TextField(blank=True, null=True, verbose_name=_("description")) description = models.TextField(blank=True, null=True, verbose_name=_("description"))
geojson = models.FileField(upload_to=upload_to, blank=True, null=True) geojson = models.FileField(
upload_to=upload_to, blank=True, null=True, storage=set_storage
)
display_on_load = models.BooleanField( display_on_load = models.BooleanField(
default=False, default=False,
verbose_name=_("display on load"), verbose_name=_("display on load"),
@ -467,50 +465,14 @@ class DataLayer(NamedModel):
class Meta: class Meta:
ordering = ("rank",) ordering = ("rank",)
def save(self, force_insert=False, force_update=False, **kwargs): def save(self, **kwargs):
is_new = not bool(self.pk) super(DataLayer, self).save(**kwargs)
super(DataLayer, self).save( self.geojson.storage.onDatalayerSave(self)
force_insert=force_insert, force_update=force_update, **kwargs
)
if is_new:
force_insert, force_update = False, True
filename = self.upload_to()
old_name = self.geojson.name
new_name = self.geojson.storage.save(filename, self.geojson)
self.geojson.storage.delete(old_name)
self.geojson.name = new_name
super(DataLayer, self).save(
force_insert=force_insert, force_update=force_update, **kwargs
)
self.purge_gzip()
self.purge_old_versions()
def delete(self, **kwargs): def delete(self, **kwargs):
self.purge_gzip() self.geojson.storage.onDatalayerDelete(self)
self.to_purgatory()
return super().delete(**kwargs) return super().delete(**kwargs)
def to_purgatory(self):
dest = Path(settings.UMAP_PURGATORY_ROOT)
dest.mkdir(parents=True, exist_ok=True)
src = Path(self.geojson.storage.location) / self.storage_root()
for version in self.versions:
name = version["name"]
shutil.move(src / name, dest / f"{self.map.pk}_{name}")
def upload_to(self):
root = self.storage_root()
name = "%s_%s.geojson" % (self.pk, int(time.time() * 1000))
return os.path.join(root, name)
def storage_root(self):
path = ["datalayer", str(self.map.pk)[-1]]
if len(str(self.map.pk)) > 1:
path.append(str(self.map.pk)[-2])
path.append(str(self.map.pk))
return os.path.join(*path)
def metadata(self, request=None): def metadata(self, request=None):
# Retrocompat: minimal settings for maps not saved after settings property # Retrocompat: minimal settings for maps not saved after settings property
# has been introduced # has been introduced
@ -532,74 +494,23 @@ class DataLayer(NamedModel):
new.pk = uuid.uuid4() new.pk = uuid.uuid4()
if map_inst: if map_inst:
new.map = map_inst new.map = map_inst
new.geojson = File(new.geojson.file.file) new.geojson = File(new.geojson.file.file, name="tmpname")
new.save() new.save()
return new return new
def is_valid_version(self, name):
valid_prefixes = [name.startswith("%s_" % self.pk)]
if self.old_id:
valid_prefixes.append(name.startswith("%s_" % self.old_id))
return any(valid_prefixes) and name.endswith(".geojson")
def extract_version_number(self, path):
version = path.split(".")[0]
if "_" in version:
return version.split("_")[-1]
return version
@property @property
def reference_version(self): def reference_version(self):
return self.extract_version_number(self.geojson.path) return self.geojson.storage.get_reference_version(self)
def version_metadata(self, name):
return {
"name": name,
"at": self.extract_version_number(name),
"size": self.geojson.storage.size(self.get_version_path(name)),
}
@property @property
def versions(self): def versions(self):
root = self.storage_root() return self.geojson.storage.list_versions(self)
names = self.geojson.storage.listdir(root)[1]
names = [name for name in names if self.is_valid_version(name)]
versions = [self.version_metadata(name) for name in names]
versions.sort(reverse=True, key=operator.itemgetter("at"))
return versions
def get_version(self, name): def get_version(self, ref):
path = self.get_version_path(name) return self.geojson.storage.get_version(ref, self)
with self.geojson.storage.open(path, "r") as f:
return f.read()
def get_version_path(self, name): def get_version_path(self, ref):
return "{root}/{name}".format(root=self.storage_root(), name=name) return self.geojson.storage.get_version_path(ref, self)
def purge_old_versions(self):
root = self.storage_root()
versions = self.versions[settings.UMAP_KEEP_VERSIONS :]
for version in versions:
name = version["name"]
# Should not be in the list, but ensure to not delete the file
# currently used in database
if self.geojson.name.endswith(name):
continue
try:
self.geojson.storage.delete(os.path.join(root, name))
except FileNotFoundError:
pass
def purge_gzip(self):
root = self.storage_root()
names = self.geojson.storage.listdir(root)[1]
prefixes = [f"{self.pk}_"]
if self.old_id:
prefixes.append(f"{self.old_id}_")
prefixes = tuple(prefixes)
for name in names:
if name.startswith(prefixes) and name.endswith(".gz"):
self.geojson.storage.delete(os.path.join(root, name))
def can_edit(self, request=None): def can_edit(self, request=None):
""" """

View file

@ -175,6 +175,9 @@ STORAGES = {
"default": { "default": {
"BACKEND": "django.core.files.storage.FileSystemStorage", "BACKEND": "django.core.files.storage.FileSystemStorage",
}, },
"data": {
"BACKEND": "umap.storage.UmapFileSystem",
},
"staticfiles": { "staticfiles": {
"BACKEND": "umap.storage.UmapManifestStaticFilesStorage", "BACKEND": "umap.storage.UmapManifestStaticFilesStorage",
}, },

View file

@ -161,609 +161,6 @@ dt {
text-align: center; text-align: center;
} }
/* *********** */
/* forms */
/* *********** */
input[type="text"], input[type="password"], input[type="date"],
input[type="datetime-local"], input[type="email"], input[type="number"],
input[type="search"], input[type="tel"], input[type="time"], input[type="file"],
input[type="url"], textarea {
background-color: white;
border: 1px solid #CCCCCC;
border-radius: 2px 2px 2px 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) inset;
color: rgba(0, 0, 0, 0.75);
display: block;
font-family: inherit;
margin: 0;
margin-bottom: var(--box-margin);
padding: 7px;
width: 100%;
}
input[type="range"] {
margin-top: 10px;
margin-bottom: 5px;
width: 100%;
}
input[type="radio"] {
margin-inline-end: var(--text-margin);
}
input[type="checkbox"] {
margin: 0 var(--text-margin);
vertical-align: middle;
appearance: none;
}
input[type="checkbox"]:after {
display: inline-block;
content: ' ';
width: 12px;
height: 12px;
border: 1px solid var(--color-lightGray);
cursor: pointer;
text-align: center;
font-size: 1rem;
line-height: 0.8rem;
}
input[type=checkbox]:checked:after {
background-color: var(--color-lightCyan);
content: '✓';
color: var(--color-darkGray);
}
input[data-modified=true] {
background-color: var(--color-lightCyan);
border: 1px solid var(--color-darkGray);
}
textarea {
height: inherit;
padding: 7px;
min-height: 15rem;
min-height: 6rlh;
}
select {
border: 1px solid #222;
width: 100%;
height: 28px;
line-height: 28px;
margin-top: 5px;
margin-bottom: var(--box-margin);
}
.dark select {
color: #efefef;
background-color: #393F3F;
}
select[multiple="multiple"] {
height: auto;
}
.button,
[type="button"],
input[type="submit"] {
display: block;
margin-bottom: 14px;
text-align: center;
border-radius: 2px;
font-weight: normal;
cursor: pointer;
padding: 7px;
width: 100%;
min-height: 32px;
line-height: 32px;
border: none;
text-decoration: none;
background-color: white;
}
.dark .button,
.dark [type="button"] {
background-color: var(--color-darkerGray);
color: var(--text-color);
border: 1px solid #1b1f20;
}
.dark .button:hover,
.dark [type="button"]:hover,
.dark input[type="submit"]:hover {
background-color: #2e3436;
}
.dark a {
color: var(--text-color);
}
button.flat,
[type="button"].flat,
.dark [type="button"].flat {
border: none;
background-color: inherit;
padding: 0;
text-align: start;
min-height: inherit;
width: initial;
display: initial;
line-height: inherit;
color: var(--text-color);
}
button.flat:hover,
[type="button"].flat:hover,
.dark [type="button"].flat:hover {
text-decoration: underline;
}
.help-text, .helptext {
display: block;
padding: 7px 7px;
margin-bottom: 14px;
background: #393F3F;
color: var(--color-lightGray);
font-size: 10px;
border-radius: 0 2px;
}
.content .helptext {
background-color: #eee;
color: #000;
}
input + .help-text {
margin-top: -14px;
}
.formbox {
min-height: 36px;
margin-bottom: 14px;
}
.formbox.with-switch {
padding-top: 2px;
}
fieldset.formbox {
border: none;
border-top: 1px solid var(--color-lightGray);
}
label {
display: block;
font-size: 12px;
line-height: 21px;
width: 100%;
}
label + label {
margin-top: var(--box-margin);
}
.content label {
font-weight: bold;
}
input[type="checkbox"] + label {
display: inline;
padding: 0 14px;
}
select + .error,
input + .error {
display: block;
padding: 7px 7px;
margin-top: -14px;
margin-bottom: 14px;
background: var(--color-lightGray);
color: #fff;
background-color: #cc0000;
font-size: 11px;
border-radius: 0 2px;
}
input[type="file"] + .error {
margin-top: 0;
}
input[value]:invalid {
border-color: red;
background-color: darkred;
}
.dark input, .dark textarea {
background-color: #232729;
border-color: #1b1f20;
color: #efefef;
}
details {
margin-bottom: 5px;
border-start-start-radius: 4px;
border-start-end-radius: 4px;
}
.dark details {
border: 1px solid #222;
}
details fieldset {
overflow: hidden;
border: 1px solid var(--color-lightGray);
margin: 0;
padding-top: 10px;
}
details summary {
cursor: pointer;
background-color: var(--color-lightGray);
line-height: 30px;
font-size: 1.2em;
padding: 0 5px;
}
.dark details summary {
background-color: #232729;
color: #fff;
}
.dark details fieldset {
border: 1px solid var(--color-darkGray);
}
fieldset legend {
font-size: .9rem;
padding: 0 5px;
}
fieldset.separator {
border: none;
border-top: 1px solid var(--color-lightGray);
}
[data-badge] {
position: relative;
}
[data-badge]:after {
position: absolute;
inset-inline-end: -6px;
top: -6px;
min-width: 8px;
min-height: 8px;
line-height: 8px;
padding: 2px;
font-weight: bold;
background-color: var(--color-accent);
color: var(--color-darkBlue);
text-align: center;
font-size: .75rem;
border-radius: 50%;
content: attr(data-badge);
}
[hidden] {
display: none!important;
}
/* Switch */
input.switch:empty {
display: none;
}
input.switch:empty ~ label {
white-space: nowrap;
position: relative;
float: inline-start;
line-height: 2em;
height: 2em;
text-indent: 6em;
margin: 0.2em 0;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-shadow: 0 1px rgba(0, 0, 0, 0.1);
width: 80px;
}
input.switch:empty ~ label:before,
input.switch:empty ~ label:after {
position: absolute;
display: block;
top: 0;
bottom: 0;
inset-inline-start: 0;
content: ' ';
width: 6em;
-webkit-transition: all 100ms ease-in;
transition: all 100ms ease-in;
color: #c9c9c7;
font-weight: bold;
background-color: #ededed;
}
.dark input.switch:empty ~ label:before,
.dark input.switch:empty ~ label:after {
background-color: #272c2e;
}
input.switch:empty ~ label:after {
width: 3em;
margin-inline-start: 0.1em;
background-color: #ededed;
content: "OFF";
text-indent: 3.5em;
border: 1px solid #374E75;
font-weight: bold;
}
.dark input.switch:empty ~ label:after {
border: 1px solid #202425;
background-color: #2c3233;
}
input.switch:checked:empty ~ label:after {
content: ' ';
}
.dark input.switch:checked ~ label:before,
input.switch:checked ~ label:before {
background-color: var(--color-lightCyan);
border: 1px solid var(--color-lightGray);
color: var(--color-darkGray);
content: "ON";
text-indent: 0.7em;
text-align: start;
font-weight: bold;
}
.dark input.switch:checked ~ label:before {
border: none;
background-color: var(--color-accent);
}
input.switch:checked ~ label:after {
margin-inline-start: 3em;
}
.button-bar, .umap-multiplechoice {
margin-top: 5px;
text-align: center;
display: grid;
width: 100%
}
.button-bar {
grid-gap: 7px;
}
.umap-multiplechoice.by2,
.button-bar.half {
grid-template-columns: 1fr 1fr;
}
.button-bar.by3,
.button-bar.by5,
.button-bar.by6,
.umap-multiplechoice.by3,
.umap-multiplechoice.by5,
.umap-multiplechoice.by6 {
grid-template-columns: 1fr 1fr 1fr;
}
.button-bar.by4,
.umap-multiplechoice.by4 {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
.button-bar .button,
.button-bar [type="button"] {
display: inline-block;
}
.umap-multiplechoice input[type='radio'] {
display: none;
}
.umap-multiplechoice label {
border: 1px solid #374E75;
cursor: pointer;
background-color: #c9c9c7;
min-height: 30px;
line-height: 30px;
text-align: center;
width: 100%;
display: inline-block;
}
.dark .umap-multiplechoice label {
border: 1px solid black;
background-color: #2c3233;
}
.umap-multiplechoice input[type='radio']:checked + label {
background-color: var(--color-accent);
box-shadow: inset 0 0 6px 0px #2c3233;
color: var(--color-darkGray);
}
.inheritable .header,
.inheritable {
clear: both;
overflow: hidden;
}
.inheritable .header {
margin-bottom: 5px;
}
.inheritable .header label {
padding-top: 6px;
}
.inheritable + .inheritable {
border-top: 1px solid #222;
padding-top: 5px;
margin-top: 5px;
}
.umap-field-iconUrl .action-button,
.inheritable .define,
.inheritable .undefine {
float: inline-end;
width: initial;
min-height: 18px;
line-height: 18px;
margin-bottom: 0;
}
.inheritable .quick-actions {
float: inline-end;
}
.inheritable .quick-actions .formbox {
margin-bottom: 0;
}
.inheritable .quick-actions input {
width: 100px;
margin-inline-end: 5px;
}
.inheritable .define,
.inheritable.undefined .undefine,
.inheritable.undefined .show-on-defined {
display: none;
}
.inheritable.undefined .define {
display: block;
}
i.info {
background-repeat: no-repeat;
background-image: url('./img/16.svg');
background-position: -170px -50px;
display: inline-block;
margin-inline-start: 5px;
vertical-align: middle;
width: 16px;
height: 18px;
}
.dark i.info {
background-image: url('./img/16-white.svg');
}
.with-transition {
transition: all .7s;
}
.umap-empty:before, .umap-to-polygon:before,
.umap-clone:before, .umap-edit:before, .umap-download:before,
.umap-to-polyline:before {
background-repeat: no-repeat;
text-indent: 36px;
height: 24px;
line-height: 24px;
display: inline-block;
background-image: url('./img/24.svg');
vertical-align: bottom;
content: " ";
}
.dark .umap-empty:before,
.dark .umap-to-polygon:before,
.dark .umap-clone:before,
.dark .umap-edit:before, .dark .umap-download:before,
.dark .umap-to-polyline:before {
background-image: url('./img/24-white.svg');
vertical-align: middle;
}
.umap-to-polygon:before {
background-position: -72px -42px;
}
.umap-to-polyline:before {
background-position: -106px -42px;
}
.umap-clone:before {
background-position: -144px -78px;
}
.umap-empty:before {
background-position: -108px -78px;
}
.umap-download:before {
background-position: -72px -78px;
}
.permissions-panel,
.umap-upload,
.umap-share,
.umap-datalayer-container,
.umap-layer-properties-container,
.umap-browse-data,
.umap-tilelayer-switcher-container {
padding: 0 10px;
}
.umap-field-datalist {
display: flex;
justify-content: space-between;
font-size: 9px;
margin-top: -8px;
padding: 0 5px;
}
.flat-tabs {
display: flex;
justify-content: space-around;
font-size: 1.2em;
margin-bottom: 20px;
border-bottom: 1px solid #bebebe;
}
.flat-tabs button {
padding: 10px;
text-decoration: none;
cursor: pointer;
border-bottom: 1px solid transparent;
}
.flat-tabs button:hover,
.flat-tabs .on {
font-weight: bold;
border-bottom: 1px solid #444;
}
.dark .flat-tabs button {
color: #fff;
}
.dark .flat-tabs button:hover,
.dark .flat-tabs .on {
border-bottom: 1px solid #fff;
}
.umap-pictogram-category h6 {
font-size: 1.3em;
}
.umap-pictogram-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 30px);
justify-content: space-between;
grid-gap: 5px;
}
.umap-pictogram-choice {
width: 30px;
height: 30px;
line-height: 30px;
cursor: pointer;
background-color: #999;
text-align: center;
margin-bottom: 5px;
display: block;
color: black;
font-weight: bold;
}
.umap-pictogram-choice img {
vertical-align: middle;
max-width: 24px;
}
.umap-pictogram-choice:hover,
.umap-color-picker span:hover {
background-color: #bebebe;
}
.umap-pictogram-choice.selected {
box-shadow: inset 0 0 0 1px #e9e9e9;
}
.umap-pictogram-choice .leaflet-marker-icon {
bottom: 0;
inset-inline-start: 30px;
position: absolute;
}
.umap-color-picker {
clear: both;
margin-bottom: 20px;
overflow: hidden;
display: none;
}
.umap-color-picker span {
width: 20px;
height: 20px;
display: block;
padding: 0;
margin: 0;
cursor: pointer;
float: inline-start;
}
input.blur {
width: calc(100% - 40px);
display: inline-block;
vertical-align: middle;
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.blur + .button:before,
.blur + [type="button"]:before {
content: '✔';
}
.blur + .button,
.blur + [type="button"] {
width: 40px;
height: 18px;
display: inline-block;
vertical-align: middle;
line-height: 18px;
border-start-start-radius: 0;
border-end-start-radius: 0;
box-sizing: border-box;
}
input[type=hidden].blur + .button,
input[type=hidden].blur + [type="button"] {
display: none;
}
.copiable-input {
display: flex;
align-items: end;
}
.copiable-input input {
border-radius: initial;
}
.copiable-input button {
background-position: -46px -92px;
display: inline;
padding: 0 10px;
height: 32px;
width: 32px;
border: 1px solid #202425;
border-radius: initial;
}
/* *********** */ /* *********** */
/* Panel */ /* Panel */

View file

@ -65,7 +65,9 @@ body.login header {
.login-grid .login-openstreetmap-oauth2 { .login-grid .login-openstreetmap-oauth2 {
background-image: url("./openstreetmap.png"); background-image: url("./openstreetmap.png");
} }
.login-grid .login-keycloak {
background-image: url("./keycloak.png");
}
/* **************************** */ /* **************************** */
/* home */ /* home */

View file

@ -0,0 +1,604 @@
input[type="text"], input[type="password"], input[type="date"],
input[type="datetime-local"], input[type="email"], input[type="number"],
input[type="search"], input[type="tel"], input[type="time"], input[type="file"],
input[type="url"], textarea {
background-color: white;
border: 1px solid #CCCCCC;
border-radius: 2px 2px 2px 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) inset;
color: rgba(0, 0, 0, 0.75);
display: block;
font-family: inherit;
margin: 0;
margin-bottom: var(--box-margin);
padding: 7px;
width: 100%;
}
input[type="range"] {
margin-top: 10px;
margin-bottom: 5px;
width: 100%;
}
input[type="radio"] {
margin-inline-end: var(--text-margin);
}
input[type="checkbox"] {
margin: 0 var(--text-margin);
vertical-align: middle;
appearance: none;
}
input[type="checkbox"]:after {
display: inline-block;
content: ' ';
width: 12px;
height: 12px;
border: 1px solid var(--color-lightGray);
cursor: pointer;
text-align: center;
font-size: 1rem;
line-height: 0.8rem;
}
input[type=checkbox]:checked:after {
background-color: var(--color-lightCyan);
content: '✓';
color: var(--color-darkGray);
}
input[data-modified=true] {
background-color: var(--color-lightCyan);
border: 1px solid var(--color-darkGray);
}
textarea {
height: inherit;
padding: 7px;
min-height: 15rem;
min-height: 6rlh;
}
select {
border: 1px solid #222;
width: 100%;
height: 28px;
line-height: 28px;
margin-top: 5px;
margin-bottom: var(--box-margin);
}
.dark select {
color: #efefef;
background-color: #393F3F;
}
select[multiple="multiple"] {
height: auto;
}
.button,
[type="button"],
input[type="submit"] {
display: block;
margin-bottom: 14px;
text-align: center;
border-radius: 2px;
font-weight: normal;
cursor: pointer;
padding: 7px;
width: 100%;
min-height: 32px;
line-height: 32px;
border: none;
text-decoration: none;
background-color: white;
}
.dark .button,
.dark [type="button"] {
background-color: var(--color-darkerGray);
color: var(--text-color);
border: 1px solid #1b1f20;
}
.dark .button:hover,
.dark [type="button"]:hover,
.dark input[type="submit"]:hover {
background-color: #2e3436;
}
.dark a {
color: var(--text-color);
}
button.flat,
[type="button"].flat,
.dark [type="button"].flat {
border: none;
background-color: inherit;
padding: 0;
text-align: start;
min-height: inherit;
width: initial;
display: initial;
line-height: inherit;
color: var(--text-color);
}
button.flat:hover,
[type="button"].flat:hover,
.dark [type="button"].flat:hover {
text-decoration: underline;
}
.help-text, .helptext {
display: block;
padding: 7px 7px;
margin-bottom: 14px;
background: #393F3F;
color: var(--color-lightGray);
font-size: 10px;
border-radius: 0 2px;
}
.content .helptext {
background-color: #eee;
color: #000;
}
input + .help-text {
margin-top: -14px;
}
.formbox {
min-height: 36px;
margin-bottom: 14px;
}
.formbox.with-switch {
padding-top: 2px;
}
fieldset.formbox {
border: none;
border-top: 1px solid var(--color-lightGray);
}
label {
display: block;
font-size: 12px;
line-height: 21px;
width: 100%;
}
label + label {
margin-top: var(--box-margin);
}
.content label {
font-weight: bold;
}
input[type="checkbox"] + label {
display: inline;
padding: 0 14px;
}
select + .error,
input + .error {
display: block;
padding: 7px 7px;
margin-top: -14px;
margin-bottom: 14px;
background: var(--color-lightGray);
color: #fff;
background-color: #cc0000;
font-size: 11px;
border-radius: 0 2px;
}
input[type="file"] + .error {
margin-top: 0;
}
input[value]:invalid {
border-color: red;
background-color: darkred;
}
.dark input, .dark textarea {
background-color: #232729;
border-color: #1b1f20;
color: #efefef;
}
details {
margin-bottom: 5px;
border-start-start-radius: 4px;
border-start-end-radius: 4px;
}
.dark details {
border: 1px solid #222;
}
details fieldset {
overflow: hidden;
border: 1px solid var(--color-lightGray);
margin: 0;
padding-top: 10px;
}
details summary {
cursor: pointer;
background-color: var(--color-lightGray);
line-height: 30px;
font-size: 1.2em;
padding: 0 5px;
}
.dark details summary {
background-color: #232729;
color: #fff;
}
.dark details fieldset {
border: 1px solid var(--color-darkGray);
}
fieldset legend {
font-size: .9rem;
padding: 0 5px;
}
fieldset.separator {
border: none;
border-top: 1px solid var(--color-lightGray);
}
[data-badge] {
position: relative;
}
[data-badge]:after {
position: absolute;
inset-inline-end: -6px;
top: -6px;
min-width: 8px;
min-height: 8px;
line-height: 8px;
padding: 2px;
font-weight: bold;
background-color: var(--color-accent);
color: var(--color-darkBlue);
text-align: center;
font-size: .75rem;
border-radius: 50%;
content: attr(data-badge);
}
[hidden] {
display: none!important;
}
/* Switch */
input.switch:empty {
display: none;
}
input.switch:empty ~ label {
white-space: nowrap;
position: relative;
float: inline-start;
line-height: 2em;
height: 2em;
text-indent: 6em;
margin: 0.2em 0;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-shadow: 0 1px rgba(0, 0, 0, 0.1);
width: 80px;
}
input.switch:empty ~ label:before,
input.switch:empty ~ label:after {
position: absolute;
display: block;
top: 0;
bottom: 0;
inset-inline-start: 0;
content: ' ';
width: 6em;
-webkit-transition: all 100ms ease-in;
transition: all 100ms ease-in;
color: #c9c9c7;
font-weight: bold;
background-color: #ededed;
}
.dark input.switch:empty ~ label:before,
.dark input.switch:empty ~ label:after {
background-color: #272c2e;
}
input.switch:empty ~ label:after {
width: 3em;
margin-inline-start: 0.1em;
background-color: #ededed;
content: "OFF";
text-indent: 3.5em;
border: 1px solid #374E75;
font-weight: bold;
}
.dark input.switch:empty ~ label:after {
border: 1px solid #202425;
background-color: #2c3233;
}
input.switch:checked:empty ~ label:after {
content: ' ';
}
.dark input.switch:checked ~ label:before,
input.switch:checked ~ label:before {
background-color: var(--color-lightCyan);
border: 1px solid var(--color-lightGray);
color: var(--color-darkGray);
content: "ON";
text-indent: 0.7em;
text-align: start;
font-weight: bold;
}
.dark input.switch:checked ~ label:before {
border: none;
background-color: var(--color-accent);
}
input.switch:checked ~ label:after {
margin-inline-start: 3em;
}
.button-bar, .umap-multiplechoice {
margin-top: 5px;
text-align: center;
display: grid;
width: 100%
}
.button-bar {
grid-gap: 7px;
}
.umap-multiplechoice.by2,
.button-bar.half {
grid-template-columns: 1fr 1fr;
}
.button-bar.by3,
.button-bar.by5,
.button-bar.by6,
.umap-multiplechoice.by3,
.umap-multiplechoice.by5,
.umap-multiplechoice.by6 {
grid-template-columns: 1fr 1fr 1fr;
}
.button-bar.by4,
.umap-multiplechoice.by4 {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
.button-bar .button,
.button-bar [type="button"] {
display: inline-block;
}
.umap-multiplechoice input[type='radio'] {
display: none;
}
.umap-multiplechoice label {
border: 1px solid #374E75;
cursor: pointer;
background-color: #c9c9c7;
min-height: 30px;
line-height: 30px;
text-align: center;
width: 100%;
display: inline-block;
}
.dark .umap-multiplechoice label {
border: 1px solid black;
background-color: #2c3233;
}
.umap-multiplechoice input[type='radio']:checked + label {
background-color: var(--color-accent);
box-shadow: inset 0 0 6px 0px #2c3233;
color: var(--color-darkGray);
}
.inheritable .header,
.inheritable {
clear: both;
overflow: hidden;
}
.inheritable .header {
margin-bottom: 5px;
}
.inheritable .header label {
padding-top: 6px;
}
.inheritable + .inheritable {
border-top: 1px solid #222;
padding-top: 5px;
margin-top: 5px;
}
.umap-field-iconUrl .action-button,
.inheritable .define,
.inheritable .undefine {
float: inline-end;
width: initial;
min-height: 18px;
line-height: 18px;
margin-bottom: 0;
}
.inheritable .quick-actions {
float: inline-end;
}
.inheritable .quick-actions .formbox {
margin-bottom: 0;
}
.inheritable .quick-actions input {
width: 100px;
margin-inline-end: 5px;
}
.inheritable .define,
.inheritable.undefined .undefine,
.inheritable.undefined .show-on-defined {
display: none;
}
.inheritable.undefined .define {
display: block;
}
i.info {
background-repeat: no-repeat;
background-image: url('../img/16.svg');
background-position: -170px -50px;
display: inline-block;
margin-inline-start: 5px;
vertical-align: middle;
width: 16px;
height: 18px;
}
.dark i.info {
background-image: url('../img/16-white.svg');
}
.with-transition {
transition: all .7s;
}
.umap-empty:before, .umap-to-polygon:before,
.umap-clone:before, .umap-edit:before, .umap-download:before,
.umap-to-polyline:before {
background-repeat: no-repeat;
text-indent: 36px;
height: 24px;
line-height: 24px;
display: inline-block;
background-image: url('../img/24.svg');
vertical-align: bottom;
content: " ";
}
.dark .umap-empty:before,
.dark .umap-to-polygon:before,
.dark .umap-clone:before,
.dark .umap-edit:before, .dark .umap-download:before,
.dark .umap-to-polyline:before {
background-image: url('../img/24-white.svg');
vertical-align: middle;
}
.umap-to-polygon:before {
background-position: -72px -42px;
}
.umap-to-polyline:before {
background-position: -106px -42px;
}
.umap-clone:before {
background-position: -144px -78px;
}
.umap-empty:before {
background-position: -108px -78px;
}
.umap-download:before {
background-position: -72px -78px;
}
.permissions-panel,
.umap-upload,
.umap-share,
.umap-datalayer-container,
.umap-layer-properties-container,
.umap-browse-data,
.umap-tilelayer-switcher-container {
padding: 0 10px;
}
.umap-field-datalist {
display: flex;
justify-content: space-between;
font-size: 9px;
margin-top: -8px;
padding: 0 5px;
}
.flat-tabs {
display: flex;
justify-content: space-around;
font-size: 1.2em;
margin-bottom: 20px;
border-bottom: 1px solid #bebebe;
}
.flat-tabs button {
padding: 10px;
text-decoration: none;
cursor: pointer;
border-bottom: 1px solid transparent;
}
.flat-tabs button:hover,
.flat-tabs .on {
font-weight: bold;
border-bottom: 1px solid #444;
}
.dark .flat-tabs button {
color: #fff;
}
.dark .flat-tabs button:hover,
.dark .flat-tabs .on {
border-bottom: 1px solid #fff;
}
.umap-pictogram-category h6 {
font-size: 1.3em;
}
.umap-pictogram-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 30px);
justify-content: space-between;
grid-gap: 5px;
}
.umap-pictogram-choice {
width: 30px;
height: 30px;
line-height: 30px;
cursor: pointer;
background-color: #999;
text-align: center;
margin-bottom: 5px;
display: block;
color: black;
font-weight: bold;
}
.umap-pictogram-choice img {
vertical-align: middle;
max-width: 24px;
}
.umap-pictogram-choice:hover,
.umap-color-picker span:hover {
background-color: #bebebe;
}
.umap-pictogram-choice.selected {
box-shadow: inset 0 0 0 1px #e9e9e9;
}
.umap-pictogram-choice .leaflet-marker-icon {
bottom: 0;
inset-inline-start: 30px;
position: absolute;
}
.umap-color-picker {
clear: both;
margin-bottom: 20px;
overflow: hidden;
display: none;
}
.umap-color-picker span {
width: 20px;
height: 20px;
display: block;
padding: 0;
margin: 0;
cursor: pointer;
float: inline-start;
}
input.blur {
width: calc(100% - 40px);
display: inline-block;
vertical-align: middle;
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.blur + .button:before,
.blur + [type="button"]:before {
content: '✔';
}
.blur + .button,
.blur + [type="button"] {
width: 40px;
height: 18px;
display: inline-block;
vertical-align: middle;
line-height: 18px;
border-start-start-radius: 0;
border-end-start-radius: 0;
box-sizing: border-box;
}
input[type=hidden].blur + .button,
input[type=hidden].blur + [type="button"] {
display: none;
}
.copiable-input {
display: flex;
align-items: end;
}
.copiable-input input {
border-radius: initial;
}
.copiable-input button {
background-position: -46px -92px;
display: inline;
padding: 0 10px;
height: 32px;
width: 32px;
border: 1px solid #202425;
border-radius: initial;
}
input.highlightable:not(:placeholder-shown) {
border: 1px solid var(--color-brightCyan);
}

View file

@ -540,11 +540,11 @@ export class DataLayer extends ServerStored {
}) })
} }
getVersionUrl(name) { getVersionUrl(ref) {
return this._umap.urls.get('datalayer_version', { return this._umap.urls.get('datalayer_version', {
pk: this.id, pk: this.id,
map_id: this._umap.id, map_id: this._umap.id,
name: name, ref: ref,
}) })
} }
@ -861,13 +861,7 @@ export class DataLayer extends ServerStored {
const date = new Date(Number.parseInt(data.at, 10)) const date = new Date(Number.parseInt(data.at, 10))
const content = `${date.toLocaleString(U.lang)} (${Number.parseInt(data.size) / 1000}Kb)` const content = `${date.toLocaleString(U.lang)} (${Number.parseInt(data.size) / 1000}Kb)`
const el = DomUtil.create('div', 'umap-datalayer-version', versionsContainer) const el = DomUtil.create('div', 'umap-datalayer-version', versionsContainer)
const button = DomUtil.createButton( const button = DomUtil.createButton('', el, '', () => this.restore(data.ref))
'',
el,
'',
() => this.restore(data.name),
this
)
button.title = translate('Restore this version') button.title = translate('Restore this version')
DomUtil.add('span', '', el, content) DomUtil.add('span', '', el, content)
} }

View file

@ -10,7 +10,7 @@ const TEMPLATE = `
<fieldset class="formbox"> <fieldset class="formbox">
<legend class="counter">${translate('Choose data')}</legend> <legend class="counter">${translate('Choose data')}</legend>
<input type="file" multiple autofocus onchange /> <input type="file" multiple autofocus onchange />
<input type="url" placeholder="${translate('Provide an URL here')}" onchange /> <input class="highlightable" type="url" placeholder="${translate('Provide an URL here')}" onchange />
<textarea onchange placeholder="${translate('Paste your data here')}"></textarea> <textarea onchange placeholder="${translate('Paste your data here')}"></textarea>
<div class="importers" hidden> <div class="importers" hidden>
<h4>${translate('Import helpers:')}</h4> <h4>${translate('Import helpers:')}</h4>

View file

@ -2,6 +2,8 @@ import { DomUtil } from '../../../vendors/leaflet/leaflet-src.esm.js'
import { BaseAjax, SingleMixin } from '../autocomplete.js' import { BaseAjax, SingleMixin } from '../autocomplete.js'
import * as Util from '../utils.js' import * as Util from '../utils.js'
import { AutocompleteCommunes } from './communesfr.js' import { AutocompleteCommunes } from './communesfr.js'
import { translate } from '../i18n.js'
import { uMapAlert as Alert } from '../../components/alerts/alert.js'
const TEMPLATE = ` const TEMPLATE = `
<h3>Cadastre</h3> <h3>Cadastre</h3>
@ -56,6 +58,8 @@ export class Importer {
.open({ .open({
template: container, template: container,
className: `${this.id} importer dark`, className: `${this.id} importer dark`,
cancel: false,
accept: translate('Choose this data'),
}) })
.then(confirm) .then(confirm)
} }

View file

@ -37,8 +37,8 @@ class Autocomplete extends SingleMixin(BaseAjax) {
} }
export class Importer { export class Importer {
constructor(map, options = {}) { constructor(umap, options = {}) {
this.map = map this.umap = umap
this.name = options.name || 'GeoDataMine' this.name = options.name || 'GeoDataMine'
this.baseUrl = options?.url || 'https://geodatamine.fr' this.baseUrl = options?.url || 'https://geodatamine.fr'
this.id = 'geodatamine' this.id = 'geodatamine'
@ -49,7 +49,7 @@ export class Importer {
let boundaryName = null let boundaryName = null
const container = DomUtil.create('div') const container = DomUtil.create('div')
container.innerHTML = TEMPLATE container.innerHTML = TEMPLATE
const response = await importer.map.request.get(`${this.baseUrl}/themes`) const response = await this.umap.request.get(`${this.baseUrl}/themes`)
const select = container.querySelector('select') const select = container.querySelector('select')
if (response?.ok) { if (response?.ok) {
const { themes } = await response.json() const { themes } = await response.json()

View file

@ -44,12 +44,12 @@ const ControlsMixin = {
new U.DrawToolbar({ map: this }).addTo(this) new U.DrawToolbar({ map: this }).addTo(this)
const editActions = [ const editActions = [
U.EditCaptionAction, U.EditCaptionAction,
U.EditPropertiesAction, U.ImportAction,
U.EditLayersAction, U.EditLayersAction,
U.ChangeTileLayerAction, U.ChangeTileLayerAction,
U.UpdateExtentAction, U.UpdateExtentAction,
U.UpdatePermsAction, U.UpdatePermsAction,
U.ImportAction, U.EditPropertiesAction,
] ]
if (this.options.editMode === 'advanced') { if (this.options.editMode === 'advanced') {
new U.SettingsToolbar({ actions: editActions }).addTo(this) new U.SettingsToolbar({ actions: editActions }).addTo(this)

View file

@ -255,7 +255,10 @@ const PathMixin = {
if (this._map.measureTools?.enabled()) { if (this._map.measureTools?.enabled()) {
this._map._umap.tooltip.open({ content: this.getMeasure(), anchor: this }) this._map._umap.tooltip.open({ content: this.getMeasure(), anchor: this })
} else if (this._map._umap.editEnabled && !this._map._umap.editedFeature) { } else if (this._map._umap.editEnabled && !this._map._umap.editedFeature) {
this._map._umap.tooltip.open({ content: translate('Click to edit'), anchor: this }) this._map._umap.tooltip.open({
content: translate('Click to edit'),
anchor: this,
})
} }
}, },
@ -267,7 +270,9 @@ const PathMixin = {
this._map.once('moveend', this.makeGeometryEditable, this) this._map.once('moveend', this.makeGeometryEditable, this)
const pointsCount = this._parts.reduce((acc, part) => acc + part.length, 0) const pointsCount = this._parts.reduce((acc, part) => acc + part.length, 0)
if (pointsCount > 100 && this._map.getZoom() < this._map.getMaxZoom()) { if (pointsCount > 100 && this._map.getZoom() < this._map.getMaxZoom()) {
this._map._umap.tooltip.open({ content: L._('Please zoom in to edit the geometry') }) this._map._umap.tooltip.open({
content: L._('Please zoom in to edit the geometry'),
})
this.disableEdit() this.disableEdit()
} else { } else {
this.enableEdit() this.enableEdit()
@ -380,8 +385,19 @@ export const LeafletPolyline = Polyline.extend({
}, },
getMeasure: function (shape) { getMeasure: function (shape) {
let shapes
if (shape) {
shapes = [shape]
} else if (LineUtil.isFlat(this._latlngs)) {
shapes = [this._latlngs]
} else {
shapes = this._latlngs
}
// FIXME: compute from data in feature (with TurfJS) // FIXME: compute from data in feature (with TurfJS)
const length = L.GeoUtil.lineLength(this._map, shape || this._defaultShape()) const length = shapes.reduce(
(acc, shape) => acc + L.GeoUtil.lineLength(this._map, shape),
0
)
return L.GeoUtil.readableDistance(length, this._map.measureTools.getMeasureUnit()) return L.GeoUtil.readableDistance(length, this._map.measureTools.getMeasureUnit())
}, },
}) })

View file

@ -537,10 +537,10 @@ export default class Umap extends ServerStored {
this._leafletMap.editTools.startPolyline() this._leafletMap.editTools.startPolyline()
break break
case 'i': case 'i':
this._leafletMap.importer.open() this.importer.open()
break break
case 'o': case 'o':
this._leafletMap.importer.openFiles() this.importer.openFiles()
break break
case 'h': case 'h':
this.help.showGetStarted() this.help.showGetStarted()
@ -596,7 +596,7 @@ export default class Umap extends ServerStored {
const panes = this._leafletMap.getPane('overlayPane') const panes = this._leafletMap.getPane('overlayPane')
this.datalayersIndex = [] this.datalayersIndex = []
for (const pane of panes) { for (const pane of panes.children) {
if (!pane.dataset || !pane.dataset.id) continue if (!pane.dataset || !pane.dataset.id) continue
this.datalayersIndex.push(this.datalayers[pane.dataset.id]) this.datalayersIndex.push(this.datalayers[pane.dataset.id])
} }
@ -1425,13 +1425,13 @@ export default class Umap extends ServerStored {
row.dataset.id = stamp(datalayer) row.dataset.id = stamp(datalayer)
}) })
const onReorder = (src, dst, initialIndex, finalIndex) => { const onReorder = (src, dst, initialIndex, finalIndex) => {
const layer = this.datalayers[src.dataset.id] const movedLayer = this.datalayers[src.dataset.id]
const other = this.datalayers[dst.dataset.id] const targetLayer = this.datalayers[dst.dataset.id]
const minIndex = Math.min(layer.getRank(), other.getRank()) const minIndex = Math.min(movedLayer.getRank(), targetLayer.getRank())
const maxIndex = Math.max(layer.getRank(), other.getRank()) const maxIndex = Math.max(movedLayer.getRank(), targetLayer.getRank())
if (finalIndex === 0) layer.bringToTop() if (finalIndex === 0) movedLayer.bringToTop()
else if (finalIndex > initialIndex) layer.insertBefore(other) else if (finalIndex > initialIndex) movedLayer.insertBefore(targetLayer)
else layer.insertAfter(other) else movedLayer.insertAfter(targetLayer)
this.eachDataLayerReverse((datalayer) => { this.eachDataLayerReverse((datalayer) => {
if (datalayer.getRank() >= minIndex && datalayer.getRank() <= maxIndex) if (datalayer.getRank() >= minIndex && datalayer.getRank() <= maxIndex)
datalayer.isDirty = true datalayer.isDirty = true

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,9 +1,16 @@
import operator
import os
import shutil
import time
from pathlib import Path from pathlib import Path
from botocore.exceptions import ClientError
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles.storage import ManifestStaticFilesStorage from django.contrib.staticfiles.storage import ManifestStaticFilesStorage
from django.core.files.storage import FileSystemStorage
from rcssmin import cssmin from rcssmin import cssmin
from rjsmin import jsmin from rjsmin import jsmin
from storages.backends.s3 import S3Storage
class UmapManifestStaticFilesStorage(ManifestStaticFilesStorage): class UmapManifestStaticFilesStorage(ManifestStaticFilesStorage):
@ -62,3 +69,151 @@ class UmapManifestStaticFilesStorage(ManifestStaticFilesStorage):
minified = cssmin(initial) minified = cssmin(initial)
path.write_text(minified) path.write_text(minified)
yield original_path, processed_path, True yield original_path, processed_path, True
class UmapS3(S3Storage):
def get_reference_version(self, instance):
metadata = self.connection.meta.client.head_object(
Bucket=self.bucket_name, Key=instance.geojson.name
)
# Do not fail if bucket does not handle versioning
return metadata.get("VersionId", metadata["ETag"])
def make_filename(self, instance):
return f"{str(instance.pk)}.geojson"
def list_versions(self, instance):
response = self.connection.meta.client.list_object_versions(
Bucket=self.bucket_name, Prefix=instance.geojson.name
)
return [
{
"ref": version["VersionId"],
"at": version["LastModified"].timestamp() * 1000,
"size": version["Size"],
}
for version in response["Versions"]
]
def get_version(self, ref, instance):
try:
data = self.connection.meta.client.get_object(
Bucket=self.bucket_name,
Key=instance.geojson.name,
VersionId=ref,
)
except ClientError:
raise ValueError(f"Invalid version reference: {ref}")
return data["Body"].read()
def get_version_path(self, ref, instance):
return self.url(instance.geojson.name, parameters={"VersionId": ref})
def onDatalayerSave(self, instance):
pass
def onDatalayerDelete(self, instance):
return self.connection.meta.client.delete_object(
Bucket=self.bucket_name,
Key=instance.geojson.name,
)
class UmapFileSystem(FileSystemStorage):
def get_reference_version(self, instance):
return self._extract_version_ref(instance.geojson.name)
def make_filename(self, instance):
root = self._base_path(instance)
name = "%s_%s.geojson" % (instance.pk, int(time.time() * 1000))
return root / name
def list_versions(self, instance):
root = self._base_path(instance)
names = self.listdir(root)[1]
names = [name for name in names if self._is_valid_version(name, instance)]
versions = [self._version_metadata(name, instance) for name in names]
versions.sort(reverse=True, key=operator.itemgetter("at"))
return versions
def get_version(self, ref, instance):
with self.open(self.get_version_path(ref, instance), "r") as f:
return f.read()
def get_version_path(self, ref, instance):
base_path = Path(settings.MEDIA_ROOT) / self._base_path(instance)
fullpath = base_path / f"{instance.pk}_{ref}.geojson"
if instance.old_id and not fullpath.exists():
fullpath = base_path / f"{instance.old_id}_{ref}.geojson"
if not fullpath.exists():
raise ValueError(f"Invalid version reference: {ref}")
return fullpath
def onDatalayerSave(self, instance):
self._purge_gzip(instance)
self._purge_old_versions(instance)
def onDatalayerDelete(self, instance):
self._purge_gzip(instance)
self._to_purgatory(instance)
def _extract_version_ref(self, path):
version = path.split(".")[0]
if "_" in version:
return version.split("_")[-1]
return version
def _base_path(self, instance):
path = ["datalayer", str(instance.map.pk)[-1]]
if len(str(instance.map.pk)) > 1:
path.append(str(instance.map.pk)[-2])
path.append(str(instance.map.pk))
return Path(os.path.join(*path))
def _is_valid_version(self, name, instance):
valid_prefixes = [name.startswith("%s_" % instance.pk)]
if instance.old_id:
valid_prefixes.append(name.startswith("%s_" % instance.old_id))
return any(valid_prefixes) and name.endswith(".geojson")
def _version_metadata(self, name, instance):
ref = self._extract_version_ref(name)
return {
"name": name,
"ref": ref,
"at": ref,
"size": self.size(self._base_path(instance) / name),
}
def _purge_old_versions(self, instance):
root = self._base_path(instance)
versions = self.list_versions(instance)[settings.UMAP_KEEP_VERSIONS :]
for version in versions:
name = version["name"]
# Should not be in the list, but ensure to not delete the file
# currently used in database
if instance.geojson.name.endswith(name):
continue
try:
self.delete(root / name)
except FileNotFoundError:
pass
def _purge_gzip(self, instance):
root = self._base_path(instance)
names = self.listdir(root)[1]
prefixes = [f"{instance.pk}_"]
if instance.old_id:
prefixes.append(f"{instance.old_id}_")
prefixes = tuple(prefixes)
for name in names:
if name.startswith(prefixes) and name.endswith(".gz"):
self.delete(root / name)
def _to_purgatory(self, instance):
dest = Path(settings.UMAP_PURGATORY_ROOT)
dest.mkdir(parents=True, exist_ok=True)
src = Path(self.location) / self._base_path(instance)
for version in self.list_versions(instance):
name = version["name"]
shutil.move(src / name, dest / f"{instance.map.pk}_{name}")

View file

@ -24,6 +24,7 @@
<link rel="stylesheet" href="{% static 'umap/font.css' %}" /> <link rel="stylesheet" href="{% static 'umap/font.css' %}" />
<link rel="stylesheet" href="{% static 'umap/css/icon.css' %}" /> <link rel="stylesheet" href="{% static 'umap/css/icon.css' %}" />
<link rel="stylesheet" href="{% static 'umap/base.css' %}" /> <link rel="stylesheet" href="{% static 'umap/base.css' %}" />
<link rel="stylesheet" href="{% static 'umap/css/form.css' %}" />
<link rel="stylesheet" href="{% static 'umap/content.css' %}" /> <link rel="stylesheet" href="{% static 'umap/content.css' %}" />
<link rel="stylesheet" href="{% static 'umap/nav.css' %}" /> <link rel="stylesheet" href="{% static 'umap/nav.css' %}" />
<link rel="stylesheet" href="{% static 'umap/map.css' %}" /> <link rel="stylesheet" href="{% static 'umap/map.css' %}" />

View file

@ -71,6 +71,7 @@ def test_umap_import_from_file(live_server, tilelayer, page):
expect(nonloaded).to_have_count(1) expect(nonloaded).to_have_count(1)
@pytest.mark.skip
def test_umap_import_from_textarea(live_server, tilelayer, page, settings): def test_umap_import_from_textarea(live_server, tilelayer, page, settings):
page.route("https://tile.openstreetmap.fr/hot/**", mock_tiles) page.route("https://tile.openstreetmap.fr/hot/**", mock_tiles)

View file

@ -49,3 +49,37 @@ def test_should_open_popup_on_click(live_server, map, page, bootstrap):
# Close popup # Close popup
page.locator("#map").click() page.locator("#map").click()
expect(line).to_have_attribute("stroke-opacity", "0.5") expect(line).to_have_attribute("stroke-opacity", "0.5")
def test_can_use_measure_on_name(live_server, map, page):
data = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"name": "linestring"},
"geometry": {
"type": "LineString",
"coordinates": [
[11.25, 53.585984],
[10.151367, 52.975108],
],
},
},
{
"type": "Feature",
"properties": {"name": "multilinestring"},
"geometry": {
"type": "MultiLineString",
"coordinates": [[[8, 53], [13, 52]], [[12, 51], [15, 52]]],
},
},
],
}
map.settings["properties"]["labelKey"] = "{name} ({measure})"
map.settings["properties"]["onLoadPanel"] = "databrowser"
map.save()
DataLayerFactory(map=map, data=data)
page.goto(f"{live_server.url}{map.get_absolute_url()}#6/10/50")
expect(page.get_by_text("linestring (99.7 km)")).to_be_visible()
expect(page.get_by_text("multilinestring (592 km)")).to_be_visible()

View file

@ -22,10 +22,11 @@ def test_datalayers_should_be_ordered_by_rank(map, datalayer):
assert list(map.datalayer_set.all()) == [c1, c2, c3, c4, datalayer] assert list(map.datalayer_set.all()) == [c1, c2, c3, c4, datalayer]
def test_upload_to(map, datalayer): def test_upload_to(map):
map.pk = 302 map.pk = 302
datalayer.pk = 17 map.save()
assert datalayer.upload_to().startswith("datalayer/2/0/302/17_") datalayer = DataLayerFactory(map=map)
assert datalayer.geojson.name.startswith(f"datalayer/2/0/302/{datalayer.pk}_")
def test_save_should_use_pk_as_name(map, datalayer): def test_save_should_use_pk_as_name(map, datalayer):
@ -65,7 +66,7 @@ def test_clone_should_clone_geojson_too(datalayer):
def test_should_remove_old_versions_on_save(map, settings): def test_should_remove_old_versions_on_save(map, settings):
datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17) datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17)
settings.UMAP_KEEP_VERSIONS = 3 settings.UMAP_KEEP_VERSIONS = 3
root = Path(datalayer.storage_root()) root = Path(datalayer.geojson.storage._base_path(datalayer))
before = len(datalayer.geojson.storage.listdir(root)[1]) before = len(datalayer.geojson.storage.listdir(root)[1])
newer = f"{datalayer.pk}_1440924889.geojson" newer = f"{datalayer.pk}_1440924889.geojson"
medium = f"{datalayer.pk}_1440923687.geojson" medium = f"{datalayer.pk}_1440923687.geojson"
@ -275,7 +276,7 @@ def test_anonymous_can_edit_in_inherit_mode_and_map_in_public_mode(
def test_should_remove_all_versions_on_delete(map, settings): def test_should_remove_all_versions_on_delete(map, settings):
settings.UMAP_PURGATORY_ROOT = tempfile.mkdtemp() settings.UMAP_PURGATORY_ROOT = tempfile.mkdtemp()
datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17) datalayer = DataLayerFactory(uuid="0f1161c0-c07f-4ba4-86c5-8d8981d8a813", old_id=17)
root = Path(datalayer.storage_root()) root = Path(datalayer.geojson.storage._base_path(datalayer))
before = len(datalayer.geojson.storage.listdir(root)[1]) before = len(datalayer.geojson.storage.listdir(root)[1])
other = "123456_1440918637.geojson" other = "123456_1440918637.geojson"
files = [ files = [

View file

@ -0,0 +1,135 @@
import json
import os
import boto3
import pytest
from botocore.errorfactory import ClientError
from django.core.files.base import ContentFile
from django.core.files.storage import storages
from moto import mock_aws
from umap.models import DataLayer
from .base import DataLayerFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(scope="module", autouse=True)
def patch_storage():
"""Mocked AWS Credentials for moto."""
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
before = DataLayer.geojson.field.storage
DataLayer.geojson.field.storage = storages.create_storage(
{
"BACKEND": "umap.storage.UmapS3",
"OPTIONS": {
"access_key": "testing",
"secret_key": "testing",
"bucket_name": "umap",
"region_name": "us-east-1",
},
}
)
yield
DataLayer.geojson.field.storage = before
@pytest.fixture(scope="module", autouse=True)
def mocked_aws():
"""
Mock all AWS interactions
Requires you to create your own boto3 clients
"""
with mock_aws():
client = boto3.client("s3", region_name="us-east-1")
client.create_bucket(Bucket="umap")
client.put_bucket_versioning(
Bucket="umap", VersioningConfiguration={"Status": "Enabled"}
)
yield
def test_can_create_datalayer(map, datalayer):
other = DataLayerFactory(map=map)
assert datalayer.geojson.name == f"{datalayer.pk}.geojson"
assert other.geojson.name == f"{other.pk}.geojson"
def test_clone_should_return_new_instance(map, datalayer):
clone = datalayer.clone()
assert datalayer.pk != clone.pk
assert datalayer.name == clone.name
assert datalayer.map == clone.map
assert datalayer.geojson != clone.geojson
assert datalayer.geojson.name != clone.geojson.name
assert clone.geojson.name == f"{clone.pk}.geojson"
def test_update_should_add_version(map, datalayer):
assert len(datalayer.versions) == 1
datalayer.geojson = ContentFile("{}", "foo.json")
datalayer.save()
assert len(datalayer.versions) == 2
def test_get_version(map, datalayer):
assert len(datalayer.versions) == 1
datalayer.geojson = ContentFile('{"foo": "bar"}', "foo.json")
datalayer.save()
assert len(datalayer.versions) == 2
latest = datalayer.versions[0]["ref"]
version = datalayer.get_version(latest)
assert json.loads(version) == {"foo": "bar"}
older = datalayer.versions[1]["ref"]
version = datalayer.get_version(older)
assert json.loads(version) == {
"_umap_options": {
"browsable": True,
"displayOnLoad": True,
"name": "test datalayer",
},
"features": [
{
"geometry": {
"coordinates": [
14.68896484375,
48.55297816440071,
],
"type": "Point",
},
"properties": {
"_umap_options": {
"color": "DarkCyan",
"iconClass": "Ball",
},
"description": "Da place anonymous again 755",
"name": "Here",
},
"type": "Feature",
},
],
"type": "FeatureCollection",
}
latest = datalayer.reference_version
version = datalayer.get_version(latest)
assert json.loads(version) == {"foo": "bar"}
def test_delete_datalayer_should_delete_all_versions(datalayer):
# create a new version
datalayer.geojson = ContentFile('{"foo": "bar"}', "foo.json")
datalayer.save()
s3_key = datalayer.geojson.name
datalayer.delete()
with pytest.raises(ClientError):
datalayer.geojson.storage.connection.meta.client.get_object(
Bucket="umap",
Key=s3_key,
)

View file

@ -231,7 +231,7 @@ def test_optimistic_concurrency_control_with_empty_version(
def test_versions_should_return_versions(client, datalayer, map, settings): def test_versions_should_return_versions(client, datalayer, map, settings):
map.share_status = Map.PUBLIC map.share_status = Map.PUBLIC
map.save() map.save()
root = datalayer.storage_root() root = datalayer.geojson.storage._base_path(datalayer)
datalayer.geojson.storage.save( datalayer.geojson.storage.save(
"%s/%s_1440924889.geojson" % (root, datalayer.pk), ContentFile("{}") "%s/%s_1440924889.geojson" % (root, datalayer.pk), ContentFile("{}")
) )
@ -248,6 +248,7 @@ def test_versions_should_return_versions(client, datalayer, map, settings):
"name": "%s_1440918637.geojson" % datalayer.pk, "name": "%s_1440918637.geojson" % datalayer.pk,
"size": 2, "size": 2,
"at": "1440918637", "at": "1440918637",
"ref": "1440918637",
} }
assert version in versions["versions"] assert version in versions["versions"]
@ -255,7 +256,7 @@ def test_versions_should_return_versions(client, datalayer, map, settings):
def test_versions_can_return_old_format(client, datalayer, map, settings): def test_versions_can_return_old_format(client, datalayer, map, settings):
map.share_status = Map.PUBLIC map.share_status = Map.PUBLIC
map.save() map.save()
root = datalayer.storage_root() root = datalayer.geojson.storage._base_path(datalayer)
datalayer.old_id = 123 # old datalayer id (now replaced by uuid) datalayer.old_id = 123 # old datalayer id (now replaced by uuid)
datalayer.save() datalayer.save()
@ -279,31 +280,32 @@ def test_versions_can_return_old_format(client, datalayer, map, settings):
"name": old_format_version, "name": old_format_version,
"size": 2, "size": 2,
"at": "1440918637", "at": "1440918637",
"ref": "1440918637",
} }
assert version in versions["versions"] assert version in versions["versions"]
client.get( client.get(reverse("datalayer_version", args=(map.pk, datalayer.pk, "1440918637")))
reverse("datalayer_version", args=(map.pk, datalayer.pk, old_format_version))
)
def test_version_should_return_one_version_geojson(client, datalayer, map): def test_version_should_return_one_version_geojson(client, datalayer, map):
map.share_status = Map.PUBLIC map.share_status = Map.PUBLIC
map.save() map.save()
root = datalayer.storage_root() root = datalayer.geojson.storage._base_path(datalayer)
name = "%s_1440924889.geojson" % datalayer.pk name = "%s_1440924889.geojson" % datalayer.pk
datalayer.geojson.storage.save("%s/%s" % (root, name), ContentFile("{}")) datalayer.geojson.storage.save("%s/%s" % (root, name), ContentFile("{}"))
url = reverse("datalayer_version", args=(map.pk, datalayer.pk, name)) url = reverse("datalayer_version", args=(map.pk, datalayer.pk, "1440924889"))
assert client.get(url).content.decode() == "{}" resp = client.get(url)
assert resp.status_code == 200
assert resp.content.decode() == "{}"
def test_version_should_return_403_if_not_allowed(client, datalayer, map): def test_version_should_return_403_if_not_allowed(client, datalayer, map):
map.share_status = Map.PRIVATE map.share_status = Map.PRIVATE
map.save() map.save()
root = datalayer.storage_root() root = datalayer.geojson.storage._base_path(datalayer)
name = "%s_1440924889.geojson" % datalayer.pk name = "%s_1440924889.geojson" % datalayer.pk
datalayer.geojson.storage.save("%s/%s" % (root, name), ContentFile("{}")) datalayer.geojson.storage.save("%s/%s" % (root, name), ContentFile("{}"))
url = reverse("datalayer_version", args=(map.pk, datalayer.pk, name)) url = reverse("datalayer_version", args=(map.pk, datalayer.pk, "1440924889"))
assert client.get(url).status_code == 403 assert client.get(url).status_code == 403

View file

@ -87,7 +87,7 @@ i18n_urls += decorated_patterns(
name="datalayer_versions", name="datalayer_versions",
), ),
path( path(
"datalayer/<int:map_id>/<uuid:pk>/<str:name>", "datalayer/<int:map_id>/<uuid:pk>/<str:ref>",
views.DataLayerVersion.as_view(), views.DataLayerVersion.as_view(),
name="datalayer_version", name="datalayer_version",
), ),

View file

@ -1,7 +1,6 @@
import io import io
import json import json
import mimetypes import mimetypes
import os
import re import re
import socket import socket
import zipfile import zipfile
@ -1105,35 +1104,8 @@ class MapAnonymousEditUrl(RedirectView):
# ############## # # ############## #
class GZipMixin(object): class DataLayerView(BaseDetailView):
EXT = ".gz" model = DataLayer
@property
def path(self):
return Path(self.object.geojson.path)
@property
def gzip_path(self):
return Path(f"{self.path}{self.EXT}")
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 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
# 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.
# cf https://github.com/umap-project/umap/issues/1212
path = (
self.gzip_path
if self.accepts_gzip and self.gzip_path.exists()
else self.path
)
return self.read_version(path)
@property @property
def accepts_gzip(self): def accepts_gzip(self):
@ -1141,43 +1113,80 @@ class GZipMixin(object):
self.request.META.get("HTTP_ACCEPT_ENCODING", "") self.request.META.get("HTTP_ACCEPT_ENCODING", "")
) )
@property
def is_s3(self):
return "S3" in settings.STORAGES["data"]["BACKEND"]
class DataLayerView(GZipMixin, BaseDetailView): @property
model = DataLayer def filepath(self):
return Path(self.object.geojson.path)
@property
def fileurl(self):
return self.object.geojson.url
@property
def filedata(self):
with self.object.geojson.open("rb") as f:
return f.read()
@property
def fileversion(self):
return self.object.reference_version
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):
response = None response = None
path = self.path
# Generate gzip if needed # Generate gzip if needed
if self.accepts_gzip: if not self.is_s3 and self.accepts_gzip:
if not self.gzip_path.exists(): gzip_path = Path(f"{self.filepath}.gz")
gzip_file(path, self.gzip_path) if not gzip_path.exists():
gzip_file(self.filepath, gzip_path)
if getattr(settings, "UMAP_XSENDFILE_HEADER", None): if getattr(settings, "UMAP_XSENDFILE_HEADER", None):
response = HttpResponse() response = HttpResponse()
internal_path = str(path).replace(settings.MEDIA_ROOT, "/internal") if self.is_s3:
internal_path = f"/s3/{self.fileurl}"
else:
internal_path = str(self.filepath).replace(
settings.MEDIA_ROOT, "/internal"
)
response[settings.UMAP_XSENDFILE_HEADER] = internal_path response[settings.UMAP_XSENDFILE_HEADER] = internal_path
else: else:
# Do not use in production # Do not use in production
# (no gzip/cache-control/If-Modified-Since/If-None-Match) # (no gzip/cache-control/If-Modified-Since/If-None-Match)
statobj = os.stat(path) data = self.filedata
with open(path, "rb") as f: response = HttpResponse(data, content_type="application/geo+json")
# Should not be used in production! response["X-Datalayer-Version"] = self.fileversion
response = HttpResponse(f.read(), content_type="application/geo+json")
response["X-Datalayer-Version"] = self.version
response["Content-Length"] = statobj.st_size
return response return response
class DataLayerVersion(DataLayerView): class DataLayerVersion(DataLayerView):
@property @property
def path(self): def filepath(self):
try:
return Path(settings.MEDIA_ROOT) / self.object.get_version_path( return Path(settings.MEDIA_ROOT) / self.object.get_version_path(
self.kwargs["name"] self.kwargs["ref"]
) )
except ValueError:
raise Http404("Invalid version reference")
@property
def fileurl(self):
return self.object.get_version_path(self.kwargs["ref"])
@property
def filedata(self):
try:
return self.object.get_version(self.kwargs["ref"])
except ValueError:
raise Http404("Invalid version reference.")
@property
def fileversion(self):
return self.kwargs["ref"]
class DataLayerCreate(FormLessEditMixin, GZipMixin, CreateView): class DataLayerCreate(FormLessEditMixin, CreateView):
model = DataLayer model = DataLayer
form_class = DataLayerForm form_class = DataLayerForm
@ -1196,16 +1205,16 @@ class DataLayerCreate(FormLessEditMixin, GZipMixin, CreateView):
# Simple response with only metadata # Simple response with only metadata
data = self.object.metadata(self.request) data = self.object.metadata(self.request)
response = simple_json_response(**data) response = simple_json_response(**data)
response["X-Datalayer-Version"] = self.version response["X-Datalayer-Version"] = self.object.reference_version
return response return response
class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView): class DataLayerUpdate(FormLessEditMixin, UpdateView):
model = DataLayer model = DataLayer
form_class = DataLayerForm form_class = DataLayerForm
def has_changes_since(self, incoming_version): def has_changes_since(self, incoming_version):
return incoming_version and self.version != incoming_version return incoming_version and self.object.reference_version != incoming_version
def merge(self, reference_version): def merge(self, reference_version):
""" """
@ -1217,11 +1226,9 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView):
# Use the provided info to find the correct version in our storage. # Use the provided info to find the correct version in our storage.
for version in self.object.versions: for version in self.object.versions:
name = version["name"] ref = version["ref"]
path = Path(settings.MEDIA_ROOT) / self.object.get_version_path(name) if reference_version == ref:
if reference_version == self.read_version(path): reference = json.loads(self.object.get_version(ref))
with open(path) as f:
reference = json.loads(f.read())
break break
else: else:
# If the reference document is not found, we can't merge. # If the reference document is not found, we can't merge.
@ -1230,7 +1237,7 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView):
incoming = json.loads(self.request.FILES["geojson"].read()) incoming = json.loads(self.request.FILES["geojson"].read())
# Latest known version of the data. # Latest known version of the data.
with open(self.path) as f: with self.object.geojson.open() as f:
latest = json.loads(f.read()) latest = json.loads(f.read())
try: try:
@ -1274,7 +1281,7 @@ class DataLayerUpdate(FormLessEditMixin, GZipMixin, UpdateView):
data["geojson"] = json.loads(self.object.geojson.read().decode()) data["geojson"] = json.loads(self.object.geojson.read().decode())
self.request.session["needs_reload"] = False self.request.session["needs_reload"] = False
response = simple_json_response(**data) response = simple_json_response(**data)
response["X-Datalayer-Version"] = self.version response["X-Datalayer-Version"] = self.object.reference_version
return response return response