refactor: harden API error handling in frontend modals and update asset code generation prefix in tests

This commit is contained in:
kdeterme 2026-08-21 09:00:14 +02:00
parent 24bf8a9bb5
commit a410cc855f
5 changed files with 388 additions and 82 deletions

View file

@ -376,12 +376,18 @@
}, },
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
.then(response => response.json()) .then(async response => {
.then(result => { let result = null;
try {
result = await response.json();
} catch (e) {
result = null;
}
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText; submitBtn.innerHTML = originalBtnText;
if (result.success) { if (response.ok && result && result.success) {
alertContainer.className = 'alert alert-success'; alertContainer.className = 'alert alert-success';
alertContainer.innerHTML = '<i class="bi bi-check-circle me-1"></i>' + result.message; alertContainer.innerHTML = '<i class="bi bi-check-circle me-1"></i>' + result.message;
alertContainer.classList.remove('d-none'); alertContainer.classList.remove('d-none');
@ -396,18 +402,20 @@
}, 1000); }, 1000);
} }
} else { } else {
const errorMsg = (result && (result.error || result.message))
|| (response.status ? `${gettext('Erreur serveur')} (${response.status})` : gettext('Erreur lors de la création'));
alertContainer.className = 'alert alert-danger'; alertContainer.className = 'alert alert-danger';
alertContainer.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + (result.error || gettext('Erreur lors de la création')); alertContainer.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + errorMsg;
alertContainer.classList.remove('d-none'); alertContainer.classList.remove('d-none');
} }
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error creating asset:', error);
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText; submitBtn.innerHTML = originalBtnText;
alertContainer.className = 'alert alert-danger'; alertContainer.className = 'alert alert-danger';
alertContainer.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + gettext('Erreur de connexion'); alertContainer.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + (error.message || gettext('Erreur de connexion'));
alertContainer.classList.remove('d-none'); alertContainer.classList.remove('d-none');
}); });
} }
@ -426,9 +434,15 @@
asset_id: assetId asset_id: assetId
}) })
}) })
.then(response => response.json()) .then(async response => {
.then(data => { let data = null;
if (data.success && data.has_templates && data.available_templates && data.available_templates.length > 0) { try {
data = await response.json();
} catch (e) {
data = null;
}
if (response.ok && data && data.success && data.has_templates && data.available_templates && data.available_templates.length > 0) {
// Afficher le modal de sélection de template // Afficher le modal de sélection de template
showTemplateSelectionModal( showTemplateSelectionModal(
interventionId, interventionId,
@ -440,7 +454,7 @@
data.message data.message
); );
} else { } else {
// Pas de templates disponibles, rediriger normalement // Pas de templates disponibles ou non applicable, rediriger normalement
setTimeout(() => { setTimeout(() => {
performRedirect(modalId, interventionId, {intervention_linked: true}); performRedirect(modalId, interventionId, {intervention_linked: true});
}, 1000); }, 1000);
@ -584,9 +598,15 @@
template_ids: selectedTemplateIds template_ids: selectedTemplateIds
}) })
}) })
.then(response => response.json()) .then(async response => {
.then(data => { let data = null;
if (data.success) { try {
data = await response.json();
} catch (e) {
data = null;
}
if (response.ok && data && data.success) {
templateAlert.className = 'alert alert-success'; templateAlert.className = 'alert alert-success';
templateAlert.innerHTML = '<i class="bi bi-check-circle me-1"></i>' + data.message; templateAlert.innerHTML = '<i class="bi bi-check-circle me-1"></i>' + data.message;
templateAlert.classList.remove('d-none'); templateAlert.classList.remove('d-none');
@ -600,17 +620,19 @@
} else { } else {
createBtn.disabled = false; createBtn.disabled = false;
createBtn.innerHTML = originalBtnText; createBtn.innerHTML = originalBtnText;
const errorMsg = (data && (data.error || data.message))
|| (response.status ? `${gettext('Erreur serveur')} (${response.status})` : gettext('Erreur lors de la création'));
templateAlert.className = 'alert alert-danger'; templateAlert.className = 'alert alert-danger';
templateAlert.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + (data.error || gettext('Erreur lors de la création')); templateAlert.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + errorMsg;
templateAlert.classList.remove('d-none'); templateAlert.classList.remove('d-none');
} }
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error creating operations from template:', error);
createBtn.disabled = false; createBtn.disabled = false;
createBtn.innerHTML = originalBtnText; createBtn.innerHTML = originalBtnText;
templateAlert.className = 'alert alert-danger'; templateAlert.className = 'alert alert-danger';
templateAlert.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + gettext('Erreur de connexion'); templateAlert.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + (error.message || gettext('Erreur de connexion'));
templateAlert.classList.remove('d-none'); templateAlert.classList.remove('d-none');
}); });
}); });

View file

@ -972,8 +972,14 @@ $(document).ready(function () {
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const result = await response.json(); let result = null;
if (result.success) { try {
result = await response.json();
} catch (e) {
result = null;
}
if (response.ok && result && result.success) {
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-check-circle me-1"></i>${escapeHtml(result.message || gettext('Asset créé.'))}`, 'success'); setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-check-circle me-1"></i>${escapeHtml(result.message || gettext('Asset créé.'))}`, 'success');
setTimeout(() => { setTimeout(() => {
bootstrap.Modal.getInstance(document.getElementById('unifiedAddAssetModal'))?.hide(); bootstrap.Modal.getInstance(document.getElementById('unifiedAddAssetModal'))?.hide();
@ -985,11 +991,13 @@ $(document).ready(function () {
else window.location.reload(); else window.location.reload();
}, 800); }, 800);
} else { } else {
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(result.error || gettext('Erreur lors de la création.'))}`, 'danger'); const errorMsg = (result && (result.error || result.message))
|| (response.status ? `${gettext('Erreur serveur')} (${response.status})` : gettext('Erreur lors de la création.'));
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(errorMsg)}`, 'danger');
} }
} catch (error) { } catch (error) {
console.error('Error creating asset:', error); console.error('Error creating asset:', error);
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(gettext('Erreur de connexion.'))}`, 'danger'); setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(error.message || gettext('Erreur de connexion.'))}`, 'danger');
} finally { } finally {
if (submitBtn) { if (submitBtn) {
submitBtn.disabled = false; submitBtn.disabled = false;

View file

@ -432,8 +432,8 @@ class StructuresLocationDetailAddAssetModalTest(TestCase):
self.assertContains(response, "Balisage Test") self.assertContains(response, "Balisage Test")
self.assertContains(response, f'name="location_id" value="{self.location.id}"') self.assertContains(response, f'name="location_id" value="{self.location.id}"')
self.assertIn("suggested_code", response.context) self.assertIn("suggested_code", response.context)
self.assertEqual(response.context["suggested_code"], "OA_TEST_01-000001") self.assertEqual(response.context["suggested_code"], "LOC_TEST_01-000001")
self.assertContains(response, 'value="OA_TEST_01-000001"') self.assertContains(response, 'value="LOC_TEST_01-000001"')
def test_incremental_code_generation_with_existing_assets(self): def test_incremental_code_generation_with_existing_assets(self):
from assets.models.structures import StructureGeoAsset, StructureAssetModel from assets.models.structures import StructureGeoAsset, StructureAssetModel
@ -441,14 +441,14 @@ class StructuresLocationDetailAddAssetModalTest(TestCase):
model = StructureAssetModel.objects.create(code="MOD_TEST", name_fr="Modèle Test", category=self.category) model = StructureAssetModel.objects.create(code="MOD_TEST", name_fr="Modèle Test", category=self.category)
StructureGeoAsset.objects.create( StructureGeoAsset.objects.create(
code="WTB30-000023", code="LOC_TEST_01-000023",
name_fr="Capteur 23", name_fr="Capteur 23",
structure=self.structure, structure=self.structure,
location=self.location, location=self.location,
model=model model=model
) )
StructureGeoAsset.objects.create( StructureGeoAsset.objects.create(
code="WTB30-000024", code="LOC_TEST_01-000024",
name_fr="Capteur 24", name_fr="Capteur 24",
structure=self.structure, structure=self.structure,
location=self.location, location=self.location,
@ -456,10 +456,10 @@ class StructuresLocationDetailAddAssetModalTest(TestCase):
) )
next_code = get_next_structure_asset_code(location=self.location) next_code = get_next_structure_asset_code(location=self.location)
self.assertEqual(next_code, "WTB30-000025") self.assertEqual(next_code, "LOC_TEST_01-000025")
suggested = get_suggested_asset_code("structures", location_id=self.location.id) suggested = get_suggested_asset_code("structures", location_id=self.location.id)
self.assertEqual(suggested, "WTB30-000025") self.assertEqual(suggested, "LOC_TEST_01-000025")
# Test API endpoint # Test API endpoint
self.client.force_login(self.user) self.client.force_login(self.user)
@ -470,13 +470,13 @@ class StructuresLocationDetailAddAssetModalTest(TestCase):
self.assertEqual(api_res.status_code, 200) self.assertEqual(api_res.status_code, 200)
data = api_res.json() data = api_res.json()
self.assertTrue(data.get("success")) self.assertTrue(data.get("success"))
self.assertEqual(data.get("suggested_code"), "WTB30-000025") self.assertEqual(data.get("suggested_code"), "LOC_TEST_01-000025")
# Test page rendering has updated suggested code # Test page rendering has updated suggested code
page_res = self.client.get(reverse("assets:structures_locations_detail", args=[self.location.id])) page_res = self.client.get(reverse("assets:structures_locations_detail", args=[self.location.id]))
self.assertEqual(page_res.status_code, 200) self.assertEqual(page_res.status_code, 200)
self.assertEqual(page_res.context["suggested_code"], "WTB30-000025") self.assertEqual(page_res.context["suggested_code"], "LOC_TEST_01-000025")
self.assertContains(page_res, 'value="WTB30-000025"') self.assertContains(page_res, 'value="LOC_TEST_01-000025"')
# Test intervention code suggestion (mobile / desktop intervention) # Test intervention code suggestion (mobile / desktop intervention)
from interventions.models import Intervention, InterventionLocation from interventions.models import Intervention, InterventionLocation
@ -495,14 +495,120 @@ class StructuresLocationDetailAddAssetModalTest(TestCase):
) )
itv_suggested = get_suggested_asset_code("structures", intervention_id=itv.id) itv_suggested = get_suggested_asset_code("structures", intervention_id=itv.id)
self.assertEqual(itv_suggested, "WTB30-000025") self.assertEqual(itv_suggested, "LOC_TEST_01-000025")
itv_api_res = self.client.get(reverse("assets:get_suggested_asset_code_api"), { itv_api_res = self.client.get(reverse("assets:get_suggested_asset_code_api"), {
"thematic_code": "structures", "thematic_code": "structures",
"intervention_id": itv.id "intervention_id": itv.id
}) })
self.assertEqual(itv_api_res.status_code, 200) self.assertEqual(itv_api_res.status_code, 200)
self.assertEqual(itv_api_res.json().get("suggested_code"), "WTB30-000025") self.assertEqual(itv_api_res.json().get("suggested_code"), "LOC_TEST_01-000025")
def test_code_suggestion_specific_location_avoids_other_sublocation_dominant_prefix(self):
"""
Verify that when generating a code for location WTB23B, it generates WTB23B-000001 (or next WTB23B)
even if another location WTB23J under the same structure has many existing assets.
"""
from assets.models.structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel
from assets.utils.asset_code_generator import get_next_structure_asset_code, get_suggested_asset_code
from interventions.models import Intervention, InterventionLocation
from django.contrib.contenttypes.models import ContentType
struct_belliard = Structure.objects.create(code="WTB23", name_fr="Tunnel Belliard")
loc_b = StructureLocation.objects.create(code="WTB23B", name_fr="Belliard B", structure=struct_belliard)
loc_j = StructureLocation.objects.create(code="WTB23J", name_fr="Belliard J", structure=struct_belliard)
model = StructureAssetModel.objects.create(code="MOD_BELL", name_fr="Modèle Belliard", category=self.category)
# Create 10 assets for WTB23J
for i in range(1, 11):
StructureGeoAsset.objects.create(
code=f"WTB23J-{i:06d}",
name_fr=f"Asset J {i}",
structure=struct_belliard,
location=loc_j,
model=model
)
# For location WTB23B with 0 existing assets, it should suggest WTB23B-000001, NOT WTB23J-000011!
suggested_b = get_next_structure_asset_code(location=loc_b)
self.assertEqual(suggested_b, "WTB23B-000001")
# For an intervention linked to WTB23B
itv = Intervention.objects.create(code="ITV_WTB23B", title="Cyclique Belliard", thematic=self.thematic)
loc_ct = ContentType.objects.get_for_model(loc_b)
InterventionLocation.objects.create(intervention=itv, content_type=loc_ct, object_id=loc_b.id)
itv_code = get_suggested_asset_code("structures", intervention_id=itv.id)
self.assertEqual(itv_code, "WTB23B-000001")
# If WTB23B already has an asset WTB23B-000001, it should suggest WTB23B-000002
StructureGeoAsset.objects.create(
code="WTB23B-000001",
name_fr="Asset B 1",
structure=struct_belliard,
location=loc_b,
model=model
)
itv_code_2 = get_suggested_asset_code("structures", intervention_id=itv.id)
self.assertEqual(itv_code_2, "WTB23B-000002")
def test_create_asset_api_success_with_model_and_intervention(self):
"""
Verify create_asset_api creates a StructureGeoAsset with model, resolves location and links to intervention.
"""
from assets.models.structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel
from interventions.models import Intervention, InterventionLocation, InterventionAsset
from django.contrib.contenttypes.models import ContentType
struct = Structure.objects.create(code="WTB23_TEST", name_fr="Tunnel Belliard Test")
loc = StructureLocation.objects.create(code="WTB23B_TEST", name_fr="Belliard B Test", structure=struct)
model = StructureAssetModel.objects.create(code="MOD_API_TEST", name_fr="Modèle API", category=self.category)
# Allow model for category
ct = ContentType.objects.get_for_model(StructureGeoAsset)
self.category.allowed_models.add(ct)
itv = Intervention.objects.create(
code="ITV_CREATE_ASSET",
title="Intervention Création",
thematic=self.thematic,
maintain_type="preventive"
)
loc_ct = ContentType.objects.get_for_model(loc)
InterventionLocation.objects.create(intervention=itv, content_type=loc_ct, object_id=loc.id)
self.client.force_login(self.user)
payload = {
"thematic_code": "structures",
"category_id": self.category.id,
"model_id": model.id,
"code": "WTB23B_TEST-000001",
"name_fr": "Nouvel Équipement Test",
"intervention_id": itv.id
}
res = self.client.post(
reverse("assets:create_asset_api"),
data=json.dumps(payload),
content_type="application/json"
)
self.assertEqual(res.status_code, 200, f"Error: {res.content}")
data = res.json()
self.assertTrue(data.get("success"))
self.assertTrue(data.get("intervention_linked"))
self.assertTrue(data.get("needs_template_selection"))
# Verify asset created in DB
created_asset = StructureGeoAsset.objects.get(code="WTB23B_TEST-000001")
self.assertEqual(created_asset.model, model)
self.assertEqual(created_asset.category, self.category)
self.assertEqual(created_asset.location, loc)
self.assertEqual(created_asset.structure, struct)
self.assertEqual(created_asset.name_fr, "Nouvel Équipement Test")
# Verify linked to intervention
self.assertTrue(InterventionAsset.objects.filter(intervention=itv, object_id=created_asset.id).exists())

View file

@ -19,6 +19,8 @@ def get_next_structure_asset_code(
""" """
from assets.models.structures import Structure, StructureLocation, StructureGeoAsset from assets.models.structures import Structure, StructureLocation, StructureGeoAsset
from django.db.models import Q
if intervention_id and not intervention: if intervention_id and not intervention:
try: try:
from interventions.models import Intervention from interventions.models import Intervention
@ -30,7 +32,16 @@ def get_next_structure_asset_code(
# 1. Look for linked structure locations # 1. Look for linked structure locations
for loc_link in intervention.locations.all(): for loc_link in intervention.locations.all():
loc_obj = loc_link.location loc_obj = loc_link.location
if isinstance(loc_obj, StructureLocation) or hasattr(loc_obj, 'structure'): if isinstance(loc_obj, StructureLocation):
location = loc_obj
structure = loc_obj.structure
break
elif isinstance(loc_obj, Structure):
structure = loc_obj
if loc_obj.main_location:
location = loc_obj.main_location
break
elif hasattr(loc_obj, 'structure'):
location = loc_obj location = loc_obj
structure = getattr(loc_obj, 'structure', None) structure = getattr(loc_obj, 'structure', None)
break break
@ -39,10 +50,13 @@ def get_next_structure_asset_code(
if not structure and not location: if not structure and not location:
for a_link in intervention.interventionasset_set.all(): for a_link in intervention.interventionasset_set.all():
asset_obj = a_link.asset asset_obj = a_link.asset
if asset_obj and hasattr(asset_obj, 'structure') and asset_obj.structure: if asset_obj:
structure = asset_obj.structure if hasattr(asset_obj, 'location') and asset_obj.location:
location = getattr(asset_obj, 'location', None) location = asset_obj.location
break if hasattr(asset_obj, 'structure') and asset_obj.structure:
structure = asset_obj.structure
if location or structure:
break
# 3. Fallback: match by location_code / address if structure exists # 3. Fallback: match by location_code / address if structure exists
if not structure and not location and intervention.location_code: if not structure and not location and intervention.location_code:
@ -54,6 +68,8 @@ def get_next_structure_asset_code(
struct_match = Structure.objects.filter(code__iexact=intervention.location_code).first() struct_match = Structure.objects.filter(code__iexact=intervention.location_code).first()
if struct_match: if struct_match:
structure = struct_match structure = struct_match
if struct_match.main_location:
location = struct_match.main_location
if location_id and not location: if location_id and not location:
try: try:
@ -73,22 +89,41 @@ def get_next_structure_asset_code(
if not structure and not location: if not structure and not location:
return "" return ""
# Fetch existing asset codes for this structure or location # Target prefix is the location code if location is present, otherwise structure code
if structure: target_prefix_base = location.code if location and location.code else (structure.code if structure and structure.code else "")
codes = list(StructureGeoAsset.objects.filter(structure=structure).values_list('code', flat=True)) if not target_prefix_base:
return ""
# Look for existing asset codes
# Priority: codes matching this specific location / prefix
if location:
codes = list(
StructureGeoAsset.objects.filter(
Q(location=location) | Q(code__istartswith=location.code)
).values_list('code', flat=True)
)
elif structure:
codes = list(
StructureGeoAsset.objects.filter(
Q(structure=structure) | Q(code__istartswith=structure.code)
).values_list('code', flat=True)
)
else: else:
codes = list(StructureGeoAsset.objects.filter(location=location).values_list('code', flat=True)) codes = []
pattern = re.compile(r'^(.*?)(\d+)$') pattern = re.compile(r'^(.*?)(\d+)$')
matches = [] matches = []
for c in codes: for c in codes:
if not c: if not c:
continue continue
m = pattern.match(c.strip()) c_str = c.strip()
m = pattern.match(c_str)
if m: if m:
prefix = m.group(1) prefix = m.group(1)
num_str = m.group(2) num_str = m.group(2)
matches.append((prefix, int(num_str), len(num_str))) # Only consider codes that start with our target prefix (case-insensitive)
if prefix.upper().startswith(target_prefix_base.upper()):
matches.append((prefix, int(num_str), len(num_str)))
if matches: if matches:
prefix_counts = Counter(m[0] for m in matches) prefix_counts = Counter(m[0] for m in matches)
@ -104,12 +139,27 @@ def get_next_structure_asset_code(
suggested_code = f"{dominant_prefix}{str(next_num).zfill(digit_len)}" suggested_code = f"{dominant_prefix}{str(next_num).zfill(digit_len)}"
return suggested_code return suggested_code
else: else:
struct_code = structure.code if structure else (location.code if location else "STR") # Default code formatting: determine digit length and separator from existing structure assets if any
digit_len = 6
separator = "-"
if structure:
struct_codes = list(StructureGeoAsset.objects.filter(structure=structure).values_list('code', flat=True))
struct_matches = [pattern.match(sc.strip()) for sc in struct_codes if sc and pattern.match(sc.strip())]
if struct_matches:
digit_len = max(len(sm.group(2)) for sm in struct_matches)
sample_prefix = struct_matches[0].group(1)
if sample_prefix.endswith('-'):
separator = "-"
elif sample_prefix.endswith('_'):
separator = "_"
elif not sample_prefix[-1].isalnum():
separator = sample_prefix[-1]
next_num = 1 next_num = 1
suggested_code = f"{struct_code}-{str(next_num).zfill(6)}" suggested_code = f"{target_prefix_base}{separator}{str(next_num).zfill(digit_len)}"
while StructureGeoAsset.objects.filter(code=suggested_code).exists(): while StructureGeoAsset.objects.filter(code=suggested_code).exists():
next_num += 1 next_num += 1
suggested_code = f"{struct_code}-{str(next_num).zfill(6)}" suggested_code = f"{target_prefix_base}{separator}{str(next_num).zfill(digit_len)}"
return suggested_code return suggested_code

View file

@ -29,6 +29,15 @@ from interventions.models import Intervention, InterventionAsset
from assets.views.locations import ASSETS_LOCATION_REGISTRY from assets.views.locations import ASSETS_LOCATION_REGISTRY
def _has_model_field(model_or_instance, field_name):
"""Safely check if a model class or instance has a specific field in its _meta."""
try:
model_or_instance._meta.get_field(field_name)
return True
except Exception:
return False
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Asset Document API # Asset Document API
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
@ -640,7 +649,7 @@ def replace_asset(request, asset_model, asset_id):
setattr(new_asset, field_name, value) setattr(new_asset, field_name, value)
# Handle new model if specified # Handle new model if specified
if new_model_id and hasattr(new_asset, 'model'): if new_model_id and _has_model_field(new_asset, 'model'):
try: try:
model_field = model_class._meta.get_field('model') model_field = model_class._meta.get_field('model')
related_model = model_field.related_model related_model = model_field.related_model
@ -1408,13 +1417,22 @@ def _get_asset_classes_for_category(thematic_code, category):
ct = allowed_cts.first() ct = allowed_cts.first()
asset_class = ct.model_class() asset_class = ct.model_class()
if asset_class: if asset_class:
import importlib asset_model_class = None
model_class_name = f"{asset_class.__name__}Model" if hasattr(asset_class, '_meta'):
try: try:
module = importlib.import_module(asset_class.__module__) model_field = asset_class._meta.get_field('model')
asset_model_class = getattr(module, model_class_name, None) if hasattr(model_field, 'related_model'):
except Exception: asset_model_class = model_field.related_model
asset_model_class = None except Exception:
asset_model_class = None
if not asset_model_class:
import importlib
model_class_name = f"{asset_class.__name__}Model"
try:
module = importlib.import_module(asset_class.__module__)
asset_model_class = getattr(module, model_class_name, None)
except Exception:
asset_model_class = None
return asset_class, asset_model_class return asset_class, asset_model_class
category_code = category.code if category and category.code else None category_code = category.code if category and category.code else None
@ -1451,6 +1469,9 @@ def _import_asset_classes(thematic_code, class_names):
'artworks': 'assets.models.artworks', 'artworks': 'assets.models.artworks',
'sign': 'assets.models.sign', 'sign': 'assets.models.sign',
'roads': 'assets.models.roads', 'roads': 'assets.models.roads',
'cleanliness': 'assets.models.cleanliness',
'buildings': 'assets.models.buildings',
'publicbuildings': 'assets.models.buildings',
} }
module_name = thematic_module_map.get(thematic_code) module_name = thematic_module_map.get(thematic_code)
@ -1462,6 +1483,13 @@ def _import_asset_classes(thematic_code, class_names):
module = importlib.import_module(module_name) module = importlib.import_module(module_name)
asset_class = getattr(module, asset_class_name, None) asset_class = getattr(module, asset_class_name, None)
model_class = getattr(module, model_class_name, None) if model_class_name else None model_class = getattr(module, model_class_name, None) if model_class_name else None
if asset_class and not model_class and hasattr(asset_class, '_meta'):
try:
model_field = asset_class._meta.get_field('model')
if hasattr(model_field, 'related_model'):
model_class = model_field.related_model
except Exception:
pass
return asset_class, model_class return asset_class, model_class
except (ImportError, AttributeError): except (ImportError, AttributeError):
return None, None return None, None
@ -1469,8 +1497,6 @@ def _import_asset_classes(thematic_code, class_names):
def _get_asset_model_class_for_thematic(thematic_code): def _get_asset_model_class_for_thematic(thematic_code):
"""Get the appropriate asset model class for a thematic.""" """Get the appropriate asset model class for a thematic."""
from django.apps import apps
# Direct imports for known thematics # Direct imports for known thematics
if thematic_code == 'structures': if thematic_code == 'structures':
from assets.models.structures import StructureGeoAsset, StructureAssetModel from assets.models.structures import StructureGeoAsset, StructureAssetModel
@ -1490,23 +1516,57 @@ def _get_asset_model_class_for_thematic(thematic_code):
elif thematic_code == 'its': elif thematic_code == 'its':
from assets.models.its import ITSGeoAsset, ITSAssetModel from assets.models.its import ITSGeoAsset, ITSAssetModel
return ITSGeoAsset, ITSAssetModel return ITSGeoAsset, ITSAssetModel
elif thematic_code == 'artworks':
from assets.models.artworks import ArtworkFountain
return ArtworkFountain, None
elif thematic_code == 'sign':
from assets.models.sign import SignPole, SignPoleModel
return SignPole, SignPoleModel
elif thematic_code == 'roads':
from assets.models.roads import Roadway, RoadwayModel
return Roadway, RoadwayModel
elif thematic_code in ('publicbuildings', 'buildings'):
from assets.models.buildings import BuildingAsset
return BuildingAsset, None
elif thematic_code == 'cleanliness':
from assets.models.cleanliness import CleanLitterBin, CleanLitterBinModel
return CleanLitterBin, CleanLitterBinModel
return None, None return None, None
def _get_asset_model_instance(thematic_code, model_id, category=None): def _get_asset_model_instance(thematic_code, model_id, category=None):
"""Get the asset model instance for a given thematic, model ID, and optionally category.""" """Get the asset model instance for a given thematic, model ID, and optionally category."""
if not model_id:
return None
# Try category-specific lookup first # Try category-specific lookup first
asset_model_class = None
if category: if category:
_, asset_model_class = _get_asset_classes_for_category(thematic_code, category) _, asset_model_class = _get_asset_classes_for_category(thematic_code, category)
else: if not asset_model_class:
_, asset_model_class = _get_asset_model_class_for_thematic(thematic_code) _, asset_model_class = _get_asset_model_class_for_thematic(thematic_code)
if asset_model_class and model_id: if asset_model_class and model_id:
try: try:
return asset_model_class.objects.get(pk=model_id) return asset_model_class.objects.get(pk=model_id)
except asset_model_class.DoesNotExist: except asset_model_class.DoesNotExist:
return None pass
# Universal fallback: search across all AbstractAssetModel subclasses
from django.apps import apps
from assets.models.core import AbstractAssetModel
for model in apps.get_models():
if model._meta.abstract:
continue
try:
if issubclass(model, AbstractAssetModel):
instance = model.objects.filter(pk=model_id).first()
if instance:
return instance
except (TypeError, Exception):
continue
return None return None
@ -1590,7 +1650,22 @@ def create_asset_api(request):
}, status=404) }, status=404)
allowed_actions = get_allowed_actions_for_thematic(request.user, thematic) allowed_actions = get_allowed_actions_for_thematic(request.user, thematic)
if 'create' not in allowed_actions: can_create = 'create' in allowed_actions
# If creating from an intervention, check if user has permission to edit assets on this intervention
intervention = None
if intervention_id:
try:
intervention = Intervention.objects.get(pk=intervention_id)
from interventions.permissions import get_allowed_update_fields, can_edit_intervention
if can_edit_intervention(request.user, intervention):
intv_allowed_fields = get_allowed_update_fields(request.user, intervention)
if 'assets' in intv_allowed_fields:
can_create = True
except Intervention.DoesNotExist:
intervention = None
if not can_create:
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
'error': _("Vous n'avez pas la permission de créer des équipements") 'error': _("Vous n'avez pas la permission de créer des équipements")
@ -1639,23 +1714,55 @@ def create_asset_api(request):
'error': _("Modèle requis") 'error': _("Modèle requis")
}, status=400) }, status=400)
# If creating from an intervention without explicit location, get the first location from intervention # If creating from an intervention without explicit location, resolve location/structure from intervention
intervention = None if intervention and not location_id and not structure_id:
if intervention_id: for loc_link in intervention.locations.all():
try: loc_obj = loc_link.location
intervention = Intervention.objects.get(pk=intervention_id) if loc_obj:
if thematic_code == 'structures':
from assets.models.structures import StructureLocation, Structure
if isinstance(loc_obj, StructureLocation):
location_id = loc_obj.id
structure_id = getattr(loc_obj, 'structure_id', None)
break
elif isinstance(loc_obj, Structure):
structure_id = loc_obj.id
if loc_obj.main_location:
location_id = loc_obj.main_location.id
break
elif hasattr(loc_obj, 'structure'):
location_id = loc_obj.id
structure_id = getattr(loc_obj, 'structure_id', None)
break
elif thematic_code == 'trafficlights':
from assets.models.trafficlights import TrafficLightIntersection
if isinstance(loc_obj, TrafficLightIntersection) or hasattr(loc_obj, 'pk'):
location_id = loc_obj.id
break
else:
if hasattr(loc_obj, 'pk'):
location_id = loc_obj.id
break
# If no location_id specified, try to get first location from intervention # Fallback: match by location_code on intervention
if not location_id and not structure_id: if not location_id and not structure_id and intervention.location_code:
from interventions.models import InterventionLocation if thematic_code == 'structures':
first_location_link = intervention.locations.first() from assets.models.structures import StructureLocation, Structure
loc_match = StructureLocation.objects.filter(code__iexact=intervention.location_code).first()
if first_location_link: if loc_match:
# Set location_id or structure_id based on what we find location_id = loc_match.id
location_id = first_location_link.object_id structure_id = getattr(loc_match, 'structure_id', None)
# We'll use the location's content type to determine what to set else:
except Intervention.DoesNotExist: struct_match = Structure.objects.filter(code__iexact=intervention.location_code).first()
intervention = None if struct_match:
structure_id = struct_match.id
if struct_match.main_location:
location_id = struct_match.main_location.id
elif thematic_code == 'trafficlights':
from assets.models.trafficlights import TrafficLightIntersection
inter_match = TrafficLightIntersection.objects.filter(code__iexact=intervention.location_code).first()
if inter_match:
location_id = inter_match.id
with transaction.atomic(): with transaction.atomic():
# Create the new asset # Create the new asset
@ -1663,16 +1770,16 @@ def create_asset_api(request):
new_asset.code = code new_asset.code = code
# Only set name fields if the model has them # Only set name fields if the model has them
if hasattr(new_asset, 'name_fr'): if _has_model_field(new_asset, 'name_fr'):
new_asset.name_fr = name_fr if name_fr else None new_asset.name_fr = name_fr if name_fr else None
if hasattr(new_asset, 'name_nl'): if _has_model_field(new_asset, 'name_nl'):
new_asset.name_nl = name_nl if name_nl else None new_asset.name_nl = name_nl if name_nl else None
# Only set category if the model has it # Only set category if the model has it
if hasattr(new_asset, 'category'): if _has_model_field(new_asset, 'category'):
new_asset.category = category new_asset.category = category
if hasattr(new_asset, 'model'): if _has_model_field(new_asset, 'model'):
new_asset.model = asset_model_instance new_asset.model = asset_model_instance
new_asset.status = 'active' new_asset.status = 'active'
new_asset.validation_status = 'to_be_validated' new_asset.validation_status = 'to_be_validated'
@ -1691,7 +1798,7 @@ def create_asset_api(request):
pass pass
# Handle structure (for structures) # Handle structure (for structures)
if structure_id: if structure_id and not getattr(new_asset, 'structure', None):
from assets.models.structures import Structure from assets.models.structures import Structure
try: try:
structure = Structure.objects.get(pk=structure_id) structure = Structure.objects.get(pk=structure_id)
@ -1700,7 +1807,7 @@ def create_asset_api(request):
pass pass
# Handle intersection (for trafficlights) # Handle intersection (for trafficlights)
if thematic_code == 'trafficlights': elif thematic_code == 'trafficlights':
intersection_id = data.get('intersection_id') or location_id intersection_id = data.get('intersection_id') or location_id
if not intersection_id: if not intersection_id:
@ -1725,20 +1832,33 @@ def create_asset_api(request):
from assets.models.trafficlights import TrafficLightPole from assets.models.trafficlights import TrafficLightPole
try: try:
pole = TrafficLightPole.objects.get(pk=pole_id) pole = TrafficLightPole.objects.get(pk=pole_id)
if hasattr(new_asset, 'pole'): if _has_model_field(new_asset, 'pole'):
new_asset.pole = pole new_asset.pole = pole
except TrafficLightPole.DoesNotExist: except TrafficLightPole.DoesNotExist:
pass pass
# Handle generic location for other thematics
elif location_id:
for field in asset_class._meta.get_fields():
if hasattr(field, 'related_model') and field.related_model and not field.auto_created:
try:
loc_instance = field.related_model.objects.get(pk=location_id)
setattr(new_asset, field.name, loc_instance)
break
except Exception:
continue
# Set additional data # Set additional data
extra_data = data.get('data', {}) extra_data = data.get('data', {})
if isinstance(extra_data, dict): if isinstance(extra_data, dict):
for field_name, value in extra_data.items(): for field_name, value in extra_data.items():
if hasattr(new_asset, field_name) and field_name not in ('id', 'code'): if _has_model_field(new_asset, field_name) and field_name not in ('id', 'code'):
setattr(new_asset, field_name, value) setattr(new_asset, field_name, value)
# Assign position from location / hierarchy # Assign position from location / hierarchy
location = getattr(new_asset, 'location', None) or getattr(new_asset, 'intersection', None) location = getattr(new_asset, 'location', None) or getattr(new_asset, 'intersection', None)
if not location and getattr(new_asset, 'structure', None):
location = getattr(new_asset.structure, 'main_location', None)
_assign_default_position(new_asset, location, thematic_code) _assign_default_position(new_asset, location, thematic_code)
new_asset.save() new_asset.save()