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.
This commit is contained in:
Yohan Boniface 2025-04-03 18:52:17 +02:00 committed by GitHub
commit 32b9217bd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 238 additions and 49 deletions

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;
# 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:

View file

@ -14,6 +14,7 @@ def settings(request):
"UMAP_DEMO_SITE": djsettings.UMAP_DEMO_SITE,
"UMAP_HOST_INFOS": djsettings.UMAP_HOST_INFOS,
"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.forms.utils import ErrorList
from django.template.defaultfilters import slugify
from django.utils.translation import gettext_lazy as _
from .models import DataLayer, Map, Team
@ -92,7 +91,7 @@ class MapSettingsForm(forms.ModelForm):
return self.cleaned_data["center"]
class Meta:
fields = ("settings", "name", "center", "slug")
fields = ("settings", "name", "center", "slug", "tags")
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.contrib.auth.models import User
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.storage import storages
from django.core.signing import Signer
@ -236,6 +237,7 @@ class Map(NamedModel):
settings = models.JSONField(
blank=True, null=True, verbose_name=_("settings"), default=dict
)
tags = ArrayField(models.CharField(max_length=200), blank=True, default=list)
objects = models.Manager()
public = PublicManager()
@ -415,12 +417,17 @@ class Map(NamedModel):
datalayer.clone(map_inst=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
def extra_schema(self):
return {
"iconUrl": {
"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
from django.conf.locale import LANG_INFO
from django.utils.translation import gettext_lazy as _
import umap as project_module
@ -290,6 +291,25 @@ UMAP_HOME_FEED = "latest"
UMAP_IMPORTERS = {}
UMAP_HOST_INFOS = {}
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_GZIP = True

View file

@ -16,6 +16,22 @@ input::-webkit-input-placeholder, ::-webkit-input-placeholder {
input:-moz-placeholder, :-moz-placeholder {
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 {
height: var(--map-fragment-height);
}
.map_list .legend {
padding-top: 7px;
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;
.grid-container hgroup {
text-align: left;
}
.umap-features-list ul {
margin-top: 14px;
@ -167,7 +165,49 @@ h2.tabs a:hover {
.more_button {
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 */
@ -541,4 +581,9 @@ dialog::backdrop {
.mhide {
display: none;
}
.flex-break {
flex-direction: column;
align-items: center;
}
}

View file

@ -203,6 +203,7 @@
line-height: initial;
height: initial;
width: auto;
padding: 0 var(--text-margin);
}
.umap-caption-bar-enabled {
--current-footer-height: var(--footer-height);

View file

@ -61,10 +61,7 @@ textarea {
select {
border: 1px solid #222;
width: 100%;
height: 28px;
line-height: 28px;
margin-top: 5px;
margin-bottom: var(--box-margin);
padding: var(--button-padding);
}
.dark select {
color: #efefef;

View file

@ -138,6 +138,8 @@ export class MutatingForm extends Form {
} else if (properties.type === Number) {
if (properties.step) properties.handler = 'Range'
else properties.handler = 'IntInput'
} else if (properties.type === Array) {
properties.handler = 'CheckBoxes'
} else if (properties.choices) {
const text_length = properties.choices.reduce(
(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 {
getTemplate() {
return `<select name="${this.name}" data-ref=select></select>`
@ -1296,12 +1319,13 @@ Fields.ManageEditors = class extends BaseElement {
placeholder: translate("Type editor's username"),
}
this.autocomplete = new AjaxAutocompleteMultiple(this.container, options)
this._values = this.toHTML()
if (this._values)
this._values = this.toHTML() || []
if (this._values) {
for (let i = 0; i < this._values.length; i++)
this.autocomplete.displaySelected({
item: { value: this._values[i].id, label: this._values[i].name },
})
}
}
value() {

View file

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

View file

@ -755,6 +755,12 @@ export default class Umap {
const form = builder.build()
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 creditsFields = [
'properties.licence',
@ -1168,6 +1174,7 @@ export default class Umap {
const formData = new FormData()
formData.append('name', this.properties.name)
formData.append('center', JSON.stringify(this.geometry()))
formData.append('tags', this.properties.tags || [])
formData.append('settings', JSON.stringify(geojson))
const uri = this.urls.get('map_save', { map_id: this.id })
const [data, _, error] = await this.server.post(uri, {}, formData)

View file

@ -1,15 +1,25 @@
{% load umap_tags i18n %}
{% for map_inst in maps %}
<div>
<div class="card">
{% map_fragment map_inst prefix=prefix page=request.GET.p %}
<hgroup>
<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>
<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 %}
{% 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>
</div>
{% endfor %}

View file

@ -4,17 +4,19 @@
{% trans "Search maps" as default_placeholder %}
<div class="wrapper search_wrapper">
<div class="row">
<form action="{% firstof action search_url %}" method="get">
<div class="col two-third mwide">
<input name="q"
type="search"
placeholder="{% firstof placeholder default_placeholder %}"
aria-label="{% firstof placeholder default_placeholder %}"
value="{{ request.GET.q|default:"" }}" />
</div>
<div class="col third mwide">
<input type="submit" value="{% trans "Search" %}" class="neutral" />
</div>
<form class="search-form flex-break" action="{% firstof action search_url %}" method="get">
<input name="q"
type="search"
placeholder="{% firstof placeholder default_placeholder %}"
aria-label="{% firstof placeholder default_placeholder %}"
value="{{ request.GET.q|default:"" }}" />
<select name="tags">
<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" />
</form>
</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
""")
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")
response = client.get(url + "?q=Blé")
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:
def get_search_queryset(self, **kwargs):
q = self.request.GET.get("q")
tags = [t for t in self.request.GET.getlist("tags") if t]
qs = Map.objects.all()
if q:
vector = SearchVector("name", config=settings.UMAP_SEARCH_CONFIGURATION)
query = SearchQuery(
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):