Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
55758cd
fix tests
PartyNell Nov 5, 2025
1cd22f4
create a popup with configurable arguments for markers
PartyNell Oct 23, 2025
6bf1129
use HTML template for popup API
PartyNell Oct 27, 2025
b883743
add tests
PartyNell Oct 27, 2025
5dc18f8
add documentation about popups
PartyNell Oct 27, 2025
7938098
add changelog entry
PartyNell Oct 28, 2025
6e275dd
fix quality
PartyNell Oct 28, 2025
f08257f
change popup's title with str(obj)
PartyNell Oct 28, 2025
15ed7ab
change traduction
PartyNell Oct 30, 2025
c93bfc0
improve text formatting in popup content api
PartyNell Oct 31, 2025
944d0bb
adapt tests
PartyNell Oct 31, 2025
a896858
adapt documentation
PartyNell Oct 31, 2025
a7c624e
add parameter to display popup or not
PartyNell Nov 3, 2025
305d89f
add changelog entry
PartyNell Nov 5, 2025
891475b
Merge branch 'fix_test_content_type' into make_labels_configurable
PartyNell Nov 5, 2025
fe4ecaa
change LABEL_PER_MODEL setting name
PartyNell Nov 5, 2025
a260c54
Update mapentity/templates/mapentity/mapentity_popup_content.html
PartyNell Nov 5, 2025
6b6a97b
remove spaces in the popup
PartyNell Nov 5, 2025
4c9e6ed
fix tests
PartyNell Nov 5, 2025
4fe047b
Merge branch 'master' into make_labels_configurable
PartyNell Nov 5, 2025
2a7ff09
add get_popup_url in the model
PartyNell Nov 6, 2025
391c65b
fix quality
PartyNell Nov 6, 2025
82acecb
Merge branch 'master' into make_labels_configurable
PartyNell Nov 12, 2025
a3bd64b
apply suggestions
PartyNell Nov 13, 2025
92dbc2f
modify magin and padding of popup
PartyNell Nov 13, 2025
62c9c22
add traduction of field name for boolean fields in popup
PartyNell Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ CHANGELOG
8.14.5+dev (XXXX-XX-XX)
-----------------------

**Improvements**

- Add popup on marker with configurable information

**Bug fixes**

- Fix the test issue in get_expected_datatables_attrs


8.14.5 (2025-10-30)
-----------------------
Expand Down
34 changes: 33 additions & 1 deletion docs/customization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,38 @@ The second way overrides these templates for all your models.
you need to create a sub-directory named ``mapentity`` in ``main/templates``.
Then you can create a file named ``override_detail_pdf.html``(or ``.css``) and it will be used for all your models if a specific template is not provided.

Popups
------

MapEntity displays a popup when clicking on a map object.
By default, it shows the object's name and a button linking to its detail page.

Configure popup fields for each model using the ``POPUP_CONTENT`` setting:

.. code-block:: python

POPUP_CONTENT = {
"museum": ["name", "city", "public"],
}

The key is the model name in lowercase (e.g., ``"museum"``), and the value is a list of field names to display.
The popup title shows the object's string representation (using `__str__()`), with the configured fields displayed below.

If there is a display function for a field in the model then it will be used in priority.

.. code-block:: python

@property
def public_display(self):
return "Public" if self.public else "Not public"

If no display function exists, the field’s string representation is used by default.

Non-existent fields can be used if a display function exist.

If a model isn't configured in ``POPUP_CONTENT``, the object's string representation is used as the title.
If a specified field doesn't exist on the model, it won't be displayed. The detail page button is always shown.
If the option ``displayPopup`` is setup to false, then the popup will not appear when clicking on the feature.

Settings
-----------
Expand Down Expand Up @@ -256,4 +288,4 @@ A help message will be added, and color of TinyMCE status bar and border will be
MAPENTITY_CONFIG['MAX_CHARACTERS_BY_FIELD'] = {
"tourism_touristicevent": [{'field': 'description_teaser_fr', 'value': 50}, {'field': 'accessibility_fr', 'value': 25}],
"trekking_trek": [{'field': 'description_teaser_fr', 'value': 150}],
}
}
5 changes: 4 additions & 1 deletion mapentity/locale/en/LC_MESSAGES/djangojs.po
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-02 15:19+0000\n"
"POT-Creation-Date: 2025-10-24 13:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
Expand All @@ -18,5 +18,8 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

msgid "Data unreachable"
msgstr ""

msgid "Locate me"
msgstr ""
3 changes: 3 additions & 0 deletions mapentity/locale/fr/LC_MESSAGES/djangojs.po
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.13.3\n"

msgid "Data unreachable"
msgstr "Données inaccessibles"

