Compare commits

..

17 commits

Author SHA1 Message Date
Yohan Boniface
32b9217bd2
feat: add Map.tags and allow to edit from client (#2530)
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
cf #2283

Note: this PR uses ArrayField, which is Postgres only, so this would
officially remove the support of spatialite as it is. I'm not sure at
all uMap still works with spatialite, so maybe that the opportunity to
either add spatialite in the CI and make sure we support it, or remove
it and only target Postgres/PostGIS.
2025-04-03 18:52:17 +02:00
Yohan Boniface
4fd57e0c11 chore: remove tag icons for now
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 18:29:39 +02:00
Yohan Boniface
54420568af chore: refine map fragment card CSS
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 18:20:50 +02:00
Yohan Boniface
8c90535105 chore: better CSS for tags in maps list
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 17:51:37 +02:00
Yohan Boniface
80a969917a chore: simplify search form CSS and HTML
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 17:51:20 +02:00
Yohan Boniface
6582e85f18
Update the Dockerfile to expose websockets (#2576)
The Dockerfile now uses ASGI by default (via uvicorn).
2025-04-03 17:16:56 +02:00
Yohan Boniface
023645a160 wip: make Redis service optional in Docker image
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 17:12:12 +02:00
Yohan Boniface
e24695b68c wip: display tags in maps list 2025-04-03 14:51:04 +02:00
Yohan Boniface
db72bfad8d wip: sort tags by label, not by value 2025-04-03 11:10:36 +02:00
Yohan Boniface
738af24dfc chore: fix tags related test 2025-04-03 11:10:36 +02:00
Yohan Boniface
06f963c07f chore: align search form elements
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 11:10:36 +02:00
Yohan Boniface
c6ed896a6d feat: associate icons to tags
Co-authored-by: David Larlet <david@larlet.fr>
2025-04-03 11:10:36 +02:00
Yohan Boniface
1344fe6b46 feat: basic filtering by tags 2025-04-03 11:10:36 +02:00
Yohan Boniface
39f38a9cdf feat: add Map.tags and allow to edit from client
cf #2283
2025-04-03 11:10:33 +02:00
d72debdeb2
Docs: Add documentation for local docker development 2025-03-14 15:29:25 +01:00
33525a1837
Docs: Remove old references to websocket configuration 2025-03-14 15:29:25 +01:00
3e0a6b8f10
Update the Dockerfile to expose websockets
The Dockerfile now uses ASGI by default (via uvicorn) and embeds a
redis server that is used for the synchronization.
2025-03-14 15:29:17 +01:00
24 changed files with 308 additions and 134 deletions

View file

@ -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/

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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
```

View file

@ -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]

View file

@ -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,
} }

View file

@ -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

View 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,
),
),
]

View file

@ -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])},
} }

View file

@ -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")

View file

@ -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;
}
} }

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -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,12 +1319,13 @@ 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() {

View file

@ -516,6 +516,9 @@ export const SCHEMA = {
helpEntries: ['sync'], helpEntries: ['sync'],
default: false, default: false,
}, },
tags: {
type: Array,
},
team: { team: {
type: Object, type: Object,
}, },

View file

@ -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)

View file

@ -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>
<h3><a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a></h3> <div>
{% with author=map_inst.get_author %} {% if map_inst.tags %}
{% if author %} <ul class="tag-list">
<p>{% trans "by" %} <a href="{{ author.get_url }}">{{ author }}</a></p> {% for tag, label in map_inst.get_tags_display %}
<li><a href="{% url 'search' %}?tags={{ tag }}">{{ label }}</a></li>
{% endfor %}
</ul>
{% endif %} {% endif %}
{% endwith %} <h3><a href="{{ map_inst.get_absolute_url }}">{{ map_inst.name }}</a></h3>
{% with author=map_inst.get_author %}
{% if author %}
<p>{% trans "by" %} <a href="{{ author.get_url }}">{{ author }}</a></p>
{% endif %}
{% endwith %}
</div>
<a class="button" href="{{ map_inst.get_absolute_url }}">{% translate "See the map" %}</a>
</hgroup> </hgroup>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -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:"" }}" /> <select name="tags">
</div> <option value="">{% trans "Any category" %}</option>
<div class="col third mwide"> {% for value, label in UMAP_TAGS %}
<input type="submit" value="{% trans "Search" %}" class="neutral" /> <option value="{{ value }}" {% if request.GET.tags == value %}selected{% endif %}>{{ label }}</option>
</div> {% endfor %}
</select>
<input type="submit" value="{% trans "Search" %}" class="neutral" />
</form> </form>
</div> </div>
</div> </div>

View file

@ -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"]

View file

@ -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()

View file

@ -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):