mirror of
https://github.com/umap-project/umap.git
synced 2025-04-28 19:42:36 +02:00
feat: add Map.tags and allow to edit from client (#2530)
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:
commit
32b9217bd2
18 changed files with 238 additions and 49 deletions
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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