msgid "Locate me"
msgstr "Localisez-moi"
50 changes: 41 additions & 9 deletions mapentity/static/mapentity/leaflet-objectslayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ L.ObjectsLayer = L.GeoJSON.extend({
indexing: true,
highlight: true,
objectUrl: null,
displayPopup: true,
styles: {
'default': {'color': 'blue', 'weight': 2, 'opacity': 0.8},
highlight: {'color': 'red', 'weight': 5, 'opacity': 1},
Expand Down Expand Up @@ -69,13 +70,25 @@ L.ObjectsLayer = L.GeoJSON.extend({
}
}, this));


// Optionnaly make them clickable
if (this.options.objectUrl) {
this.on('click', function(e) {
window.location = this.options.objectUrl(e.layer.properties, e.layer);
}, this);
}
this.on('click', async function (e) {
if(this.options.displayPopup){
var popup_content;
try{
popup_content = await this.getPopupContent(e.layer);
} catch (error) {
popup_content = gettext('Data unreachable');
}
if (e.target._popup){
// update popup content if it has been already bind
var popup = e.target._popup;
popup.setContent(popup_content);
popup.update();
} else {
// bind a new popup
this.bindPopup(popup_content).openPopup(e.latlng);
}
}
}, this);

var dataurl = null;
if (typeof(geojson) == 'string') {
Expand Down Expand Up @@ -220,8 +233,27 @@ L.ObjectsLayer = L.GeoJSON.extend({
layer._defaultStyle = this.options.styles['default'];
layer.setStyle(layer._defaultStyle);
}
}
});
},

getPopupContent: async function (layer){
const popup_url = window.SETTINGS.urls.popup.replace(new RegExp('modelname', 'g'), this.options.modelname)
.replace('0', layer.properties.id);

// fetch data
var response = await window.fetch(popup_url);
if (!response.ok){
throw new Error(`HTTP error! Status: ${response.status}`);
} else {
// parse data
try {
var json = await response.json();
return json.data;
} catch (error) {
throw new Error('Cannot parse data');
}
}
},

});

L.ObjectsLayer.include(L.LayerIndexMixin);
9 changes: 9 additions & 0 deletions mapentity/templates/mapentity/mapentity_popup_content.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div class="d-flex flex-column justify-content-center">
<p class="text-center m-0 p-2"><strong>{{ title }}</strong></p>
{% if attributes %}
<p>
{% for field in attributes %}{{ field }}<br>{% endfor %}
</p>
{% endif %}
<button id="detail-btn" class="btn btn-sm btn-info" onclick="window.location.href='{{ detail_url }}'">{{ button_label }}</button>
</div>
22 changes: 22 additions & 0 deletions mapentity/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ def get_expected_geojson_attrs(self):
def get_expected_datatables_attrs(self):
return {}

def get_expected_popup_content(self):
return ""

def setUp(self):
if self.user:
self.client.force_login(user=self.user)
Expand Down Expand Up @@ -444,6 +447,25 @@ def test_api_geojson_list_for_model(self):
},
)

def test_api_popup_content(self):
if self.model is None:
return # Abstract test should not run

self.obj = self.modelfactory.create()
popup_url = f"/api/{self.model._meta.model_name}/drf/{self.model._meta.model_name}s/{self.obj.pk}/popup_content"
response = self.client.get(popup_url)
self.assertEqual(response.status_code, 200, f"{popup_url} not found")
content_json = response.json()
self.assertEqual(
content_json,
{
"data": self.get_expected_popup_content(),
"draw": 1,
"recordsFiltered": 1,
"recordsTotal": 1,
},
)


class MapEntityLiveTest(LiveServerTestCase):
model = None
Expand Down
53 changes: 53 additions & 0 deletions mapentity/views/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import logging

from django.conf import settings
from django.contrib.gis.db.models.functions import Transform
from django.core.exceptions import FieldDoesNotExist
from django.db import models
from django.template import loader
from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import renderers, viewsets
from rest_framework.decorators import action
Expand Down Expand Up @@ -89,9 +94,57 @@ def filter_infos(self, request, *args, **kwargs):
{
"pk_list": qs.values_list("pk", flat=True),
"count": self.get_filter_count_infos(qs),
"attributes": [],
}
)

@action(detail=True, methods=["get"])
def popup_content(self, request, *args, **kwargs):
obj = self.get_object()
model_name = self.model.__name__.lower()
label_config = getattr(settings, "POPUP_CONTENT", {})
fields = label_config.get(model_name, []).copy()

def get_field_value(obj, field):
try:
modelfield = self.model._meta.get_field(field)
except FieldDoesNotExist:
modelfield = None

