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__)
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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