From 6c8002890652ee5f5aa8bd1206afd6f33408f150 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 17 Sep 2026 17:07:51 +0200 Subject: [PATCH] feat: add concerned categories and target asset model to intervention subtypes for dynamic inspection targeting --- loko/inspections/services.py | 148 ++++++++++++++++-- loko/inspections/tests.py | 142 +++++++++++++++++ loko/inspections/views.py | 14 +- loko/interventions/admin.py | 76 ++++++++- ...onsubtype_concerned_categories_and_more.py | 26 +++ loko/interventions/models.py | 17 ++ .../admin_intervention_subtype.js | 81 ++++++++++ 7 files changed, 479 insertions(+), 25 deletions(-) create mode 100644 loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py create mode 100644 loko/interventions/static/interventions/admin_intervention_subtype.js diff --git a/loko/inspections/services.py b/loko/inspections/services.py index 523239d..8a13218 100644 --- a/loko/inspections/services.py +++ b/loko/inspections/services.py @@ -6,6 +6,130 @@ from django.contrib.contenttypes.models import ContentType logger = logging.getLogger(__name__) +def _resolve_inspection_target(intervention, sub_type): + """ + Détermine l'équipement (Asset), la localisation ou l'intervention cible pour l'inspection. + 1. Si un équipement valide est déjà attaché à l'intervention (interventionasset_set), on le conserve + (en vérifiant s'il correspond au modèle/catégories configurés si applicables). + 2. Sinon, on identifie les modèles d'assets candidats : + - sub_type.target_asset_model si configuré + - allowed_models des sub_type.concerned_categories + - Fallback : Roadway si thématique roads ou sous-type de type Hot Box / voirie + 3. On sélectionne l'équipement du modèle candidat le plus proche spatialement + du centroïde de l'intervention (en priorité sur la rue associée). + 4. Fallback sur la première localisation ou l'intervention elle-même. + """ + from django.contrib.gis.db import models as gis_models + from django.contrib.gis.geos import Point + from django.contrib.gis.db.models.functions import Distance + + # 1. Vérifier si un équipement correspondant est déjà rattaché à l'intervention + if hasattr(intervention, 'interventionasset_set') and intervention.interventionasset_set.exists(): + if sub_type and sub_type.target_asset_model_id: + matching = intervention.interventionasset_set.filter(content_type=sub_type.target_asset_model).first() + if matching and matching.content_type_id and matching.object_id: + return matching.content_type, matching.object_id + + if sub_type and sub_type.concerned_categories.exists(): + allowed_cts = ContentType.objects.filter(asset_categories__in=sub_type.concerned_categories.all()).distinct() + if allowed_cts.exists(): + matching = intervention.interventionasset_set.filter(content_type__in=allowed_cts).first() + if matching and matching.content_type_id and matching.object_id: + return matching.content_type, matching.object_id + + first_asset = intervention.interventionasset_set.first() + if first_asset and first_asset.content_type_id and first_asset.object_id: + return first_asset.content_type, first_asset.object_id + + # 2. Identifier les modèles d'assets cibles candidats + candidate_cts = [] + if sub_type: + if sub_type.target_asset_model_id: + candidate_cts.append(sub_type.target_asset_model) + elif sub_type.concerned_categories.exists(): + allowed_cts = list(ContentType.objects.filter(asset_categories__in=sub_type.concerned_categories.all()).distinct()) + candidate_cts.extend(allowed_cts) + + # Fallback si aucun modèle configuré : pour les voiries ou Hot Box, cibler Roadway + if not candidate_cts: + thematic_code = getattr(intervention.thematic, 'code', None) if intervention.thematic else None + is_road_repair = (thematic_code == 'roads') or ( + sub_type and any(k in (sub_type.code or '').lower() or k in (sub_type.name_fr or '').lower() for k in ('hotbox', 'hot_box', 'hot box', 'chaussee', 'chaussée')) + ) + if is_road_repair: + roadway_ct = ContentType.objects.filter(app_label='assets', model='roadway').first() + if roadway_ct: + candidate_cts.append(roadway_ct) + + # 3. Résolution spatiale du plus proche asset + centroid = None + if intervention.geom: + try: + centroid = intervention.geom.centroid + except Exception as e: + logger.debug(f"Error computing centroid from intervention.geom: {e}") + + if not centroid and intervention.lon and intervention.lat: + try: + p = Point(float(intervention.lon), float(intervention.lat), srid=4326) + p.transform(3812) + centroid = p + except Exception as e: + logger.debug(f"Error transforming intervention coordinates to 3812: {e}") + + # Récupérer les rues associées à l'intervention + street_ids = [] + if hasattr(intervention, 'locations'): + for loc in intervention.locations.all(): + if loc.content_type and loc.content_type.model == 'roadstreet': + street_ids.append(loc.object_id) + + for ct in candidate_cts: + model_cls = ct.model_class() + if not model_cls: + continue + + has_geom = any( + isinstance(f, gis_models.GeometryField) + for f in model_cls._meta.fields + if hasattr(f, 'geom_type') + ) or hasattr(model_cls, 'geom') + + if has_geom and centroid: + # Recherche prioritaire sur la même rue + if street_ids and hasattr(model_cls, 'street'): + closest = model_cls.objects.filter( + street_id__in=street_ids, + geom__isnull=False + ).annotate(distance=Distance('geom', centroid)).order_by('distance').first() + if closest: + return ct, closest.pk + + # Recherche globale du plus proche + closest = model_cls.objects.filter( + geom__isnull=False + ).annotate(distance=Distance('geom', centroid)).order_by('distance').first() + if closest: + return ct, closest.pk + + # Sans géométrie ou centroïde : filtrer par rue si possible + if street_ids and hasattr(model_cls, 'street'): + obj = model_cls.objects.filter(street_id__in=street_ids).first() + if obj: + return ct, obj.pk + + first_obj = model_cls.objects.first() + if first_obj: + return ct, first_obj.pk + + # 4. Fallback sur la première localisation ou l'intervention + first_loc = intervention.locations.first() if hasattr(intervention, 'locations') else None + if first_loc and first_loc.content_type_id and first_loc.object_id: + return first_loc.content_type, first_loc.object_id + + return ContentType.objects.get_for_model(intervention), intervention.pk + + def schedule_post_intervention_inspection(intervention): """ Planifie automatiquement une inspection de durabilité pour une intervention @@ -23,6 +147,14 @@ def schedule_post_intervention_inspection(intervention): nature=sub_type.post_inspection_nature ).exclude(status='cancelled').first() if existing: + # Si l'inspection existante pointe sur une localisation (ex: RoadStreet) et qu'un asset plus précis est trouvé + if existing.asset_content_type and existing.asset_content_type.model == 'roadstreet': + better_ct, better_id = _resolve_inspection_target(intervention, sub_type) + if better_ct and better_ct.model != 'roadstreet': + existing.asset_content_type = better_ct + existing.asset_object_id = better_id + existing.save(update_fields=['asset_content_type', 'asset_object_id']) + logger.info(f"Updated inspection {existing.pk} target from roadstreet to {better_ct.model} #{better_id}") return existing # Calcul des dates de la fenêtre d'inspection [fin + x, fin + x + y] @@ -63,21 +195,7 @@ def schedule_post_intervention_inspection(intervention): break # Détermination de la cible (Asset, Localisation ou Intervention elle-même) - target_ct = None - target_id = None - - first_asset = intervention.interventionasset_set.first() if hasattr(intervention, 'interventionasset_set') else None - if first_asset and first_asset.content_type_id and first_asset.object_id: - target_ct = first_asset.content_type - target_id = first_asset.object_id - else: - first_loc = intervention.locations.first() if hasattr(intervention, 'locations') else None - if first_loc and first_loc.content_type_id and first_loc.object_id: - target_ct = first_loc.content_type - target_id = first_loc.object_id - else: - target_ct = ContentType.objects.get_for_model(intervention) - target_id = intervention.pk + target_ct, target_id = _resolve_inspection_target(intervention, sub_type) # Pré-assignation si un seul inspecteur est assigné à la zone assigned_inspector = None diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py index 17bfd7f..64583a7 100644 --- a/loko/inspections/tests.py +++ b/loko/inspections/tests.py @@ -1632,4 +1632,146 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): self.assertEqual(Inspection.objects.filter(checked_intervention=intervention).count(), 0) + def test_inspection_datetime_localtime_formatting(self): + """Vérifie que les dates et heures sont correctement converties dans le fuseau horaire local.""" + from datetime import datetime, timezone as dt_tz + # 14:32 UTC correspond à 16:32 en été (UTC+2 Europe/Brussels) + utc_dt = datetime(2026, 9, 17, 14, 32, tzinfo=dt_tz.utc) + + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='in_progress', + created_by=self.user + ) + insp = Inspection.objects.create( + asset_content_type=ContentType.objects.get_for_model(intervention), + asset_object_id=intervention.id, + checked_intervention=intervention, + nature='post_intervention', + status='scheduled', + inspection_date=utc_dt + ) + + self.client.force_login(self.user) + response = self.client.get(reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': insp.id})) + self.assertEqual(response.status_code, 200) + data = response.json() + # 14:32 UTC -> 16:32 local + self.assertEqual(data['inspection_date'], '17/09/2026 16:32') + + def test_spatial_target_resolution_closest_roadway(self): + """Vérifie que pour un Hot Box, l'asset cible sélectionné est la Roadway la plus proche du centroïde de l'intervention.""" + from django.contrib.gis.geos import MultiPolygon, Polygon + from assets.models.roads import RoadStreet, Roadway + from interventions.models import InterventionLocation + + street = RoadStreet.objects.create(code='WAT_R22_TEST', name_fr='Chaussée de Test') + + # Deux chaussées sur la même rue : une proche (150000, 170000), une lointaine (160000, 180000) + p_near = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000))) + roadway_near = Roadway.objects.create( + street=street, + geom=MultiPolygon(p_near, srid=3812) + ) + + p_far = Polygon(((160000, 180000), (160010, 180000), (160010, 180010), (160000, 180010), (160000, 180000))) + roadway_far = Roadway.objects.create( + street=street, + geom=MultiPolygon(p_far, srid=3812) + ) + + # Configurer le sous-type Hot Box avec target_asset_model = Roadway + self.hotbox_subtype.target_asset_model = ContentType.objects.get_for_model(Roadway) + self.hotbox_subtype.save() + + # Créer une intervention à proximité de roadway_near (150002, 170002) avec la rue en localisation + p_intv = Polygon(((150002, 170002), (150005, 170002), (150005, 170005), (150002, 170005), (150002, 170002))) + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='in_progress', + end_time=timezone.now(), + geom=MultiPolygon(p_intv, srid=3812) + ) + InterventionLocation.objects.create( + intervention=intervention, + content_type=ContentType.objects.get_for_model(street), + object_id=street.pk + ) + + # Terminer l'intervention + intervention.status = 'finished' + intervention.save() + + insp = Inspection.objects.get(checked_intervention=intervention) + self.assertEqual(insp.asset_content_type, ContentType.objects.get_for_model(Roadway)) + self.assertEqual(insp.asset_object_id, roadway_near.pk) + + def test_existing_inspection_roadstreet_upgraded_to_roadway(self): + """Vérifie qu'une inspection existante ciblant une rue est mise à niveau vers la Roadway la plus proche.""" + from django.contrib.gis.geos import MultiPolygon, Polygon + from assets.models.roads import RoadStreet, Roadway + from interventions.models import InterventionLocation + from .services import schedule_post_intervention_inspection + + street = RoadStreet.objects.create(code='WAT_R22_UPGRADE', name_fr='Rue Upgrade') + p_near = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000))) + roadway = Roadway.objects.create( + street=street, + geom=MultiPolygon(p_near, srid=3812) + ) + + self.hotbox_subtype.target_asset_model = ContentType.objects.get_for_model(Roadway) + self.hotbox_subtype.save() + + p_intv = Polygon(((150002, 170002), (150005, 170002), (150005, 170005), (150002, 170005), (150002, 170002))) + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='finished', + end_time=timezone.now(), + geom=MultiPolygon(p_intv, srid=3812) + ) + InterventionLocation.objects.create( + intervention=intervention, + content_type=ContentType.objects.get_for_model(street), + object_id=street.pk + ) + + # Créer manuellement une inspection ciblant la rue (comme avant le fix) + existing_insp = Inspection.objects.create( + asset_content_type=ContentType.objects.get_for_model(street), + asset_object_id=street.pk, + checked_intervention=intervention, + nature='post_intervention', + status='scheduled' + ) + + # Appel à schedule_post_intervention_inspection + res = schedule_post_intervention_inspection(intervention) + self.assertEqual(res.pk, existing_insp.pk) + + existing_insp.refresh_from_db() + self.assertEqual(existing_insp.asset_content_type, ContentType.objects.get_for_model(Roadway)) + self.assertEqual(existing_insp.asset_object_id, roadway.pk) + + def test_admin_form_category_and_target_model_filtering(self): + """Vérifie le filtrage du formulaire admin pour les catégories et le modèle d'asset cible.""" + from interventions.admin import InterventionSubTypeAdminForm + from assets.models import AssetCategory + + cat_roads = AssetCategory.objects.create(code='cat_roads', name_fr='Voirie Test', thematic=self.roads_thematic) + other_thematic = Thematic.objects.create(code='mobility', name_fr='Mobilité') + cat_mobility = AssetCategory.objects.create(code='cat_mob', name_fr='Vélo Test', thematic=other_thematic) + + form = InterventionSubTypeAdminForm(instance=self.hotbox_subtype) + self.assertIn(cat_roads, form.fields['concerned_categories'].queryset) + self.assertNotIn(cat_mobility, form.fields['concerned_categories'].queryset) + self.assertIn('data-cat-models', form.fields['target_asset_model'].widget.attrs) + + diff --git a/loko/inspections/views.py b/loko/inspections/views.py index 2f7e191..80d0a30 100644 --- a/loko/inspections/views.py +++ b/loko/inspections/views.py @@ -873,9 +873,9 @@ def get_inspection_detail_api(request, inspection_id): 'details_html': rendered_detail_html, 'apply_changes_to_asset': leaf.apply_changes_to_asset, 'is_cancelled': leaf.is_cancelled, - 'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '', - 'created_at': leaf.created_at.strftime('%d/%m/%Y %H:%M') if leaf.created_at else '', - 'updated_at': leaf.updated_at.strftime('%d/%m/%Y %H:%M') if leaf.updated_at else '', + 'inspection_date': timezone.localtime(leaf.inspection_date).strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '', + 'created_at': timezone.localtime(leaf.created_at).strftime('%d/%m/%Y %H:%M') if leaf.created_at else '', + 'updated_at': timezone.localtime(leaf.updated_at).strftime('%d/%m/%Y %H:%M') if leaf.updated_at else '', 'can_edit': leaf.can_edit(request.user), 'documents': docs_data, } @@ -1029,7 +1029,7 @@ def update_inspection_api(request, inspection_id): return JsonResponse({ 'success': True, 'inspection_id': inspection.id, - 'updated_at': inspection.updated_at.strftime('%d/%m/%Y %H:%M'), + 'updated_at': timezone.localtime(inspection.updated_at).strftime('%d/%m/%Y %H:%M'), 'message': str(_("Inspection mise à jour avec succès.")) }) except Exception as e: @@ -1262,7 +1262,7 @@ def inspections_geojson(request): inspections_data.append({ 'id': insp.id, - 'date': insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', + 'date': timezone.localtime(insp.inspection_date).strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', 'result_status': insp.result_status, 'result_status_display': insp.get_result_status_display(), 'status_badge_class': insp.status_badge_class, @@ -1395,7 +1395,7 @@ def inspections_geojson(request): "inspector": get_short_name(insp.inspector) if insp.inspector else '-', "inspector_username": insp.inspector.username if insp.inspector else '', "inspection_date": insp.inspection_date.isoformat() if insp.inspection_date else '', - "inspection_date_formatted": insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', + "inspection_date_formatted": timezone.localtime(insp.inspection_date).strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', "notes": insp.notes or '', "details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '', "verified_obsolescence": insp.verified_obsolescence or '', @@ -1495,7 +1495,7 @@ def inspections_export_excel(request): row_data = [ f"INSP#{insp.id:05d}", - insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', + timezone.localtime(insp.inspection_date).strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', th.name if th else '', insp.asset_content_type.name if insp.asset_content_type else '', target_display, diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py index 3e72c62..234932b 100644 --- a/loko/interventions/admin.py +++ b/loko/interventions/admin.py @@ -42,6 +42,8 @@ from .services import generate_interventions_excel from common.models import Thematic from assets.models import AssetCategory from interventions.views.preventive_import import process_worksheet as _process_worksheet +import json +from django.contrib.contenttypes.models import ContentType from interventions.models import STATUS_CHOICES, DirectContractRouting, TYPE_CHOICES @@ -58,18 +60,79 @@ class InterventionSubTypeAdminForm(forms.ModelForm): model = InterventionSubType fields = '__all__' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # 1. Filtrer les catégories selon la thématique + thematic = None + if self.instance and self.instance.pk and self.instance.thematic_id: + thematic = self.instance.thematic + else: + thematic_id = self.data.get('thematic') or self.initial.get('thematic') + if thematic_id: + thematic = Thematic.objects.filter(pk=thematic_id).first() + + if 'concerned_categories' in self.fields: + if thematic: + self.fields['concerned_categories'].queryset = AssetCategory.objects.filter(thematic=thematic) + else: + self.fields['concerned_categories'].queryset = AssetCategory.objects.all() + + # 2. Configurer le champ target_asset_model + if 'target_asset_model' in self.fields: + self.fields['target_asset_model'].label_from_instance = ( + lambda obj: f"{obj.model_class()._meta.verbose_name.capitalize()} ({obj.model})" if obj.model_class() else obj.model + ) + + category_ids = [] + if 'concerned_categories' in self.data: + try: + category_ids = [int(x) for x in self.data.getlist('concerned_categories') if str(x).isdigit()] + except Exception: + pass + elif self.instance.pk: + category_ids = list(self.instance.concerned_categories.values_list('pk', flat=True)) + elif self.initial.get('concerned_categories'): + try: + category_ids = [int(x) for x in self.initial.get('concerned_categories') if str(x).isdigit()] + except Exception: + pass + + allowed_models_qs = ContentType.objects.none() + if category_ids: + allowed_models_qs = ContentType.objects.filter(asset_categories__id__in=category_ids).distinct() + + if allowed_models_qs.exists(): + qs = allowed_models_qs + if self.instance.pk and self.instance.target_asset_model_id: + qs = (qs | ContentType.objects.filter(id=self.instance.target_asset_model_id)).distinct() + self.fields['target_asset_model'].queryset = qs.order_by('model') + else: + self.fields['target_asset_model'].queryset = ContentType.objects.filter(app_label='assets').order_by('model') + + # Mapping JSON des modèles autorisés par catégorie pour le script admin_intervention_subtype.js + cat_model_map = {} + for cat in AssetCategory.objects.prefetch_related('allowed_models').all(): + model_ids = list(cat.allowed_models.values_list('id', flat=True)) + if model_ids: + cat_model_map[str(cat.id)] = model_ids + self.fields['target_asset_model'].widget.attrs['data-cat-models'] = json.dumps(cat_model_map) + @admin.register(InterventionSubType) class InterventionSubTypeAdmin(admin.ModelAdmin): form = InterventionSubTypeAdminForm list_display = ( "code", "name_fr", "thematic", "types_display", - "requires_post_inspection", "post_inspection_delay_min_days", - "post_inspection_delay_max_days", "is_active" + "target_asset_model_display", "requires_post_inspection", + "post_inspection_delay_min_days", "post_inspection_delay_max_days", "is_active" ) list_filter = ("thematic", "requires_post_inspection", "is_active") search_fields = ("code", "name_fr", "name_nl") - filter_horizontal = ("notification_teams",) + filter_horizontal = ("notification_teams", "concerned_categories") + + class Media: + js = ('interventions/admin_intervention_subtype.js',) def types_display(self, obj): if not obj.intervention_types: @@ -78,6 +141,13 @@ class InterventionSubTypeAdmin(admin.ModelAdmin): return ", ".join(str(type_dict.get(t, t)) for t in obj.intervention_types) types_display.short_description = _("Types d'intervention") + def target_asset_model_display(self, obj): + if not obj.target_asset_model: + return "—" + cls = obj.target_asset_model.model_class() + return cls._meta.verbose_name.capitalize() if cls else obj.target_asset_model.model + target_asset_model_display.short_description = _("Asset cible") + @admin.register(Intervention) class InterventionAdmin(admin.ModelAdmin): diff --git a/loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py b/loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py new file mode 100644 index 0000000..d81b9c8 --- /dev/null +++ b/loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.7 on 2026-09-17 14:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0026_add_naturetree_specific_fields'), + ('contenttypes', '0002_remove_content_type_name'), + ('interventions', '0011_alter_interventionsubtype_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='interventionsubtype', + name='concerned_categories', + field=models.ManyToManyField(blank=True, help_text="Catégories d'équipements concernées par ce sous-type pour cibler les inspections post-travaux.", related_name='intervention_subtypes', to='assets.assetcategory', verbose_name='Catégories concernées'), + ), + migrations.AddField( + model_name='interventionsubtype', + name='target_asset_model', + field=models.ForeignKey(blank=True, help_text="Modèle d'équipement ciblé automatiquement pour l'inspection de durabilité (ex: Roadway / Chaussée). L'objet le plus proche du centroïde de l'intervention sera sélectionné.", limit_choices_to=models.Q(('app_label', 'assets')), null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='intervention_subtypes_as_target', to='contenttypes.contenttype', verbose_name="Modèle d'asset cible"), + ), + ] diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 495f321..29cf51b 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -232,6 +232,23 @@ class InterventionSubType(models.Model): default='post_intervention', verbose_name=_("Nature de l'inspection") ) + concerned_categories = models.ManyToManyField( + AssetCategory, + blank=True, + related_name='intervention_subtypes', + verbose_name=_("Catégories concernées"), + help_text=_("Catégories d'équipements concernées par ce sous-type pour cibler les inspections post-travaux.") + ) + target_asset_model = models.ForeignKey( + ContentType, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='intervention_subtypes_as_target', + limit_choices_to=models.Q(app_label='assets'), + verbose_name=_("Modèle d'asset cible"), + help_text=_("Modèle d'équipement ciblé automatiquement pour l'inspection de durabilité (ex: Roadway / Chaussée). L'objet le plus proche du centroïde de l'intervention sera sélectionné.") + ) notification_teams = models.ManyToManyField( CompanyTeam, blank=True, diff --git a/loko/interventions/static/interventions/admin_intervention_subtype.js b/loko/interventions/static/interventions/admin_intervention_subtype.js new file mode 100644 index 0000000..2ae1aa0 --- /dev/null +++ b/loko/interventions/static/interventions/admin_intervention_subtype.js @@ -0,0 +1,81 @@ +(function() { + 'use strict'; + window.addEventListener('DOMContentLoaded', function() { + const targetModelSelect = document.getElementById('id_target_asset_model'); + if (!targetModelSelect) return; + + const rawMap = targetModelSelect.getAttribute('data-cat-models'); + if (!rawMap) return; + + let catModelMap = {}; + try { + catModelMap = JSON.parse(rawMap); + } catch (e) { + return; + } + + // Sauvegarder toutes les options initiales de target_asset_model + const originalOptions = Array.from(targetModelSelect.options).map(opt => ({ + value: opt.value, + text: opt.text, + selected: opt.selected + })); + + function updateTargetModelOptions() { + const catSelectTo = document.getElementById('id_concerned_categories_to') || document.getElementById('id_concerned_categories'); + if (!catSelectTo) return; + + const selectedCatIds = []; + for (let i = 0; i < catSelectTo.options.length; i++) { + const opt = catSelectTo.options[i]; + if (catSelectTo.id.endsWith('_to') || opt.selected) { + if (opt.value) selectedCatIds.push(opt.value); + } + } + + // Réunir tous les model_ids autorisés pour les catégories sélectionnées + let allowedModelIds = new Set(); + let hasCategoryRules = false; + selectedCatIds.forEach(catId => { + if (catModelMap[catId] && catModelMap[catId].length > 0) { + hasCategoryRules = true; + catModelMap[catId].forEach(mid => allowedModelIds.add(String(mid))); + } + }); + + const currentValue = targetModelSelect.value; + targetModelSelect.innerHTML = ''; + + originalOptions.forEach(opt => { + if (!opt.value || !hasCategoryRules || allowedModelIds.has(opt.value) || opt.value === currentValue) { + const newOpt = document.createElement('option'); + newOpt.value = opt.value; + newOpt.text = opt.text; + if (opt.value === currentValue) { + newOpt.selected = true; + } + targetModelSelect.appendChild(newOpt); + } + }); + } + + const catSelectTo = document.getElementById('id_concerned_categories_to') || document.getElementById('id_concerned_categories'); + const catSelectFrom = document.getElementById('id_concerned_categories_from'); + + if (catSelectTo) { + catSelectTo.addEventListener('change', updateTargetModelOptions); + } + if (catSelectFrom) { + catSelectFrom.addEventListener('change', updateTargetModelOptions); + } + + if (catSelectTo && window.MutationObserver) { + const observer = new MutationObserver(function() { + updateTargetModelOptions(); + }); + observer.observe(catSelectTo, { childList: true }); + } + + updateTargetModelOptions(); + }); +})();