{% for acteur in acteurs %}
{% endfor %}
diff --git a/qfdmd/models.py b/qfdmd/models.py
index 012ec15fd..7558ec722 100644
--- a/qfdmd/models.py
+++ b/qfdmd/models.py
@@ -1,11 +1,11 @@
import logging
-from urllib.parse import urlencode
import sites_faciles
from django import forms
from django.contrib.gis.db import models
from django.db.models import CheckConstraint, Q
from django.db.models.functions import Now
+from django.http import QueryDict
from django.template.loader import render_to_string
from django.urls.base import reverse
from django.utils.functional import cached_property
@@ -463,18 +463,25 @@ def get_etats_descriptions(self) -> tuple[str, str] | None:
@property
def carte_settings(self):
- # TODO : gérer plusieurs catégories ici
- sous_categorie = self.sous_categories.filter(afficher_carte=True).first()
- if not sous_categorie:
- return {}
-
- return {
- "direction": "jai",
- "first_dir": "jai",
- "limit": 25,
- "sc_id": sous_categorie.id,
- "sous_categorie_objet": sous_categorie.libelle,
- }
+ sous_categories = self.sous_categories.filter(afficher_carte=True).all()
+ settings_querydict = QueryDict(mutable=True)
+
+ if not sous_categories:
+ return settings_querydict
+
+ settings_querydict.update(
+ {
+ "direction": "jai",
+ "first_dir": "jai",
+ "limit": 25,
+ }
+ )
+
+ settings_querydict.setlist(
+ "sous_categories", sous_categories.values_list("id", flat=True)
+ )
+
+ return settings_querydict
@cached_property
def en_savoir_plus(self):
@@ -590,16 +597,20 @@ def get_url_carte(self, actions=None, map_container_id=None):
carte_settings = self.produit.carte_settings
if actions:
carte_settings.update(
- action_list=actions,
- action_displayed=actions,
+ {
+ "action_list": actions,
+ "action_displayed": actions,
+ }
)
if map_container_id:
carte_settings.update(
- map_container_id=map_container_id,
+ {
+ "map_container_id": map_container_id,
+ }
)
- params = urlencode(carte_settings)
+ params = carte_settings.urlencode()
url = reverse("qfdmd:carte", args=[self.slug])
return f"{url}?{params}"
diff --git a/qfdmo/forms.py b/qfdmo/forms.py
index f58ab920d..e31f15de5 100644
--- a/qfdmo/forms.py
+++ b/qfdmo/forms.py
@@ -6,6 +6,7 @@
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from dsfr.forms import DsfrBaseForm
+from typing_extensions import deprecated
from qfdmo.fields import GroupeActionChoiceField
from qfdmo.geo_api import epcis_from, formatted_epcis_as_list_of_tuple
@@ -26,6 +27,16 @@
)
+class DisplayedActeursForm(forms.Form):
+ sous_categories = forms.ModelMultipleChoiceField(
+ required=False,
+ initial=SousCategorieObjet.objects.none(),
+ queryset=SousCategorieObjet.objects.filter(afficher=True),
+ widget=forms.MultipleHiddenInput,
+ )
+
+
+@deprecated("This form will be dropped soon in favor of DisplayedActeursForm")
class AddressesForm(forms.Form):
def load_choices(self, request: HttpRequest, **kwargs) -> None:
if address_placeholder := request.GET.get("address_placeholder"):
@@ -502,9 +513,7 @@ def load_choices(self):
action_displayed = forms.MultipleChoiceField(
widget=DSFRCheckboxSelectMultiple(
attrs={
- "class": (
- "fr-checkbox qf-inline-grid qf-grid-cols-4 qf-gap-4" " qf-m-1w"
- ),
+ "class": ("fr-checkbox qf-inline-grid qf-grid-cols-4 qf-gap-4 qf-m-1w"),
},
),
choices=[],
@@ -530,9 +539,7 @@ def load_choices(self):
action_list = forms.MultipleChoiceField(
widget=DSFRCheckboxSelectMultiple(
attrs={
- "class": (
- "fr-checkbox qf-inline-grid qf-grid-cols-4 qf-gap-4" " qf-m-1w"
- ),
+ "class": ("fr-checkbox qf-inline-grid qf-grid-cols-4 qf-gap-4 qf-m-1w"),
},
),
choices=[],
diff --git a/qfdmo/models/acteur.py b/qfdmo/models/acteur.py
index 7abdf24e1..cc12d0146 100644
--- a/qfdmo/models/acteur.py
+++ b/qfdmo/models/acteur.py
@@ -5,7 +5,7 @@
import string
import uuid
from copy import deepcopy
-from typing import Any, List, cast
+from typing import Any, cast
from urllib.parse import urlencode
import opening_hours
@@ -1103,16 +1103,16 @@ def get_absolute_url(self):
return reverse("qfdmo:acteur-detail", args=[self.uuid])
def acteur_actions(
- self, direction=None, actions_codes=None, sous_categorie_id=None
+ self, direction=None, actions_codes=None, sous_categorie_ids=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)
+ list[Action], cache.get_or_set("_action_instances", get_action_instances)
)
- if sous_categorie_id:
- pss = pss.filter(sous_categories__id__in=[sous_categorie_id])
+ if sous_categorie_ids:
+ pss = pss.filter(sous_categories__id__in=sous_categorie_ids)
if direction:
pss = pss.filter(action__directions__code__in=[direction])
if actions_codes:
@@ -1166,14 +1166,25 @@ def json_acteur_for_display(
carte: bool = False,
carte_config: CarteConfig = None,
sous_categorie_id: str | None = None,
+ displayed_acteur_form=None,
) -> str:
# TODO: refacto jinja: once the shared/results.html template
# will be migrated to django template, this method should
# live in a template_tags instead.
+ sous_categorie_ids = []
+
+ if (
+ sous_categories := displayed_acteur_form
+ and displayed_acteur_form.cleaned_data.get("sous_categories")
+ ):
+ sous_categorie_ids = sous_categories.values_list("id", flat=True)
+ elif sous_categorie_id:
+ sous_categorie_ids.append(sous_categorie_id)
+
actions = self.acteur_actions(
direction=direction,
actions_codes=action_list,
- sous_categorie_id=sous_categorie_id,
+ sous_categorie_ids=sous_categorie_ids,
)
def sort_actions_by_action_principale_and_order(a):
diff --git a/qfdmo/views/adresses.py b/qfdmo/views/adresses.py
index 9d0c28c10..7fea0ef90 100644
--- a/qfdmo/views/adresses.py
+++ b/qfdmo/views/adresses.py
@@ -82,7 +82,94 @@ def get_context_data(self, **kwargs):
return context
+class LegacyMethodsMixin:
+ """
+ A previous approach in carte form / views consisted on getting values from
+ request directly.
+
+ This is not the best way to get these using django, usually a form is
+ instantiated from the request to bring powerful form validation.
+
+ In order to gradually migrate the view from the previous to the new approach, every
+ data manually pulled from the request is isolated in its own method.
+
+ These method can then easily be overriden in the class that inherits from
+ SearchActeursView.
+
+ The current mixin has been created in order to easily read the legacy methods
+ so that they can be removed later.
+ """
+
+ def get_sous_categorie_objet(self) -> str:
+ return self.get_data_from_request_or_bounded_form("sous_categorie_objet")
+
+ def get_sc_id(self, initial) -> str | None:
+ return (
+ self.request.GET.get("sc_id") if initial["sous_categorie_objet"] else None
+ )
+
+ def get_sous_categories_ids(self) -> list[int]:
+ sous_categorie = self.get_data_from_request_or_bounded_form("sc_id", 0)
+ if not sous_categorie:
+ return []
+
+ return [sous_categorie]
+
+ def get_adresse(self) -> str:
+ return self.request.GET.get("adresse")
+
+ def get_longitude(self) -> str:
+ return self.request.GET.get("longitude")
+
+ def get_latitude(self) -> str:
+ return self.request.GET.get("latitude")
+
+ def get_digital(self) -> str:
+ return self.request.GET.get("digital", "0")
+
+ def get_data_from_request_or_bounded_form(self, key: str, default=None):
+ """Temporary dummy method
+
+ There is a flaw in the way the form is instantiated, because the
+ form is never bounded to its data.
+ The request is directly used to perform various tasks, like
+ populating some multiple choice field choices, hence missing all
+ the validation provided by django forms.
+
+ To prepare a future refactor of this form, the method here calls
+ the cleaned_data when the form is bounded and the request.GET
+ QueryDict when it is not bounded.
+ Note : we call getlist and not get because in some cases, the request
+ parameters needs to be treated as a list.
+
+ The form is currently used for various use cases:
+ - The map form
+ - The "iframe form" form (for https://epargnonsnosressources.gouv.fr)
+ - The turbo-frames
+ The form should be bounded at least when used in turbo-frames.
+
+ The name is explicitely very verbose because it is not meant to stay
+ a long time as is.
+
+ TODO: refacto forms : get rid of this method and use cleaned_data when
+ form is valid and request.GET for non-field request parameters
+
+ Edit 25 july 2025 : the refactoring has started and can be found in
+ LegacyMethodMixin above.
+ """
+ try:
+ return self.cleaned_data.get(key, default)
+ except AttributeError:
+ pass
+
+ try:
+ return self.request.GET.get(key, default)
+ except AttributeError:
+ return self.request.GET.getlist(key, default)
+
+
class SearchActeursView(
+ LegacyMethodsMixin,
DigitalMixin,
TurboFormMixin,
FormView,
@@ -95,15 +182,15 @@ class SearchActeursView(
def get_initial(self):
initial = super().get_initial()
# TODO: refacto forms : delete this line
- initial["sous_categorie_objet"] = self.request.GET.get("sous_categorie_objet")
+ initial["sous_categorie_objet"] = self.get_sous_categorie_objet()
# TODO: refacto forms : delete this line
- initial["adresse"] = self.request.GET.get("adresse")
- initial["digital"] = self.request.GET.get("digital", "0")
+ initial["adresse"] = self.get_adresse()
+ initial["digital"] = self.get_digital()
initial["direction"] = get_direction(self.request, self.is_carte)
# TODO: refacto forms : delete this line
- initial["latitude"] = self.request.GET.get("latitude")
+ initial["latitude"] = self.get_latitude()
# TODO: refacto forms : delete this line
- initial["longitude"] = self.request.GET.get("longitude")
+ initial["longitude"] = self.get_longitude()
# TODO: refacto forms : delete this line
initial["label_reparacteur"] = self.request.GET.get("label_reparacteur")
initial["epci_codes"] = self.request.GET.getlist("epci_codes")
@@ -116,10 +203,7 @@ def get_initial(self):
initial["ess"] = self.request.GET.get("ess")
# TODO: refacto forms : delete this line
initial["bounding_box"] = self.request.GET.get("bounding_box")
- initial["sc_id"] = (
- self.request.GET.get("sc_id") if initial["sous_categorie_objet"] else None
- )
-
+ initial["sc_id"] = self.get_sc_id(initial)
# Action to display and check
action_displayed = self._set_action_displayed()
initial["action_displayed"] = "|".join([a.code for a in action_displayed])
@@ -160,50 +244,12 @@ def get_form(self, form_class=None):
return form
- def get_data_from_request_or_bounded_form(self, key: str, default=None):
- """Temporary dummy method
-
- There is a flaw in the way the form is instantiated, because the
- form is never bounded to its data.
- The request is directly used to perform various tasks, like
- populating some multiple choice field choices, hence missing all
- the validation provided by django forms.
-
- To prepare a future refactor of this form, the method here calls
- the cleaned_data when the form is bounded and the request.GET
- QueryDict when it is not bounded.
- Note : we call getlist and not get because in some cases, the request
- parameters needs to be treated as a list.
-
- The form is currently used for various use cases:
- - The map form
- - The "iframe form" form (for https://epargnonsnosressources.gouv.fr)
- - The turbo-frames
- The form should be bounded at least when used in turbo-frames.
-
- The name is explicitely very verbose because it is not meant to stay
- a long time as is.
-
- TODO: refacto forms : get rid of this method and use cleaned_data when
- form is valid and request.GET for non-field request parameters"""
- try:
- return self.cleaned_data.get(key, default)
- except AttributeError:
- pass
-
- try:
- return self.request.GET.get(key, default)
- except AttributeError:
- return self.request.GET.getlist(key, default)
-
def get_context_data(self, **kwargs):
form = self.get_form_class()(self.request.GET)
kwargs.update(
# TODO: refacto forms : define a BooleanField carte on CarteAddressesForm
carte=self.is_carte,
- # TODO: refacto forms, return bounded form in template
- # form=form,
location="{}",
)
@@ -444,9 +490,9 @@ def _compile_acteurs_queryset(
if self.get_data_from_request_or_bounded_form("bonus"):
filters &= Q(labels__bonus=True)
- if sous_categorie_id := self.get_data_from_request_or_bounded_form("sc_id", 0):
+ if sous_categorie_ids := self.get_sous_categories_ids():
filters &= Q(
- proposition_services__sous_categories__id=sous_categorie_id,
+ proposition_services__sous_categories__id__in=sous_categorie_ids,
)
actions_filters = Q()
diff --git a/qfdmo/views/carte.py b/qfdmo/views/carte.py
index 6bd301e3f..ec53375e1 100644
--- a/qfdmo/views/carte.py
+++ b/qfdmo/views/carte.py
@@ -4,7 +4,7 @@
from django.utils.functional import cached_property
from django.views.generic import DetailView
-from qfdmo.forms import CarteForm
+from qfdmo.forms import CarteForm, DisplayedActeursForm
from qfdmo.models import CarteConfig
from qfdmo.views.adresses import SearchActeursView
@@ -33,13 +33,31 @@ def get_initial(self, *args, **kwargs):
return initial
def get_context_data(self, **kwargs):
+ self.displayed_acteur_form = DisplayedActeursForm(self.request.GET)
+
+ if not self.displayed_acteur_form.is_valid():
+ logger.error(f"Form is valid {self.displayed_acteur_form=}")
+
context = super().get_context_data(**kwargs)
- context.update(is_carte=True, map_container_id="carte")
+ context.update(
+ is_carte=True,
+ map_container_id="carte",
+ displayed_acteur_form=self.displayed_acteur_form,
+ )
+
return context
def _get_selected_action_ids(self):
return [a.id for a in self._get_selected_action()]
+ def get_sous_categories_ids(self):
+ if sous_categories := self.displayed_acteur_form.cleaned_data.get(
+ "sous_categories"
+ ):
+ return sous_categories.values_list("pk", flat=True)
+
+ return super().get_sous_categories_ids()
+
class ProductCarteView(CarteSearchActeursView):
def get_context_data(self, **kwargs):
@@ -58,11 +76,6 @@ class CustomCarteView(DetailView, CarteSearchActeursView):
model = CarteConfig
context_object_name = "carte_config"
- def get_context_data(self, *args, **kwargs):
- ctx = super().get_context_data(*args, **kwargs)
-
- return ctx
-
@cached_property
def groupe_actions(self):
# TODO: cache
diff --git a/static/to_compile/controllers/assistant/state.ts b/static/to_compile/controllers/assistant/state.ts
index 2bb8b74df..16d685850 100644
--- a/static/to_compile/controllers/assistant/state.ts
+++ b/static/to_compile/controllers/assistant/state.ts
@@ -114,17 +114,17 @@ export default class extends Controller {
updateUIFromGlobalState(outlet) {
const value = this.locationValue
let touched = false
- if (value.adresse && value.adresse !== outlet.inputTarget.value) {
+ if (value.adresse) {
outlet.inputTarget.value = value.adresse
touched = true
}
- if (value.latitude && outlet.latitudeTarget.value !== value.latitude) {
+ if (value.latitude) {
outlet.latitudeTarget.value = value.latitude
touched = true
}
- if (value.longitude && outlet.longitudeTarget.value !== value.longitude) {
+ if (value.longitude) {
outlet.longitudeTarget.value = value.longitude
touched = true
}
diff --git a/templates/snippets/share_and_embed.html b/templates/snippets/share_and_embed.html
index d88486e09..128c9bc40 100644
--- a/templates/snippets/share_and_embed.html
+++ b/templates/snippets/share_and_embed.html
@@ -6,14 +6,14 @@
{% endif %}
-
+
{% include "modals/share.html" with button_extra_classes="qf-w-full qf-justify-center fr-btn--secondary fr-btn--icon-left fr-icon fr-icon-share-line" %}
-
+
{% include "modals/embed.html" with button_extra_classes="qf-w-full qf-justify-center fr-btn--tertiary fr-btn--icon-left fr-icon fr-icon-code-s-slash-line" %}