mirror of
https://github.com/umap-project/umap.git
synced 2025-05-05 06:01:48 +02:00
Compare commits
17 commits
e2f154f62e
...
32b9217bd2
Author | SHA1 | Date | |
---|---|---|---|
![]() |
32b9217bd2 | ||
![]() |
4fd57e0c11 | ||
![]() |
54420568af | ||
![]() |
8c90535105 | ||
![]() |
80a969917a | ||
![]() |
6582e85f18 | ||
![]() |
023645a160 | ||
![]() |
e24695b68c | ||
![]() |
db72bfad8d | ||
![]() |
738af24dfc | ||
![]() |
06f963c07f | ||
![]() |
c6ed896a6d | ||
![]() |
1344fe6b46 | ||
![]() |
39f38a9cdf | ||
d72debdeb2 | |||
33525a1837 | |||
3e0a6b8f10 |
24 changed files with 308 additions and 134 deletions
|
@ -1,10 +1,9 @@
|
||||||
# This part installs deps needed at runtime.
|
# This part installs deps needed at runtime.
|
||||||
FROM python:3.11-slim AS common
|
FROM python:3.11-slim AS runtime
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
tini \
|
tini \
|
||||||
uwsgi \
|
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gdal-bin \
|
gdal-bin \
|
||||||
|
@ -14,7 +13,7 @@ RUN apt-get update && \
|
||||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
# This part adds deps needed only at buildtime.
|
# This part adds deps needed only at buildtime.
|
||||||
FROM common AS build
|
FROM runtime AS build
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
@ -39,9 +38,9 @@ WORKDIR /srv/umap
|
||||||
|
|
||||||
COPY . /srv/umap
|
COPY . /srv/umap
|
||||||
|
|
||||||
RUN /venv/bin/pip install .[docker,s3]
|
RUN /venv/bin/pip install .[docker,s3,sync]
|
||||||
|
|
||||||
FROM common
|
FROM runtime
|
||||||
|
|
||||||
COPY --from=build /srv/umap/docker/ /srv/umap/docker/
|
COPY --from=build /srv/umap/docker/ /srv/umap/docker/
|
||||||
COPY --from=build /venv/ /venv/
|
COPY --from=build /venv/ /venv/
|
||||||
|
|
|
@ -1,5 +1,15 @@
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
# Usefull only to use the real time collaboration
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
command: ["redis-server"]
|
||||||
|
|
||||||
db:
|
db:
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||||
|
@ -14,7 +24,9 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
image: umap/umap:2.0.2
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
image: umap/umap:2.9.3
|
||||||
ports:
|
ports:
|
||||||
- "${PORT-8000}:8000"
|
- "${PORT-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
|
@ -23,6 +35,8 @@ services:
|
||||||
- SITE_URL=https://umap.local/
|
- SITE_URL=https://umap.local/
|
||||||
- UMAP_ALLOW_ANONYMOUS=True
|
- UMAP_ALLOW_ANONYMOUS=True
|
||||||
- DEBUG=1
|
- DEBUG=1
|
||||||
|
- WEBSOCKET_ENABLED=1
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
volumes:
|
volumes:
|
||||||
- data:/srv/umap/uploads
|
- data:/srv/umap/uploads
|
||||||
|
|
||||||
|
|
|
@ -9,5 +9,5 @@ umap collectstatic --noinput
|
||||||
umap wait_for_database
|
umap wait_for_database
|
||||||
# then migrate the database
|
# then migrate the database
|
||||||
umap migrate
|
umap migrate
|
||||||
# run uWSGI
|
# run the server
|
||||||
exec uwsgi --ini docker/uwsgi.ini
|
exec uvicorn --proxy-headers --no-access-log --host 0.0.0.0 umap.asgi:application
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
[uwsgi]
|
|
||||||
http = :$(PORT)
|
|
||||||
home = /venv
|
|
||||||
module = umap.wsgi:application
|
|
||||||
master = True
|
|
||||||
vacuum = True
|
|
||||||
max-requests = 5000
|
|
||||||
processes = 4
|
|
||||||
enable-threads = true
|
|
||||||
static-map = /static=/srv/umap/static
|
|
||||||
static-map = /uploads=/srv/umap/uploads
|
|
||||||
buffer-size = 32768
|
|
||||||
|
|
||||||
; Run the websocket server only when the env variable
|
|
||||||
; WEBSOCKET_ENABLED is set to True.
|
|
||||||
; This is enough for the base docker image, but does not
|
|
||||||
; take into account the settings as the source of truth.
|
|
||||||
if-env = WEBSOCKET_ENABLED
|
|
||||||
websocket_enabled = %(_)
|
|
||||||
endif =
|
|
||||||
|
|
||||||
if-opt = websocket_enabled=True
|
|
||||||
print = Starting the Websocket Server (WEBSOCKET_ENABLED=%(websocket_enabled))
|
|
||||||
attach-daemon = umap run_websocket_server
|
|
||||||
endif =
|
|
||||||
lazy-apps = true
|
|
|
@ -323,7 +323,10 @@ CREATE EXTENSION btree_gin;
|
||||||
ALTER TEXT SEARCH CONFIGURATION umapdict ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;
|
ALTER TEXT SEARCH CONFIGURATION umapdict ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;
|
||||||
|
|
||||||
# Now create the index
|
# Now create the index
|
||||||
CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', COALESCE(name, ''::character varying)::text), share_status);
|
CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', COALESCE(name, ''::character varying)::text), share_status, tags);
|
||||||
|
|
||||||
|
# You should also create an index for tag filtering:
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_idx ON umap_map USING GIN(share_status, tags);
|
||||||
```
|
```
|
||||||
|
|
||||||
Then set:
|
Then set:
|
||||||
|
@ -354,52 +357,7 @@ Otherwise, use any valid [python-social-auth configuration](https://python-socia
|
||||||
|
|
||||||
#### WEBSOCKET_ENABLED
|
#### WEBSOCKET_ENABLED
|
||||||
|
|
||||||
A WebSocket server is packaged with uMap, and can be turned-on to activate
|
Setting `WEBSOCKET_ENABLED` to `True` will allow users to enable real-time collaboration.
|
||||||
"real-time collaboration". In practice, in order to enable it, a few settings
|
A switch will be available for them in the "advanced properties" of the map.
|
||||||
are exposed.
|
|
||||||
|
|
||||||
Setting `WEBSOCKET_ENABLED` to `True` will **not** enable real-time
|
See [the documentation about ASGI deployment](../deploy/asgi.md) for more information.
|
||||||
collaboration on all the maps served by the server. Instead, a switch will be
|
|
||||||
available in the "advanced properties" of the map.
|
|
||||||
|
|
||||||
The websocket server can be started with the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
umap run_websocket_server
|
|
||||||
```
|
|
||||||
|
|
||||||
And can take optional settings `--host` and `--port` (default values are defined in
|
|
||||||
the settings).
|
|
||||||
|
|
||||||
Configuration example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
WEBSOCKET_ENABLED = True
|
|
||||||
WEBSOCKET_BACK_HOST = "localhost"
|
|
||||||
WEBSOCKET_BACK_PORT = 8002
|
|
||||||
WEBSOCKET_FRONT_URI = "ws://localhost:8002"
|
|
||||||
```
|
|
||||||
|
|
||||||
These settings can also be set with the (same names) environment variables.
|
|
||||||
|
|
||||||
#### WEBSOCKET_BACK_HOST
|
|
||||||
#### WEBSOCKET_BACK_PORT
|
|
||||||
|
|
||||||
The internal host and port the websocket server will connect to.
|
|
||||||
|
|
||||||
#### WEBSOCKET_FRONT_URI
|
|
||||||
|
|
||||||
The connection string that will be used by the client to connect to the
|
|
||||||
websocket server. In practice, as it's useful to put the WebSocket server behind
|
|
||||||
TLS encryption, the values defined by `WEBSOCKET_FRONT_URI` are different than
|
|
||||||
the values defined by `WEBSOCKET_BACK_PORT` and `WEBSOCKET_BACK_HOST`.
|
|
||||||
|
|
||||||
This value is comprised of three parts:
|
|
||||||
|
|
||||||
```
|
|
||||||
protocol://host:port
|
|
||||||
```
|
|
||||||
|
|
||||||
- `protocol`: can either be `ws` for plain unencrypted WebSockets, or `wss` when using TLS encryption.
|
|
||||||
- `host`: is the address where the connection will be sent. It should be public facing.
|
|
||||||
- `port`: is the port that is open on the host.
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
# Check https://hub.docker.com/r/umap/umap/tags to find the latest version
|
# Check https://hub.docker.com/r/umap/umap/tags to find the latest version
|
||||||
image: umap/umap:2.0.2
|
image: umap/umap:2.9.3
|
||||||
ports:
|
ports:
|
||||||
# modify the external port (8001, on the left) if desired, but make sure it matches SITE_URL, below
|
# modify the external port (8001, on the left) if desired, but make sure it matches SITE_URL, below
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
|
@ -48,3 +48,45 @@ User accounts can be managed via the Django admin page ({SITE_URL}/admin). The r
|
||||||
```bash
|
```bash
|
||||||
umap createsuperuser
|
umap createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Developping with Docker
|
||||||
|
|
||||||
|
If you want to develop with podman or docker, here are commands that might be useful, given that you have a postgreSQL server running locally and that your settings are located at `umap.conf`:
|
||||||
|
|
||||||
|
You can build the docker image with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build -t umap .
|
||||||
|
```
|
||||||
|
|
||||||
|
And run it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman run -v ./umap.conf:/tmp/umap.conf -e UMAP_SETTINGS=/tmp/umap.conf -it --network host umap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real time collaboration
|
||||||
|
|
||||||
|
To enable real time collaboration when using Docker, a Redis service must be added. Something like this in `docker-compose.py` world:
|
||||||
|
|
||||||
|
```yaml title="docker-compose.yml"
|
||||||
|
services
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||||
|
…
|
||||||
|
command: ["redis-server"]
|
||||||
|
…
|
||||||
|
app:
|
||||||
|
depends_on:
|
||||||
|
…
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
…
|
||||||
|
environment:
|
||||||
|
- WEBSOCKET_ENABLED=1
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
…
|
||||||
|
|
||||||
|
```
|
||||||
|
|
|
@ -65,7 +65,7 @@ test = [
|
||||||
"moto[s3]==5.1.1"
|
"moto[s3]==5.1.1"
|
||||||
]
|
]
|
||||||
docker = [
|
docker = [
|
||||||
"uwsgi==2.0.28",
|
"uvicorn==0.34.0",
|
||||||
]
|
]
|
||||||
s3 = [
|
s3 = [
|
||||||
"django-storages[s3]==1.14.5",
|
"django-storages[s3]==1.14.5",
|
||||||
|
@ -73,6 +73,7 @@ s3 = [
|
||||||
sync = [
|
sync = [
|
||||||
"pydantic==2.11.1",
|
"pydantic==2.11.1",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
|
"websockets==15.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
@ -14,6 +14,7 @@ def settings(request):
|
||||||
"UMAP_DEMO_SITE": djsettings.UMAP_DEMO_SITE,
|
"UMAP_DEMO_SITE": djsettings.UMAP_DEMO_SITE,
|
||||||
"UMAP_HOST_INFOS": djsettings.UMAP_HOST_INFOS,
|
"UMAP_HOST_INFOS": djsettings.UMAP_HOST_INFOS,
|
||||||
"UMAP_ALLOW_EDIT_PROFILE": djsettings.UMAP_ALLOW_EDIT_PROFILE,
|
"UMAP_ALLOW_EDIT_PROFILE": djsettings.UMAP_ALLOW_EDIT_PROFILE,
|
||||||
|
"UMAP_TAGS": djsettings.UMAP_TAGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
from django.forms.utils import ErrorList
|
from django.forms.utils import ErrorList
|
||||||
from django.template.defaultfilters import slugify
|
from django.template.defaultfilters import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from .models import DataLayer, Map, Team
|
from .models import DataLayer, Map, Team
|
||||||
|
|
||||||
|
@ -92,7 +91,7 @@ class MapSettingsForm(forms.ModelForm):
|
||||||
return self.cleaned_data["center"]
|
return self.cleaned_data["center"]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = ("settings", "name", "center", "slug")
|
fields = ("settings", "name", "center", "slug", "tags")
|
||||||
model = Map
|
model = Map
|
||||||
|
|
||||||
|
|
||||||
|
|
23
umap/migrations/0027_map_tags.py
Normal file
23
umap/migrations/0027_map_tags.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 5.1.6 on 2025-02-26 16:18
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("umap", "0026_datalayer_modified_at_datalayer_share_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="map",
|
||||||
|
name="tags",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(max_length=200),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -4,6 +4,7 @@ import uuid
|
||||||
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.contrib.postgres.fields import ArrayField
|
||||||
from django.core.files.base import File
|
from django.core.files.base import File
|
||||||
from django.core.files.storage import storages
|
from django.core.files.storage import storages
|
||||||
from django.core.signing import Signer
|
from django.core.signing import Signer
|
||||||
|
@ -236,6 +237,7 @@ class Map(NamedModel):
|
||||||
settings = models.JSONField(
|
settings = models.JSONField(
|
||||||
blank=True, null=True, verbose_name=_("settings"), default=dict
|
blank=True, null=True, verbose_name=_("settings"), default=dict
|
||||||
)
|
)
|
||||||
|
tags = ArrayField(models.CharField(max_length=200), blank=True, default=list)
|
||||||
|
|
||||||
objects = models.Manager()
|
objects = models.Manager()
|
||||||
public = PublicManager()
|
public = PublicManager()
|
||||||
|
@ -415,12 +417,17 @@ class Map(NamedModel):
|
||||||
datalayer.clone(map_inst=new)
|
datalayer.clone(map_inst=new)
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
def get_tags_display(self):
|
||||||
|
labels = dict(settings.UMAP_TAGS)
|
||||||
|
return [(t, labels.get(t, t)) for t in self.tags]
|
||||||
|
|
||||||
@classproperty
|
@classproperty
|
||||||
def extra_schema(self):
|
def extra_schema(self):
|
||||||
return {
|
return {
|
||||||
"iconUrl": {
|
"iconUrl": {
|
||||||
"default": "%sumap/img/marker.svg" % settings.STATIC_URL,
|
"default": "%sumap/img/marker.svg" % settings.STATIC_URL,
|
||||||
}
|
},
|
||||||
|
"tags": {"choices": sorted(settings.UMAP_TAGS, key=lambda i: i[0])},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ from email.utils import parseaddr
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
from django.conf.locale import LANG_INFO
|
from django.conf.locale import LANG_INFO
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import umap as project_module
|
import umap as project_module
|
||||||
|
|
||||||
|
@ -290,6 +291,25 @@ UMAP_HOME_FEED = "latest"
|
||||||
UMAP_IMPORTERS = {}
|
UMAP_IMPORTERS = {}
|
||||||
UMAP_HOST_INFOS = {}
|
UMAP_HOST_INFOS = {}
|
||||||
UMAP_LABEL_KEYS = ["name", "title"]
|
UMAP_LABEL_KEYS = ["name", "title"]
|
||||||
|
UMAP_TAGS = (
|
||||||
|
("arts", _("Art and Culture")),
|
||||||
|
("cycling", _("Cycling")),
|
||||||
|
("business", _("Business")),
|
||||||
|
("environment", _("Environment")),
|
||||||
|
("education", _("Education")),
|
||||||
|
("food", _("Food and Agriculture")),
|
||||||
|
("geopolitics", _("Geopolitics")),
|
||||||
|
("health", _("Health")),
|
||||||
|
("hiking", _("Hiking")),
|
||||||
|
("history", _("History")),
|
||||||
|
("public", _("Public sector")),
|
||||||
|
("science", _("Science")),
|
||||||
|
("shopping", _("Shopping")),
|
||||||
|
("sport", _("Sport and Leisure")),
|
||||||
|
("travel", _("Travel")),
|
||||||
|
("transports", _("Transports")),
|
||||||
|
("tourism", _("Tourism")),
|
||||||
|
)
|
||||||
|
|
||||||
UMAP_READONLY = env("UMAP_READONLY", default=False)
|
UMAP_READONLY = env("UMAP_READONLY", default=False)
|
||||||
UMAP_GZIP = True
|
UMAP_GZIP = True
|
||||||
|
@ -346,4 +366,4 @@ WEBSOCKET_ENABLED = env.bool("WEBSOCKET_ENABLED", default=False)
|
||||||
WEBSOCKET_BACK_HOST = env("WEBSOCKET_BACK_HOST", default="localhost")
|
WEBSOCKET_BACK_HOST = env("WEBSOCKET_BACK_HOST", default="localhost")
|
||||||
WEBSOCKET_BACK_PORT = env.int("WEBSOCKET_BACK_PORT", default=8001)
|
WEBSOCKET_BACK_PORT = env.int("WEBSOCKET_BACK_PORT", default=8001)
|
||||||
|
|
||||||
REDIS_URL = "redis://localhost:6379"
|
REDIS_URL = env("REDIS_URL", default="redis://localhost:6379")
|
||||||
|
|
|
@ -16,6 +16,22 @@ input::-webkit-input-placeholder, ::-webkit-input-placeholder {
|
||||||
input:-moz-placeholder, :-moz-placeholder {
|
input:-moz-placeholder, :-moz-placeholder {
|
||||||
color: #a5a5a5;
|
color: #a5a5a5;
|
||||||
}
|
}
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: calc(var(--gutter) / 2);
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.search-form select {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
.search-form input[type=submit] {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.flex-break {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* **************** */
|
/* **************** */
|
||||||
|
@ -60,26 +76,8 @@ body.login header {
|
||||||
.demo_map .map_fragment {
|
.demo_map .map_fragment {
|
||||||
height: var(--map-fragment-height);
|
height: var(--map-fragment-height);
|
||||||
}
|
}
|
||||||
.map_list .legend {
|
.grid-container hgroup {
|
||||||
padding-top: 7px;
|
text-align: left;
|
||||||
margin-bottom: 28px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
.map_list .legend a {
|
|
||||||
color: #222;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.map_list .legend em,
|
|
||||||
.map_list .legend em a {
|
|
||||||
color: #444;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.map_list hr {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.map_list .wide + hr {
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
.umap-features-list ul {
|
.umap-features-list ul {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
|
@ -167,7 +165,49 @@ h2.tabs a:hover {
|
||||||
.more_button {
|
.more_button {
|
||||||
min-height: var(--map-fragment-height);
|
min-height: var(--map-fragment-height);
|
||||||
}
|
}
|
||||||
|
.tag-list {
|
||||||
|
margin-top: var(--text-margin);
|
||||||
|
margin-bottom: var(--text-margin);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: calc(var(--gutter) / 2 );
|
||||||
|
}
|
||||||
|
.tag-list li {
|
||||||
|
border: 1px solid var(--color-darkBlue);
|
||||||
|
border-radius: 3vmin;
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--button-padding-small);
|
||||||
|
}
|
||||||
|
.tag-list li a {
|
||||||
|
color: var(--color-darkBlue);
|
||||||
|
max-width: 125px;
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--color-lightGray);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--box-padding);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.card .button {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.card hgroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
gap: var(--gutter);
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* **************************** */
|
/* **************************** */
|
||||||
/* colors */
|
/* colors */
|
||||||
|
@ -541,4 +581,9 @@ dialog::backdrop {
|
||||||
.mhide {
|
.mhide {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.flex-break {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -203,6 +203,7 @@
|
||||||
line-height: initial;
|
line-height: initial;
|
||||||
height: initial;
|
height: initial;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
padding: 0 var(--text-margin);
|
||||||
}
|
}
|
||||||
.umap-caption-bar-enabled {
|
.umap-caption-bar-enabled {
|
||||||
--current-footer-height: var(--footer-height);
|
--current-footer-height: var(--footer-height);
|
||||||
|
|
|
@ -61,10 +61,7 @@ textarea {
|
||||||
select {
|
select {
|
||||||
border: 1px solid #222;
|
border: 1px solid #222;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 28px;
|
padding: var(--button-padding);
|
||||||
line-height: 28px;
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: var(--box-margin);
|
|
||||||
}
|
}
|
||||||
.dark select {
|
.dark select {
|
||||||
color: #efefef;
|
color: #efefef;
|
||||||
|
|
|
@ -138,6 +138,8 @@ export class MutatingForm extends Form {
|
||||||
} else if (properties.type === Number) {
|
} else if (properties.type === Number) {
|
||||||
if (properties.step) properties.handler = 'Range'
|
if (properties.step) properties.handler = 'Range'
|
||||||
else properties.handler = 'IntInput'
|
else properties.handler = 'IntInput'
|
||||||
|
} else if (properties.type === Array) {
|
||||||
|
properties.handler = 'CheckBoxes'
|
||||||
} else if (properties.choices) {
|
} else if (properties.choices) {
|
||||||
const text_length = properties.choices.reduce(
|
const text_length = properties.choices.reduce(
|
||||||
(acc, [_, label]) => acc + label.length,
|
(acc, [_, label]) => acc + label.length,
|
||||||
|
|
|
@ -324,6 +324,29 @@ Fields.CheckBox = class extends BaseElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Fields.CheckBoxes = class extends BaseElement {
|
||||||
|
getInputTemplate(value, label) {
|
||||||
|
return `<label><input type=checkbox value="${value}" name="${this.name}" data-ref=input />${label}</label>`
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
const initial = this.get() || []
|
||||||
|
for (const [value, label] of this.properties.choices) {
|
||||||
|
const [root, { input }] = Utils.loadTemplateWithRefs(
|
||||||
|
this.getInputTemplate(value, label)
|
||||||
|
)
|
||||||
|
this.container.appendChild(root)
|
||||||
|
input.checked = initial.includes(value)
|
||||||
|
input.addEventListener('change', () => this.sync())
|
||||||
|
}
|
||||||
|
super.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
value() {
|
||||||
|
return Array.from(this.root.querySelectorAll('input:checked')).map((el) => el.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Fields.Select = class extends BaseElement {
|
Fields.Select = class extends BaseElement {
|
||||||
getTemplate() {
|
getTemplate() {
|
||||||
return `<select name="${this.name}" data-ref=select></select>`
|
return `<select name="${this.name}" data-ref=select></select>`
|
||||||
|
@ -1296,13 +1319,14 @@ Fields.ManageEditors = class extends BaseElement {
|
||||||
placeholder: translate("Type editor's username"),
|
placeholder: translate("Type editor's username"),
|
||||||
}
|
}
|
||||||
this.autocomplete = new AjaxAutocompleteMultiple(this.container, options)
|
this.autocomplete = new AjaxAutocompleteMultiple(this.container, options)
|
||||||
this._values = this.toHTML()
|
this._values = this.toHTML() || []
|
||||||
if (this._values)
|
if (this._values) {
|
||||||
for (let i = 0; i < this._values.length; i++)
|
for (let i = 0; i < this._values.length; i++)
|
||||||
this.autocomplete.displaySelected({
|
this.autocomplete.displaySelected({
|
||||||
item: { value: this._values[i].id, label: this._values[i].name },
|
item: { value: this._values[i].id, label: this._values[i].name },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
value() {
|
value() {
|
||||||
return this._values
|
return this._values
|
||||||
|
|
|
@ -516,6 +516,9 @@ export const SCHEMA = {
|
||||||
helpEntries: ['sync'],
|
helpEntries: ['sync'],
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
tags: {
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
team: {
|
team: {
|
||||||
type: Object,
|
type: Object,
|
||||||
},
|
},
|
||||||
|
|
|
@ -755,6 +755,12 @@ export default class Umap {
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
container.appendChild(form)
|
container.appendChild(form)
|
||||||
|
|
||||||
|
const tags = DomUtil.createFieldset(container, translate('Tags'))
|
||||||
|
const tagsFields = ['properties.tags']
|
||||||
|
const tagsBuilder = new MutatingForm(this, tagsFields, {
|
||||||
|
umap: this,
|
||||||
|
})
|
||||||
|
tags.appendChild(tagsBuilder.build())
|
||||||
const credits = DomUtil.createFieldset(container, translate('Credits'))
|
const credits = DomUtil.createFieldset(container, translate('Credits'))
|
||||||
const creditsFields = [
|
const creditsFields = [
|
||||||
'properties.licence',
|
'properties.licence',
|
||||||
|
@ -1168,6 +1174,7 @@ export default class Umap {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('name', this.properties.name)
|
formData.append('name', this.properties.name)
|
||||||
formData.append('center', JSON.stringify(this.geometry()))
|
formData.append('center', JSON.stringify(this.geometry()))
|
||||||
|
formData.append('tags', this.properties.tags || [])
|
||||||
formData.append('settings', JSON.stringify(geojson))
|
formData.append('settings', JSON.stringify(geojson))
|
||||||
const uri = this.urls.get('map_save', { map_id: this.id })
|
const uri = this.urls.get('map_save', { map_id: this.id })
|
||||||
const [data, _, error] = await this.server.post(uri, {}, formData)
|
const [data, _, error] = await this.server.post(uri, {}, formData)
|
||||||
|
|
|
@ -1,15 +1,25 @@
|
||||||
{% load umap_tags i18n %}
|
{% load umap_tags i18n %}
|
||||||
|
|
||||||
{% for map_inst in maps %}
|
{% for map_inst in maps %}
|
||||||
<div>
|
<div class="card">
|
||||||
{% map_fragment map_inst prefix=prefix page=request.GET.p %}
|
{% map_fragment map_inst prefix=prefix page=request.GET.p %}
|
||||||
<hgroup>
|
<hgroup>
|
||||||
|
<div>
|
||||||
|
{% if map_inst.tags %}
|
||||||
|
<ul class="tag-list">
|
||||||
|
{% for tag, label in map_inst.get_tags_display %}
|
||||||
|
<li><a href="{% url 'search' %}?tags={{ tag }}">{{ label }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
<h3><a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a></h3>
|
<h3><a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a></h3>
|
||||||
{% with author=map_inst.get_author %}
|
{% with author=map_inst.get_author %}
|
||||||
{% if author %}
|
{% if author %}
|
||||||
<p>{% trans "by" %} <a href="{{ author.get_url }}">{{ author }}</a></p>
|
<p>{% trans "by" %} <a href="{{ author.get_url }}">{{ author }}</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<a class="button" href="{{ map_inst.get_absolute_url }}">{% translate "See the map" %}</a>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -4,17 +4,19 @@
|
||||||
{% trans "Search maps" as default_placeholder %}
|
{% trans "Search maps" as default_placeholder %}
|
||||||
<div class="wrapper search_wrapper">
|
<div class="wrapper search_wrapper">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<form action="{% firstof action search_url %}" method="get">
|
<form class="search-form flex-break" action="{% firstof action search_url %}" method="get">
|
||||||
<div class="col two-third mwide">
|
|
||||||
<input name="q"
|
<input name="q"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="{% firstof placeholder default_placeholder %}"
|
placeholder="{% firstof placeholder default_placeholder %}"
|
||||||
aria-label="{% firstof placeholder default_placeholder %}"
|
aria-label="{% firstof placeholder default_placeholder %}"
|
||||||
value="{{ request.GET.q|default:"" }}" />
|
value="{{ request.GET.q|default:"" }}" />
|
||||||
</div>
|
<select name="tags">
|
||||||
<div class="col third mwide">
|
<option value="">{% trans "Any category" %}</option>
|
||||||
|
{% for value, label in UMAP_TAGS %}
|
||||||
|
<option value="{{ value }}" {% if request.GET.tags == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
<input type="submit" value="{% trans "Search" %}" class="neutral" />
|
<input type="submit" value="{% trans "Search" %}" class="neutral" />
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -226,3 +226,18 @@ def test_hover_tooltip_setting_should_be_persistent(live_server, map, page):
|
||||||
- text: always never on hover
|
- text: always never on hover
|
||||||
""")
|
""")
|
||||||
expect(page.locator(".umap-field-showLabel input[value=null]")).to_be_checked()
|
expect(page.locator(".umap-field-showLabel input[value=null]")).to_be_checked()
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_edit_map_tags(live_server, map, page):
|
||||||
|
map.settings["properties"]["tags"] = ["arts"]
|
||||||
|
map.edit_status = Map.ANONYMOUS
|
||||||
|
map.save()
|
||||||
|
page.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
page.get_by_role("button", name="Edit map name and caption").click()
|
||||||
|
page.get_by_text("Tags").click()
|
||||||
|
expect(page.get_by_label("Art and Culture")).to_be_checked()
|
||||||
|
page.get_by_label("Cycling").check()
|
||||||
|
with page.expect_response(re.compile("./update/settings/.*")):
|
||||||
|
page.get_by_role("button", name="Save").click()
|
||||||
|
saved = Map.objects.get(pk=map.pk)
|
||||||
|
assert saved.tags == ["arts", "cycling"]
|
||||||
|
|
|
@ -486,3 +486,27 @@ def test_cannot_search_deleted_map(client, map):
|
||||||
url = reverse("search")
|
url = reverse("search")
|
||||||
response = client.get(url + "?q=Blé")
|
response = client.get(url + "?q=Blé")
|
||||||
assert "Blé dur" not in response.content.decode()
|
assert "Blé dur" not in response.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_filter_by_tag(client, map):
|
||||||
|
# Very basic search, that do not deal with accent nor case.
|
||||||
|
# See install.md for how to have a smarter dict + index.
|
||||||
|
map.name = "Blé dur"
|
||||||
|
map.tags = ["bike"]
|
||||||
|
map.save()
|
||||||
|
url = reverse("search")
|
||||||
|
response = client.get(url + "?tags=bike")
|
||||||
|
assert "Blé dur" in response.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_can_combine_search_and_filter(client, map):
|
||||||
|
# Very basic search, that do not deal with accent nor case.
|
||||||
|
# See install.md for how to have a smarter dict + index.
|
||||||
|
map.name = "Blé dur"
|
||||||
|
map.tags = ["bike"]
|
||||||
|
map.save()
|
||||||
|
url = reverse("search")
|
||||||
|
response = client.get(url + "?q=dur&tags=bike")
|
||||||
|
assert "Blé dur" in response.content.decode()
|
||||||
|
|
|
@ -334,12 +334,18 @@ class TeamMaps(PaginatorMixin, DetailView):
|
||||||
class SearchMixin:
|
class SearchMixin:
|
||||||
def get_search_queryset(self, **kwargs):
|
def get_search_queryset(self, **kwargs):
|
||||||
q = self.request.GET.get("q")
|
q = self.request.GET.get("q")
|
||||||
|
tags = [t for t in self.request.GET.getlist("tags") if t]
|
||||||
|
qs = Map.objects.all()
|
||||||
if q:
|
if q:
|
||||||
vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION)
|
vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION)
|
||||||
query = SearchQuery(
|
query = SearchQuery(
|
||||||
q, config=settings.UMAP_SEARCH_CONFIGURATION, search_type="websearch"
|
q, config=settings.UMAP_SEARCH_CONFIGURATION, search_type="websearch"
|
||||||
)
|
)
|
||||||
return Map.objects.annotate(search=vector).filter(search=query)
|
qs = qs.annotate(search=vector).filter(search=query)
|
||||||
|
if tags:
|
||||||
|
qs = qs.filter(tags__contains=tags)
|
||||||
|
if q or tags:
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class Search(PaginatorMixin, TemplateView, PublicMapsMixin, SearchMixin):
|
class Search(PaginatorMixin, TemplateView, PublicMapsMixin, SearchMixin):
|
||||||
|
|
Loading…
Reference in a new issue