mirror of
https://github.com/umap-project/umap.git
synced 2025-04-28 19:42:36 +02:00
parent
82b81706ab
commit
a04624c4c8
13 changed files with 331 additions and 194 deletions
|
@ -89,13 +89,6 @@ Running uMap / Django with a known SECRET_KEY defeats many of Django’s 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.
|
||||||
|
|
61
docs/config/storage.md
Normal file
61
docs/config/storage.md
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
# 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See more about the configuration on the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html).
|
|
@ -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
|
||||||
|
|
|
@ -65,6 +65,9 @@ test = [
|
||||||
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",
|
||||||
|
|
121
umap/models.py
121
umap/models.py
|
@ -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 _
|
||||||
|
@ -293,7 +288,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
|
||||||
|
@ -431,7 +426,7 @@ class Pictogram(NamedModel):
|
||||||
|
|
||||||
attribution = models.CharField(max_length=300)
|
attribution = models.CharField(max_length=300)
|
||||||
category = models.CharField(max_length=300, null=True, blank=True)
|
category = models.CharField(max_length=300, null=True, blank=True)
|
||||||
pictogram = models.FileField(upload_to="pictogram")
|
pictogram = models.FileField(upload_to="pictogram", storage=storages["default"])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def json(self):
|
def json(self):
|
||||||
|
@ -447,10 +442,7 @@ 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)
|
|
||||||
|
|
||||||
|
|
||||||
class DataLayer(NamedModel):
|
class DataLayer(NamedModel):
|
||||||
|
@ -477,7 +469,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=storages["data"]
|
||||||
|
)
|
||||||
display_on_load = models.BooleanField(
|
display_on_load = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("display on load"),
|
verbose_name=_("display on load"),
|
||||||
|
@ -496,42 +490,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(keep=settings.UMAP_KEEP_VERSIONS)
|
|
||||||
|
|
||||||
def delete(self, **kwargs):
|
def delete(self, **kwargs):
|
||||||
self.purge_gzip()
|
self.geojson.storage.onDatalayerDelete(self)
|
||||||
self.purge_old_versions(keep=None)
|
|
||||||
return super().delete(**kwargs)
|
return super().delete(**kwargs)
|
||||||
|
|
||||||
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
|
||||||
|
@ -557,72 +523,19 @@ class DataLayer(NamedModel):
|
||||||
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, keep=None):
|
|
||||||
root = self.storage_root()
|
|
||||||
versions = self.versions
|
|
||||||
if keep is not None:
|
|
||||||
versions = versions[keep:]
|
|
||||||
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 keep is not None and 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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
@ -549,11 +549,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -870,13 +870,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)
|
||||||
}
|
}
|
||||||
|
|
145
umap/storage.py
145
umap/storage.py
|
@ -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,141 @@ 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
|
||||||
|
)
|
||||||
|
return metadata["VersionId"]
|
||||||
|
|
||||||
|
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):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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, keep=settings.UMAP_KEEP_VERSIONS)
|
||||||
|
|
||||||
|
def onDatalayerDelete(self, instance):
|
||||||
|
self._purge_gzip(instance)
|
||||||
|
self._purge_old_versions(instance, keep=None)
|
||||||
|
|
||||||
|
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, keep=None):
|
||||||
|
root = self._base_path(instance)
|
||||||
|
versions = self.list_versions(instance)
|
||||||
|
if keep is not None:
|
||||||
|
versions = versions[keep:]
|
||||||
|
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 keep is not None and 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)
|
||||||
|
|
|
@ -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):
|
||||||
settings.UMAP_ALLOW_ANONYMOUS = True
|
settings.UMAP_ALLOW_ANONYMOUS = True
|
||||||
page.goto(f"{live_server.url}/map/new/")
|
page.goto(f"{live_server.url}/map/new/")
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -274,7 +275,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):
|
||||||
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 = [
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
),
|
),
|
||||||
|
|
123
umap/views.py
123
umap/views.py
|
@ -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
|
||||||
|
@ -1117,35 +1116,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):
|
||||||
|
@ -1153,43 +1125,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):
|
||||||
return Path(settings.MEDIA_ROOT) / self.object.get_version_path(
|
try:
|
||||||
self.kwargs["name"]
|
return Path(settings.MEDIA_ROOT) / self.object.get_version_path(
|
||||||
)
|
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
|
||||||
|
|
||||||
|
@ -1208,16 +1217,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):
|
||||||
"""
|
"""
|
||||||
|
@ -1229,11 +1238,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.
|
||||||
|
@ -1242,7 +1249,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:
|
||||||
|
@ -1286,7 +1293,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
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue