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) {
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@
|
|||
<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>
|
||||
</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">
|
||||
<div class="form-text text-muted">
|
||||
{% translate "Code unique pour identifier l'équipement." %}
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@
|
|||
|
||||
<!-- Add Asset Modal -->
|
||||
{% 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%}
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>/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/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('<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})
|
||||
|
||||
|
||||
@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 = {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue