feat: add favorite filters feature for user configurations and interventions module

This commit is contained in:
kdeterme 2026-10-12 14:49:07 +02:00
parent e4d182596b
commit 3d76855d0e
27 changed files with 1277 additions and 6 deletions

View file

@ -560,3 +560,19 @@ msgstr "Reset"
msgid "Format de fond de plan invalide"
msgstr "Invalid basemap format"
#: common/views.py:863
msgid "Module invalide"
msgstr "Invalid module"
#: common/views.py:874
msgid "Le nom du filtre est obligatoire"
msgstr "Filter name is required"
#: common/views.py:937
msgid "Identifiant de filtre manquant"
msgstr "Missing filter ID"
#: common/models.py:382
msgid "Filtres favoris"
msgstr "Favorite filters"

View file

@ -546,3 +546,19 @@ msgstr "Réinitialiser"
msgid "Format de fond de plan invalide"
msgstr "Format de fond de plan invalide"
#: common/views.py:863
msgid "Module invalide"
msgstr "Module invalide"
#: common/views.py:874
msgid "Le nom du filtre est obligatoire"
msgstr "Le nom du filtre est obligatoire"
#: common/views.py:937
msgid "Identifiant de filtre manquant"
msgstr "Identifiant de filtre manquant"
#: common/models.py:382
msgid "Filtres favoris"
msgstr "Filtres favoris"

View file

@ -546,3 +546,19 @@ msgstr "Herstellen"
msgid "Format de fond de plan invalide"
msgstr "Ongeldig basiskaartformaat"
#: common/views.py:863
msgid "Module invalide"
msgstr "Ongeldige module"
#: common/views.py:874
msgid "Le nom du filtre est obligatoire"
msgstr "Filternaam is verplicht"
#: common/views.py:937
msgid "Identifiant de filtre manquant"
msgstr "Filter-ID ontbreekt"
#: common/models.py:382
msgid "Filtres favoris"
msgstr "Favoriete filters"

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-10-12 12:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0020_userconfig_can_view_all_posts_and_more'),
]
operations = [
migrations.AddField(
model_name='userconfig',
name='favorite_filters',
field=models.JSONField(blank=True, default=dict, help_text='Filtres favoris enregistrés par module (interventions, observations, assets, etc.).', verbose_name='Filtres favoris'),
),
]

View file

@ -377,6 +377,12 @@ class UserConfig(models.Model):
verbose_name="Préférences du calendrier",
help_text="Préférences de la page calendrier (mode d'affichage, zoom, filtres actifs, état d'expansion du backlog).",
)
favorite_filters = models.JSONField(
default=dict,
blank=True,
verbose_name="Filtres favoris",
help_text="Filtres favoris enregistrés par module (interventions, observations, assets, etc.).",
)
def __str__(self):
return f"Configuration de {self.user.username}"

View file

@ -665,6 +665,81 @@ class HelpDocumentTests(TestCase):
self.assertNotIn("StreetUp", rendered_nl)
class FavoriteFiltersAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="fav_tester", password="password123")
self.config = UserConfig.objects.create(user=self.user)
self.client.force_login(self.user)
def test_save_and_update_favorite_filter(self):
# 1. Create a new favorite filter
url = reverse("common:save_favorite_filter", kwargs={"module": "interventions"})
payload = {
"name": "Urgences Voirie",
"filters": {
"thematic": ["road"],
"priority": "high",
"status": ["in_progress"],
},
}
response = self.client.post(url, json.dumps(payload), content_type="application/json")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["success"])
fav = data["favorite"]
self.assertEqual(fav["name"], "Urgences Voirie")
self.assertIn("id", fav)
self.assertEqual(len(data["favorites"]), 1)
# Verify persistence in UserConfig
self.config.refresh_from_db()
interventions_favs = self.config.favorite_filters.get("interventions", [])
self.assertEqual(len(interventions_favs), 1)
self.assertEqual(interventions_favs[0]["name"], "Urgences Voirie")
# 2. Update the existing favorite filter
fav_id = fav["id"]
update_payload = {
"id": fav_id,
"name": "Urgences Voirie Modifiées",
"filters": {
"thematic": ["road"],
"priority": "high",
"status": ["in_progress", "assigned"],
},
}
update_response = self.client.post(url, json.dumps(update_payload), content_type="application/json")
self.assertEqual(update_response.status_code, 200)
update_data = update_response.json()
self.assertEqual(update_data["favorite"]["name"], "Urgences Voirie Modifiées")
self.assertEqual(len(update_data["favorites"]), 1)
# 3. Create a second favorite
second_payload = {
"name": "Espaces verts",
"filters": {"thematic": ["green"]},
}
self.client.post(url, json.dumps(second_payload), content_type="application/json")
self.config.refresh_from_db()
self.assertEqual(len(self.config.favorite_filters["interventions"]), 2)
# 4. Delete the first favorite
delete_url = reverse("common:delete_favorite_filter", kwargs={"module": "interventions"})
delete_response = self.client.post(delete_url, json.dumps({"id": fav_id}), content_type="application/json")
self.assertEqual(delete_response.status_code, 200)
delete_data = delete_response.json()
self.assertEqual(len(delete_data["favorites"]), 1)
self.assertEqual(delete_data["favorites"][0]["name"], "Espaces verts")
self.config.refresh_from_db()
self.assertEqual(len(self.config.favorite_filters["interventions"]), 1)
def test_invalid_module_rejected(self):
url = reverse("common:save_favorite_filter", kwargs={"module": "unknown_module"})
response = self.client.post(url, json.dumps({"name": "Test", "filters": {}}), content_type="application/json")
self.assertEqual(response.status_code, 400)

View file

@ -20,5 +20,7 @@ urlpatterns = [
path("save-assets-index-preferences/", views.save_assets_index_preferences, name="save_assets_index_preferences"),
path("save-calendar-preferences/", views.save_calendar_preferences, name="save_calendar_preferences"),
path("save-project-list-preferences/", views.save_project_list_preferences, name="save_project_list_preferences"),
path("favorite-filters/<str:module>/save/", views.save_favorite_filter, name="save_favorite_filter"),
path("favorite-filters/<str:module>/delete/", views.delete_favorite_filter, name="delete_favorite_filter"),
path("help/<str:doc_name>/", views.get_help_document, name="get_help_document"),
]

View file

@ -167,6 +167,7 @@ def copy_user_config(source_user_or_config: Any, target_user_or_config: Any, cop
target_config.calendar_preferences = (
dict(source_config.calendar_preferences) if source_config.calendar_preferences is not None else None
)
target_config.favorite_filters = dict(source_config.favorite_filters or {})
# Permissions spécifiques (eSign, documents, panoramax, transport exceptionnel, contrats)
target_config.can_add_unique_tag_documents = source_config.can_add_unique_tag_documents

View file

@ -852,5 +852,119 @@ def set_language(request):
return response
ALLOWED_FAVORITE_MODULES = [
'interventions',
'observations',
'assets',
'controls',
'inspections',
'projects',
]
@login_required
@require_POST
def save_favorite_filter(request, module):
"""API endpoint to save or update a favorite filter for a specific module."""
import json
import uuid
from django.utils import timezone
if module not in ALLOWED_FAVORITE_MODULES:
return JsonResponse({'error': _('Module invalide')}, status=400)
try:
data = json.loads(request.body)
name = (data.get('name') or '').strip()
filters = data.get('filters', {})
favorite_id = data.get('id')
except (json.JSONDecodeError, AttributeError):
return JsonResponse({'error': _('Données invalides')}, status=400)
if not name:
return JsonResponse({'error': _('Le nom du filtre est obligatoire')}, status=400)
if not isinstance(filters, dict):
return JsonResponse({'error': _('Format de filtres invalide')}, status=400)
try:
user_config, _created = UserConfig.objects.get_or_create(user=request.user)
all_favorites = dict(user_config.favorite_filters or {})
module_favorites = list(all_favorites.get(module, []))
now_str = timezone.now().isoformat()
saved_item = None
if favorite_id:
# Update existing
for item in module_favorites:
if item.get('id') == favorite_id:
item['name'] = name
item['filters'] = filters
item['updated_at'] = now_str
saved_item = item
break
if not saved_item:
# Create new
new_id = f"fav_{uuid.uuid4().hex[:12]}"
saved_item = {
'id': new_id,
'name': name,
'filters': filters,
'created_at': now_str,
}
module_favorites.append(saved_item)
all_favorites[module] = module_favorites
user_config.favorite_filters = all_favorites
user_config.save(update_fields=['favorite_filters'])
return JsonResponse({
'success': True,
'favorite': saved_item,
'favorites': module_favorites,
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
@login_required
@require_POST
def delete_favorite_filter(request, module):
"""API endpoint to delete a favorite filter for a specific module."""
import json
if module not in ALLOWED_FAVORITE_MODULES:
return JsonResponse({'error': _('Module invalide')}, status=400)
try:
data = json.loads(request.body)
favorite_id = data.get('id')
except (json.JSONDecodeError, AttributeError):
return JsonResponse({'error': _('Données invalides')}, status=400)
if not favorite_id:
return JsonResponse({'error': _('Identifiant de filtre manquant')}, status=400)
try:
user_config, _created = UserConfig.objects.get_or_create(user=request.user)
all_favorites = dict(user_config.favorite_filters or {})
module_favorites = list(all_favorites.get(module, []))
updated_favorites = [item for item in module_favorites if item.get('id') != favorite_id]
all_favorites[module] = updated_favorites
user_config.favorite_filters = all_favorites
user_config.save(update_fields=['favorite_filters'])
return JsonResponse({
'success': True,
'favorites': updated_favorites,
})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)

View file

@ -8431,5 +8431,32 @@ msgstr "-- Select a role… --"
msgid "-- Choisir une personne… --"
msgstr "-- Choose a person… --"
msgid "— Sélectionner un favori —"
msgstr "— Select a favorite —"
msgid "Enregistrer en favori"
msgstr "Save as favorite"
msgid "Enregistrer comme favori"
msgstr "Save as favorite"
msgid "Nom du filtre favori"
msgstr "Favorite filter name"
msgid "Ex. Mes urgences voirie"
msgstr "Ex. My road emergencies"
msgid "Donnez un nom clair pour retrouver facilement cette sélection de filtres."
msgstr "Give a clear name to easily find this selection of filters."
msgid "Critères enregistrés :"
msgstr "Saved criteria:"
msgid "Mes filtres favoris"
msgstr "My favorite filters"
msgid "Enregistrer le filtre actuel..."
msgstr "Save current filter..."

View file

@ -1207,6 +1207,97 @@ msgstr "Error while saving"
msgid "Erreur lors de la mise à jour"
msgstr "Error during update"
msgid "Favoris"
msgstr "Favorites"
msgid "Aucun favori enregistré"
msgstr "No saved favorites"
msgid "Mettre à jour avec les filtres actuels"
msgstr "Update with current filters"
msgid "Sélectionner un favori"
msgstr "Select a favorite"
msgid "Aucun filtre sélectionné (toutes les interventions)."
msgstr "No filter selected (all interventions)."
msgid "Thématique(s)"
msgstr "Thematic(s)"
msgid "Statut(s)"
msgstr "Status(es)"
msgid "Priorité"
msgstr "Priority"
msgid "Type maintenance"
msgstr "Maintenance type"
msgid "Catégories"
msgstr "Categories"
msgid "sélectionnée(s)"
msgstr "selected"
msgid "Date début"
msgstr "Start date"
msgid "Date fin"
msgstr "End date"
msgid "Non traitées (en retard)"
msgstr "Pending (overdue)"
msgid "Suivies uniquement"
msgstr "Followed only"
msgid "Marché bloqué : Oui"
msgstr "Contract blocked: Yes"
msgid "Marché bloqué : Non"
msgstr "Contract blocked: No"
msgid "Localisations"
msgstr "Locations"
msgid "Filtres divers appliqués."
msgstr "Various filters applied."
msgid "Filtre favori enregistré avec succès."
msgstr "Favorite filter saved successfully."
msgid "Erreur lors de la sauvegarde du favori."
msgstr "Error saving favorite filter."
msgid "Erreur de communication avec le serveur."
msgstr "Communication error with server."
#, javascript-format
msgid "Voulez-vous vraiment supprimer le favori '%s' ?"
msgstr "Are you sure you want to delete favorite '%s'?"
msgid "Filtre favori supprimé."
msgstr "Favorite filter deleted."
msgid "Erreur lors de la suppression du favori."
msgstr "Error deleting favorite filter."
#, javascript-format
msgid "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?"
msgstr "Update favorite '%s' with currently active filters?"
#, javascript-format
msgid "Favori '%s' mis à jour."
msgstr "Favorite '%s' updated."
msgid "Erreur lors de la mise à jour du favori."
msgstr "Error updating favorite filter."
#, javascript-format
msgid "Filtre '%s' appliqué."
msgstr "Filter '%s' applied."
#~ msgid "Veuillez entrer un entier de maximum 6 chiffres"
#~ msgstr "Please enter an integer with maximum 6 digits"

View file

@ -8538,5 +8538,32 @@ msgstr "-- Sélectionner un rôle… --"
msgid "-- Choisir une personne… --"
msgstr "-- Choisir une personne… --"
msgid "— Sélectionner un favori —"
msgstr "— Sélectionner un favori —"
msgid "Enregistrer en favori"
msgstr "Enregistrer en favori"
msgid "Enregistrer comme favori"
msgstr "Enregistrer comme favori"
msgid "Nom du filtre favori"
msgstr "Nom du filtre favori"
msgid "Ex. Mes urgences voirie"
msgstr "Ex. Mes urgences voirie"
msgid "Donnez un nom clair pour retrouver facilement cette sélection de filtres."
msgstr "Donnez un nom clair pour retrouver facilement cette sélection de filtres."
msgid "Critères enregistrés :"
msgstr "Critères enregistrés :"
msgid "Mes filtres favoris"
msgstr "Mes filtres favoris"
msgid "Enregistrer le filtre actuel..."
msgstr "Enregistrer le filtre actuel..."

View file

@ -1214,6 +1214,97 @@ msgstr "Erreur lors de la sauvegarde"
msgid "Erreur lors de la mise à jour"
msgstr "Erreur lors de la mise à jour"
msgid "Favoris"
msgstr "Favoris"
msgid "Aucun favori enregistré"
msgstr "Aucun favori enregistré"
msgid "Mettre à jour avec les filtres actuels"
msgstr "Mettre à jour avec les filtres actuels"
msgid "Sélectionner un favori"
msgstr "Sélectionner un favori"
msgid "Aucun filtre sélectionné (toutes les interventions)."
msgstr "Aucun filtre sélectionné (toutes les interventions)."
msgid "Thématique(s)"
msgstr "Thématique(s)"
msgid "Statut(s)"
msgstr "Statut(s)"
msgid "Priorité"
msgstr "Priorité"
msgid "Type maintenance"
msgstr "Type maintenance"
msgid "Catégories"
msgstr "Catégories"
msgid "sélectionnée(s)"
msgstr "sélectionnée(s)"
msgid "Date début"
msgstr "Date début"
msgid "Date fin"
msgstr "Date fin"
msgid "Non traitées (en retard)"
msgstr "Non traitées (en retard)"
msgid "Suivies uniquement"
msgstr "Suivies uniquement"
msgid "Marché bloqué : Oui"
msgstr "Marché bloqué : Oui"
msgid "Marché bloqué : Non"
msgstr "Marché bloqué : Non"
msgid "Localisations"
msgstr "Localisations"
msgid "Filtres divers appliqués."
msgstr "Filtres divers appliqués."
msgid "Filtre favori enregistré avec succès."
msgstr "Filtre favori enregistré avec succès."
msgid "Erreur lors de la sauvegarde du favori."
msgstr "Erreur lors de la sauvegarde du favori."
msgid "Erreur de communication avec le serveur."
msgstr "Erreur de communication avec le serveur."
#, javascript-format
msgid "Voulez-vous vraiment supprimer le favori '%s' ?"
msgstr "Voulez-vous vraiment supprimer le favori '%s' ?"
msgid "Filtre favori supprimé."
msgstr "Filtre favori supprimé."
msgid "Erreur lors de la suppression du favori."
msgstr "Erreur lors de la suppression du favori."
#, javascript-format
msgid "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?"
msgstr "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?"
#, javascript-format
msgid "Favori '%s' mis à jour."
msgstr "Favori '%s' mis à jour."
msgid "Erreur lors de la mise à jour du favori."
msgstr "Erreur lors de la mise à jour du favori."
#, javascript-format
msgid "Filtre '%s' appliqué."
msgstr "Filtre '%s' appliqué."
#~ msgid "Veuillez entrer un entier de maximum 6 chiffres"
#~ msgstr "Veuillez entrer un entier de maximum 6 chiffres"

View file

@ -8472,4 +8472,31 @@ msgstr "-- Selecteer een rol… --"
msgid "-- Choisir une personne… --"
msgstr "-- Kies een persoon… --"
msgid "— Sélectionner un favori —"
msgstr "— Selecteer een favoriet —"
msgid "Enregistrer en favori"
msgstr "Opslaan als favoriet"
msgid "Enregistrer comme favori"
msgstr "Opslaan als favoriet"
msgid "Nom du filtre favori"
msgstr "Naam van favoriet filter"
msgid "Ex. Mes urgences voirie"
msgstr "Bijv. Mijn wegongevallen"
msgid "Donnez un nom clair pour retrouver facilement cette sélection de filtres."
msgstr "Geef een duidelijke naam om deze filterselectie gemakkelijk terug te vinden."
msgid "Critères enregistrés :"
msgstr "Opgeslagen criteria:"
msgid "Mes filtres favoris"
msgstr "Mijn favoriete filters"
msgid "Enregistrer le filtre actuel..."
msgstr "Huidige filter opslaan..."

View file

@ -1206,6 +1206,97 @@ msgstr "Fout bij het opslaan"
msgid "Erreur lors de la mise à jour"
msgstr "Fout bij het bijwerken"
msgid "Favoris"
msgstr "Favorieten"
msgid "Aucun favori enregistré"
msgstr "Geen opgeslagen favorieten"
msgid "Mettre à jour avec les filtres actuels"
msgstr "Bijwerken met huidige filters"
msgid "Sélectionner un favori"
msgstr "Selecteer een favoriet"
msgid "Aucun filtre sélectionné (toutes les interventions)."
msgstr "Geen filter geselecteerd (alle interventies)."
msgid "Thématique(s)"
msgstr "Thema('s)"
msgid "Statut(s)"
msgstr "Status(sen)"
msgid "Priorité"
msgstr "Prioriteit"
msgid "Type maintenance"
msgstr "Onderhoudstype"
msgid "Catégories"
msgstr "Categorieën"
msgid "sélectionnée(s)"
msgstr "geselecteerd"
msgid "Date début"
msgstr "Startdatum"
msgid "Date fin"
msgstr "Einddatum"
msgid "Non traitées (en retard)"
msgstr "Niet behandeld (achterstallig)"
msgid "Suivies uniquement"
msgstr "Alleen gevolgde"
msgid "Marché bloqué : Oui"
msgstr "Overeenkomst geblokkeerd: Ja"
msgid "Marché bloqué : Non"
msgstr "Overeenkomst geblokkeerd: Nee"
msgid "Localisations"
msgstr "Locaties"
msgid "Filtres divers appliqués."
msgstr "Diverse filters toegepast."
msgid "Filtre favori enregistré avec succès."
msgstr "Favoriet filter succesvol opgeslagen."
msgid "Erreur lors de la sauvegarde du favori."
msgstr "Fout bij het opslaan van het favoriete filter."
msgid "Erreur de communication avec le serveur."
msgstr "Communicatiefout met server."
#, javascript-format
msgid "Voulez-vous vraiment supprimer le favori '%s' ?"
msgstr "Weet u zeker dat u favoriet '%s' wilt verwijderen?"
msgid "Filtre favori supprimé."
msgstr "Favoriet filter verwijderd."
msgid "Erreur lors de la suppression du favori."
msgstr "Fout bij het verwijderen van het favoriete filter."
#, javascript-format
msgid "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?"
msgstr "Favoriet '%s' bijwerken met momenteel actieve filters?"
#, javascript-format
msgid "Favori '%s' mis à jour."
msgstr "Favoriet '%s' bijgewerkt."
msgid "Erreur lors de la mise à jour du favori."
msgstr "Fout bij het bijwerken van het favoriete filter."
#, javascript-format
msgid "Filtre '%s' appliqué."
msgstr "Filter '%s' toegepast."
#~ msgid "Veuillez entrer un entier de maximum 6 chiffres"
#~ msgstr "Voer een geheel getal van maximaal 6 cijfers in"

View file

@ -314,6 +314,10 @@ document.getElementById('apply-filters').addEventListener('click', async functio
if (filterModal) {
filterModal.hide();
}
if (typeof checkActiveFavoriteMatch === 'function') {
checkActiveFavoriteMatch();
}
});
@ -361,6 +365,10 @@ document.getElementById('reset-filters').addEventListener('click', async functio
// Update filter badge after URL change
updateFilterBadge();
if (typeof checkActiveFavoriteMatch === 'function') {
checkActiveFavoriteMatch();
}
// Clear search input too
$('#interventions-search-input').val('');
$('#interventions-search-clear').css('visibility', 'hidden');
@ -2414,7 +2422,7 @@ function countActiveFilters() {
'provider', 'contract', 'assigned_team', 'assigned_member',
'assigned_controller', 'intervention_manager', 'inspector', 'pause_reason',
'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max', 'locations',
'overdue', 'to_be_planned_soon'
'overdue', 'to_be_planned_soon', 'followed_only', 'is_blocked_due_to_contract'
];
filterParams.forEach(param => {
@ -2459,7 +2467,7 @@ function savePreferencesFromUrl() {
'provider', 'contract', 'assigned_team', 'assigned_member',
'assigned_controller', 'intervention_manager', 'inspector', 'pause_reason',
'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max', 'locations',
'overdue', 'to_be_planned_soon'
'overdue', 'to_be_planned_soon', 'followed_only', 'is_blocked_due_to_contract'
];
filterParams.forEach(param => {
@ -2513,7 +2521,7 @@ async function savePreferencesFromForm(urlParams) {
'provider', 'contract', 'assigned_team', 'assigned_member',
'assigned_controller', 'intervention_manager', 'inspector', 'pause_reason',
'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max', 'locations',
'overdue', 'to_be_planned_soon'
'overdue', 'to_be_planned_soon', 'followed_only', 'is_blocked_due_to_contract'
];
filterParams.forEach(param => {
@ -2566,6 +2574,7 @@ document.addEventListener('DOMContentLoaded', function() {
updateFilterBadge();
initDateFilterPresets();
initRolePersonFilter();
initFavoriteFilters();
// Save preferences when table sort changes (user action only)
const $table = $('#interventions-table');
@ -2579,6 +2588,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Update badge after page fully loads (including after filter redirect)
window.addEventListener('load', function() {
updateFilterBadge();
checkActiveFavoriteMatch();
// Restore search input from URL if present
const _urlParams = new URLSearchParams(window.location.search);
@ -3109,3 +3119,547 @@ function initDateFilterPresets() {
updateDateFilterTimelinePreview();
}
// ==================== Favorite Filters Management ====================
function initFavoriteFilters() {
renderFavoriteFiltersUI();
checkActiveFavoriteMatch();
// Open save modal buttons
const btnOpenSave = document.getElementById('btn-open-save-favorite-modal');
if (btnOpenSave) {
btnOpenSave.addEventListener('click', function(e) {
e.preventDefault();
openSaveFavoriteModal();
});
}
const btnModalSave = document.getElementById('modal-save-favorite-btn');
if (btnModalSave) {
btnModalSave.addEventListener('click', function(e) {
e.preventDefault();
openSaveFavoriteModal();
});
}
// Modal favorite select change
const modalSelect = document.getElementById('modal-favorite-filter-select');
if (modalSelect) {
modalSelect.addEventListener('change', function() {
if (this.value) {
applyFavoriteFilter(this.value);
}
});
}
// Confirm save button
const btnConfirmSave = document.getElementById('btn-confirm-save-favorite');
if (btnConfirmSave) {
btnConfirmSave.addEventListener('click', function() {
confirmSaveFavorite();
});
}
// Submit on Enter in save favorite modal
const saveNameInput = document.getElementById('save-favorite-name');
if (saveNameInput) {
saveNameInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
confirmSaveFavorite();
}
});
}
}
function renderFavoriteFiltersUI() {
const countBadge = document.getElementById('favorite-filters-count');
const itemsContainer = document.getElementById('favorite-filters-items');
const modalSelect = document.getElementById('modal-favorite-filter-select');
if (countBadge) {
countBadge.textContent = (typeof userFavoriteFilters !== 'undefined' && Array.isArray(userFavoriteFilters)) ? userFavoriteFilters.length : 0;
}
if (itemsContainer) {
if (!userFavoriteFilters || !userFavoriteFilters.length) {
itemsContainer.innerHTML = `
<li class="px-3 py-2 text-muted small text-center fst-italic">
${gettext('Aucun favori enregistré')}
</li>
`;
} else {
let html = '';
userFavoriteFilters.forEach(fav => {
html += `
<li class="dropdown-item-favorite d-flex align-items-center justify-content-between px-3 py-1-5" id="fav-item-${fav.id}">
<div class="d-flex align-items-center gap-2 flex-grow-1 overflow-hidden" role="button" onclick="applyFavoriteFilter('${fav.id}')" style="cursor: pointer;" title="${fav.name}">
<i class="bi bi-star favorite-star-icon text-muted" id="fav-star-${fav.id}"></i>
<span class="text-truncate fav-name small fw-medium">${fav.name}</span>
</div>
<div class="favorite-actions d-flex align-items-center gap-1 ms-2">
<button type="button" class="btn btn-xs btn-outline-secondary py-0 px-1 border-0" title="${gettext('Mettre à jour avec les filtres actuels')}" onclick="updateFavoriteWithCurrentFilters('${fav.id}', event)">
<i class="bi bi-arrow-repeat"></i>
</button>
<button type="button" class="btn btn-xs btn-outline-danger py-0 px-1 border-0" title="${gettext('Supprimer')}" onclick="deleteFavoriteFilter('${fav.id}', event)">
<i class="bi bi-trash"></i>
</button>
</div>
</li>
`;
});
itemsContainer.innerHTML = html;
}
}
if (modalSelect) {
const currentVal = modalSelect.value;
let selectHtml = `<option value="">— ${gettext('Sélectionner un favori')} —</option>`;
if (typeof userFavoriteFilters !== 'undefined' && Array.isArray(userFavoriteFilters)) {
userFavoriteFilters.forEach(fav => {
selectHtml += `<option value="${fav.id}">${fav.name}</option>`;
});
}
modalSelect.innerHTML = selectHtml;
modalSelect.value = currentVal;
}
}
function getCurrentFilterValues() {
const form = document.getElementById('filter-form');
const filters = {};
if (!form) return filters;
const formData = new FormData(form);
const multiFields = [
'thematic', 'status', 'priority', 'maintain_type', 'category', 'type',
'provider', 'contract', 'assigned_team', 'assigned_member', 'assigned_control_team',
'pause_reason'
];
multiFields.forEach(field => {
const values = formData.getAll(field).filter(v => v.trim() !== '');
if (values.length > 0) {
filters[field] = values;
}
});
const singleFields = [
'assigned_controller', 'intervention_manager', 'inspector',
'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max',
'overdue', 'followed_only', 'to_be_planned_soon', 'is_blocked_due_to_contract'
];
singleFields.forEach(field => {
const val = formData.get(field);
if (val && val.trim() !== '') {
filters[field] = val.trim();
}
});
// Locations
if (typeof selectedLocations !== 'undefined' && selectedLocations.length > 0) {
filters['locations'] = selectedLocations.map(x => ({ ct: x.ct, obj: x.obj }));
}
return filters;
}
function areFilterObjectsEqual(f1, f2) {
if (!f1 || !f2) return false;
const keys1 = Object.keys(f1).filter(k => f1[k] !== undefined && f1[k] !== null && f1[k] !== '' && (!Array.isArray(f1[k]) || f1[k].length > 0)).sort();
const keys2 = Object.keys(f2).filter(k => f2[k] !== undefined && f2[k] !== null && f2[k] !== '' && (!Array.isArray(f2[k]) || f2[k].length > 0)).sort();
if (keys1.length !== keys2.length) return false;
for (let i = 0; i < keys1.length; i++) {
const k = keys1[i];
if (k !== keys2[i]) return false;
let v1 = f1[k];
let v2 = f2[k];
if (Array.isArray(v1) || Array.isArray(v2)) {
const arr1 = (Array.isArray(v1) ? v1 : [v1]).map(String).sort();
const arr2 = (Array.isArray(v2) ? v2 : [v2]).map(String).sort();
if (arr1.length !== arr2.length) return false;
for (let j = 0; j < arr1.length; j++) {
if (arr1[j] !== arr2[j]) return false;
}
} else if (k === 'locations') {
if (JSON.stringify(v1) !== JSON.stringify(v2)) return false;
} else {
if (String(v1) !== String(v2)) return false;
}
}
return true;
}
function checkActiveFavoriteMatch() {
const currentFilters = getCurrentFilterValues();
let matchedFav = null;
if (typeof userFavoriteFilters !== 'undefined' && Array.isArray(userFavoriteFilters)) {
for (const fav of userFavoriteFilters) {
if (areFilterObjectsEqual(fav.filters, currentFilters)) {
matchedFav = fav;
break;
}
}
}
const activeLabel = document.getElementById('active-favorite-label');
const modalSelect = document.getElementById('modal-favorite-filter-select');
// Reset all star icons and active classes
document.querySelectorAll('.dropdown-item-favorite').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.favorite-star-icon').forEach(icon => {
icon.className = 'bi bi-star favorite-star-icon text-muted';
});
if (matchedFav) {
if (activeLabel) {
activeLabel.textContent = matchedFav.name;
}
const activeItem = document.getElementById(`fav-item-${matchedFav.id}`);
if (activeItem) {
activeItem.classList.add('active');
}
const activeStar = document.getElementById(`fav-star-${matchedFav.id}`);
if (activeStar) {
activeStar.className = 'bi bi-star-fill favorite-star-icon text-warning';
}
if (modalSelect) {
modalSelect.value = matchedFav.id;
}
} else {
if (activeLabel) {
activeLabel.textContent = gettext('Favoris');
}
if (modalSelect) {
modalSelect.value = '';
}
}
}
function generateFilterSummary(filters) {
if (!filters || Object.keys(filters).length === 0) {
return `<span class="text-muted fst-italic">${gettext('Aucun filtre sélectionné (toutes les interventions).')}</span>`;
}
const badges = [];
if (filters.thematic && filters.thematic.length) {
const names = filters.thematic.map(code => {
const lbl = document.querySelector(`label[for="thematic_${code}"]`);
return lbl ? lbl.textContent.trim() : code;
});
badges.push(`<strong>${gettext('Thématique(s)')} :</strong> ${names.join(', ')}`);
}
if (filters.status && filters.status.length) {
const names = filters.status.map(s => {
const lbl = document.querySelector(`label[for="status_${s}"]`);
return lbl ? lbl.textContent.trim() : s;
});
badges.push(`<strong>${gettext('Statut(s)')} :</strong> ${names.join(', ')}`);
}
if (filters.priority && filters.priority.length) {
badges.push(`<strong>${gettext('Priorité')} :</strong> ${filters.priority.join(', ')}`);
}
if (filters.maintain_type && filters.maintain_type.length) {
badges.push(`<strong>${gettext('Type maintenance')} :</strong> ${filters.maintain_type.join(', ')}`);
}
if (filters.category && filters.category.length) {
badges.push(`<strong>${gettext('Catégories')} :</strong> ${filters.category.length} ${gettext('sélectionnée(s)')}`);
}
if (filters.begin_date_min || filters.begin_date_max) {
badges.push(`<strong>${gettext('Date début')} :</strong> [${filters.begin_date_min || '—'} → ${filters.begin_date_max || '—'}]`);
}
if (filters.end_date_min || filters.end_date_max) {
badges.push(`<strong>${gettext('Date fin')} :</strong> [${filters.end_date_min || '—'} → ${filters.end_date_max || '—'}]`);
}
if (filters.overdue === '1') {
badges.push(`<strong>${gettext('Non traitées (en retard)')}</strong>`);
}
if (filters.followed_only === '1') {
badges.push(`<strong>${gettext('Suivies uniquement')}</strong>`);
}
if (filters.is_blocked_due_to_contract === '1') {
badges.push(`<strong>${gettext('Marché bloqué : Oui')}</strong>`);
} else if (filters.is_blocked_due_to_contract === '0') {
badges.push(`<strong>${gettext('Marché bloqué : Non')}</strong>`);
}
if (filters.locations && filters.locations.length) {
badges.push(`<strong>${gettext('Localisations')} :</strong> ${filters.locations.length}`);
}
if (badges.length === 0) {
return `<span class="text-muted fst-italic">${gettext('Filtres divers appliqués.')}</span>`;
}
return badges.map(b => `<div class="mb-1">${b}</div>`).join('');
}
function openSaveFavoriteModal(existingId = null) {
const currentFilters = getCurrentFilterValues();
const modalEl = document.getElementById('saveFavoriteModal');
if (!modalEl) return;
const idInput = document.getElementById('save-favorite-id');
const nameInput = document.getElementById('save-favorite-name');
const summaryEl = document.getElementById('save-favorite-summary');
if (existingId) {
const existing = userFavoriteFilters.find(f => f.id === existingId);
if (existing) {
idInput.value = existing.id;
nameInput.value = existing.name;
}
} else {
idInput.value = '';
nameInput.value = '';
}
if (summaryEl) {
summaryEl.innerHTML = generateFilterSummary(currentFilters);
}
nameInput.classList.remove('is-invalid');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
setTimeout(() => {
nameInput.focus();
}, 400);
}
async function confirmSaveFavorite() {
const nameInput = document.getElementById('save-favorite-name');
const idInput = document.getElementById('save-favorite-id');
const name = nameInput.value.trim();
if (!name) {
nameInput.classList.add('is-invalid');
return;
}
nameInput.classList.remove('is-invalid');
const favoriteId = idInput.value || null;
const filters = getCurrentFilterValues();
try {
const response = await fetch(saveFavoriteUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
id: favoriteId,
name: name,
filters: filters
})
});
const data = await response.json();
if (data.success) {
userFavoriteFilters = data.favorites;
renderFavoriteFiltersUI();
checkActiveFavoriteMatch();
const modalEl = document.getElementById('saveFavoriteModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
showToast(gettext('Filtre favori enregistré avec succès.'), 'success');
} else {
alert(data.error || gettext('Erreur lors de la sauvegarde du favori.'));
}
} catch (err) {
alert(gettext('Erreur de communication avec le serveur.'));
}
}
async function deleteFavoriteFilter(favoriteId, event) {
if (event) event.stopPropagation();
const fav = userFavoriteFilters.find(f => f.id === favoriteId);
if (!fav) return;
const confirmMsg = gettext("Voulez-vous vraiment supprimer le favori '%s' ?").replace('%s', fav.name);
if (!confirm(confirmMsg)) return;
try {
const response = await fetch(deleteFavoriteUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({ id: favoriteId })
});
const data = await response.json();
if (data.success) {
userFavoriteFilters = data.favorites;
renderFavoriteFiltersUI();
checkActiveFavoriteMatch();
showToast(gettext('Filtre favori supprimé.'), 'info');
} else {
alert(data.error || gettext('Erreur lors de la suppression du favori.'));
}
} catch (err) {
alert(gettext('Erreur de communication avec le serveur.'));
}
}
async function updateFavoriteWithCurrentFilters(favoriteId, event) {
if (event) event.stopPropagation();
const fav = userFavoriteFilters.find(f => f.id === favoriteId);
if (!fav) return;
const confirmMsg = gettext("Mettre à jour le favori '%s' avec les filtres actuellement actifs ?").replace('%s', fav.name);
if (!confirm(confirmMsg)) return;
const currentFilters = getCurrentFilterValues();
try {
const response = await fetch(saveFavoriteUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
id: favoriteId,
name: fav.name,
filters: currentFilters
})
});
const data = await response.json();
if (data.success) {
userFavoriteFilters = data.favorites;
renderFavoriteFiltersUI();
checkActiveFavoriteMatch();
showToast(gettext("Favori '%s' mis à jour.").replace('%s', fav.name), 'success');
} else {
alert(data.error || gettext('Erreur lors de la mise à jour du favori.'));
}
} catch (err) {
alert(gettext('Erreur de communication avec le serveur.'));
}
}
async function applyFavoriteFilter(favoriteId) {
if (!favoriteId) return;
const fav = userFavoriteFilters.find(f => f.id === favoriteId);
if (!fav) return;
const form = document.getElementById('filter-form');
if (!form) return;
// 1. Reset all fields first
form.reset();
form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.checked = false;
});
document.getElementById('begin_date_min').value = '';
document.getElementById('begin_date_max').value = '';
document.getElementById('end_date_min').value = '';
document.getElementById('end_date_max').value = '';
const blockedSelect = document.getElementById('is_blocked_due_to_contract');
if (blockedSelect) blockedSelect.value = '';
selectedLocations = [];
// 2. Restore values from fav.filters
const filters = fav.filters || {};
for (const [key, value] of Object.entries(filters)) {
if (key === 'locations') {
try {
let locs = value;
if (typeof locs === 'string') {
locs = JSON.parse(locs);
}
if (Array.isArray(locs)) {
selectedLocations = locs;
}
} catch (e) {
selectedLocations = [];
}
} else if (['begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max'].includes(key)) {
const input = document.getElementById(key);
if (input) input.value = value;
} else if (key === 'is_blocked_due_to_contract') {
if (blockedSelect) blockedSelect.value = value;
} else {
// Checkboxes
const valList = Array.isArray(value) ? value.map(String) : [String(value)];
form.querySelectorAll(`input[type="checkbox"][name="${key}"]`).forEach(cb => {
cb.checked = valList.includes(String(cb.value));
});
}
}
renderLocationChips();
togglePauseReasonFilter();
filterCategoriesByThematics();
filterProvidersAndContracts();
updateDateFilterTimelinePreview();
// 3. Build URL params & save preferences
const formData = new FormData(form);
const params = new URLSearchParams();
for (const [k, v] of formData.entries()) {
if (v.trim() !== '') params.append(k, v);
}
if (selectedLocations.length) {
const payload = selectedLocations.map(x => ({ ct: x.ct, obj: x.obj }));
params.set('locations', JSON.stringify(payload));
} else {
params.delete('locations');
}
// Save as current active preferences
await savePreferencesFromForm(params);
// Preserve search input if present
const _activeQ = $('#interventions-search-input').val().trim();
const _activeMode = document.querySelector('input[name="search-mode"]:checked')?.value || 'and';
if (_activeQ) {
params.set('q', _activeQ);
params.set('search_mode', _activeMode);
}
// Update URL & filter badge
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.pushState({}, '', newUrl);
updateFilterBadge();
// Reload table and map data
handleGeojsonMapLoading(false, interventionsgeojsonloader, 'interventions');
interventionsgeojsonloader.loadGeojson(interventionsgeojsonUrl + '?' + params);
// Update active label and stars
checkActiveFavoriteMatch();
// Close modal if open
const filterModalEl = document.getElementById('filterModal');
if (filterModalEl) {
const filterModal = bootstrap.Modal.getInstance(filterModalEl);
if (filterModal) filterModal.hide();
}
showToast(gettext("Filtre '%s' appliqué.").replace('%s', fav.name), 'info');
}
// Make functions globally available for inline onclicks
window.applyFavoriteFilter = applyFavoriteFilter;
window.deleteFavoriteFilter = deleteFavoriteFilter;
window.updateFavoriteWithCurrentFilters = updateFavoriteWithCurrentFilters;
window.openSaveFavoriteModal = openSaveFavoriteModal;

