Repository: incubateur-ademe/quefairedemesobjets
Pull request #1464: 🗺️ Carte sur mesure - Cyclevia
Author: login: fabienheureux
Created: 2025-03-25T14:40:55Z | Merged: 2025-04-16T14:31:43Z

# Description succincte du problème résolu

https://www.notion.so/accelerateur-transition-ecologique-ademe/Cyclevia-Besoins-sp-cifiques-de-personnalisation-de-la-carte-1996523d57d7809d95a4e0dcbce5ad9a?pvs=4

**🗺️ contexte**: Carte Cyclevia

**💡 quoi**: ajout d'une manière de configurer la carte 

**🎯 pourquoi**: pour la rendre réutilisable par des éco organismes

**🤔 comment**: 
- ajout d'un modèle django `CarteConfig`
- ajout d'une nouvelle vue héritant de la vue Carte, qui supplante certaines méthodes afin de personnaliser les filtres appliqués sur les acteurs affichés

## Exemple résultats / UI / Data

<img width="1624" alt="image" src="https://github.com/user-attachments/assets/56002647-5770-4657-bc62-b598be94b075" />

![image](https://github.com/user-attachments/assets/ef901a2b-b206-4f3d-8b9d-bfe2d51bdad8)



## Auto-review

Les trucs à faire avant de demander une review :

- [x] J'ai bien relu mon code
- [x] La CI passe bien
- [ ] En cas d'ajout de variable d'environnement, j'ai bien mis à jour le `.env.template`
- [ ] J'ai ajouté des tests qui couvrent le nouveau code

## ✅ Reste à faire (PR en cours)

- [x] optimisations performances

## 📆 A faire (prochaine PR)

- [ ] refacto formulaire adresse pour mieux gérer les actions dans la légende
- [ ] afficher le bouton légende au format mobile lorsqu'on a qu'une seule option 


Diff:
diff --git a/Makefile b/Makefile
index 068eaf244..8ea6a833f 100644
--- a/Makefile
+++ b/Makefile
@@ -85,18 +85,6 @@ createsuperuser:
 seed-database:
 	$(DJANGO_ADMIN) loaddata categories actions acteur_services acteur_types acteurs objets
 
-.PHONY: drop-db
-drop-db:
-	docker compose exec lvao-db dropdb -f -i -U qfdmo -e qfdmo
-
-.PHONY: create-db
-create-db:
-	docker compose exec lvao-db createdb -U qfdmo -e qfdmo
-
-.PHONY: restore-prod
-restore-prod:
-	./scripts/restore_prod_locally.sh
-
 .PHONY: clear-cache
 clear-cache:
 	$(DJANGO_ADMIN) clear_cache --all
@@ -135,17 +123,31 @@ extract-dsfr:
 	$(PYTHON) ./dsfr_hacks/extract_used_colors.py
 	$(PYTHON) ./dsfr_hacks/extract_used_icons.py
 
-# RESTORE DB LOCALLY
-.PHONY: db-restore
-db-restore:
+# Postgres database utilities: restore, drop database etc
+.PHONY: drop-db
+drop-db:
+	docker compose exec lvao-db dropdb -f -i -U qfdmo -e qfdmo
+
+.PHONY: create-db
+create-db:
+	docker compose exec lvao-db createdb -U qfdmo -e qfdmo
+
+.PHONY: dump-production
+dump-production:
 	mkdir -p tmpbackup
 	scalingo --app quefairedemesobjets --addon postgresql backups-download --output tmpbackup/backup.tar.gz
 	tar xfz tmpbackup/backup.tar.gz --directory tmpbackup
-	@DUMP_FILE=$$(find tmpbackup -type f -name "*.pgsql" -print -quit); \
-	for table in $$(psql "$(DB_URL)" -t -c "SELECT tablename FROM pg_tables WHERE schemaname='public'"); do \
-	    psql "$(DB_URL)" -c "DROP TABLE IF EXISTS $$table CASCADE"; \
-	done || true
+
+.PHONY: load-production-dump
+load-production-dump:
 	@DUMP_FILE=$$(find tmpbackup -type f -name "*.pgsql" -print -quit); \
 	pg_restore -d "$(DB_URL)" --clean --no-acl --no-owner --no-privileges "$$DUMP_FILE" || true
 	rm -rf tmpbackup
-	$(DJANGO_ADMIN) migrate
+
+.PHONY: db-restore
+db-restore:
+	make drop-db
+	make create-db
+	make dump-production
+	make load-production-dump
+	make migrate
diff --git a/core/test_settings.py b/core/test_settings.py
index c2a6cd48e..7b847a2a6 100644
--- a/core/test_settings.py
+++ b/core/test_settings.py
@@ -8,3 +8,22 @@
         "BACKEND": "django.core.cache.backends.dummy.DummyCache",
     },
 }
+
+# Storages is defined in settings.py the undefined
+# error can safely be ignored here
+STORAGES["default"][  # noqa: F405
+    "BACKEND"
+] = "django.core.files.storage.InMemoryStorage"
+STORAGES["staticfiles"] = {  # noqa: F405
+    "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
+}
+
+MIDDLEWARE = [
+    middleware
+    for middleware in MIDDLEWARE  # noqa: F405
+    if middleware
+    not in [
+        "whitenoise.middleware.WhiteNoiseMiddleware",
+        "debug_toolbar.middleware.DebugToolbarMiddleware",
+    ]
+]
diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py
deleted file mode 100644
index e34cfd69d..000000000
--- a/integration_tests/conftest.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from contextlib import suppress
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def disable_whitenoise(settings):
-    settings.STORAGES = {
-        "staticfiles": {
-            "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
-        }
-    }
-    settings.MIDDLEWARE = [
-        middleware
-        for middleware in settings.MIDDLEWARE
-        if middleware != "whitenoise.middleware.WhiteNoiseMiddleware"
-    ]
-
-
-@pytest.fixture(autouse=True)
-def disable_debug_toolbar(settings):
-    with suppress(ValueError):
-        settings.MIDDLEWARE.remove("debug_toolbar.middleware.DebugToolbarMiddleware")
diff --git a/jinja2/qfdmo/carte/header.html b/jinja2/qfdmo/carte/header.html
index 2796b42ff..e9df2f0e2 100644
--- a/jinja2/qfdmo/carte/header.html
+++ b/jinja2/qfdmo/carte/header.html
@@ -25,6 +25,7 @@
             Infos
         </button>
         <div class="qf-flex qf-flex-row-reverse">
+            {% if form.legend_grouped_action|length > 1 %}
             <button
                 class="fr-btn fr-btn--sm fr-btn--secondary fr-ml-1w fr-btn--icon-left fr-icon-eye-line"
                 type="button"
@@ -32,6 +33,7 @@
             >
                 Légende
             </button>
+            {% endif %}
             <button
                 class="fr-btn fr-btn--sm fr-btn--secondary fr-ml-1w fr-btn--icon-left fr-icon-equalizer-line"
                 type="button"
diff --git a/jinja2/qfdmo/carte/panels/legend.html b/jinja2/qfdmo/carte/panels/legend.html
index 0ac9c7fa9..c2743c1a4 100644
--- a/jinja2/qfdmo/carte/panels/legend.html
+++ b/jinja2/qfdmo/carte/panels/legend.html
@@ -1,16 +1,19 @@
 <aside
     data-testid="carte-legend"
     class="
-    qf-pl-3w qf-pb-2w qf-pr-2w qf-max-w-[17rem]
+    qf-pl-3w qf-py-2w qf-pr-2w qf-max-w-[17rem]
     qf-hidden lg:qf-block
     qf-absolute qf-bottom-0 qf-left-0 qf-z-10
     qf-bg-white qf-drop-shadow-lg"
 >
-    <h2 class="fr-h4 fr-my-2w">
+
+    {% if form.legend_grouped_action|length > 1 %}
+    <h2 class="fr-h4 fr-mb-2w">
         Actions possibles
     </h2>
     {{form.legend_grouped_action}}
     <hr class="qf-pb-2w qf-pt-0">
+    {% endif %}
     <button
         class="fr-mb-1w fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left
         fr-icon-equalizer-line
diff --git a/jinja2/qfdmo/shared/results.html b/jinja2/qfdmo/shared/results.html
index c16e9eb7a..9521c0e72 100644
--- a/jinja2/qfdmo/shared/results.html
+++ b/jinja2/qfdmo/shared/results.html
@@ -33,7 +33,7 @@
         >
             {% for acteur in acteurs %}
                 <script type="application/json" data-map-target="acteur">
-                    {{ acteur.json_acteur_for_display(direction=form.initial.direction, action_list=form.initial.action_list, carte=carte) | safe }}
+                    {{ acteur.json_acteur_for_display(direction=form.initial.direction, action_list=form.initial.action_list, carte=carte, carte_config=object) | safe }}
                 </script>
             {% endfor %}
         </div>
diff --git a/package-lock.json b/package-lock.json
index df0ee60ad..079e83c31 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,7 +25,7 @@
                 "@types/jest": "^29.5.13",
                 "@types/leaflet": "^1.9.17",
                 "@types/lodash": "^4.17.15",
-                "@types/node": "^22.14.1",
+                "@types/node": "^22.13.10",
                 "@typescript-eslint/eslint-plugin": "^8.29.0",
                 "buffer": "^6.0.3",
                 "eslint": "^9.20.1",
@@ -3950,13 +3950,13 @@
             "license": "MIT"
         },
         "node_modules/@types/node": {
-            "version": "22.14.1",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
-            "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
+            "version": "22.13.10",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
+            "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
             "devOptional": true,
             "license": "MIT",
             "dependencies": {
-                "undici-types": "~6.21.0"
+                "undici-types": "~6.20.0"
             }
         },
         "node_modules/@types/stack-utils": {
@@ -12264,9 +12264,9 @@
             }
         },
         "node_modules/undici-types": {
-            "version": "6.21.0",
-            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
-            "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+            "version": "6.20.0",
+            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+            "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
             "devOptional": true,
             "license": "MIT"
         },
diff --git a/package.json b/package.json
index 1a2d5e4d6..30d6593e5 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,7 @@
         "@types/jest": "^29.5.13",
         "@types/leaflet": "^1.9.17",
         "@types/lodash": "^4.17.15",
-        "@types/node": "^22.14.1",
+        "@types/node": "^22.13.10",
         "@typescript-eslint/eslint-plugin": "^8.29.0",
         "buffer": "^6.0.3",
         "eslint": "^9.20.1",
diff --git a/qfdmo/admin/__init__.py b/qfdmo/admin/__init__.py
index 983bf9b07..c86125ef8 100644
--- a/qfdmo/admin/__init__.py
+++ b/qfdmo/admin/__init__.py
@@ -1,3 +1,8 @@
-from .acteur import *  # noqa
-from .action import *  # noqa
-from .categorie_objet import *  # noqa
+# The 403 rule is ignored here to provide
+# easier module resolution
+# https://www.flake8rules.com/rules/F403.html
+#
+from .acteur import *  # noqa: F403
+from .action import *  # noqa: F403
+from .categorie_objet import *  # noqa: F403
+from .config import *  # noqa: F403
diff --git a/qfdmo/admin/action.py b/qfdmo/admin/action.py
index 595459beb..b5fc5c972 100644
--- a/qfdmo/admin/action.py
+++ b/qfdmo/admin/action.py
@@ -17,7 +17,7 @@ class ActionDirectionAdmin(OnlyEditableMixin, CodeLibelleModelMixin, admin.Model
 
 class GroupeActionAdmin(OnlyEditableMixin, CodeLibelleModelMixin, admin.ModelAdmin):
     list_display = ("code", "libelle", "order")
-    search_fields = ["code", "libelle"]
+    search_fields = ["code"]
 
 
 admin.site.register(Action, ActionAdmin)
diff --git a/qfdmo/admin/config.py b/qfdmo/admin/config.py
new file mode 100644
index 000000000..fceed5297
--- /dev/null
+++ b/qfdmo/admin/config.py
@@ -0,0 +1,18 @@
+from django.contrib import admin
+
+from qfdmo.models import CarteConfig, GroupeActionConfig
+
+
+class GroupeActionConfigInline(admin.StackedInline):
+    fields = ("groupe_action", "acteur_type", "icon")
+    autocomplete_fields = ["groupe_action", "acteur_type"]
+    model = GroupeActionConfig
+    extra = 0
+    verbose_name = "Configuration des groupes d'action associés"
+
+
+@admin.register(CarteConfig)
+class CarteConfigAdmin(admin.ModelAdmin):
+    prepopulated_fields = {"slug": ["nom"]}
+    autocomplete_fields = ["sous_categorie_objet", "source", "groupe_action"]
+    inlines = [GroupeActionConfigInline]
diff --git a/qfdmo/migrations/0140_carteconfig_groupeactionconfig_squashed_0146_alter_groupeactionconfig_carte_config.py b/qfdmo/migrations/0140_carteconfig_groupeactionconfig_squashed_0146_alter_groupeactionconfig_carte_config.py
new file mode 100644
index 000000000..353f04d9d
--- /dev/null
+++ b/qfdmo/migrations/0140_carteconfig_groupeactionconfig_squashed_0146_alter_groupeactionconfig_carte_config.py
@@ -0,0 +1,152 @@
+# Generated by Django 5.1.6 on 2025-04-15 11:49
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    replaces = [
+        ("qfdmo", "0140_carteconfig_groupeactionconfig"),
+        ("qfdmo", "0141_groupeactionconfig_couleur"),
+        ("qfdmo", "0142_alter_groupeactionconfig_unique_together"),
+        ("qfdmo", "0143_remove_groupeactionconfig_couleur"),
+        ("qfdmo", "0144_alter_groupeactionconfig_groupe_action"),
+        ("qfdmo", "0145_merge_20250411_1423"),
+        ("qfdmo", "0146_alter_groupeactionconfig_carte_config"),
+    ]
+
+    dependencies = [
+        ("qfdmo", "0140_drop_views"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="CarteConfig",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("nom", models.CharField(unique=True)),
+                (
+                    "slug",
+                    models.SlugField(
+                        help_text="Le slug est utilisé pour générer l'url de carte, par exemple: https://quefairedemesobjets.fr/carte/<strong>cyclevia</strong>",
+                        unique=True,
+                    ),
+                ),
+                (
+                    "groupe_action",
+                    models.ManyToManyField(
+                        blank=True,
+                        help_text="Seules les actions sélectionnées s'afficheront sur la carte\nSi le champ n'est pas renseigné il sera ignoré",
+                        to="qfdmo.groupeaction",
+                        verbose_name="Groupe d'actions",
+                    ),
+                ),
+                (
+                    "source",
+                    models.ManyToManyField(
+                        blank=True,
+                        help_text="Seules les sources sélectionnées s'afficheront sur la carte\nSi le champ n'est pas renseigné il sera ignoré",
+                        to="qfdmo.source",
+                        verbose_name="Source(s)",
+                    ),
+                ),
+                (
+                    "sous_categorie_objet",
+                    models.ManyToManyField(
+                        blank=True,
+                        help_text="Seules les objets sélectionnés s'afficheront sur la carte\nSi le champ n'est pas renseigné il sera ignoré",
+                        to="qfdmo.souscategorieobjet",
+                        verbose_name="Sous-catégories d'objets filtrés",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Carte sur mesure",
+                "verbose_name_plural": "Cartes sur mesure",
+            },
+        ),
+        migrations.CreateModel(
+            name="GroupeActionConfig",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "icon",
+                    models.FileField(
+                        blank=True,
+                        help_text="L'icône doit être au format SVG. ",
+                        null=True,
+                        upload_to="config/groupeaction/icones/",
+                        validators=[
+                            django.core.validators.FileExtensionValidator(
+                                allowed_extensions=["svg"]
+                            )
+                        ],
+                        verbose_name="Supplanter l'icône utilisée pour l'action",
+                    ),
+                ),
+                (
+                    "acteur_type",
+                    models.ForeignKey(
+                        blank=True,
+                        help_text="La configuration peut être limitée à un type d'acteur spécifique. Auquel cas il doit être indiqué ici.\nSi aucun type d'acteur n'est renseigné, cette configuration s'appliquera à tout type d'acteur.",
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="qfdmo.acteurtype",
+                        verbose_name="Types d'acteur concernés par la configuration",
+                    ),
+                ),
+                (
+                    "carte_config",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="groupe_action_config",
+                        to="qfdmo.carteconfig",
+                    ),
+                ),
+                (
+                    "groupe_action",
+                    models.ForeignKey(
+                        blank=True,
+                        help_text="La configuration peut être limitée à un groupe d'action spécifique. Auquel cas il doit être indiqué ici.\nSi aucune action n'est renseignée, cette configuration s'appliquera à tout type d'acteur.",
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="qfdmo.groupeaction",
+                        verbose_name="Groupe d'action",
+                    ),
+                ),
+            ],
+            options={
+                "unique_together": {("carte_config", "groupe_action", "acteur_type")},
+            },
+        ),
+        migrations.AlterField(
+            model_name="groupeactionconfig",
+            name="carte_config",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="groupe_action_configs",
+                to="qfdmo.carteconfig",
+            ),
+        ),
+    ]
diff --git a/qfdmo/models/__init__.py b/qfdmo/models/__init__.py
index 80f4d9eba..73b3d81fa 100644
--- a/qfdmo/models/__init__.py
+++ b/qfdmo/models/__init__.py
@@ -1,4 +1,9 @@
-from .acteur import *  # noqa
-from .action import *  # noqa
-from .categorie_objet import *  # noqa
-from .utils import *  # noqa
+# The 403 rule is ignored here to provide
+# easier module resolution
+# https://www.flake8rules.com/rules/F403.html
+
+from .acteur import *  # noqa: F403
+from .action import *  # noqa: F403
+from .categorie_objet import *  # noqa: F403
+from .config import *  # noqa: F403
+from .utils import *  # noqa: F403
diff --git a/qfdmo/models/acteur.py b/qfdmo/models/acteur.py
index 38a7e59d0..52903afa3 100644
--- a/qfdmo/models/acteur.py
+++ b/qfdmo/models/acteur.py
@@ -44,6 +44,11 @@
 )
 from qfdmo.models.action import Action, get_action_instances
 from qfdmo.models.categorie_objet import SousCategorieObjet
