feat: implement asset editing module with modal support and automated asset code generation

This commit is contained in:
Karl Determe 2026-08-19 06:56:15 +02:00
parent 360db5aaad
commit 1bdb771f3b
8 changed files with 259 additions and 3 deletions

View file

@ -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) { function initializeForm(form, modalId) {
const thematicCode = form.dataset.thematic; const thematicCode = form.dataset.thematic;
const categorySelect = form.querySelector('.asset-category-select'); const categorySelect = form.querySelector('.asset-category-select');
@ -41,6 +78,25 @@
const modelRequiredStar = form.querySelector(`#${modalId}ModelRequiredStar`); const modelRequiredStar = form.querySelector(`#${modalId}ModelRequiredStar`);
const alertContainer = form.querySelector(`#${modalId}Alert`); const alertContainer = form.querySelector(`#${modalId}Alert`);
const submitBtn = form.querySelector(`#${modalId}Submit`); 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 // Fetch and cache categories for this thematic
if (thematicCode) { if (thematicCode) {
@ -190,9 +246,15 @@
submitNewAsset(form, modalId, alertContainer, submitBtn); submitNewAsset(form, modalId, alertContainer, submitBtn);
}); });
// Reset form when modal is hidden // Modal events
const modal = document.getElementById(modalId); const modal = document.getElementById(modalId);
if (modal) { 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() { modal.addEventListener('hidden.bs.modal', function() {
resetForm(form, modelSelect, alertContainer); resetForm(form, modelSelect, alertContainer);
}); });
@ -615,6 +677,14 @@
function resetForm(form, modelSelect, alertContainer) { function resetForm(form, modelSelect, alertContainer) {
form.reset(); 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 modalId = form.closest('.modal').id;
const typeContainer = form.querySelector(`#${modalId}TypeContainer`); const typeContainer = form.querySelector(`#${modalId}TypeContainer`);
const typeSelect = form.querySelector('.asset-type-select'); const typeSelect = form.querySelector('.asset-type-select');

View file

@ -75,7 +75,8 @@
<label for="{{ modal_id|default:'addAssetModal' }}Code" class="form-label"> <label for="{{ modal_id|default:'addAssetModal' }}Code" class="form-label">
<i class="bi bi-hash me-1"></i>{% translate "Code" %} <span class="text-danger">*</span> <i class="bi bi-hash me-1"></i>{% translate "Code" %} <span class="text-danger">*</span>
</label> </label>
<input type="text" class="form-control" id="{{ modal_id|default:'addAssetModal' }}Code" name="code" required <input type="text" class="form-control asset-code-input" id="{{ modal_id|default:'addAssetModal' }}Code" name="code" required
value="{{ suggested_code|default:'' }}" data-default-code="{{ suggested_code|default:'' }}"
placeholder="{% translate 'Ex: STR-001' %}" maxlength="50"> placeholder="{% translate 'Ex: STR-001' %}" maxlength="50">
<div class="form-text text-muted"> <div class="form-text text-muted">
{% translate "Code unique pour identifier l'équipement." %} {% translate "Code unique pour identifier l'équipement." %}

View file

@ -382,7 +382,7 @@
<!-- Add Asset Modal --> <!-- Add Asset Modal -->
{% if can_create_assets %} {% 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 %} {% endif %}
{% endblock content%} {% endblock content%}

View file

@ -430,6 +430,53 @@ class StructuresLocationDetailAddAssetModalTest(TestCase):
self.assertTrue(any(c.id == self.category.id for c in categories_in_context)) self.assertTrue(any(c.id == self.category.id for c in categories_in_context))
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.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"')

View file

@ -70,6 +70,7 @@ urlpatterns = [
path('<str:thematic_code>/category/<int:category_id>/models/', asset_editing.get_asset_models_for_category, name='get_asset_models'), path('<str:thematic_code>/category/<int:category_id>/models/', asset_editing.get_asset_models_for_category, name='get_asset_models'),
path('<str:thematic_code>/asset/<int:asset_id>/available-locations/', asset_editing.get_available_locations_for_asset, name='get_available_locations'), path('<str:thematic_code>/asset/<int:asset_id>/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/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('api/bulk-edit/', asset_editing.bulk_edit_assets, name='bulk_edit_assets'),
path('locations/autocomplete/', locations.locations_autocomplete, name='locations_autocomplete'), path('locations/autocomplete/', locations.locations_autocomplete, name='locations_autocomplete'),
# path('<str:thematic>/<str:asset_model>/<int:asset_id>/add-document/', core.add_document, name='assets_add_document'), # path('<str:thematic>/<str:asset_model>/<int:asset_id>/add-document/', core.add_document, name='assets_add_document'),

View file

@ -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 ""

View file

@ -1278,6 +1278,44 @@ def get_asset_categories_for_thematic(request, thematic_code):
return JsonResponse({'categories': categories_data}) 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 # Mapping of thematic codes to default asset model classes
# For thematics with multiple asset types, we use category code to determine the right class # For thematics with multiple asset types, we use category code to determine the right class
THEMATIC_ASSET_MAPPING = { THEMATIC_ASSET_MAPPING = {

View file

@ -625,11 +625,15 @@ def structures_locations_detail(request, location_id):
categories = list(AssetCategory.objects.filter(thematic__code='structures')) categories = list(AssetCategory.objects.filter(thematic__code='structures'))
categories.sort(key=lambda m: m.get_name().lower()) 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", { return render(request, "assets/structures_location_detail.html", {
"location": location, "location": location,
"interventions": interventions, # toutes les interventions de l'arbre "interventions": interventions, # toutes les interventions de l'arbre
"assets": assets, "assets": assets,
"categories": categories, "categories": categories,
"suggested_code": suggested_code,
"main_documents": main_documents, "main_documents": main_documents,
"other_documents": other_docs, "other_documents": other_docs,
"add_document_form": StructureDocumentForm(), "add_document_form": StructureDocumentForm(),