Cas pratique - annuaire de psychologues sur une carte¶
Ce guide répond à une question récurrente : comment ajouter une entité métier (par exemple un annuaire de psychologues), l’afficher sur une carte interactive, et l’exposer via une API ?
Le parcours :
Installer
sites-conformesdans un projet Django existant.Modéliser l’entité
Psychologuecomme un snippet Wagtail.Créer un bloc StreamField réutilisable qui affiche la liste sur une carte.
Exposer le tout via l’API REST de Wagtail (
/api/v2/).
0. Prérequis¶
sites-conformes doit être installé et configuré dans votre projet Django.
Voir Installation si ce n’est pas fait.
Le guide utilise uv pour gérer l’environnement
Python et exécuter les commandes Django (uv run python ...). Si vous
préférez un autre gestionnaire, retirez le préfixe uv run : les commandes
restent identiques.
L’exemple suppose une app Django locale nommée annuaire :
uv run python manage.py startapp annuaire
Ajoutez "annuaire" à INSTALLED_APPS, juste après les apps sites_conformes.*.
Note
Une implémentation complète et exécutable de ce guide se trouve dans
demo/annuaire/.
Pour la lancer :
clonez le dépôt et placez-vous dans
demo/lancez
just setup(installe les dépendances, applique les migrations et insère des psychologues + une page d’annuaire publiée à/annuaire/)lancez
just runserverpuis ouvrez http://localhost:8000/annuaire/
1. Le snippet Psychologue¶
Un snippet Wagtail est un modèle Django éditable depuis le back office sans qu’il s’agisse d’une page. Parfait pour des données réutilisables sur plusieurs pages : un annuaire de psys, une liste de lieux, des contacts.
On stocke les coordonnées en
DecimalField
plutôt qu’en
PointField
(PostGIS), pour rester sur une stack Postgres ou SQLite standard. Pour les
requêtes spatiales avancées (recherche par rayon, etc.), passez à
django.contrib.gis.
# annuaire/models.py
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
@register_snippet
class Psychologue(models.Model):
nom = models.CharField(max_length=120)
ville = models.CharField(max_length=80)
email = models.EmailField(blank=True)
telephone = models.CharField(max_length=20, blank=True)
latitude = models.DecimalField(
max_digits=9, decimal_places=6,
help_text="Latitude WGS84 (ex: 48.856614 pour Paris)",
)
longitude = models.DecimalField(
max_digits=9, decimal_places=6,
help_text="Longitude WGS84 (ex: 2.352222 pour Paris)",
)
panels = [
FieldPanel("nom"),
FieldPanel("ville"),
FieldPanel("email"),
FieldPanel("telephone"),
FieldPanel("latitude"),
FieldPanel("longitude"),
]
class Meta:
ordering = ("nom",)
verbose_name = "Psychologue"
verbose_name_plural = "Psychologues"
def __str__(self):
return f"{self.nom} ({self.ville})"
Migrations :
uv run python manage.py makemigrations annuaire
uv run python manage.py migrate
Le snippet apparaît dans l’admin Wagtail sous Snippets → Psychologues.
2. Un bloc StreamField qui affiche la carte¶
Pour intégrer la carte dans une page, on crée un bloc
Wagtail. Comme
l’éditeur ne doit pas saisir manuellement les psys - ils viennent de la base -
on utilise
StaticBlock
: un bloc sans champs éditables qui rend simplement un template à partir du
contexte fourni par
get_context().
# annuaire/blocks.py
from wagtail import blocks
from .models import Psychologue
class ListePsychologuesBlock(blocks.StaticBlock):
"""Affiche la liste des psychologues sur une carte interactive."""
class Meta:
icon = "group"
label = "Liste des psychologues (carte)"
template = "annuaire/blocks/liste_psychologues.html"
admin_text = "Affiche tous les psychologues sur une carte Carte Facile."
def get_context(self, value, parent_context=None):
context = super().get_context(value, parent_context=parent_context)
qs = Psychologue.objects.all()
context["psychologues"] = qs
# Sérialisation JSON-safe : les DecimalField deviennent des float
# pour le passage côté JavaScript.
context["psychologues_geojson"] = [
{
"nom": psy.nom,
"ville": psy.ville,
"lat": float(psy.latitude),
"lng": float(psy.longitude),
}
for psy in qs
]
return context
3. Le template : carte interactive avec Carte Facile¶
Carte Facile est une
bibliothèque de styles cartographiques fournie par la Fabrique des Géocommuns
(IGN). Elle s’appuie sur maplibre-gl pour le rendu.
Pour un POC, le plus simple est de charger les deux via CDN (unpkg.com) avec
un import map - pas de bundler nécessaire :
{# annuaire/templates/annuaire/blocks/liste_psychologues.html #}
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css">
<link rel="stylesheet" href="https://unpkg.com/carte-facile@0.9.0/dist/carte-facile.css">
<section class="fr-container fr-py-6w" aria-labelledby="annuaire-titre">
<h2 id="annuaire-titre" class="fr-h3">Annuaire des psychologues</h2>
<div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-7">
<div id="annuaire-carte"
style="height: 480px; width: 100%;"
role="application"
aria-label="Carte interactive de l'annuaire"></div>
</div>
<div class="fr-col-12 fr-col-md-5">
<ul class="fr-list" style="max-height: 480px; overflow-y: auto;">
{% for psy in psychologues %}
<li>
<strong>{{ psy.nom }}</strong> — {{ psy.ville }}
{% if psy.email %}<br><a href="mailto:{{ psy.email }}">{{ psy.email }}</a>{% endif %}
</li>
{% empty %}
<li>Aucun psychologue dans l'annuaire pour le moment.</li>
{% endfor %}
</ul>
</div>
</div>
{# `json_script` sérialise correctement avec l'échappement requis. #}
{{ psychologues_geojson|json_script:"annuaire-data" }}
<script type="importmap">
{
"imports": {
"maplibre-gl": "https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js",
"carte-facile": "https://unpkg.com/carte-facile@0.9.0/dist/carte-facile.esm.js"
}
}
</script>
<script type="module">
import maplibregl from "maplibre-gl";
import { mapStyles } from "carte-facile";
const data = JSON.parse(document.getElementById("annuaire-data").textContent);
const map = new maplibregl.Map({
container: "annuaire-carte",
style: mapStyles.simple,
center: [2.5, 47.0], // centre France
zoom: 5,
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }));
map.on("load", () => {
if (data.length === 0) return;
const bounds = new maplibregl.LngLatBounds();
for (const psy of data) {
const popup = new maplibregl.Popup({ offset: 18 }).setHTML(
`<strong>${psy.nom}</strong><br>${psy.ville}`
);
new maplibregl.Marker()
.setLngLat([psy.lng, psy.lat])
.setPopup(popup)
.addTo(map);
bounds.extend([psy.lng, psy.lat]);
}
map.fitBounds(bounds, { padding: 40, maxZoom: 10 });
});
</script>
</section>
4. Brancher le bloc sur une page¶
Le plus simple est de définir une AnnuairePage (une
Page Wagtail) qui
expose un
StreamField
contenant uniquement notre bloc :
# annuaire/models.py (suite)
from wagtail.fields import StreamField
from wagtail.models import Page
def _annuaire_stream_blocks():
# Lazy-import pour éviter un cycle blocks ↔ models.
from .blocks import ListePsychologuesBlock
return [("liste_psychologues", ListePsychologuesBlock())]
class AnnuairePage(Page):
body = StreamField(_annuaire_stream_blocks, blank=True, use_json_field=True)
content_panels = Page.content_panels + [FieldPanel("body")]
Pour ajouter votre bloc à toutes les pages sites-conformes, héritez de
CommonStreamBlock à la place - voir
sites_conformes.core.blocks.CommonStreamBlock et le pattern utilisé par
quefairedemesobjets/webapp/qfdmd/blocks.py.
Avertissement
Hériter de CommonStreamBlock couple votre site aux blocs sites-conformes :
à chaque montée de version où sites-conformes modifie ses blocs communs,
Django détectera un changement de StreamField et exigera une migration côté
projet hôte (uv run python manage.py makemigrations puis migrate).
C’est un compromis connu de l’approche par héritage. La page d’exemple ci-dessus
(AnnuairePage avec un StreamField qui ne contient que notre bloc) n’a pas
ce problème : elle reste stable tant que vous ne touchez pas à
ListePsychologuesBlock.
Migration, puis dans l’admin Wagtail vous pouvez créer une Annuaire page et
sa carte se rend automatiquement à partir des Psychologue en base.
5. Exposer les psychologues via l’API Wagtail¶
Wagtail fournit nativement une
API REST v2
en /api/v2/.
5.1 Endpoint snippets¶
Wagtail expose les pages et les images/documents par défaut, mais pas les snippets. On écrit un viewset personnalisé :
# annuaire/api.py
from rest_framework import serializers
from wagtail.api.v2.views import BaseAPIViewSet
from .models import Psychologue
class PsychologueSerializer(serializers.ModelSerializer):
class Meta:
model = Psychologue
fields = ["id", "nom", "ville", "email", "telephone", "latitude", "longitude"]
class PsychologuesAPIViewSet(BaseAPIViewSet):
model = Psychologue
base_serializer_class = PsychologueSerializer
body_fields = BaseAPIViewSet.body_fields + [
"nom", "ville", "email", "telephone", "latitude", "longitude",
]
listing_default_fields = BaseAPIViewSet.listing_default_fields + ["nom", "ville"]
5.2 Enregistrement sur le routeur¶
sites-conformes instancie déjà le routeur Wagtail dans
sites_conformes.config.api.api_router. On ajoute notre endpoint depuis
AppConfig.ready() :
# annuaire/apps.py
from django.apps import AppConfig
class AnnuaireConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "annuaire"
verbose_name = "Annuaire"
def ready(self):
from sites_conformes.config.api import api_router
from .api import PsychologuesAPIViewSet
api_router.register_endpoint("psychologues", PsychologuesAPIViewSet)
Cette approche évite de modifier les urls.py du projet : le routeur reste
celui de sites-conformes, on l’enrichit depuis notre app.
5.3 Exemples d’appels¶
# Liste de tous les psychologues, format JSON
curl http://localhost:8000/api/v2/psychologues/
# Un seul, par id
curl http://localhost:8000/api/v2/psychologues/42/
# Filtrer par ville (filtres Wagtail standards)
curl "http://localhost:8000/api/v2/psychologues/?ville=Lyon"
# Pagination - limit/offset
curl "http://localhost:8000/api/v2/psychologues/?limit=20&offset=40"
Les pages contenant le bloc Liste des psychologues sont également
disponibles via /api/v2/pages/,
et le StreamField body apparaît sérialisé en JSON.