mirror of
https://github.com/umap-project/umap.git
synced 2025-05-05 14:01:50 +02:00
Compare commits
No commits in common. "32b9217bd28c11de774439cbb0ae80c4aea993b5" and "e2f154f62e2bd6effd87158222165bd35aa9bd33" have entirely different histories.
32b9217bd2
...
e2f154f62e
24 changed files with 134 additions and 308 deletions
|
@ -1,9 +1,10 @@
|
|||
# This part installs deps needed at runtime.
|
||||
FROM python:3.11-slim AS runtime
|
||||
FROM python:3.11-slim AS common
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
uwsgi \
|
||||
sqlite3 \
|
||||
libpq-dev \
|
||||
gdal-bin \
|
||||
|
@ -13,7 +14,7 @@ RUN apt-get update && \
|
|||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# This part adds deps needed only at buildtime.
|
||||
FROM runtime AS build
|
||||
FROM common AS build
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
@ -38,9 +39,9 @@ WORKDIR /srv/umap
|
|||
|
||||
COPY . /srv/umap
|
||||
|
||||
RUN /venv/bin/pip install .[docker,s3,sync]
|
||||
RUN /venv/bin/pip install .[docker,s3]
|
||||
|
||||
FROM runtime
|
||||
FROM common
|
||||
|
||||
COPY --from=build /srv/umap/docker/ /srv/umap/docker/
|
||||
COPY --from=build /venv/ /venv/
|
||||
|
|
|
@ -1,15 +1,5 @@
|
|||
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:
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
|
@ -24,9 +14,7 @@ services:
|
|||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
image: umap/umap:2.9.3
|
||||
image: umap/umap:2.0.2
|
||||
ports:
|
||||
- "${PORT-8000}:8000"
|
||||
environment:
|
||||
|
@ -35,8 +23,6 @@ services:
|
|||
- SITE_URL=https://umap.local/
|
||||
- UMAP_ALLOW_ANONYMOUS=True
|
||||
- DEBUG=1
|
||||
- WEBSOCKET_ENABLED=1
|
||||
- REDIS_URL=redis://redis:6379
|
||||
volumes:
|
||||
- data:/srv/umap/uploads
|
||||
|
||||
|
|
|
@ -9,5 +9,5 @@ umap collectstatic --noinput
|
|||
umap wait_for_database
|
||||
# then migrate the database
|
||||
umap migrate
|
||||
# run the server
|
||||
exec uvicorn --proxy-headers --no-access-log --host 0.0.0.0 umap.asgi:application
|
||||
# run uWSGI
|
||||
exec uwsgi --ini docker/uwsgi.ini
|
||||
|
|
26
docker/uwsgi.ini
Normal file
26
docker/uwsgi.ini
Normal file
|
@ -0,0 +1,26 @@
|
|||
[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,10 +323,7 @@ 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, 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);
|
||||
CREATE INDEX IF NOT EXISTS search_idx ON umap_map USING GIN(to_tsvector('umapdict', COALESCE(name, ''::character varying)::text), share_status);
|
||||
```
|
||||
|
||||
Then set:
|
||||
|
@ -357,7 +354,52 @@ Otherwise, use any valid [python-social-auth configuration](https://python-socia
|
|||
|
||||
#### WEBSOCKET_ENABLED
|
||||
|
||||
Setting `WEBSOCKET_ENABLED` to `True` will allow users to enable real-time collaboration.
|
||||
A switch will be available for them in the "advanced properties" of the map.
|
||||
A WebSocket server is packaged with uMap, and can be turned-on to activate
|
||||
"real-time collaboration". In practice, in order to enable it, a few settings
|
||||
are exposed.
|
||||
|
||||
See [the documentation about ASGI deployment](../deploy/asgi.md) for more information.
|
||||
Setting `WEBSOCKET_ENABLED` to `True` will **not** enable real-time
|
||||
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:
|
||||
# Check https://hub.docker.com/r/umap/umap/tags to find the latest version
|
||||
image: umap/umap:2.9.3
|
||||
image: umap/umap:2.0.2
|
||||
ports:
|
||||
# modify the external port (8001, on the left) if desired, but make sure it matches SITE_URL, below
|
||||
- "8001:8000"
|
||||
|
@ -48,45 +48,3 @@ User accounts can be managed via the Django admin page ({SITE_URL}/admin). The r
|
|||
```bash
|
||||
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"
|
||||
]
|
||||
docker = [
|
||||
"uvicorn==0.34.0",
|
||||
"uwsgi==2.0.28",
|
||||
]
|
||||
s3 = [
|
||||
"django-storages[s3]==1.14.5",
|
||||
|
@ -73,7 +73,6 @@ s3 = [
|
|||
sync = [
|
||||
"pydantic==2.11.1",
|
||||
"redis==5.2.1",
|
||||
"websockets==15.0.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
@ -14,7 +14,6 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ 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
|
||||
|
||||
|
@ -91,7 +92,7 @@ class MapSettingsForm(forms.ModelForm):
|
|||
return self.cleaned_data["center"]
|
||||
|
||||
class Meta:
|
||||
fields = ("settings", "name", "center", "slug", "tags")
|
||||
fields = ("settings", "name", "center", "slug")
|
||||
model = Map
|
||||
|
||||
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
# 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,7 +4,6 @@ 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
|
||||
|
@ -237,7 +236,6 @@ 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()
|
||||
|
@ -417,17 +415,12 @@ 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])},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
||||
|
@ -291,25 +290,6 @@ 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
|
||||
|
@ -366,4 +346,4 @@ WEBSOCKET_ENABLED = env.bool("WEBSOCKET_ENABLED", default=False)
|
|||
WEBSOCKET_BACK_HOST = env("WEBSOCKET_BACK_HOST", default="localhost")
|
||||
WEBSOCKET_BACK_PORT = env.int("WEBSOCKET_BACK_PORT", default=8001)
|
||||
|
||||
REDIS_URL = env("REDIS_URL", default="redis://localhost:6379")
|
||||
REDIS_URL = "redis://localhost:6379"
|
||||
|
|
|
@ -16,22 +16,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
/* **************** */
|
||||
|
@ -76,8 +60,26 @@ body.login header {
|
|||
.demo_map .map_fragment {
|
||||
height: var(--map-fragment-height);
|
||||
}
|
||||
.grid-container hgroup {
|
||||
text-align: left;
|
||||
.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;
|
||||
}
|
||||
.umap-features-list ul {
|
||||
margin-top: 14px;
|
||||
|
@ -165,49 +167,7 @@ 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 */
|
||||
|
@ -581,9 +541,4 @@ dialog::backdrop {
|
|||
.mhide {
|
||||
display: none;
|
||||
}
|
||||
.flex-break {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -203,7 +203,6 @@
|
|||
line-height: initial;
|
||||
height: initial;
|
||||
width: auto;
|
||||
padding: 0 var(--text-margin);
|
||||
}
|
||||
.umap-caption-bar-enabled {
|
||||
--current-footer-height: var(--footer-height);
|
||||
|
|
|
@ -61,7 +61,10 @@ textarea {
|
|||
select {
|
||||
border: 1px solid #222;
|
||||
width: 100%;
|
||||
padding: var(--button-padding);
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: var(--box-margin);
|
||||
}
|
||||
.dark select {
|
||||
color: #efefef;
|
||||
|
|
|
@ -138,8 +138,6 @@ 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,
|
||||
|
|
|
@ -324,29 +324,6 @@ 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>`
|
||||
|
@ -1319,13 +1296,12 @@ 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() {
|
||||
|
|
|
@ -516,9 +516,6 @@ export const SCHEMA = {
|
|||
helpEntries: ['sync'],
|
||||
default: false,
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
},
|
||||
team: {
|
||||
type: Object,
|
||||
},
|
||||
|
|
|
@ -755,12 +755,6 @@ 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',
|
||||
|
@ -1174,7 +1168,6 @@ 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)
|
||||
|
|
|
@ -1,25 +1,15 @@
|
|||
{% load umap_tags i18n %}
|
||||
|
||||
{% for map_inst in maps %}
|
||||
<div class="card">
|
||||
<div>
|
||||
{% map_fragment map_inst prefix=prefix page=request.GET.p %}
|
||||
<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>
|
||||
<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 %}
|
||||
<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>
|
||||
{% endwith %}
|
||||
</hgroup>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
|
@ -4,19 +4,17 @@
|
|||
{% trans "Search maps" as default_placeholder %}
|
||||
<div class="wrapper search_wrapper">
|
||||
<div class="row">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -226,18 +226,3 @@ 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"]
|
||||
|
|
|
@ -486,27 +486,3 @@ 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()
|
||||
|
|
|
@ -334,18 +334,12 @@ 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"
|
||||
)
|
||||
qs = qs.annotate(search=vector).filter(search=query)
|
||||
if tags:
|
||||
qs = qs.filter(tags__contains=tags)
|
||||
if q or tags:
|
||||
return qs
|
||||
return Map.objects.annotate(search=vector).filter(search=query)
|
||||
|
||||
|
||||
class Search(PaginatorMixin, TemplateView, PublicMapsMixin, SearchMixin):
|
||||
|
|
Loading…
Reference in a new issue