View file

@ -45,6 +45,29 @@
margin-top: 2px;
}
.dropdown-item-favorite {
transition: background-color 0.15s ease;
border-radius: 4px;
margin: 2px 4px;
}
.dropdown-item-favorite:hover {
background-color: #f1f5f9;
}
.dropdown-item-favorite.active {
background-color: #e7f1ff;
}
.dropdown-item-favorite.active .fav-name {
color: #0c5cc9;
font-weight: 600 !important;
}
.favorite-actions button {
opacity: 0.6;
transition: opacity 0.15s;
}
.dropdown-item-favorite:hover .favorite-actions button {
opacity: 1;
}
.popup-scrollable {
max-height: 200px;
overflow-y: auto;
@ -555,6 +578,20 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
<!-- Barre de sélection rapide de favoris -->
<div class="d-flex flex-wrap align-items-center justify-content-between bg-light p-2 rounded mb-3 border gap-2">
<div class="d-flex align-items-center gap-2 flex-grow-1" style="min-width: 220px;">
<i class="bi bi-bookmark-star text-warning flex-shrink-0"></i>
<label for="modal-favorite-filter-select" class="form-label small fw-semibold mb-0 text-nowrap">{% translate "Favori :" %}</label>
<select id="modal-favorite-filter-select" class="form-select form-select-sm">
<option value="">{% translate "— Sélectionner un favori —" %}</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-primary text-nowrap" id="modal-save-favorite-btn">
<i class="bi bi-star me-1"></i>{% translate "Enregistrer en favori" %}
</button>
</div>
<!-- Formulaire de filtre -->
<form method="get" id="filter-form" class="mb-4">
<!-- Thématique, Statut et Catégorie -->
@ -1098,6 +1135,42 @@
</div>
</div>
<!-- Modale pour enregistrer un filtre favori -->
<div class="modal fade" id="saveFavoriteModal" tabindex="-1" aria-labelledby="saveFavoriteModalLabel" aria-hidden="true" style="z-index: 1070;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="saveFavoriteModalLabel">
<i class="bi bi-bookmark-star text-warning me-2"></i>{% translate "Enregistrer comme favori" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<form id="save-favorite-form" onsubmit="return false;">
<input type="hidden" id="save-favorite-id" value="">
<div class="mb-3">
<label for="save-favorite-name" class="form-label fw-semibold">{% translate "Nom du filtre favori" %}</label>
<input type="text" class="form-control" id="save-favorite-name" placeholder="{% translate 'Ex. Mes urgences voirie' %}" required maxlength="100">
<div class="form-text">{% translate "Donnez un nom clair pour retrouver facilement cette sélection de filtres." %}</div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold text-muted mb-1">{% translate "Critères enregistrés :" %}</label>
<div id="save-favorite-summary" class="small text-muted p-2 rounded bg-light border" style="max-height: 120px; overflow-y: auto;">
<!-- Résumé généré dynamiquement -->
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-primary" id="btn-confirm-save-favorite">
<i class="bi bi-check-lg me-1"></i>{% translate "Enregistrer" %}
</button>
</div>
</div>
</div>
</div>
<div class="position-relative">
<!-- Layout selection buttons -->
<div class="position-absolute top-0 end-0 mt-2" style="z-index: 10;">
@ -1225,18 +1298,40 @@
<div id="interventions-table-container" style="display: none; flex: 1; min-height: 0; overflow: hidden;">
<!-- Bouton de filtre -->
<div id="filter-dropdown-wrapper" class="float-left mb-2">
<!-- Bouton de filtre et Favoris -->
<div id="filter-dropdown-wrapper" class="float-left mb-2 d-flex align-items-center gap-1">
<button class="btn btn-light position-relative" type="button" data-bs-toggle="modal" data-bs-target="#filterModal">
<i class="bi bi-funnel"></i> {% translate "Filtre" %}
<span id="filter-badge" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-black d-none">
<span id="filter-count">0</span>
</span>
</button>
<div class="dropdown d-inline-block" id="favorite-filters-dropdown-container">
<button class="btn btn-light dropdown-toggle d-flex align-items-center gap-1" type="button" id="favoriteFiltersDropdown" data-bs-toggle="dropdown" aria-expanded="false" title="{% translate 'Filtres favoris' %}">
<i class="bi bi-bookmark-star text-warning"></i>
<span id="active-favorite-label" class="d-none d-md-inline text-truncate" style="max-width: 180px;">{% translate "Favoris" %}</span>
</button>
<ul class="dropdown-menu shadow-sm" id="favorite-filters-menu" aria-labelledby="favoriteFiltersDropdown" style="min-width: 280px; max-height: 400px; overflow-y: auto;">
<li class="dropdown-header small text-uppercase fw-bold text-muted d-flex justify-content-between align-items-center py-1">
<span>{% translate "Mes filtres favoris" %}</span>
<span class="badge bg-secondary" id="favorite-filters-count">0</span>
</li>
<div id="favorite-filters-items">
<!-- Peuplé dynamiquement par JS -->
</div>
<li><hr class="dropdown-divider my-1"></li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center gap-2 text-primary py-2" id="btn-open-save-favorite-modal">
<i class="bi bi-plus-circle"></i>
<span>{% translate "Enregistrer le filtre actuel..." %}</span>
</button>
</li>
</ul>
</div>
<!-- Alerte limite atteinte (cachée par défaut) -->
<span id="filter-limit-alert" class="alert alert-info py-1 px-2 ms-2 d-none align-middle" role="alert">
<i class="bi bi-info-circle me-1"></i>
{% translate "Contenu limité à 5 000 éléments." %}"
{% translate "Contenu limité à 5 000 éléments." %}
</span>
</div>
@ -1480,6 +1575,9 @@
const executionReportDownloadBaseUrl = "{% url 'interventions:download_bulk_execution_report' token='__TOKEN__' %}";
const saveLayoutUrl = "{% url 'common:save_intervention_list_layout' %}";
const savePreferencesUrl = "{% url 'common:save_intervention_list_preferences' %}";
const saveFavoriteUrl = "{% url 'common:save_favorite_filter' module='interventions' %}";
const deleteFavoriteUrl = "{% url 'common:delete_favorite_filter' module='interventions' %}";
let userFavoriteFilters = {{ favorite_filters_json|default:"[]"|safe }};
var selected_interventions_set = new Set(JSON.parse('{{ selected_interventions_ids|default:"[]"|escapejs }}'));
const PRESELECTED_LOCATIONS = JSON.parse('{{ preselected_locations|default:"[]"|escapejs }}');

View file

@ -544,6 +544,7 @@ def index(request):
'saved_columns': json.dumps(user_config.intervention_list_columns, ensure_ascii=False),
'is_admin': user_config.roles.filter(name='admin').exists(),
'selected_is_blocked': request.GET.get('is_blocked') or request.GET.get('is_blocked_due_to_contract') or request.GET.get('blocked') or '',
'favorite_filters_json': json.dumps((user_config.favorite_filters or {}).get('interventions', []) if isinstance((user_config.favorite_filters or {}).get('interventions'), list) else [], ensure_ascii=False),
}
return render(request, "interventions/interventions_index.html", context)