diff --git a/loko/assets/static/assets/asset_add_modal.js b/loko/assets/static/assets/asset_add_modal.js
index 3be3477..df3e02a 100644
--- a/loko/assets/static/assets/asset_add_modal.js
+++ b/loko/assets/static/assets/asset_add_modal.js
@@ -32,6 +32,43 @@
});
}
+ function fetchAndSetSuggestedCode(form, force = false) {
+ const codeInput = form.querySelector('.asset-code-input') || form.querySelector('[name="code"]');
+ if (!codeInput) return;
+
+ const defaultCode = codeInput.dataset.defaultCode || '';
+ const currentValue = codeInput.value.trim();
+ if (currentValue && currentValue !== defaultCode && !force) {
+ return;
+ }
+
+ const thematicCode = form.dataset.thematic || form.querySelector('[name="thematic_code"]')?.value || '';
+ const locationId = form.querySelector('[name="location_id"]')?.value || '';
+ const structureId = form.querySelector('[name="structure_id"]')?.value || '';
+
+ if (!thematicCode) return;
+
+ const params = new URLSearchParams({
+ thematic_code: thematicCode,
+ location_id: locationId,
+ structure_id: structureId
+ });
+
+ fetch(`/assets/api/suggested-code/?${params.toString()}`)
+ .then(response => response.json())
+ .then(data => {
+ if (data.success && data.suggested_code) {
+ if (!codeInput.value.trim() || codeInput.value.trim() === codeInput.dataset.defaultCode || force) {
+ codeInput.value = data.suggested_code;
+ codeInput.dataset.defaultCode = data.suggested_code;
+ }
+ }
+ })
+ .catch(error => {
+ console.error('Error fetching suggested code:', error);
+ });
+ }
+
function initializeForm(form, modalId) {
const thematicCode = form.dataset.thematic;
const categorySelect = form.querySelector('.asset-category-select');
@@ -41,6 +78,25 @@
const modelRequiredStar = form.querySelector(`#${modalId}ModelRequiredStar`);
const alertContainer = form.querySelector(`#${modalId}Alert`);
const submitBtn = form.querySelector(`#${modalId}Submit`);
+ const locationSelect = form.querySelector('select[name="location_id"]');
+ const structureSelect = form.querySelector('select[name="structure_id"]');
+ const codeInput = form.querySelector('.asset-code-input') || form.querySelector('[name="code"]');
+
+ // Suggest code if empty
+ if (codeInput && !codeInput.value.trim()) {
+ fetchAndSetSuggestedCode(form);
+ }
+
+ if (locationSelect) {
+ locationSelect.addEventListener('change', function() {
+ fetchAndSetSuggestedCode(form);
+ });
+ }
+ if (structureSelect) {
+ structureSelect.addEventListener('change', function() {
+ fetchAndSetSuggestedCode(form);
+ });
+ }
// Fetch and cache categories for this thematic
if (thematicCode) {
@@ -190,9 +246,15 @@
submitNewAsset(form, modalId, alertContainer, submitBtn);
});
- // Reset form when modal is hidden
+ // Modal events
const modal = document.getElementById(modalId);
if (modal) {
+ modal.addEventListener('show.bs.modal', function() {
+ const codeInput = form.querySelector('.asset-code-input') || form.querySelector('[name="code"]');
+ if (codeInput && !codeInput.value.trim()) {
+ fetchAndSetSuggestedCode(form);
+ }
+ });
modal.addEventListener('hidden.bs.modal', function() {
resetForm(form, modelSelect, alertContainer);
});
@@ -615,6 +677,14 @@
function resetForm(form, modelSelect, alertContainer) {
form.reset();
+ const codeInput = form.querySelector('.asset-code-input') || form.querySelector('[name="code"]');
+ if (codeInput) {
+ codeInput.value = codeInput.dataset.defaultCode || '';
+ if (!codeInput.value.trim()) {
+ fetchAndSetSuggestedCode(form);
+ }
+ }
+
const modalId = form.closest('.modal').id;
const typeContainer = form.querySelector(`#${modalId}TypeContainer`);
const typeSelect = form.querySelector('.asset-type-select');
diff --git a/loko/assets/templates/assets/components/add_asset_modal.html b/loko/assets/templates/assets/components/add_asset_modal.html
index b40b4a9..8513244 100644
--- a/loko/assets/templates/assets/components/add_asset_modal.html
+++ b/loko/assets/templates/assets/components/add_asset_modal.html
@@ -75,7 +75,8 @@
-
{% translate "Code unique pour identifier l'équipement." %}
diff --git a/loko/assets/templates/assets/structures_location_detail.html b/loko/assets/templates/assets/structures_location_detail.html
index ecb7e53..15f9f3f 100644
--- a/loko/assets/templates/assets/structures_location_detail.html
+++ b/loko/assets/templates/assets/structures_location_detail.html
@@ -382,7 +382,7 @@
{% if can_create_assets %}
-{% include "assets/components/add_asset_modal.html" with modal_id="addAssetLocationModal" thematic_code="structures" location=location categories=categories %}
+{% include "assets/components/add_asset_modal.html" with modal_id="addAssetLocationModal" thematic_code="structures" location=location categories=categories suggested_code=suggested_code %}
{% endif %}
{% endblock content%}
diff --git a/loko/assets/tests.py b/loko/assets/tests.py
index 853b0f9..0d86bfa 100644
--- a/loko/assets/tests.py
+++ b/loko/assets/tests.py
@@ -430,6 +430,53 @@ class StructuresLocationDetailAddAssetModalTest(TestCase):
self.assertTrue(any(c.id == self.category.id for c in categories_in_context))
self.assertContains(response, "Balisage Test")
self.assertContains(response, f'name="location_id" value="{self.location.id}"')
+ self.assertIn("suggested_code", response.context)
+ self.assertEqual(response.context["suggested_code"], "OA_TEST_01-000001")
+ self.assertContains(response, 'value="OA_TEST_01-000001"')
+
+ def test_incremental_code_generation_with_existing_assets(self):
+ from assets.models.structures import StructureGeoAsset, StructureAssetModel
+ from assets.utils.asset_code_generator import get_next_structure_asset_code, get_suggested_asset_code
+
+ model = StructureAssetModel.objects.create(code="MOD_TEST", name_fr="Modèle Test", category=self.category)
+ StructureGeoAsset.objects.create(
+ code="WTB30-000023",
+ name_fr="Capteur 23",
+ structure=self.structure,
+ location=self.location,
+ model=model
+ )
+ StructureGeoAsset.objects.create(
+ code="WTB30-000024",
+ name_fr="Capteur 24",
+ structure=self.structure,
+ location=self.location,
+ model=model
+ )
+
+ next_code = get_next_structure_asset_code(location=self.location)
+ self.assertEqual(next_code, "WTB30-000025")
+
+ suggested = get_suggested_asset_code("structures", location_id=self.location.id)
+ self.assertEqual(suggested, "WTB30-000025")
+
+ # Test API endpoint
+ self.client.force_login(self.user)
+ api_res = self.client.get(reverse("assets:get_suggested_asset_code_api"), {
+ "thematic_code": "structures",
+ "location_id": self.location.id
+ })
+ self.assertEqual(api_res.status_code, 200)
+ data = api_res.json()
+ self.assertTrue(data.get("success"))
+ self.assertEqual(data.get("suggested_code"), "WTB30-000025")
+
+ # Test page rendering has updated suggested code
+ 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.context["suggested_code"], "WTB30-000025")
+ self.assertContains(page_res, 'value="WTB30-000025"')
+
diff --git a/loko/assets/urls.py b/loko/assets/urls.py
index 7f6a057..c783aa8 100644
--- a/loko/assets/urls.py
+++ b/loko/assets/urls.py
@@ -70,6 +70,7 @@ urlpatterns = [
path('/category//models/', asset_editing.get_asset_models_for_category, name='get_asset_models'),
path('/asset//available-locations/', asset_editing.get_available_locations_for_asset, name='get_available_locations'),
path('api/create-asset/', asset_editing.create_asset_api, name='create_asset_api'),
+ path('api/suggested-code/', asset_editing.get_suggested_asset_code_api, name='get_suggested_asset_code_api'),
path('api/bulk-edit/', asset_editing.bulk_edit_assets, name='bulk_edit_assets'),
path('locations/autocomplete/', locations.locations_autocomplete, name='locations_autocomplete'),
# path('///add-document/', core.add_document, name='assets_add_document'),
diff --git a/loko/assets/utils/asset_code_generator.py b/loko/assets/utils/asset_code_generator.py
new file mode 100644
index 0000000..0b4f5de
--- /dev/null
+++ b/loko/assets/utils/asset_code_generator.py
@@ -0,0 +1,95 @@
+import re
+from collections import Counter
+from typing import Optional
+
+
+def get_next_structure_asset_code(
+ structure=None,
+ location=None,
+ structure_id: Optional[int] = None,
+ location_id: Optional[int] = None
+) -> str:
+ """
+ Computes the next incremental asset code for a structure / tunnel location.
+ Looks up existing asset codes (e.g. WTB30-000023, WTB30-000412), determines
+ the dominant prefix and maximum integer number, and returns the next code
+ (e.g. WTB30-000413), ensuring it does not collide with existing codes.
+ """
+ from assets.models.structures import Structure, StructureLocation, StructureGeoAsset
+
+ if location_id and not location:
+ try:
+ location = StructureLocation.objects.select_related('structure').get(pk=location_id)
+ except StructureLocation.DoesNotExist:
+ location = None
+
+ if structure_id and not structure:
+ try:
+ structure = Structure.objects.get(pk=structure_id)
+ except Structure.DoesNotExist:
+ structure = None
+
+ if location and not structure:
+ structure = getattr(location, 'structure', None)
+
+ if not structure and not location:
+ return ""
+
+ # Fetch existing asset codes for this structure or location
+ if structure:
+ codes = list(StructureGeoAsset.objects.filter(structure=structure).values_list('code', flat=True))
+ else:
+ codes = list(StructureGeoAsset.objects.filter(location=location).values_list('code', flat=True))
+
+ pattern = re.compile(r'^(.*?)(\d+)$')
+ matches = []
+ for c in codes:
+ if not c:
+ continue
+ m = pattern.match(c.strip())
+ if m:
+ prefix = m.group(1)
+ num_str = m.group(2)
+ matches.append((prefix, int(num_str), len(num_str)))
+
+ if matches:
+ prefix_counts = Counter(m[0] for m in matches)
+ dominant_prefix = prefix_counts.most_common(1)[0][0]
+ prefix_matches = [m for m in matches if m[0] == dominant_prefix]
+ max_num = max(m[1] for m in prefix_matches)
+ digit_len = max(m[2] for m in prefix_matches)
+
+ next_num = max_num + 1
+ suggested_code = f"{dominant_prefix}{str(next_num).zfill(digit_len)}"
+ while StructureGeoAsset.objects.filter(code=suggested_code).exists():
+ next_num += 1
+ suggested_code = f"{dominant_prefix}{str(next_num).zfill(digit_len)}"
+ return suggested_code
+ else:
+ struct_code = structure.code if structure else (location.code if location else "STR")
+ next_num = 1
+ suggested_code = f"{struct_code}-{str(next_num).zfill(6)}"
+ while StructureGeoAsset.objects.filter(code=suggested_code).exists():
+ next_num += 1
+ suggested_code = f"{struct_code}-{str(next_num).zfill(6)}"
+ return suggested_code
+
+
+def get_suggested_asset_code(
+ thematic_code: str,
+ location_id: Optional[int] = None,
+ structure_id: Optional[int] = None,
+ location=None,
+ structure=None
+) -> str:
+ """
+ Returns a suggested asset code based on thematic and context.
+ """
+ if thematic_code == 'structures':
+ return get_next_structure_asset_code(
+ structure=structure,
+ location=location,
+ structure_id=structure_id,
+ location_id=location_id
+ )
+ return ""
diff --git a/loko/assets/views/asset_editing.py b/loko/assets/views/asset_editing.py
index 9833f89..c875dc9 100644
--- a/loko/assets/views/asset_editing.py
+++ b/loko/assets/views/asset_editing.py
@@ -1278,6 +1278,44 @@ def get_asset_categories_for_thematic(request, thematic_code):
return JsonResponse({'categories': categories_data})
+@require_GET
+def get_suggested_asset_code_api(request):
+ """
+ API endpoint to fetch the next suggested asset code.
+ GET params:
+ - thematic_code (str)
+ - location_id (int, optional)
+ - structure_id (int, optional)
+ """
+ from assets.utils.asset_code_generator import get_suggested_asset_code
+
+ thematic_code = request.GET.get('thematic_code') or ''
+ location_id = request.GET.get('location_id')
+ structure_id = request.GET.get('structure_id')
+
+ try:
+ loc_id = int(location_id) if location_id else None
+ except (ValueError, TypeError):
+ loc_id = None
+
+ try:
+ struct_id = int(structure_id) if structure_id else None
+ except (ValueError, TypeError):
+ struct_id = None
+
+ suggested_code = get_suggested_asset_code(
+ thematic_code=thematic_code,
+ location_id=loc_id,
+ structure_id=struct_id
+ )
+
+ return JsonResponse({
+ 'success': True,
+ 'suggested_code': suggested_code
+ })
+
+
+
# Mapping of thematic codes to default asset model classes
# For thematics with multiple asset types, we use category code to determine the right class
THEMATIC_ASSET_MAPPING = {
diff --git a/loko/assets/views/structures.py b/loko/assets/views/structures.py
index a7465c7..de6be35 100644
--- a/loko/assets/views/structures.py
+++ b/loko/assets/views/structures.py
@@ -625,11 +625,15 @@ def structures_locations_detail(request, location_id):
categories = list(AssetCategory.objects.filter(thematic__code='structures'))
categories.sort(key=lambda m: m.get_name().lower())
+ from assets.utils.asset_code_generator import get_suggested_asset_code
+ suggested_code = get_suggested_asset_code('structures', location=location)
+
return render(request, "assets/structures_location_detail.html", {
"location": location,
"interventions": interventions, # toutes les interventions de l'arbre
"assets": assets,
"categories": categories,
+ "suggested_code": suggested_code,
"main_documents": main_documents,
"other_documents": other_docs,
"add_document_form": StructureDocumentForm(),