From 1bdb771f3bf8208b63b56875812c9a97060b4219 Mon Sep 17 00:00:00 2001 From: Karl Determe Date: Wed, 19 Aug 2026 06:56:15 +0200 Subject: [PATCH] feat: implement asset editing module with modal support and automated asset code generation --- loko/assets/static/assets/asset_add_modal.js | 72 +++++++++++++- .../assets/components/add_asset_modal.html | 3 +- .../assets/structures_location_detail.html | 2 +- loko/assets/tests.py | 47 +++++++++ loko/assets/urls.py | 1 + loko/assets/utils/asset_code_generator.py | 95 +++++++++++++++++++ loko/assets/views/asset_editing.py | 38 ++++++++ loko/assets/views/structures.py | 4 + 8 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 loko/assets/utils/asset_code_generator.py 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(),