value = getattr(obj, field + "_display", getattr(obj, field, None))
if isinstance(value, bool):
field_name = obj._meta.get_field(field).verbose_name
value = f"{field_name}: " + (_("no"), _("yes"))[value]

if isinstance(modelfield, models.ManyToManyField) and not isinstance(
value, str
):
value = ", ".join([str(val) for val in value.all()])

return mapentity_serializers.smart_plain_text(value, ascii)

def clean_value(value):
if isinstance(value, str):
# truncate long text (around 2 rows)
if len(value) > 100:
value = value[:100] + "..."
return value

context = {
"button_label": _("Detail sheet"),
"detail_url": obj.get_detail_url(),
"attributes": [],
"title": str(obj),
}

for field_name in fields:
value = get_field_value(obj, field_name)
if value:
context["attributes"].append(clean_value(value))

template = loader.get_template("mapentity/mapentity_popup_content.html")
return Response(template.render(context))

@view_cache_latest()
@view_cache_response_content()
def list(self, request, *args, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions mapentity/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def __str__(self):
dictsettings["urls"]["static"] = settings.STATIC_URL
dictsettings["urls"]["layer"] = options.model.get_layer_url()
dictsettings["urls"]["detail"] = f"{root_url}modelname/0/"
dictsettings["urls"]["popup"] = "/api/modelname/drf/modelnames/0/popup_content"
dictsettings["urls"]["format_list"] = (
f"{root_url}{options._url_path(mapentity_models.ENTITY_FORMAT_LIST)[1:-1]}"
)
Expand Down
4 changes: 4 additions & 0 deletions test_project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,7 @@

if "test" in sys.argv:
MEDIA_ROOT = TemporaryDirectory().name

POPUP_CONTENT = {
"dummymodel": ["short_description", "public", "tags", "name", "test"],
}
1 change: 1 addition & 0 deletions test_project/test_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def __str__(self):
def is_public(self):
return self.public

@property
def name_display(self):
return f'<a href="{self.get_detail_url()}">{self.name or self.id}</a>'

Expand Down
1 change: 1 addition & 0 deletions test_project/test_app/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Meta:

class DummyModelFactory(factory.django.DjangoModelFactory):
name = "a dummy model"
short_description = "a dummy model with a dummy name, a dummy geom, dummy tags, dummy makinins. It is the perfect object to make tests"

@factory.lazy_attribute
def geom(self):
Expand Down
19 changes: 17 additions & 2 deletions test_project/test_app/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,21 @@ def get_expected_datatables_attrs(self):
"name_fr": "",
"name_zh_hant": "",
"public": '<i class="bi bi-x-circle text-danger"></i>',
"short_description": "",
"short_description": "a dummy model with a dummy name, a dummy geom, dummy tags, dummy makinins. It is the perfect object to make tests",
"tags": [self.obj.tags.first().pk],
}

def get_expected_popup_content(self):
return (
f'<div class="d-flex flex-column justify-content-center">\n'
f' <p class="text-center mb-0 p-2"><strong>a dummy model (1)</strong></p>\n'
f" <p>\n"
f" a dummy model with a dummy name, a dummy geom, dummy tags, dummy makinins. It is the perfect object ...<br>public: no<br>{self.obj.tags.first().label}<br>a dummy model<br>\n"
f" </p>\n"
f' <button id="detail-btn" class="btn btn-sm btn-info" onclick="window.location.href=\'/dummymodel/{self.model.objects.first().pk}/\'">Detail sheet</button>\n'
f"</div>"
)

def get_good_data(self):
return {"geom": '{"type": "Point", "coordinates":[0, 0]}'}

Expand Down Expand Up @@ -289,6 +300,7 @@ def test_js_settings_urls(self):
"layer": "/api/modelname/drf/modelnames.geojson",
"screenshot": "/map_screenshot/",
"detail": "/modelname/0/",
"popup": "/api/modelname/drf/modelnames/0/popup_content",
"format_list": "/modelname/list/export/",
"static": "/static/",
"root": "/",
Expand Down Expand Up @@ -560,7 +572,7 @@ def get_expected_datatables_attrs(self):
"action_flag": "Addition",
"action_time": "10/06/2022 12:40:10",
"change_message": "",
"content_type": 13,
"content_type": 12,
"id": 1,
"object": '<a data-pk="1" href="/dummymodel/1/" >Test_App | Dummy '
"Model <class 'object'></a>",
Expand Down Expand Up @@ -634,6 +646,9 @@ def test_no_basic_format_fail(self):
def test_no_html_in_csv(self):
pass

def test_api_popup_content(self):
pass


class LogViewMapentityTestlLiveTest(MapEntityLiveTest):
userfactory = SuperUserFactory
Expand Down
Loading