feat: add concerned categories and target asset model to intervention subtypes for dynamic inspection targeting
This commit is contained in:
parent
368a167b9c
commit
6c80028906
7 changed files with 479 additions and 25 deletions
|
|
@ -6,6 +6,130 @@ from django.contrib.contenttypes.models import ContentType
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def schedule_post_intervention_inspection(intervention):
|
||||||
"""
|
"""
|
||||||
Planifie automatiquement une inspection de durabilité pour une 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
|
nature=sub_type.post_inspection_nature
|
||||||
).exclude(status='cancelled').first()
|
).exclude(status='cancelled').first()
|
||||||
if existing:
|
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
|
return existing
|
||||||
|
|
||||||
# Calcul des dates de la fenêtre d'inspection [fin + x, fin + x + y]
|
# 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
|
break
|
||||||
|
|
||||||
# Détermination de la cible (Asset, Localisation ou Intervention elle-même)
|
# Détermination de la cible (Asset, Localisation ou Intervention elle-même)
|
||||||
target_ct = None
|
target_ct, target_id = _resolve_inspection_target(intervention, sub_type)
|
||||||
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
|
|
||||||
|
|
||||||
# Pré-assignation si un seul inspecteur est assigné à la zone
|
# Pré-assignation si un seul inspecteur est assigné à la zone
|
||||||
assigned_inspector = None
|
assigned_inspector = None
|
||||||
|
|
|
||||||
|
|
@ -1632,4 +1632,146 @@ class PostInterventionDurabilityInspectionTestCase(TestCase):
|
||||||
|
|
||||||
self.assertEqual(Inspection.objects.filter(checked_intervention=intervention).count(), 0)
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -873,9 +873,9 @@ def get_inspection_detail_api(request, inspection_id):
|
||||||
'details_html': rendered_detail_html,
|
'details_html': rendered_detail_html,
|
||||||
'apply_changes_to_asset': leaf.apply_changes_to_asset,
|
'apply_changes_to_asset': leaf.apply_changes_to_asset,
|
||||||
'is_cancelled': leaf.is_cancelled,
|
'is_cancelled': leaf.is_cancelled,
|
||||||
'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '',
|
'inspection_date': timezone.localtime(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 '',
|
'created_at': timezone.localtime(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 '',
|
'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),
|
'can_edit': leaf.can_edit(request.user),
|
||||||
'documents': docs_data,
|
'documents': docs_data,
|
||||||
}
|
}
|
||||||
|
|
@ -1029,7 +1029,7 @@ def update_inspection_api(request, inspection_id):
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': True,
|
'success': True,
|
||||||
'inspection_id': inspection.id,
|
'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."))
|
'message': str(_("Inspection mise à jour avec succès."))
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1262,7 +1262,7 @@ def inspections_geojson(request):
|
||||||
|
|
||||||
inspections_data.append({
|
inspections_data.append({
|
||||||
'id': insp.id,
|
'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': insp.result_status,
|
||||||
'result_status_display': insp.get_result_status_display(),
|
'result_status_display': insp.get_result_status_display(),
|
||||||
'status_badge_class': insp.status_badge_class,
|
'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": get_short_name(insp.inspector) if insp.inspector else '-',
|
||||||
"inspector_username": insp.inspector.username 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": 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 '',
|
"notes": insp.notes or '',
|
||||||
"details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
|
"details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
|
||||||
"verified_obsolescence": insp.verified_obsolescence or '',
|
"verified_obsolescence": insp.verified_obsolescence or '',
|
||||||
|
|
@ -1495,7 +1495,7 @@ def inspections_export_excel(request):
|
||||||
|
|
||||||
row_data = [
|
row_data = [
|
||||||
f"INSP#{insp.id:05d}",
|
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 '',
|
th.name if th else '',
|
||||||
insp.asset_content_type.name if insp.asset_content_type else '',
|
insp.asset_content_type.name if insp.asset_content_type else '',
|
||||||
target_display,
|
target_display,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ from .services import generate_interventions_excel
|
||||||
from common.models import Thematic
|
from common.models import Thematic
|
||||||
from assets.models import AssetCategory
|
from assets.models import AssetCategory
|
||||||
from interventions.views.preventive_import import process_worksheet as _process_worksheet
|
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
|
from interventions.models import STATUS_CHOICES, DirectContractRouting, TYPE_CHOICES
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,18 +60,79 @@ class InterventionSubTypeAdminForm(forms.ModelForm):
|
||||||
model = InterventionSubType
|
model = InterventionSubType
|
||||||
fields = '__all__'
|
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)
|
@admin.register(InterventionSubType)
|
||||||
class InterventionSubTypeAdmin(admin.ModelAdmin):
|
class InterventionSubTypeAdmin(admin.ModelAdmin):
|
||||||
form = InterventionSubTypeAdminForm
|
form = InterventionSubTypeAdminForm
|
||||||
list_display = (
|
list_display = (
|
||||||
"code", "name_fr", "thematic", "types_display",
|
"code", "name_fr", "thematic", "types_display",
|
||||||
"requires_post_inspection", "post_inspection_delay_min_days",
|
"target_asset_model_display", "requires_post_inspection",
|
||||||
"post_inspection_delay_max_days", "is_active"
|
"post_inspection_delay_min_days", "post_inspection_delay_max_days", "is_active"
|
||||||
)
|
)
|
||||||
list_filter = ("thematic", "requires_post_inspection", "is_active")
|
list_filter = ("thematic", "requires_post_inspection", "is_active")
|
||||||
search_fields = ("code", "name_fr", "name_nl")
|
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):
|
def types_display(self, obj):
|
||||||
if not obj.intervention_types:
|
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)
|
return ", ".join(str(type_dict.get(t, t)) for t in obj.intervention_types)
|
||||||
types_display.short_description = _("Types d'intervention")
|
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)
|
@admin.register(Intervention)
|
||||||
class InterventionAdmin(admin.ModelAdmin):
|
class InterventionAdmin(admin.ModelAdmin):
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -232,6 +232,23 @@ class InterventionSubType(models.Model):
|
||||||
default='post_intervention',
|
default='post_intervention',
|
||||||
verbose_name=_("Nature de l'inspection")
|
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(
|
notification_teams = models.ManyToManyField(
|
||||||
CompanyTeam,
|
CompanyTeam,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue