refactor: remove automatic contract assignment logic from intervention detail view

This commit is contained in:
kdeterme 2026-08-17 09:57:09 +02:00
parent 1e7e8ae697
commit 4e35da62b8
6 changed files with 963 additions and 200 deletions

View file

@ -123,10 +123,10 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">{% translate "Symptôme" %}</label> <label class="form-label fw-semibold">{% translate "Symptôme" %}</label>
<select name="symptom_id" class="form-select"> <select name="symptom_id" id="asset_intervention_symptom_select" class="form-select">
<option value="">{% translate "-- Sélectionner un symptôme --" %}</option> <option value="">{% translate "-- Sélectionner un symptôme --" %}</option>
{% for s in symptoms %} {% for s in symptoms %}
<option value="{{ s.id }}">{{ s.get_name }}</option> <option value="{{ s.id }}" data-name="{{ s.get_name }}" data-priority="{{ s.priority|default:'3' }}">{{ s.get_name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@ -156,7 +156,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">{% translate "Priorité" %}</label> <label class="form-label fw-semibold">{% translate "Priorité" %}</label>
<select name="priority" class="form-select"> <select name="priority" id="asset_intervention_priority_select" class="form-select">
<option value="1">{% translate "Urgent" %}</option> <option value="1">{% translate "Urgent" %}</option>
<option value="2">{% translate "Élevé" %}</option> <option value="2">{% translate "Élevé" %}</option>
<option value="3" selected>{% translate "Normal" %}</option> <option value="3" selected>{% translate "Normal" %}</option>
@ -186,6 +186,36 @@
const form = document.getElementById('createInterventionForAssetForm'); const form = document.getElementById('createInterventionForAssetForm');
if (!form) return; if (!form) return;
const symptomSelect = document.getElementById('asset_intervention_symptom_select');
const titleInput = form.querySelector('input[name="title"]');
const prioritySelect = document.getElementById('asset_intervention_priority_select');
let lastAutoTitle = '';
if (symptomSelect && titleInput) {
symptomSelect.addEventListener('change', function() {
const selectedOpt = symptomSelect.options[symptomSelect.selectedIndex];
if (selectedOpt && selectedOpt.value) {
const symName = selectedOpt.getAttribute('data-name') || selectedOpt.text.trim();
const symPriority = selectedOpt.getAttribute('data-priority');
if (!titleInput.value || titleInput.value === lastAutoTitle) {
titleInput.value = symName;
lastAutoTitle = symName;
}
if (symPriority && prioritySelect) {
prioritySelect.value = symPriority;
}
}
});
titleInput.addEventListener('input', function() {
if (titleInput.value !== lastAutoTitle) {
lastAutoTitle = '';
}
});
}
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
const alertDiv = document.getElementById('createInterventionAlert'); const alertDiv = document.getElementById('createInterventionAlert');

View file

@ -495,9 +495,11 @@ def asset_interventions(context, asset):
'interventions': [], 'interventions': [],
'can_add_intervention': False, 'can_add_intervention': False,
'symptoms': [], 'symptoms': [],
'symptoms_json': '[]',
} }
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import get_language
from interventions.models import Intervention, InterventionAsset, Symptom, STATUS_COLORS, STATUS_COLORS_DARK_TEXT from interventions.models import Intervention, InterventionAsset, Symptom, STATUS_COLORS, STATUS_COLORS_DARK_TEXT
from interventions.permissions import can_add_intervention from interventions.permissions import can_add_intervention
@ -513,22 +515,50 @@ def asset_interventions(context, asset):
'symptom', 'contract', 'assigned_provider', 'assigned_team', 'created_by' 'symptom', 'contract', 'assigned_provider', 'assigned_team', 'created_by'
).order_by('-creation_time')) ).order_by('-creation_time'))
thematic = None from assets.permissions import _get_asset_thematic
if hasattr(asset, 'category') and asset.category and asset.category.thematic: thematic = _get_asset_thematic(asset)
thematic = asset.category.thematic
request = context.get('request') request = context.get('request')
user = request.user if request else None user = request.user if request else None
can_add = False can_add = False
symptoms = [] symptoms = []
symptoms_json = '[]'
if user and user.is_authenticated: if user and user.is_authenticated:
can_add = can_add_intervention(user, thematic) can_add = can_add_intervention(user, thematic)
if can_add: if can_add:
if thematic: if thematic:
symptoms = list(Symptom.objects.filter(thematic=thematic, is_active=True).order_by('order_number', 'name_fr')) symptoms_qs = Symptom.objects.filter(thematic=thematic, is_active=True).prefetch_related('asset_categories')
else: else:
symptoms = list(Symptom.objects.filter(is_active=True).order_by('name_fr')) symptoms_qs = Symptom.objects.filter(is_active=True).prefetch_related('asset_categories')
lang = (get_language() or "fr").lower()
symptoms_list = list(symptoms_qs)
def get_display_name(s):
if lang.startswith("nl") and s.name_nl:
return s.name_nl
return s.name_fr
def sort_key(s):
has_order = 0 if s.order_number is not None else 1
order_num = s.order_number if s.order_number is not None else 999999
name = (get_display_name(s) or "").lower()
return (has_order, order_num, name, s.pk)
symptoms_list.sort(key=sort_key)
symptoms = symptoms_list
symptoms_data = [
{
'id': s.id,
'name': get_display_name(s),
'priority': s.priority or '3',
'origin_type': s.origin_type or '',
}
for s in symptoms
]
symptoms_json = json.dumps(symptoms_data)
return { return {
'asset': asset, 'asset': asset,
@ -536,6 +566,7 @@ def asset_interventions(context, asset):
'interventions': interventions, 'interventions': interventions,
'can_add_intervention': can_add, 'can_add_intervention': can_add,
'symptoms': symptoms, 'symptoms': symptoms,
'symptoms_json': symptoms_json,
'status_colors': STATUS_COLORS, 'status_colors': STATUS_COLORS,
'status_colors_dark_text': STATUS_COLORS_DARK_TEXT, 'status_colors_dark_text': STATUS_COLORS_DARK_TEXT,
} }

View file

@ -2760,3 +2760,345 @@ class ParentChildAssetLocationAndGeoSyncTest(TestCase):
lantern.refresh_from_db() lantern.refresh_from_db()
self.assertEqual(lantern.geom, new_point) self.assertEqual(lantern.geom, new_point)
class CreateInterventionForAssetWorkflowTest(TestCase):
def setUp(self):
from django.contrib.auth.models import User, Permission
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import Point
from common.models import Thematic, UserThematics, UserConfig
from contracts.models import Contract, Company
from interventions.models import (
Intervention, InterventionAsset, InterventionLocation,
InterventionTimeLine, InterventionPlanificationTimeLine, InterventionNote,
Symptom, DirectContractRouting
)
from assets.models import (
AssetCategory,
PublicLightingStreet, PublicLightingPole, PublicLightingPoleModel,
TrafficLightIntersection, TrafficLightContract, TrafficLightPole, TrafficLightPoleModel,
Structure, StructureContract, StructureLocation
)
self.thematic_pl, _ = Thematic.objects.get_or_create(code="publiclighting", defaults={'name_fr': "Éclairage public"})
self.thematic_tl, _ = Thematic.objects.get_or_create(code="trafficlights", defaults={'name_fr': "Signalisation lumineuse"})
self.thematic_struct, _ = Thematic.objects.get_or_create(code="structures", defaults={'name_fr': "Ouvrages d'art"})
self.user = User.objects.create_user(username="asset_agent", password="secret_password", email="agent@example.com")
self.user.is_staff = True
self.user.save()
# Donner la permission add_intervention
perm = Permission.objects.get(codename="add_intervention")
self.user.user_permissions.add(perm)
# Configurer UserConfig et thématiques
self.user_config = UserConfig.objects.create(
user=self.user,
is_intern=True,
limit_interventions_to_contracts=False
)
UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic_pl, can_edit_interventions=True)
UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic_tl, can_edit_interventions=True)
UserThematics.objects.create(user_config=self.user_config, thematic=self.thematic_struct, can_edit_interventions=True)
import datetime
# Prestataires et contrats
self.company_a = Company.objects.create(name="Company Alpha")
self.company_b = Company.objects.create(name="Company Beta")
self.contract_a = Contract.objects.create(
company=self.company_a,
contract_number="CTR-PL-001",
start_date=datetime.date(2025, 1, 1),
end_date=datetime.date(2030, 1, 1),
description="Contrat PL Alpha"
)
self.contract_tl = Contract.objects.create(
company=self.company_b,
contract_number="CTR-TL-001",
start_date=datetime.date(2025, 1, 1),
end_date=datetime.date(2030, 1, 1),
description="Contrat TL Curatif"
)
# Assets et localisations
self.pt_pl = Point(648819, 670788, srid=3812)
self.pl_category = AssetCategory.objects.create(name_fr="Support PL", code="CAT-PL-1", thematic=self.thematic_pl)
self.pl_street = PublicLightingStreet.objects.create(code="ST-PL-1", name_fr="Rue Royale", geom=MultiPolygon(self.pt_pl.buffer(5)))
self.pl_model = PublicLightingPoleModel.objects.create(code="PLM-1", name_fr="Candélabre 8m", category=self.pl_category)
self.pl_pole = PublicLightingPole.objects.create(
code="POLE-PL-001",
street=self.pl_street,
model=self.pl_model,
geom=self.pt_pl
)
self.client.login(username="asset_agent", password="secret_password")
def test_create_intervention_full_workflow_from_asset(self):
from interventions.models import Symptom, Intervention, InterventionTimeLine, InterventionPlanificationTimeLine, InterventionNote
from django.urls import reverse
symptom = Symptom.objects.create(
thematic=self.thematic_pl,
name_fr="Candélabre accidenté",
priority="1",
origin_type="damage",
contract=self.contract_a,
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'publiclightingpole',
'asset_id': self.pl_pole.pk
})
response = self.client.post(url, {
'title': '', # vide, doit être rempli automatiquement par le symptôme
'symptom_id': symptom.id,
'maintain_type': 'corrective',
'init_description': 'Candélabre heurté par un camion hier soir.',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention_id = data.get('intervention_id')
self.assertIsNotNone(intervention_id)
intervention = Intervention.objects.get(pk=intervention_id)
# 1. Titre déduit du symptôme
self.assertEqual(intervention.title, "Candélabre accidenté")
# 2. Prestataire et contrat résolus depuis le symptôme
self.assertEqual(intervention.contract, self.contract_a)
self.assertEqual(intervention.assigned_provider, self.company_a)
# 3. Géométrie et localisation créées depuis l'asset
self.assertIsNotNone(intervention.geom)
self.assertEqual(intervention.geom.srid, 3812)
self.assertEqual(intervention.geom.geom_type, 'MultiPolygon')
self.assertIsNotNone(intervention.lon)
self.assertIsNotNone(intervention.lat)
self.assertEqual(intervention.location_code, "ST-PL-1")
self.assertEqual(intervention.address, "Rue Royale")
# 4. Type, catégorie, priorité et type d'origine remplis automatiquement
self.assertEqual(intervention.type, 'fixing')
self.assertEqual(intervention.origin_type, 'damage')
self.assertEqual(intervention.priority, '1')
self.assertEqual(intervention.asset_category, self.pl_category)
# 5. Date de début prévue (+2h) & timeline de planification
self.assertIsNotNone(intervention.expected_begin_time)
plan_event = InterventionPlanificationTimeLine.objects.filter(
intervention=intervention,
event_type='expected_begin'
).first()
self.assertIsNotNone(plan_event)
self.assertEqual(plan_event.event_user, self.user)
# 6. Historique de création (Timeline) & Origine
timeline_event = InterventionTimeLine.objects.filter(
intervention=intervention,
event_type='creation'
).first()
self.assertIsNotNone(timeline_event)
self.assertEqual(timeline_event.event_user, self.user)
self.assertEqual(timeline_event.to_status, 'in_preparation')
# 7. Note d'origine créée
origin_note = InterventionNote.objects.filter(
intervention=intervention,
note_type=InterventionNote.NoteType.ORIGIN
).first()
self.assertIsNotNone(origin_note)
self.assertEqual(origin_note.content, 'Candélabre heurté par un camion hier soir.')
self.assertEqual(origin_note.note_author, self.user)
def test_create_intervention_trafficlight_contract_resolution(self):
from assets.models.trafficlights import TrafficLightIntersection, TrafficLightPole, TrafficLightPoleModel, TrafficLightContract
from interventions.models import Symptom, Intervention
from django.urls import reverse
from django.contrib.gis.geos import Point
intersection = TrafficLightIntersection.objects.create(
code="TL-INT-100",
name_fr="Carrefour Arts-Loi",
geom=Point(649000, 671000, srid=3812)
)
tl_pole_model = TrafficLightPoleModel.objects.create(code="TLPM-100", name_fr="Modèle Poteau TL")
tl_pole = TrafficLightPole.objects.create(
code="TL-POL-100",
intersection=intersection,
model=tl_pole_model,
geom=Point(649000, 671000, srid=3812)
)
tl_contract_rel = TrafficLightContract.objects.create(
intersection=intersection,
contract=self.contract_tl,
maintenance_type='corrective',
status='active'
)
symptom_tl = Symptom.objects.create(
thematic=self.thematic_tl,
name_fr="Feu éteint",
maintenance_type='corrective',
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'trafficlightpole',
'asset_id': tl_pole.pk
})
response = self.client.post(url, {
'title': 'Panne de signalisation',
'symptom_id': symptom_tl.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.contract, self.contract_tl)
self.assertEqual(intervention.assigned_provider, self.company_b)
self.assertEqual(intervention.thematic, self.thematic_tl)
def test_create_intervention_structure_direct_contract_routing(self):
from assets.models.structures import Structure, StructureLocation
from contracts.models import Contract
from interventions.models import Symptom, Intervention, DirectContractRouting
from django.urls import reverse
from django.contrib.gis.geos import Point, MultiPolygon
import datetime
struct_loc = StructureLocation.objects.create(
code="OA-001",
name_fr="Pont de Buda",
geom=MultiPolygon(Point(650000, 672000, srid=3812).buffer(10))
)
structure = Structure.objects.create(
code="STR-OA-001",
name_fr="Pont de Buda",
main_location=struct_loc
)
contract_struct = Contract.objects.create(
company=self.company_a,
contract_number="CTR-STRUCT-001",
start_date=datetime.date(2025, 1, 1),
end_date=datetime.date(2030, 1, 1),
description="Contrat Ouvrages d'Art"
)
symptom_struct = Symptom.objects.create(
thematic=self.thematic_struct,
name_fr="Fissure tablier",
is_active=True
)
DirectContractRouting.objects.create(
thematic='structures',
asset_code="STR-OA-001",
symptom=symptom_struct,
contract=contract_struct
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'structure',
'asset_id': structure.pk
})
response = self.client.post(url, {
'title': "Inspection fissure",
'symptom_id': symptom_struct.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.contract, contract_struct)
self.assertEqual(intervention.assigned_provider, self.company_a)
self.assertEqual(intervention.thematic, self.thematic_struct)
def test_create_intervention_user_default_contract_fallback(self):
from interventions.models import Symptom, Intervention
from django.urls import reverse
self.user_config.limit_interventions_to_contracts = True
self.user_config.default_contract = self.contract_a
self.user_config.save()
symptom = Symptom.objects.create(
thematic=self.thematic_pl,
name_fr="Ampoule grillée",
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'publiclightingpole',
'asset_id': self.pl_pole.pk
})
response = self.client.post(url, {
'title': "Ampoule HS",
'symptom_id': symptom.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.contract, self.contract_a)
self.assertEqual(intervention.assigned_provider, self.company_a)
def test_create_intervention_external_user_forces_to_be_approved(self):
from interventions.models import Symptom, Intervention
from django.urls import reverse
# Utilisateur externe sans accès au contrat du symptôme
self.user_config.is_intern = False
self.user_config.limit_interventions_to_contracts = True
self.user_config.save()
symptom = Symptom.objects.create(
thematic=self.thematic_pl,
name_fr="Candélabre endommagé",
contract=self.contract_a,
is_active=True
)
url = reverse('assets:create_intervention_for_asset', kwargs={
'asset_model': 'publiclightingpole',
'asset_id': self.pl_pole.pk
})
response = self.client.post(url, {
'title': "Candélabre endommagé",
'symptom_id': symptom.id,
'maintain_type': 'corrective',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
intervention = Intervention.objects.get(pk=data['intervention_id'])
self.assertEqual(intervention.status, 'to_be_approved')
# Vérifier que le premier événement de timeline reflète to_be_approved
first_event = intervention.events.first()
self.assertEqual(first_event.to_status, 'to_be_approved')
self.assertEqual(first_event.event_user, self.user)

View file

@ -2388,36 +2388,94 @@ def create_intervention_for_asset(request, asset_model, asset_id):
asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id) asset, model_class, content_type = _get_asset_by_ct(asset_model, asset_id)
thematic = None from assets.permissions import _get_asset_thematic
if hasattr(asset, 'category') and asset.category and asset.category.thematic: thematic = _get_asset_thematic(asset)
thematic = asset.category.thematic
from interventions.permissions import can_add_intervention from interventions.permissions import can_add_intervention
if not can_add_intervention(request.user, thematic): if not can_add_intervention(request.user, thematic):
return JsonResponse({'success': False, 'error': _("Permission refusée pour créer une intervention.")}, status=403) return JsonResponse({'success': False, 'error': _("Permission refusée pour créer une intervention.")}, status=403)
title = request.POST.get('title', '').strip() from interventions.models import (
if not title: Intervention, InterventionAsset, Symptom, InterventionLocation,
return JsonResponse({'success': False, 'error': _("Le titre est obligatoire.")}, status=400) InterventionTimeLine, InterventionPlanificationTimeLine, InterventionNote,
STATUS_ORDERS
)
from interventions.views.helpers import (
resolve_intervention_contract_and_provider,
resolve_asset_spatial_data,
resolve_asset_category,
auto_check_if_eligible,
)
from datetime import timedelta
from django.utils.timezone import now
symptom_id = request.POST.get('symptom_id') symptom_id = request.POST.get('symptom_id')
maintain_type = request.POST.get('maintain_type', 'corrective')
priority = request.POST.get('priority', '3')
init_description = request.POST.get('init_description', '').strip()
from interventions.models import Intervention, InterventionAsset, Symptom, InterventionLocation
symptom = None symptom = None
if symptom_id: if symptom_id:
symptom = Symptom.objects.filter(pk=symptom_id, is_active=True).first() symptom = Symptom.objects.filter(pk=symptom_id, is_active=True).first()
geom = getattr(asset, 'geom', None) title = request.POST.get('title', '').strip()
if geom is not None: if not title and symptom:
from django.contrib.gis.geos import Polygon, MultiPolygon title = symptom.get_name()
if isinstance(geom, Polygon): if not title:
geom = MultiPolygon(geom) return JsonResponse({'success': False, 'error': _("Le titre est obligatoire.")}, status=400)
elif not isinstance(geom, MultiPolygon):
geom = None maintain_type = request.POST.get('maintain_type', 'corrective')
priority = request.POST.get('priority')
intervention_type = request.POST.get('type')
origin_type = request.POST.get('origin_type')
init_description = request.POST.get('init_description', '').strip()
# Déduction automatique de la priorité
if not priority or priority == '3':
if symptom and symptom.priority:
priority = symptom.priority
else:
priority = priority or '3'
# Déduction automatique du type d'intervention
if not intervention_type:
if maintain_type == 'corrective':
intervention_type = 'fixing'
elif maintain_type == 'ameliorative':
intervention_type = 'repair'
elif maintain_type == 'preventive':
intervention_type = 'inspection'
# Déduction automatique de l'origin_type
if not origin_type:
if symptom and symptom.origin_type:
origin_type = symptom.origin_type
elif maintain_type == 'ameliorative':
origin_type = 'demand'
else:
origin_type = 'damage'
# Déduction automatique de la catégorie d'asset
asset_category = resolve_asset_category(asset, symptom)
# Déduction des données spatiales et localisation
spatial_data = resolve_asset_spatial_data(asset=asset)
geom = spatial_data['geom']
lon = spatial_data['lon']
lat = spatial_data['lat']
address = spatial_data['address']
location_code = spatial_data['location_code']
loc_obj = spatial_data['location_obj']
# Résolution contrat et prestataire
contract, assigned_provider, force_to_be_approved = resolve_intervention_contract_and_provider(
thematic=thematic,
symptom=symptom,
asset=asset,
location=loc_obj,
user=request.user
)
status = 'to_be_approved' if force_to_be_approved else 'in_preparation'
status_order = STATUS_ORDERS.get(status, 1)
expected_begin_time = now() + timedelta(hours=2)
try: try:
with transaction.atomic(): with transaction.atomic():
@ -2426,20 +2484,28 @@ def create_intervention_for_asset(request, asset_model, asset_id):
thematic=thematic, thematic=thematic,
symptom=symptom, symptom=symptom,
maintain_type=maintain_type, maintain_type=maintain_type,
type=intervention_type,
origin_type=origin_type,
priority=priority, priority=priority,
init_description=init_description, init_description=init_description,
status='in_preparation', status=status,
status_order=status_order,
created_by=request.user, created_by=request.user,
asset_category=asset_category,
contract=contract,
assigned_provider=assigned_provider,
geom=geom, geom=geom,
lon=lon,
lat=lat,
address=address,
location_code=location_code,
expected_begin_time=expected_begin_time,
) )
InterventionAsset.objects.create( InterventionAsset.objects.create(
intervention=intervention, intervention=intervention,
content_type=content_type, content_type=content_type,
object_id=asset.pk object_id=asset.pk
) )
loc_obj = (
asset.get_location() if hasattr(asset, 'get_location') else None
) or getattr(asset, 'street', None) or getattr(asset, 'location', None) or getattr(asset, 'intersection', None)
if loc_obj: if loc_obj:
loc_ct = ContentType.objects.get_for_model(loc_obj) loc_ct = ContentType.objects.get_for_model(loc_obj)
InterventionLocation.objects.get_or_create( InterventionLocation.objects.get_or_create(
@ -2448,6 +2514,38 @@ def create_intervention_for_asset(request, asset_model, asset_id):
object_id=loc_obj.pk object_id=loc_obj.pk
) )
# Auto-vérification si l'utilisateur a le droit can_check_interventions
auto_check_if_eligible(request.user, intervention)
# Enregistrement dans l'historique (Timeline) & Section Origine
InterventionTimeLine.objects.create(
intervention=intervention,
event_user=request.user,
event_time=now(),
event_type='creation',
from_status=None,
to_status=intervention.status,
)
# Date de début prévue (Timeline planification)
InterventionPlanificationTimeLine.objects.create(
intervention=intervention,
event_user=request.user,
event_time=expected_begin_time,
event_type='expected_begin',
event_description=_("Début prévu initial."),
)
# Note d'origine si description initiale fournie
if init_description:
InterventionNote.objects.create(
intervention=intervention,
note_author=request.user,
note_time=now(),
content=init_description,
note_type=InterventionNote.NoteType.ORIGIN,
)
redirect_url = reverse('interventions:intervention_detail', args=[intervention.id]) redirect_url = reverse('interventions:intervention_detail', args=[intervention.id])
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,

View file

@ -1638,106 +1638,12 @@ def interventions_add(request, thematic_code):
# mémoriser le 1er objet pour updater location_code # mémoriser le 1er objet pour updater location_code
if first_loc_obj is None: if first_loc_obj is None:
Model = ct.model_class() Model = ct.model_class()
first_loc_obj = Model.objects.filter(pk=obj_id).only('code').first() first_loc_obj = Model.objects.filter(pk=obj_id).first()
if first_loc_obj and hasattr(first_loc_obj, 'code'): if first_loc_obj and hasattr(first_loc_obj, 'code'):
intervention.location_code = first_loc_obj.code intervention.location_code = first_loc_obj.code
intervention.save(update_fields=['location_code']) intervention.save(update_fields=['location_code'])
# -------- Recherche contrat --------
# Si thématique 'structures', recherche d'une règle de routage spécifique dans contract_routing.json
if thematic.code == 'structures':
structure_location_ct = ContentType.objects.get_for_model(StructureLocation)
structure_loc = InterventionLocation.objects.filter(
intervention=intervention,
content_type=structure_location_ct
).first()
if structure_loc:
structure_location = StructureLocation.objects.filter(id=structure_loc.object_id).only('code').first()
structure = structure_location.structure if structure_location else None
if structure:
from interventions.models import DirectContractRouting
# 1. Tenter de trouver une règle avec le symptôme spécifique
routing = DirectContractRouting.objects.filter(
thematic='structures',
asset_code=structure.code,
symptom=intervention.symptom
).select_related('contract').first()
# 2. Si non trouvé, tenter de trouver la règle par défaut pour cet asset
if not routing:
routing = DirectContractRouting.objects.filter(
thematic='structures',
asset_code=structure.code,
symptom__isnull=True
).select_related('contract').first()
if routing and routing.contract:
contract = routing.contract
intervention.contract = contract
intervention.assigned_provider = contract.company
intervention.save(update_fields=['contract', 'assigned_provider'])
# Si pas de contrat défini et que le symptôme a un maintenance_type,
# chercher le contrat actif lié à ce type de maintenance.
if (
not intervention.contract
and intervention.symptom
and intervention.symptom.maintenance_type
):
contract = None
if thematic.code == 'trafficlights':
# Récupérer l'intersection depuis les localisations
tl_intersection_ct = ContentType.objects.get_for_model(TrafficLightIntersection)
intersection_location = InterventionLocation.objects.filter(
intervention=intervention,
content_type=tl_intersection_ct
).first()
if intersection_location:
# Chercher le TrafficLightContract correspondant au maintenance_type du symptôme
contract = TrafficLightContract.objects.filter(
intersection_id=intersection_location.object_id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
elif thematic.code == 'structures':
structure_location_ct = ContentType.objects.get_for_model(StructureLocation)
structure_loc = InterventionLocation.objects.filter(
intervention=intervention,
content_type=structure_location_ct
).first()
structure_location = StructureLocation.objects.filter(id=structure_loc.object_id).first() if structure_loc else None
structure = structure_location.structure if structure_location else None
if structure:
contract = StructureContract.objects.filter(
structure_id=structure.id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
elif thematic.code == 'clean':
location_ct = ContentType.objects.get_for_model(CleanLocation)
clean_loc = InterventionLocation.objects.filter(
intervention=intervention,
content_type=location_ct
).first()
clean_location = CleanLocation.objects.filter(id=clean_loc.object_id).first() if clean_loc else None
if clean_location:
contract = CleanLocationContract.objects.filter(
location_id=clean_location.id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
if contract:
intervention.contract = contract.contract
if not intervention.assigned_provider:
intervention.assigned_provider = contract.contract.company
intervention.save(update_fields=['contract', 'assigned_provider'])
# -------- Assets (optionnel) -------- # -------- Assets (optionnel) --------
# 1) JSON 'assets' = [{ct,obj}] (recommandé) # 1) JSON 'assets' = [{ct,obj}] (recommandé)
asset_pairs = _parse_pairs_json(request.POST.get('assets')) asset_pairs = _parse_pairs_json(request.POST.get('assets'))
@ -1754,6 +1660,7 @@ def interventions_add(request, thematic_code):
ct = ContentType.objects.get(model=equip_type.lower()) ct = ContentType.objects.get(model=equip_type.lower())
asset_pairs = [(ct.id, int(equip_id))] asset_pairs = [(ct.id, int(equip_id))]
first_asset_obj = None
for ct_id, obj_id in asset_pairs: for ct_id, obj_id in asset_pairs:
ct = ContentType.objects.get_for_id(ct_id) ct = ContentType.objects.get_for_id(ct_id)
InterventionAsset.objects.get_or_create( InterventionAsset.objects.get_or_create(
@ -1761,6 +1668,9 @@ def interventions_add(request, thematic_code):
content_type=ct, content_type=ct,
object_id=obj_id, object_id=obj_id,
) )
if first_asset_obj is None:
Model = ct.model_class()
first_asset_obj = Model.objects.filter(pk=obj_id).first()
if inspection_config: if inspection_config:
templates = list(inspection_config.operation_templates.all()) templates = list(inspection_config.operation_templates.all())
@ -1772,78 +1682,26 @@ def interventions_add(request, thematic_code):
resolved_assets = _resolve_assets_from_pairs(asset_pairs) resolved_assets = _resolve_assets_from_pairs(asset_pairs)
_create_equipment_operations_for_assets(intervention, resolved_assets, ignore_filters=True) _create_equipment_operations_for_assets(intervention, resolved_assets, ignore_filters=True)
# -------- Recherche contrat ITS (après sauvegarde des assets) -------- # -------- Résolution contrat et prestataire --------
if ( if not intervention.contract:
thematic.code == 'its' resolved_contract, resolved_provider, force_to_be_approved = resolve_intervention_contract_and_provider(
and not intervention.contract thematic=thematic,
and intervention.symptom symptom=intervention.symptom,
and intervention.symptom.maintenance_type asset=first_asset_obj,
): location=first_loc_obj,
its_equipment_ct = ContentType.objects.get_for_model(ITSGeoAsset) user=request.user
its_equipment = InterventionAsset.objects.filter( )
intervention=intervention, if resolved_contract:
content_type=its_equipment_ct intervention.contract = resolved_contract
).first() if resolved_provider and not intervention.assigned_provider:
intervention.assigned_provider = resolved_provider
if its_equipment: if resolved_contract or resolved_provider:
its_contract = ITSGeoAssetContract.objects.filter(
asset_id=its_equipment.object_id,
maintenance_type=intervention.symptom.maintenance_type,
status='active'
).select_related('contract__company').first()
if its_contract:
intervention.contract = its_contract.contract
if not intervention.assigned_provider:
intervention.assigned_provider = its_contract.contract.company
intervention.save(update_fields=['contract', 'assigned_provider']) intervention.save(update_fields=['contract', 'assigned_provider'])
# Fallback contrat par défaut pour les utilisateurs à visibilité limitée aux contrats if force_to_be_approved and intervention.status != 'to_be_approved':
if not intervention.contract and user_config and user_config.limit_interventions_to_contracts:
default_contract = user_config.get_default_contract()
if default_contract:
intervention.contract = default_contract
if not intervention.assigned_provider:
intervention.assigned_provider = default_contract.company
intervention.save(update_fields=['contract', 'assigned_provider'])
# -------- Forcer le statut 'à approuver' pour les utilisateurs externes --------
# Applicable si limit_interventions_to_contracts est activé et que l'utilisateur
# n'a pas accès au contrat résolu (ou à celui du symptôme).
if user_config and not user_config.is_intern and user_config.limit_interventions_to_contracts:
_force_approved = False
_symptom = intervention.symptom
if _symptom:
if _symptom.contract_id:
# Cas 1 : le symptôme est explicitement lié à un contrat
# → l'utilisateur doit avoir accès à ce contrat pour éviter to_be_approved
_has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=_symptom.contract_id,
).exists()
if not _has_access:
_force_approved = True
elif _symptom.maintenance_type:
# Cas 2 : pas de contrat sur le symptôme mais maintenance_type défini
# → vérifier si le contrat résolu (depuis la localisation/asset) est accessible
if intervention.contract_id:
_has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=intervention.contract_id,
).exists()
if not _has_access:
_force_approved = True
else:
# Aucun contrat résolu : la localisation/asset n'est pas liée
# à un contrat de l'utilisateur → approbation requise
_force_approved = True
if _force_approved and intervention.status != 'to_be_approved':
intervention.status = 'to_be_approved' intervention.status = 'to_be_approved'
intervention.status_order = STATUS_ORDERS.get('to_be_approved', 2)
intervention.save(update_fields=['status', 'status_order']) intervention.save(update_fields=['status', 'status_order'])
# Mettre à jour l'entrée de timeline créée lors de la création
event.to_status = 'to_be_approved' event.to_status = 'to_be_approved'
event.save(update_fields=['to_status']) event.save(update_fields=['to_status'])

View file

@ -60,7 +60,8 @@ from interventions.models import (InterventionSubscription, Intervention, Interv
InterventionOccupation, InterventionOccupation,
Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition, Operation, OperationTemplate, OperationTemplateItem, OperationStatus, OperationStatusSet, MeasurementDefinition,
GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia, GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia,
InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment) InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment,
DirectContractRouting)
from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES
from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm, from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm,
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm, InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
@ -95,6 +96,7 @@ from assets.models import (
AbstractAsset, AbstractAsset,
AbstractAssetModel, AbstractAssetModel,
ITSGeoAsset, ITSGeoAssetContract, ITSGeoAsset, ITSGeoAssetContract,
ITSLocation, ITSLocationContract,
CleanLocation, CleanLocationContract, CleanLocation, CleanLocationContract,
) )
from assets.permissions import get_allowed_actions_for_asset from assets.permissions import get_allowed_actions_for_asset
@ -211,6 +213,10 @@ __all__ = [
'get_intervention_attachments', 'get_intervention_attachments',
'inspection_configuration_geojson', 'inspection_configuration_geojson',
'is_smtp_available', 'is_smtp_available',
'resolve_asset_category',
'resolve_asset_spatial_data',
'resolve_coords_from_hierarchy',
'resolve_intervention_contract_and_provider',
'resolve_selected_assets', 'resolve_selected_assets',
'to_multipolygon', 'to_multipolygon',
'update_operation_comment' 'update_operation_comment'
@ -3083,16 +3089,414 @@ def to_multipolygon(geom, srid=None):
gtype = geom.geom_type # 'Polygon', 'MultiPolygon', 'GeometryCollection', etc. gtype = geom.geom_type # 'Polygon', 'MultiPolygon', 'GeometryCollection', etc.
if gtype == 'Polygon': if gtype == 'Polygon':
return MultiPolygon(geom) # ou MultiPolygon([geom]) return MultiPolygon(geom)
if gtype == 'MultiPolygon': if gtype == 'MultiPolygon':
return geom # ne pas ré-envelopper return geom
if gtype == 'GeometryCollection': if gtype == 'GeometryCollection':
polys = [g for g in geom if g.geom_type == 'Polygon'] polys = [g for g in geom if g.geom_type in ['Polygon', 'MultiPolygon']]
return MultiPolygon(polys) if polys else None if polys:
# Optionnel: gérer LineString/Point, sinon considérer comme erreur all_polys = []
for p in polys:
if p.geom_type == 'MultiPolygon':
all_polys.extend(list(p))
else:
all_polys.append(p)
return MultiPolygon(all_polys)
return None
if gtype in ['Point', 'MultiPoint', 'LineString', 'MultiLineString']:
buffered = geom.buffer(1)
if buffered.geom_type == 'Polygon':
return MultiPolygon(buffered)
elif buffered.geom_type == 'MultiPolygon':
return buffered
raise ValueError(f"Type géométrique non supporté: {gtype}") raise ValueError(f"Type géométrique non supporté: {gtype}")
def resolve_coords_from_hierarchy(loc, max_depth=10):
"""
Remonte la hiérarchie parent_location pour trouver lon/lat/geom.
Retourne (lon, lat, geom) - les valeurs peuvent être None.
"""
current = loc
depth = 0
lon, lat, geom = None, None, None
while current is not None and depth < max_depth:
current_lon = getattr(current, "lon", None) or getattr(current, "longitude", None)
current_lat = getattr(current, "lat", None) or getattr(current, "latitude", None)
current_geom = getattr(current, "geom", None)
if lon is None and current_lon is not None:
lon = current_lon
if lat is None and current_lat is not None:
lat = current_lat
if geom is None and current_geom is not None:
geom = current_geom
if lon is not None and lat is not None and geom is not None:
break
parent = getattr(current, "parent_location", None)
if parent is None:
struct = getattr(current, "structure", None)
parent = getattr(struct, "main_location", None) if struct else None
current = parent
depth += 1
return lon, lat, geom
def resolve_intervention_contract_and_provider(thematic, symptom, asset=None, location=None, user=None):
"""
Détermine le contrat et le prestataire associés pour une intervention, ainsi que le flag force_to_be_approved
si l'utilisateur externe n'a pas accès au contrat résolu.
Retourne un tuple (contract, assigned_provider, force_to_be_approved)
"""
contract = None
assigned_provider = None
force_to_be_approved = False
thematic_code = thematic.code if hasattr(thematic, 'code') else (str(thematic) if thematic else None)
# 1. Contrat / prestataire direct du symptôme
if symptom:
if symptom.contract:
contract = symptom.contract
assigned_provider = symptom.provider or (contract.company if contract else None)
elif symptom.provider:
assigned_provider = symptom.provider
# 2. Routage direct (thématique 'structures')
if not contract and thematic_code == 'structures':
structure = None
if asset:
if isinstance(asset, Structure):
structure = asset
elif hasattr(asset, 'structure') and asset.structure:
structure = asset.structure
if not structure and location:
if isinstance(location, Structure):
structure = location
elif hasattr(location, 'structure') and location.structure:
structure = location.structure
if structure and getattr(structure, 'code', None):
routing = DirectContractRouting.objects.filter(
thematic='structures',
asset_code=structure.code,
symptom=symptom
).select_related('contract__company').first()
if not routing:
routing = DirectContractRouting.objects.filter(
thematic='structures',
asset_code=structure.code,
symptom__isnull=True
).select_related('contract__company').first()
if routing and routing.contract:
contract = routing.contract
if not assigned_provider:
assigned_provider = contract.company
# 3. Contrat actif lié au maintenance_type du symptôme
if not contract and symptom and symptom.maintenance_type:
mt = symptom.maintenance_type
if thematic_code == 'trafficlights':
intersection_id = None
if location:
if isinstance(location, TrafficLightIntersection):
intersection_id = location.id
elif hasattr(location, 'intersection_id') and location.intersection_id:
intersection_id = location.intersection_id
if not intersection_id and asset:
if isinstance(asset, TrafficLightIntersection):
intersection_id = asset.id
elif hasattr(asset, 'intersection_id') and asset.intersection_id:
intersection_id = asset.intersection_id
if intersection_id:
tl_contract = TrafficLightContract.objects.filter(
intersection_id=intersection_id,
maintenance_type=mt,
status='active'
).select_related('contract__company').first()
if tl_contract and tl_contract.contract:
contract = tl_contract.contract
if not assigned_provider:
assigned_provider = contract.company
elif thematic_code == 'structures':
structure_id = None
if location:
if isinstance(location, Structure):
structure_id = location.id
elif hasattr(location, 'structure_id') and location.structure_id:
structure_id = location.structure_id
if not structure_id and asset:
if isinstance(asset, Structure):
structure_id = asset.id
elif hasattr(asset, 'structure_id') and asset.structure_id:
structure_id = asset.structure_id
if structure_id:
struct_contract = StructureContract.objects.filter(
structure_id=structure_id,
maintenance_type=mt,
status='active'
).select_related('contract__company').first()
if struct_contract and struct_contract.contract:
contract = struct_contract.contract
if not assigned_provider:
assigned_provider = contract.company
elif thematic_code == 'clean':
clean_loc_id = None
if location:
if isinstance(location, CleanLocation):
clean_loc_id = location.id
elif hasattr(location, 'location_id') and location.location_id:
clean_loc_id = location.location_id
if not clean_loc_id and asset:
if isinstance(asset, CleanLocation):
clean_loc_id = asset.id
elif hasattr(asset, 'location_id') and asset.location_id:
clean_loc_id = asset.location_id
if clean_loc_id:
clean_contract = CleanLocationContract.objects.filter(
location_id=clean_loc_id,
maintenance_type=mt,
status='active'
).select_related('contract__company').first()
if clean_contract and clean_contract.contract:
contract = clean_contract.contract
if not assigned_provider:
assigned_provider = contract.company
elif thematic_code == 'its':
its_contract = None
if asset and isinstance(asset, ITSGeoAsset):
its_contract = ITSGeoAssetContract.objects.filter(
asset_id=asset.id,
maintenance_type=mt,
status='active'
).select_related('contract__company').first()
elif location and isinstance(location, ITSLocation):
its_contract = ITSLocationContract.objects.filter(
location_id=location.id,
maintenance_type=mt,
status='active'
).select_related('contract__company').first()
if its_contract and its_contract.contract:
contract = its_contract.contract
if not assigned_provider:
assigned_provider = contract.company
# 4. Fallback contrat par défaut de l'utilisateur (limit_interventions_to_contracts)
user_config = None
if user:
try:
user_config = getattr(user, 'config', None) or UserConfig.objects.filter(user=user).first()
except Exception:
user_config = None
if not contract and user_config and user_config.limit_interventions_to_contracts:
default_contract = user_config.get_default_contract()
if default_contract:
contract = default_contract
if not assigned_provider:
assigned_provider = default_contract.company
# 5. Vérification d'accès contrat pour utilisateur externe (limit_interventions_to_contracts)
if user_config and not user_config.is_intern and user_config.limit_interventions_to_contracts:
if symptom:
if symptom.contract_id:
has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=symptom.contract_id,
).exists()
if not has_access:
force_to_be_approved = True
elif symptom.maintenance_type:
if contract:
has_access = UserContractAccess.objects.filter(
user_config=user_config,
contract_id=contract.id,
).exists()
if not has_access:
force_to_be_approved = True
else:
force_to_be_approved = True
return contract, assigned_provider, force_to_be_approved
def resolve_asset_spatial_data(asset=None, location=None):
"""
Extrait et calcule la géométrie (MultiPolygon SRID 3812), les coordonnées (lon, lat WGS84 4326),
l'adresse, le location_code et l'objet de localisation parent.
Retourne un dictionnaire:
{
'geom': MultiPolygon | None (SRID 3812),
'lon': float | None (WGS84 4326),
'lat': float | None (WGS84 4326),
'address': str | None,
'location_code': str | None,
'location_obj': Model | None,
}
"""
geom = None
lon = None
lat = None
address = None
location_code = None
loc_obj = location
# 1. Déterminer l'objet localisation parent si non fourni
if not loc_obj and asset:
loc_obj = (
asset.get_location() if hasattr(asset, 'get_location') else None
) or getattr(asset, 'street', None) or getattr(asset, 'location', None) or getattr(asset, 'intersection', None) or getattr(asset, 'structure', None) or getattr(asset, 'section', None)
# 2. Extraction adresse et location_code
if asset:
address = getattr(asset, 'address', None)
if not address and hasattr(asset, 'get_address'):
try:
address = asset.get_address()
except Exception:
address = None
location_code = getattr(asset, 'code', None)
if not address and loc_obj:
address = getattr(loc_obj, 'address', None)
if not address and hasattr(loc_obj, 'get_name'):
try:
address = loc_obj.get_name()
except Exception:
address = None
if loc_obj and hasattr(loc_obj, 'code') and loc_obj.code:
location_code = loc_obj.code
# 3. Extraction de la géométrie source
raw_geom = getattr(asset, 'geom', None) if asset else None
if raw_geom is None and loc_obj:
raw_geom = getattr(loc_obj, 'geom', None)
if raw_geom is None:
_, _, raw_geom = resolve_coords_from_hierarchy(loc_obj)
# Coordonnées directes (lat/lon)
if asset:
lon = getattr(asset, 'lon', None) or getattr(asset, 'longitude', None)
lat = getattr(asset, 'lat', None) or getattr(asset, 'latitude', None)
if lon is None and lat is None and hasattr(asset, 'get_coordinates'):
try:
coords = asset.get_coordinates()
if coords and len(coords) >= 2:
lat, lon = coords[0], coords[1]
except Exception:
pass
if (lon is None or lat is None) and loc_obj:
h_lon, h_lat, _ = resolve_coords_from_hierarchy(loc_obj)
if lon is None:
lon = h_lon
if lat is None:
lat = h_lat
# 4. Traitement de la géométrie vers MultiPolygon SRID 3812
if raw_geom is not None and not raw_geom.empty:
try:
geom_3812 = raw_geom.clone()
if getattr(geom_3812, 'srid', None) and geom_3812.srid != 3812:
geom_3812.transform(3812)
elif not getattr(geom_3812, 'srid', None):
geom_3812.srid = 3812
gtype = geom_3812.geom_type
if gtype in ['Point', 'MultiPoint', 'LineString', 'MultiLineString']:
buffered = geom_3812.buffer(1)
geom = to_multipolygon(buffered, srid=3812)
elif gtype == 'Polygon':
geom = MultiPolygon(geom_3812)
geom.srid = 3812
elif gtype == 'MultiPolygon':
geom = geom_3812
elif gtype == 'GeometryCollection':
polys = [g for g in geom_3812 if g.geom_type in ['Polygon', 'MultiPolygon']]
if polys:
all_polys = []
for p in polys:
if p.geom_type == 'MultiPolygon':
all_polys.extend(list(p))
else:
all_polys.append(p)
geom = MultiPolygon(all_polys)
geom.srid = 3812
# Extraire lon/lat si non encore définis
if lon is None or lat is None:
geom_4326 = raw_geom.clone()
if getattr(geom_4326, 'srid', None) != 4326:
if not getattr(geom_4326, 'srid', None):
geom_4326.srid = 3812
geom_4326.transform(4326)
centroid = geom_4326.centroid
if lon is None:
lon = centroid.x
if lat is None:
lat = centroid.y
except Exception as e:
logger.warning(f"Impossible de convertir la géométrie : {e}")
# Si pas de géométrie valide mais lon/lat sont présents : créer un buffer de 1m
if geom is None and lon is not None and lat is not None:
try:
pt = Point(float(lon), float(lat), srid=4326)
pt.transform(3812)
geom = MultiPolygon(pt.buffer(1))
geom.srid = 3812
except Exception as e:
logger.warning(f"Impossible de générer le buffer géométrique depuis lon/lat : {e}")
return {
'geom': geom,
'lon': float(lon) if lon is not None else None,
'lat': float(lat) if lat is not None else None,
'address': address,
'location_code': location_code,
'location_obj': loc_obj,
}
def resolve_asset_category(asset, symptom=None):
"""
Résout la catégorie d'asset appropriée pour une intervention liée à un asset.
"""
category = getattr(asset, 'category', None)
if not category:
# Vérifier si l'asset a un modèle avec catégorie
for model_attr in ('pole_model', 'lantern_model', 'controller_model', 'cabinet_model', 'detector_model', 'radar_model', 'model'):
model_obj = getattr(asset, model_attr, None)
if model_obj and getattr(model_obj, 'category', None):
category = model_obj.category
break
# Si toujours pas de catégorie et qu'un symptôme est fourni avec une seule catégorie
if not category and symptom:
symptom_cats = list(symptom.asset_categories.all())
if len(symptom_cats) == 1:
category = symptom_cats[0]
return category