feat: implement asset editing module with modal support and automated asset code generation
This commit is contained in:
parent
360db5aaad
commit
1bdb771f3b
8 changed files with 259 additions and 3 deletions
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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." %}
|
||||||
|
|
|
||||||
|
|
@ -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%}
|
||||||
|
|
|
||||||
|
|
@ -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"')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
95
loko/assets/utils/asset_code_generator.py
Normal file
95
loko/assets/utils/asset_code_generator.py
Normal 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 ""
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue