feat: add concerned categories and target asset model to intervention subtypes for dynamic inspection targeting

This commit is contained in:
kdeterme 2026-09-17 17:07:51 +02:00
parent 368a167b9c
commit 6c80028906
7 changed files with 479 additions and 25 deletions

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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):

View file

@ -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"),
),
]

View file

@ -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,

View file

@ -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();
});
})();