+
+# Explicit imports from models config, action, categories, utils
+# and not from qfdmo.models are required here to prevent circular
+# dependency import error.
+from qfdmo.models.config import CarteConfig, GroupeActionConfig
 from qfdmo.models.utils import (
     CodeAsNaturalKeyModel,
     NomAsNaturalKeyManager,
@@ -312,7 +317,6 @@ def get_queryset(self):
 
 
 class BaseActeur(TimestampedModel, NomAsNaturalKeyModel):
-
     class Meta:
         abstract = True
 
@@ -969,19 +973,23 @@ def nombre_enfants(self) -> int:
     def get_absolute_url(self):
         return reverse("qfdmo:acteur-detail", args=[self.uuid])
 
-    def acteur_actions(self, direction=None):
-        ps_action_ids = list(
-            {ps.action_id for ps in self.proposition_services.all()}  # type: ignore
-        )
+    def acteur_actions(self, direction=None, actions_codes=None):
+        pss = self.proposition_services.all()
         # Cast needed because of the cache
         cached_action_instances = cast(
             List[Action], cache.get_or_set("_action_instances", get_action_instances)
         )
+
+        if direction:
+            pss = pss.filter(action__directions__code__in=[direction])
+        if actions_codes:
+            pss = pss.filter(action__code__in=actions_codes.split("|"))
+
+        action_ids_to_display = pss.values_list("action__id", flat=True)
         return [
             action
             for action in cached_action_instances
-            if (not direction or direction in [d.code for d in action.directions.all()])
-            and action.id in ps_action_ids
+            if action.id in action_ids_to_display
         ]
 
     def json_acteur_for_display(
@@ -989,13 +997,14 @@ def json_acteur_for_display(
         direction: str | None = None,
         action_list: str | None = None,
         carte: bool = False,
+        carte_config: CarteConfig | None = None,
     ) -> str:
-        actions = self.acteur_actions(direction=direction)
+        # TODO: refacto jinja: once the shared/results.html template
+        # will be migrated to django template, this method should
+        # live in a template_tags instead.
+        actions = self.acteur_actions(direction=direction, actions_codes=action_list)
 
-        if action_list:
-            actions = [a for a in actions if a.code in action_list.split("|")]
-
-        def sort_actions(a):
+        def sort_actions_by_action_principale_and_order(a):
             if a == self.action_principale:
                 return -1
 
@@ -1004,27 +1013,54 @@ def sort_actions(a):
                 base_order += (a.groupe_action.order or 0) * 100
             return base_order
 
-        actions = sorted(actions, key=sort_actions)
-
         acteur_dict = {
             "uuid": self.uuid,
             "location": orjson.loads(self.location.geojson),
         }
 
         if not actions:
+            # TODO: explain why we need to decode here
             return orjson.dumps(acteur_dict).decode("utf-8")
 
-        displayed_action = actions[0]
+        action_to_display = sorted(
+            actions, key=sort_actions_by_action_principale_and_order
+        )[0]
 
-        if carte and displayed_action.groupe_action:
-            displayed_action = displayed_action.groupe_action
+        if carte and action_to_display.groupe_action:
+            action_to_display = action_to_display.groupe_action
 
-        acteur_dict.update(icon=displayed_action.icon, couleur=displayed_action.couleur)
+        acteur_dict.update(
+            icon=action_to_display.icon,
+            couleur=action_to_display.couleur,
+        )
+
+        if carte_config:
+            queryset = Q()
+            if action_to_display:
+                queryset &= Q(groupe_action__actions__code__in=[action_to_display]) | Q(
+                    groupe_action__actions__code=None
+                )
+
+            if self.acteur_type:
+                queryset &= Q(acteur_type=self.acteur_type) | Q(acteur_type=None)
 
-        if carte and displayed_action.code == "reparer":
+            try:
+                groupe_action_config = carte_config.groupe_action_configs.get(queryset)
+                if groupe_action_config.icon:
+                    # Property is camelcased as it is used in javascript
+                    acteur_dict.update(iconFile=groupe_action_config.icon.url)
+                    # In this case, a svg file uploaded by a user is used
+                    # in place of the DSFR's icon code.
+                    del acteur_dict["icon"]
+
+            except GroupeActionConfig.DoesNotExist:
+                pass
+
+        if carte and action_to_display.code == "reparer":
             acteur_dict.update(
                 bonus=getattr(self, "bonus", False),
                 reparer=getattr(self, "reparer", False),
+                fillBackground=True,
             )
 
         return orjson.dumps(acteur_dict).decode("utf-8")
@@ -1051,7 +1087,6 @@ def should_display_adresse(self) -> bool:
 
 
 class DisplayedActeurTemp(BaseActeur):
-
     uuid = models.CharField(max_length=255, default=shortuuid.uuid, editable=False)
 
     labels = models.ManyToManyField(
diff --git a/qfdmo/models/action.py b/qfdmo/models/action.py
index 1710d1748..4e055e54d 100644
--- a/qfdmo/models/action.py
+++ b/qfdmo/models/action.py
@@ -213,7 +213,7 @@ def __str__(self):
 
 
 def get_action_instances() -> List[Action]:
-    return list(Action.objects.prefetch_related("directions", "groupe_action"))
+    return Action.objects.prefetch_related("directions", "groupe_action")
 
 
 def get_reparer_action_id() -> int:
diff --git a/qfdmo/models/config.py b/qfdmo/models/config.py
new file mode 100644
index 000000000..9f6753810
--- /dev/null
+++ b/qfdmo/models/config.py
@@ -0,0 +1,88 @@
+from django.core.validators import FileExtensionValidator
+from django.db import models
+from django.urls import reverse
+
+
+class GroupeActionConfig(models.Model):
+    carte_config = models.ForeignKey(
+        "qfdmo.CarteConfig",
+        on_delete=models.CASCADE,
+        related_name="groupe_action_configs",
+        null=True,
+        blank=True,
+    )
+    groupe_action = models.ForeignKey(
+        "qfdmo.GroupeAction",
+        on_delete=models.CASCADE,
+        verbose_name="Groupe d'action",
+        help_text="La configuration peut être limitée à un groupe d'action "
+        "spécifique. Auquel cas il doit être indiqué ici.\n"
+        "Si aucune action n'est renseignée, cette configuration "
+        "s'appliquera à tout type d'acteur.",
+        null=True,
+        blank=True,
+    )
+
+    acteur_type = models.ForeignKey(
+        "qfdmo.ActeurType",
+        on_delete=models.CASCADE,
+        verbose_name="Types d'acteur concernés par la configuration",
+        help_text="La configuration peut être limitée à un type d'acteur "
+        "spécifique. Auquel cas il doit être indiqué ici.\n"
+        "Si aucun type d'acteur n'est renseigné, cette configuration "
+        "s'appliquera à tout type d'acteur.",
+        null=True,
+        blank=True,
+    )
+
+    icon = models.FileField(
+        upload_to="config/groupeaction/icones/",
+        verbose_name="Supplanter l'icône utilisée pour l'action",
+        help_text="L'icône doit être au format SVG. ",
+        validators=[FileExtensionValidator(allowed_extensions=["svg"])],
+        blank=True,
+        null=True,
+    )
+
+    class Meta:
+        unique_together = ["carte_config", "groupe_action", "acteur_type"]
+
+
+class CarteConfig(models.Model):
+    nom = models.CharField(unique=True)
+    slug = models.SlugField(
+        unique=True,
+        help_text="Le slug est utilisé pour générer l'url de carte, "
+        "par exemple: https://quefairedemesobjets.fr/carte/<strong>cyclevia</strong>",
+    )
+    sous_categorie_objet = models.ManyToManyField(
+        "qfdmo.SousCategorieObjet",
+        verbose_name="Sous-catégories d'objets filtrés",
+        help_text="Seules les objets sélectionnés s'afficheront sur la carte"
+        "\nSi le champ n'est pas renseigné il sera ignoré",
+        blank=True,
+    )
+    groupe_action = models.ManyToManyField(
+        "qfdmo.GroupeAction",
+        verbose_name="Groupe d'actions",
+        help_text="Seules les actions sélectionnées s'afficheront sur la carte"
+        "\nSi le champ n'est pas renseigné il sera ignoré",
+        blank=True,
+    )
+    source = models.ManyToManyField(
+        "qfdmo.Source",
+        verbose_name="Source(s)",
+        help_text="Seules les sources sélectionnées s'afficheront sur la carte"
+        "\nSi le champ n'est pas renseigné il sera ignoré",
+        blank=True,
+    )
+
+    def get_absolute_url(self):
+        return reverse("qfdmo:carte_custom", kwargs={"slug": self.slug})
+
+    def __str__(self):
+        return self.nom
+
+    class Meta:
+        verbose_name = "Carte sur mesure"
+        verbose_name_plural = "Cartes sur mesure"
diff --git a/qfdmo/urls.py b/qfdmo/urls.py
index ac687e4ca..ab0808cca 100644
--- a/qfdmo/urls.py
+++ b/qfdmo/urls.py
@@ -18,6 +18,7 @@
     solution_admin,
 )
 from qfdmo.views.auth import LVAOLoginView
+from qfdmo.views.carte import CustomCarteView
 from qfdmo.views.configurator import AdvancedConfiguratorView, ConfiguratorView
 
 urlpatterns = [
@@ -26,6 +27,7 @@
     path("static/carte.js", get_carte_iframe_script, name="carte_script"),
     path("static/iframe.js", get_formulaire_iframe_script, name="formulaire_script"),
     path("carte", CarteSearchActeursView.as_view(), name="carte"),
+    path("carte/<slug:slug>/", CustomCarteView.as_view(), name="carte_custom"),
     path("carte.json", CarteSearchActeursView.as_view(), name="carte_json"),
     path("formulaire", FormulaireSearchActeursView.as_view(), name="formulaire"),
     path("connexion", LVAOLoginView.as_view(), name="login"),
diff --git a/qfdmo/views/adresses.py b/qfdmo/views/adresses.py
index f707865ca..22c037434 100644
--- a/qfdmo/views/adresses.py
+++ b/qfdmo/views/adresses.py
@@ -115,19 +115,6 @@ def get_initial(self):
         action_list = self._set_action_list(action_displayed)
         initial["action_list"] = "|".join([a.code for a in action_list])
 
-        if self.is_carte:
-            grouped_action_choices = self._get_grouped_action_choices(action_displayed)
-            actions_to_select = self._get_selected_action()
-            initial["grouped_action"] = self._grouped_action_from(
-                grouped_action_choices, actions_to_select
-            )
-            # TODO : refacto forms, merge with grouped_action field
-            initial["legend_grouped_action"] = initial["grouped_action"]
-
-            initial["action_list"] = "|".join(
-                [a for ga in initial["grouped_action"] for a in ga.split("|")]
-            )
-
         return initial
 
     def get_form(self, form_class=None):
@@ -473,19 +460,18 @@ def _compile_acteurs_queryset(
 
         return filters, excludes
 
-    def _get_grouped_action_choices(
-        self, action_displayed: list[Action]
-    ) -> list[list[str]]:
-        groupe_with_displayed_actions = []
-
+    def get_cached_groupe_action_with_displayed_actions(self, action_displayed):
         # Cast needed because of the cache
         cached_groupe_action_instances = cast(
             QuerySet[GroupeAction],
             cache.get_or_set("groupe_action_instances", get_groupe_action_instances),
         )
 
+        # Fetch actions from cache only if they are displayed
+        # for the end user
+        cached_groupe_action_with_displayed_actions = []
         for cached_groupe in cached_groupe_action_instances:
-            if groupe_actions := [
+            if displayed_actions_from_groupe_actions := [
                 action
                 # TODO : à optimiser avec le cache
                 for action in cached_groupe.actions.all().order_by(  # type: ignore
@@ -493,10 +479,24 @@ def _get_grouped_action_choices(
                 )
                 if action in action_displayed
             ]:
-                groupe_with_displayed_actions.append([cached_groupe, groupe_actions])
+                cached_groupe_action_with_displayed_actions.append(
+                    [cached_groupe, displayed_actions_from_groupe_actions]
+                )
+        return cached_groupe_action_with_displayed_actions
 
+    def _get_grouped_action_choices(
+        self, action_displayed: list[Action]
+    ) -> list[list[str]]:
+        """Generate a list of choices for form field
+        used in legend"""
+        # Generate a tuple of (code, libelle) used in the frontend.
+        # The libelle is automatically picked up by django templating
+        # when generating the form.
         grouped_action_choices = []
-        for [groupe, groupe_displayed_actions] in groupe_with_displayed_actions:
+        for [
+            groupe,
+            groupe_displayed_actions,
+        ] in self.get_cached_groupe_action_with_displayed_actions(action_displayed):
             libelle = ""
             if groupe.icon:
                 libelle = (
@@ -523,11 +523,28 @@ def _grouped_action_from(
         ]
 
 
+# TODO: move in views/carte.py
 class CarteSearchActeursView(SearchActeursView):
     is_carte = True
     template_name = "qfdmo/carte.html"
     form_class = CarteForm
 
+    def get_initial(self, *args, **kwargs):
+        initial = super().get_initial(*args, **kwargs)
+        action_displayed = self._set_action_displayed()
+        grouped_action_choices = self._get_grouped_action_choices(action_displayed)
+        actions_to_select = self._get_selected_action()
+        initial["grouped_action"] = self._grouped_action_from(
+            grouped_action_choices, actions_to_select
+        )
+        # TODO : refacto forms, merge with grouped_action field
+        initial["legend_grouped_action"] = initial["grouped_action"]
+
+        initial["action_list"] = "|".join(
+            [a for ga in initial["grouped_action"] for a in ga.split("|")]
+        )
+        return initial
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context.update(is_carte=True)
diff --git a/qfdmo/views/carte.py b/qfdmo/views/carte.py
new file mode 100644
index 000000000..4be4a47d7
--- /dev/null
+++ b/qfdmo/views/carte.py
@@ -0,0 +1,80 @@
+import logging
+
+from django.db.models import Q
+from django.utils.functional import cached_property
+from django.views.generic import DetailView
+
+from qfdmo.models import CarteConfig
+from qfdmo.views.adresses import CarteSearchActeursView
+
+logger = logging.getLogger(__name__)
+
+
+class CustomCarteView(DetailView, CarteSearchActeursView):
+    model = CarteConfig
+
+    def get_context_data(self, *args, **kwargs):
+        ctx = super().get_context_data(*args, **kwargs)
+
+        return ctx
+
+    @cached_property
+    def groupe_actions(self):
+        # TODO: cache
+        return self.get_object().groupe_action.all().order_by("order")
+
+    def _set_action_list(self, *args, **kwargs):
+        if self.groupe_actions:
+            return self.groupe_actions
+        return super()._set_action_list(*args, **kwargs)
+
+    def _set_action_displayed(self, *args, **kwargs):
+        if self.groupe_actions:
+            return self.groupe_actions
+
+        return super()._set_action_displayed(*args, **kwargs)
+
+    def _get_selected_action_code(self, *args, **kwargs):
+        if self.groupe_actions:
+            return self.groupe_actions
+
+        return super()._get_selected_action_code(*args, **kwargs)
+
+    def get_cached_groupe_action_with_displayed_actions(self, *args, **kwargs):
+        if self.groupe_actions:
+            return [
+                [groupe_action, groupe_action.actions.all()]
+                for groupe_action in self.groupe_actions
+            ]
+        else:
+            return super().get_cached_groupe_action_with_displayed_actions(
+                *args, **kwargs
+            )
+
+    def _grouped_action_from(self, *args, **kwargs):
+        if self.groupe_actions:
+            return [
+                "|".join(groupe_action.actions.all().values_list("code", flat=True))
+                for groupe_action in self.groupe_actions
+            ]
+        return super()._grouped_action_from(*args, **kwargs)
+
+    def _compile_acteurs_queryset(self, *args, **kwargs):
+        filters, excludes = super()._compile_acteurs_queryset(*args, **kwargs)
+
+        if source_filter := self.get_object().source.all():
+            filters &= Q(source__in=source_filter)
+
+        if sous_categorie_filter := self.get_object().sous_categorie_objet.all():
+            filters &= Q(
+                proposition_services__sous_categories__in=sous_categorie_filter,
+            )
+
+        if groupe_action_filter := self.get_object().groupe_action.all():
+            action_ids = list(
+                groupe_action_filter.values_list("actions__id", flat=True)
+            )
+            filters &= Q(
+                proposition_services__action_id__in=action_ids,
+            )
+        return filters, excludes
diff --git a/static/to_compile/js/map_controller.ts b/static/to_compile/js/map_controller.ts
index 48bfdee9a..d5e399650 100644
--- a/static/to_compile/js/map_controller.ts
+++ b/static/to_compile/js/map_controller.ts
@@ -6,8 +6,10 @@ import { ActorLocation, DisplayedActeur } from "./types"
 
 export class Actor implements DisplayedActeur {
   uuid: string
+  fillBackground: boolean
   location: ActorLocation
   icon: string
+  iconFile: string
   couleur: string
   bonus: boolean
   reparer: boolean
@@ -16,6 +18,8 @@ export class Actor implements DisplayedActeur {
     this.uuid = actorFields.uuid
     this.location = actorFields.location
     this.icon = actorFields.icon
+    this.iconFile = actorFields.iconFile
+    this.fillBackground = actorFields.fillBackground
     this.couleur = actorFields.couleur
     this.bonus = actorFields.bonus
     this.reparer = actorFields.reparer
@@ -40,10 +44,10 @@ export default class extends Controller<HTMLElement> {
     })
     //fixme : find how do not allow undefined from map
     const actors: Array<Actor> = this.acteurTargets
-      .map((ecoCirTarget: HTMLScriptElement) => {
-        if (ecoCirTarget.textContent !== null) {
-          const actor_fields: DisplayedActeur = JSON.parse(ecoCirTarget.textContent)
-          return new Actor(actor_fields)
+      .map((actorTarget: HTMLScriptElement) => {
+        if (actorTarget.textContent !== null) {
+          const actorFields: DisplayedActeur = JSON.parse(actorTarget.textContent)
+          return new Actor(actorFields)
         }
       })
       .filter((actor) => actor !== undefined)
diff --git a/static/to_compile/js/solution_map.ts b/static/to_compile/js/solution_map.ts
index fcf724e22..58f7a0138 100644
--- a/static/to_compile/js/solution_map.ts
+++ b/static/to_compile/js/solution_map.ts
@@ -68,8 +68,27 @@ export class SolutionMap {
   }
 
   #generateMarkerHTMLStringFrom(acteur?: DisplayedActeur): string {
+    /**
+    This method uses complex scale and translate css attributes in
+    order to compensate an issue that causes the marker to not be
+    at the location it is supposed to be when zooming in / out.
+
+    This could definitely be fixed by using appropriately sized
+    svg, but works fine as is.
+
+    If you need to add a new marker's design in the future, it
+    is advised to follow the approach to not this bug.
+    */
+    if (acteur?.iconFile) {
+      return [
+        `<div data-animated class="qf-scale-75">`,
+        `<img class="qf--translate-y-2/4" height="61" width="46" src="${acteur.iconFile}">`,
+        `</div>`
+      ].join("")
+    }
+
     const markerHtmlStyles = `color: ${acteur?.couleur};`
-    const background = acteur?.reparer ? pinBackgroundFillSvg : pinBackgroundSvg
+    const background = acteur?.fillBackground ? pinBackgroundFillSvg : pinBackgroundSvg
     const cornerIcon = acteur?.bonus ? bonusIconSvg : ""
     const icon = acteur?.icon || "fr-icon-checkbox-circle-line"
     const markerIconClasses = `qf-absolute qf-top-[10] qf-left-[10.5] qf-margin-auto
diff --git a/static/to_compile/js/types.ts b/static/to_compile/js/types.ts
index 1a8540141..f51f1a6c1 100644
--- a/static/to_compile/js/types.ts
+++ b/static/to_compile/js/types.ts
@@ -10,8 +10,10 @@ export interface ActorLocation {
 }
 
 export interface DisplayedActeur {
+  fillBackground: boolean
   uuid: string
   icon: string
+  iconFile: string
   couleur: string
   location: ActorLocation
   bonus: boolean
diff --git a/unit_tests/conftest.py b/unit_tests/conftest.py
deleted file mode 100644
index e34cfd69d..000000000
--- a/unit_tests/conftest.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from contextlib import suppress
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def disable_whitenoise(settings):
-    settings.STORAGES = {
-        "staticfiles": {
-            "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
-        }
-    }
-    settings.MIDDLEWARE = [
-        middleware
-        for middleware in settings.MIDDLEWARE
-        if middleware != "whitenoise.middleware.WhiteNoiseMiddleware"
-    ]
-
-
-@pytest.fixture(autouse=True)
-def disable_debug_toolbar(settings):
-    with suppress(ValueError):
-        settings.MIDDLEWARE.remove("debug_toolbar.middleware.DebugToolbarMiddleware")
diff --git a/unit_tests/qfdmo/test_acteur.py b/unit_tests/qfdmo/test_acteur.py
index d72f776c7..ea9236315 100644
--- a/unit_tests/qfdmo/test_acteur.py
+++ b/unit_tests/qfdmo/test_acteur.py
@@ -284,7 +284,6 @@ def test_revision_acteur_parent_validator(self):
 
 @pytest.mark.django_db
 class TestCreateRevisionActeurCreateParent:
-
     @pytest.fixture
     def acteurs_fields(self):
         acteur_type = ActeurTypeFactory()
@@ -511,7 +510,6 @@ def test_duplicate_proposition_services(self):
 
 @pytest.mark.django_db
 class TestRevisionActeurRemoveParentWithoutChildren:
-
     def test_revision_acteur_remove_parent_without_children(self):
         revision_acteur_original_parent = RevisionActeurFactory()
         revision_acteur = RevisionActeurFactory(parent=revision_acteur_original_parent)
@@ -564,12 +562,11 @@ def test_revision_acteur_dont_remove_parent_with_children(self):
 
 @pytest.mark.django_db
 class TestActeurService:
-
     @pytest.fixture
     def displayed_acteur(self):
         return DisplayedActeurFactory()
 
-    def test_acteur_actions_basic(self, displayed_acteur):
+    def test_acteur_services_basic(self, displayed_acteur):
         displayed_acteur.acteur_services.add(
             ActeurServiceFactory(libelle="Par un professionnel")
         )
@@ -578,7 +575,7 @@ def test_acteur_actions_basic(self, displayed_acteur):
             "Par un professionnel"
         ]
 
-    def test_acteur_actions_multiple(self, displayed_acteur):
+    def test_acteur_services_multiple(self, displayed_acteur):
         displayed_acteur.acteur_services.add(
             ActeurServiceFactory(code="pro", libelle="Par un professionnel"),
             ActeurServiceFactory(
@@ -592,6 +589,44 @@ def test_acteur_actions_multiple(self, displayed_acteur):
         ]
 
 
+@pytest.mark.django_db
+class TestActeurActions:
+    @pytest.fixture
+    def displayed_acteur(self):
+        return DisplayedActeurFactory()
+
+    def test_acteur_actions_filtered(self, displayed_acteur):
+        direction_jai = ActionDirectionFactory(code="jai")
+        action = ActionFactory(code="reparer")
+        action.directions.add(direction_jai)
+        DisplayedPropositionServiceFactory(acteur=displayed_acteur, action=action)
+
+        assert len(displayed_acteur.acteur_actions()) == 1, (
+            "ensure no filters on the action or direction"
+            "generates a non-empty list of acteurs"
+        )
+        assert len(displayed_acteur.acteur_actions(direction="jai")) == 1, (
+            "a filter with acteurs providing services for this action direct"
+            "for this direction generates an empty list of acteurs",
+        )
+        assert len(displayed_acteur.acteur_actions(direction="jecherche")) == 0, (
+            "a filter without acteurs providing services for this action direct"
+            "for this direction generates an empty list of acteurs",
+        )
+        assert (
+            len(displayed_acteur.acteur_actions(actions_codes="reparer|vendre")) == 1
+        ), (
+            "a filter with acteur providing services for this action code returns",
+            "a non-empty list",
+        )
+        assert (
+            len(displayed_acteur.acteur_actions(actions_codes="trier|vendre")) == 0
+        ), (
+            "a filter without acteurs providing services for this action code returns",
+            "an empty list",
+        )
+
+
 class TestActeurPropositionServicesByDirection:
     @pytest.mark.django_db
     def test_proposition_services_by_direction(self):
@@ -618,7 +653,6 @@ def test_has_label_reparacteur(self):
 
 @pytest.mark.django_db
 class TestDisplayActeurActeurActions:
-
     def test_basic(self):
         displayed_acteur = DisplayedActeurFactory()
         direction = ActionDirectionFactory(code="jai")
@@ -649,7 +683,7 @@ def test_with_direction(self):
         action = ActionFactory()
         action.directions.add(direction)
         DisplayedPropositionServiceFactory(action=action, acteur=displayed_acteur)
-        assert displayed_acteur.acteur_actions(direction="fake") == []
+        assert len(displayed_acteur.acteur_actions(direction="fake")) == 0
         assert [
             model_to_dict(a, exclude=["directions"])
             for a in displayed_acteur.acteur_actions(direction="jai")
@@ -683,7 +717,6 @@ def test_ordered_action(self):
 
 @pytest.mark.django_db
 class TestDisplayedActeurJsonActeurForDisplay:
-
     @pytest.fixture
     def displayed_acteur(self):
         displayed_acteur = DisplayedActeurFactory()
@@ -875,7 +908,6 @@ def test_json_acteur_for_display_action_principale_not_in_list(
 
 
 class TestDisplayedActeurDisplayPostalAddress:
-
     def test_should_display_adresse(self):
         displayed_acteur = DisplayedActeurFactory.build()
 
diff --git a/unit_tests/qfdmo/test_carte_config.py b/unit_tests/qfdmo/test_carte_config.py
new file mode 100644
index 000000000..56fc8a90a
--- /dev/null
+++ b/unit_tests/qfdmo/test_carte_config.py
@@ -0,0 +1,54 @@
+import json
+
+import pytest
+from django.core.files.base import ContentFile
+
+from qfdmo.models import (
+    CarteConfig,
+    GroupeActionConfig,
+)
+from unit_tests.qfdmo.acteur_factory import (
+    DisplayedActeurFactory,
+    DisplayedPropositionServiceFactory,
+)
+from unit_tests.qfdmo.action_factory import (
+    ActionDirectionFactory,
+    ActionFactory,
+    GroupeActionFactory,
+)
+
+
+@pytest.mark.django_db
+class TestCarteConfig:
+    @pytest.fixture
+    def displayed_acteur(self):
+        displayed_acteur = DisplayedActeurFactory()
+        direction = ActionDirectionFactory()
+        groupe_action = GroupeActionFactory(
+            icon="icon-groupe_action",
+            order=1,
+        )
+        action = ActionFactory(
+            icon="icon-actionjai1",
+            groupe_action=groupe_action,
+            order=1,
+        )
+        action.directions.add(direction)
+        DisplayedPropositionServiceFactory(action=action, acteur=displayed_acteur)
+        return displayed_acteur
+
+    def test_json_acteur_for_display_carte_config(self, displayed_acteur):
+        carte_config = CarteConfig.objects.create(
+            nom="Une carte sur mesure", slug="une-carte-sur-mesure"
+        )
+        groupe_action_config = GroupeActionConfig.objects.create(
+            icon=ContentFile("<svg>some-icon</svg>", name="some-icon.svg"),
+            carte_config=carte_config,
+            acteur_type=displayed_acteur.acteur_type,
+        )
+        acteur_for_display = json.loads(
+            displayed_acteur.json_acteur_for_display(carte_config=carte_config)
+        )
+
+        assert "icon" not in acteur_for_display
+        assert acteur_for_display["iconFile"] == groupe_action_config.icon.url


Reviews:
user: login: fabienheureux
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T08:45:01Z
user: login: kolok
body: Petit pb sur l'admin lors de la recherche de groupe_action

![CleanShot 2025-04-09 at 17 45 51](https://github.com/user-attachments/assets/e01caaf1-c0d3-4ce0-a7b3-99acf88be995)

Je n'ai pas réussi à faire marcher la sur définition de pinpoint non plus

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:47:13Z
user: login: kolok
body: petit pb sur le nombre de requêtes

juste sur la homepage de cyclevia : http://localhost:8000/carte/cyclevia

![CleanShot 2025-04-09 at 17 49 27](https://github.com/user-attachments/assets/6c57a873-ff3a-49a9-ab1b-e1c0a3f93d1f)

Aussi beaucoup de duplicat sur la carte de base : http://localhost:8000/carte (pb qui semble déjà être présent sur main)

![CleanShot 2025-04-09 at 17 50 49](https://github.com/user-attachments/assets/f43d7e49-2635-4ffb-83cb-7b1a0c1881fd)

Et beaucoup de requêtes sur l'affichage d'un page avec 10 résultats : http://localhost:8000/carte?adresse=8%20Rue%20Jesse%20Owens%2093200%20Saint-Denis&longitude=2.364709&latitude=48.924201&legend_grouped_action=reparer&legend_grouped_action=donner|echanger&legend_grouped_action=preter|emprunter|louer|mettreenlocation&legend_grouped_action=acheter|revendre&legend_grouped_action=trier&grouped_action=reparer&grouped_action=donner|echanger&grouped_action=preter|emprunter|louer|mettreenlocation&grouped_action=acheter|revendre&grouped_action=trier&carte=&r=286&bounding_box={%22center%22:{%22lat%22:48.92496303818943,%22lng%22:2.369613647460938},%22southWest%22:{%22lat%22:48.92313373235778,%22lng%22:2.3593139648437504},%22northEast%22:{%22lat%22:48.92679234402108,%22lng%22:2.3799133300781254}}&direction=&action_displayed=preter|emprunter|louer|mettreenlocation|reparer|donner|echanger|acheter|revendre|trier&sous_categorie_objet=&sc_id=&pas_exclusivite_reparation=on

uniquement 21 requêtes sur la branche `main` 

![CleanShot 2025-04-09 at 17 54 34](https://github.com/user-attachments/assets/e71ecb3d-4f38-4dbd-805e-534266f987a7)

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:51:29Z
user: login: kolok
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T16:03:28Z
user: login: kolok
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T16:07:12Z
user: login: fabienheureux
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T14:59:13Z
user: login: fabienheureux
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T15:01:38Z
user: login: fabienheureux
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T15:02:19Z

Review comments:
diff_hunk: @@ -68,8 +68,27 @@ export class SolutionMap {
   }
 
   #generateMarkerHTMLStringFrom(acteur?: DisplayedActeur): string {
+    /**
+    This method uses complex scale and translate css attributes in
+    order to compensate an issue that causes the marker to not be
+    at the location it is supposed to be when zooming in / out.
+
+    This could definitely be fixed by using appropriately sized
+    svg, but works fine as is.
+
+    If you need to add a new marker's design in the future, it
+    is advised to follow the approach to not this bug.
+    */
+    if (acteur?.iconFile) {
+      return [
+        `<div data-animated class="qf-scale-75">`,
+        `<img class="qf--translate-y-2/4" height="61" width="46" src="${acteur.iconFile}">`,
+        `</div>`
+      ].join("")
+    }
+
     const markerHtmlStyles = `color: ${acteur?.couleur};`
-    const background = acteur?.reparer ? pinBackgroundFillSvg : pinBackgroundSvg
+    const background = acteur?.fillBackground ? pinBackgroundFillSvg : pinBackgroundSvg
path: static/to_compile/js/solution_map.ts
user: login: fabienheureux
body: j'ai ajouté cette notion ici, elle n'a rien à voir mais rend le json plus explicite, ça permettra de réutiliser l'approche d'un fond plein pour d'autres choses que le bonus réparation 
created_at: 2025-04-02T08:45:01Z
author_association: MEMBER
diff_hunk: @@ -1,3 +1,4 @@
 from .acteur import *  # noqa
 from .action import *  # noqa
 from .categorie_objet import *  # noqa
+from .config import *
path: qfdmo/admin/__init__.py
user: login: kolok
body: ```suggestion
# The 403 rule is ignored here to provide
# easier module resolution
# https://www.flake8rules.com/rules/F403.html

from .acteur import *  # noqa: F403
from .action import *  # noqa: F403
from .categorie_objet import *  # noqa: F403
from .config import *  # noqa: F403
```
created_at: 2025-04-09T13:41:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -970,33 +974,35 @@ def nombre_enfants(self) -> int:
     def get_absolute_url(self):
         return reverse("qfdmo:acteur-detail", args=[self.uuid])
 
-    def acteur_actions(self, direction=None):
-        ps_action_ids = list(
-            {ps.action_id for ps in self.proposition_services.all()}  # type: ignore
-        )
+    def acteur_actions(self, direction=None, actions_codes=None):
+        pss = self.proposition_services.all()
         # Cast needed because of the cache
         cached_action_instances = cast(
             List[Action], cache.get_or_set("_action_instances", get_action_instances)
         )
-        return [
-            action
-            for action in cached_action_instances
-            if (not direction or direction in [d.code for d in action.directions.all()])
-            and action.id in ps_action_ids
-        ]
+
+        if direction:
+            pss = pss.filter(action__directions__code__in=[direction])
+        if actions_codes:
+            pss = pss.filter(action__code__in=actions_codes.split("|"))
+
+        action_ids_to_display = pss.values_list("action__id", flat=True)
+        return cached_action_instances.filter(id__in=action_ids_to_display)
path: qfdmo/models/acteur.py
user: login: kolok
body: A tester si ça n'ajoute pas de Query N+1 ?
created_at: 2025-04-09T13:47:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -970,33 +974,35 @@ def nombre_enfants(self) -> int:
     def get_absolute_url(self):
         return reverse("qfdmo:acteur-detail", args=[self.uuid])
 
-    def acteur_actions(self, direction=None):
-        ps_action_ids = list(
-            {ps.action_id for ps in self.proposition_services.all()}  # type: ignore
-        )
+    def acteur_actions(self, direction=None, actions_codes=None):
+        pss = self.proposition_services.all()
         # Cast needed because of the cache
         cached_action_instances = cast(
             List[Action], cache.get_or_set("_action_instances", get_action_instances)
         )
-        return [
-            action
-            for action in cached_action_instances
-            if (not direction or direction in [d.code for d in action.directions.all()])
-            and action.id in ps_action_ids
-        ]
+
+        if direction:
+            pss = pss.filter(action__directions__code__in=[direction])
+        if actions_codes:
+            pss = pss.filter(action__code__in=actions_codes.split("|"))
+
+        action_ids_to_display = pss.values_list("action__id", flat=True)
+        return cached_action_instances.filter(id__in=action_ids_to_display)
 
     def json_acteur_for_display(
         self,
         direction: str | None = None,
         action_list: str | None = None,
         carte: bool = False,
+        carte_config: CarteConfig = None,
path: qfdmo/models/acteur.py
user: login: kolok
body: besoin de carte_config essentiellement pour le choix de l'icon pour un acteur donné ?
created_at: 2025-04-09T13:55:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,88 @@
+from django.core.validators import FileExtensionValidator
+from django.db import models
+from django.urls import reverse
+
+
+class GroupeActionConfig(models.Model):
+    carte_config = models.ForeignKey(
+        "qfdmo.CarteConfig",
+        on_delete=models.CASCADE,
+        related_name="groupe_action_config",
path: qfdmo/models/config.py
user: login: kolok
body: ```suggestion
        related_name="groupe_action_configs",
```
?
created_at: 2025-04-09T13:58:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,88 @@
+from django.core.validators import FileExtensionValidator
+from django.db import models
+from django.urls import reverse
+
+
+class GroupeActionConfig(models.Model):
+    carte_config = models.ForeignKey(
+        "qfdmo.CarteConfig",
+        on_delete=models.CASCADE,
+        related_name="groupe_action_config",
+        null=True,
+        blank=True,
+    )
+    groupe_action = models.ForeignKey(
+        "qfdmo.GroupeAction",
+        on_delete=models.CASCADE,
+        verbose_name="Groupe d'action",
+        help_text="La configuration peut être limitée à un type d'actaction "
path: qfdmo/models/config.py
user: login: kolok
body: ```suggestion
        help_text="La configuration peut être limitée à un groupe d'action "
```
created_at: 2025-04-09T13:59:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,3 +1,4 @@
 from .acteur import *  # noqa
 from .action import *  # noqa
 from .categorie_objet import *  # noqa
+from .config import *
path: qfdmo/admin/__init__.py
user: login: kolok
body: je confirme 

![CleanShot 2025-04-09 at 18 02 25](https://github.com/user-attachments/assets/95aee292-9a04-4979-abf2-525a8323859f)

created_at: 2025-04-09T16:03:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -970,33 +974,35 @@ def nombre_enfants(self) -> int:
     def get_absolute_url(self):
         return reverse("qfdmo:acteur-detail", args=[self.uuid])
 
-    def acteur_actions(self, direction=None):
-        ps_action_ids = list(
-            {ps.action_id for ps in self.proposition_services.all()}  # type: ignore
-        )
+    def acteur_actions(self, direction=None, actions_codes=None):
+        pss = self.proposition_services.all()
         # Cast needed because of the cache
         cached_action_instances = cast(
             List[Action], cache.get_or_set("_action_instances", get_action_instances)
         )
-        return [
-            action
-            for action in cached_action_instances
-            if (not direction or direction in [d.code for d in action.directions.all()])
-            and action.id in ps_action_ids
-        ]
+
+        if direction:
+            pss = pss.filter(action__directions__code__in=[direction])
+        if actions_codes:
+            pss = pss.filter(action__code__in=actions_codes.split("|"))
+
+        action_ids_to_display = pss.values_list("action__id", flat=True)
+        return cached_action_instances.filter(id__in=action_ids_to_display)
path: qfdmo/models/acteur.py
user: login: kolok
body: cf. mon commentaire sur le nombre de requêtes
created_at: 2025-04-09T16:07:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,3 +1,4 @@
 from .acteur import *  # noqa
 from .action import *  # noqa
 from .categorie_objet import *  # noqa
+from .config import *
path: qfdmo/admin/__init__.py
user: login: fabienheureux
body: 🤔 

Je pensais l'avoir résolu mais ca devait etre dans l'autre branché basée sur celle là...
created_at: 2025-04-10T14:59:13Z
author_association: MEMBER
diff_hunk: @@ -970,33 +974,35 @@ def nombre_enfants(self) -> int:
     def get_absolute_url(self):
         return reverse("qfdmo:acteur-detail", args=[self.uuid])
 
-    def acteur_actions(self, direction=None):
-        ps_action_ids = list(
-            {ps.action_id for ps in self.proposition_services.all()}  # type: ignore
-        )
+    def acteur_actions(self, direction=None, actions_codes=None):
+        pss = self.proposition_services.all()
         # Cast needed because of the cache
         cached_action_instances = cast(
             List[Action], cache.get_or_set("_action_instances", get_action_instances)
         )
-        return [
-            action
-            for action in cached_action_instances
-            if (not direction or direction in [d.code for d in action.directions.all()])
-            and action.id in ps_action_ids
-        ]
+
+        if direction:
+            pss = pss.filter(action__directions__code__in=[direction])
+        if actions_codes:
+            pss = pss.filter(action__code__in=actions_codes.split("|"))
+
+        action_ids_to_display = pss.values_list("action__id", flat=True)
+        return cached_action_instances.filter(id__in=action_ids_to_display)
 
     def json_acteur_for_display(
         self,
         direction: str | None = None,
         action_list: str | None = None,
         carte: bool = False,
+        carte_config: CarteConfig = None,
path: qfdmo/models/acteur.py
user: login: fabienheureux
body: oui
created_at: 2025-04-10T15:01:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,88 @@
+from django.core.validators import FileExtensionValidator
+from django.db import models
+from django.urls import reverse
+
+
+class GroupeActionConfig(models.Model):
+    carte_config = models.ForeignKey(
+        "qfdmo.CarteConfig",
+        on_delete=models.CASCADE,
+        related_name="groupe_action_config",
path: qfdmo/models/config.py
user: login: fabienheureux
body: arf, `configs` ca sonne bizarre, mais oui dans l'idée je vais renommer 

created_at: 2025-04-10T15:02:19Z
author_association: MEMBER

PR comments:
user: login: gitguardian[bot]
created_at: 2025-03-25T14:42:55Z
body: #### ️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find [here](https://docs.gitguardian.com/platform/remediate/remediate-incidents) more information about risks.

---

<sup><sub>🦉 [GitGuardian](https://dashboard.gitguardian.com/auth/login/?utm_medium=checkruns&amp;utm_source=github&amp;utm_campaign=cr1) detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.<br/></sup></sub>

author_association: NONE
user: login: kolok
created_at: 2025-04-09T16:06:33Z
body: dommage qu'on ne puisse pas configurer un pinpoint pourtous les geste par type d'acteur 

![CleanShot 2025-04-09 at 18 04 15](https://github.com/user-attachments/assets/d7685998-26db-4c84-af13-d709f2a60a74)

-> cette configuration ne suffit pas à afficher un pinpoint différent pour toute les décheteries
author_association: CONTRIBUTOR
user: login: fabienheureux
created_at: 2025-04-09T16:29:52Z
body: Oui j'avais mis dans la todo sur la PR l'optimisation des perfs 

author_association: MEMBER
user: login: fabienheureux
created_at: 2025-04-15T13:04:21Z
body: <img width="760" alt="image" src="https://github.com/user-attachments/assets/dec81a9b-1940-44b6-9d23-f06cc969027d" />


on peut désormais afficher les icones si les champs sont vides !
author_association: MEMBER