diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index b5f7433..2036e30 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -471,7 +471,7 @@ def get_allowed_update_fields(user, intervention): # Type d'intervention, Osiris, PMO, Assets if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'manager', 'controller', 'external_manager', 'inspector']): - allowed_fields.update(['type', 'osiris', 'pmo_number']) + allowed_fields.update(['type', 'sub_type', 'osiris', 'pmo_number']) if intervention.status_order < STATUS_ORDERS.get('validated'): allowed_fields.add('category') diff --git a/loko/interventions/static/interventions/detail/cms_edit_mode.js b/loko/interventions/static/interventions/detail/cms_edit_mode.js index 5986e27..484f93c 100644 --- a/loko/interventions/static/interventions/detail/cms_edit_mode.js +++ b/loko/interventions/static/interventions/detail/cms_edit_mode.js @@ -10,6 +10,8 @@ let isEditMode = false; let originalValues = {}; let originalContractOptions = null; // Store original contract options for cancel + let originalSubtypeOptions = null; // Store original subtype options for cancel + let originalSubtypeVisible = null; // Store original subtype visibility for cancel let isSavingInProgress = false; let isAutoSavingAndNavigating = false; let isSubmittingAutoSavedForm = false; @@ -29,6 +31,67 @@ // Setup dynamic contract loading when provider changes setupProviderContractLink(); + // Setup dynamic subtype loading/visibility when type changes + setupTypeSubtypeLink(); + + /** + * Setup the dynamic link between type and subtype selects. + * When the type changes, filter the subtype options and show/hide the subtype field. + */ + function setupTypeSubtypeLink() { + const typeField = document.querySelector('.editable-field[data-field="type"]'); + const subtypeField = document.querySelector('.editable-field[data-field="sub_type"]'); + + if (!typeField || !subtypeField) return; + + const typeSelect = typeField.querySelector('.field-edit select'); + const subtypeSelect = subtypeField.querySelector('.field-edit select'); + + if (!typeSelect || !subtypeSelect) return; + + let subtypes = []; + try { + if (subtypeField.dataset.subtypes) { + subtypes = JSON.parse(subtypeField.dataset.subtypes); + } else if (typeof interventionSubtypesData !== 'undefined') { + subtypes = interventionSubtypesData; + } + } catch (e) { + console.error('Error parsing subtypes:', e); + } + + function updateSubtype(selectedType, keepValue) { + const available = subtypes.filter(st => !st.types || st.types.length === 0 || st.types.includes(selectedType)); + const hasSubtypes = available.length > 0; + + if (!hasSubtypes) { + subtypeField.classList.add('d-none'); + subtypeSelect.value = ''; + } else { + subtypeField.classList.remove('d-none'); + const currentVal = subtypeSelect.value; + subtypeSelect.innerHTML = ''; + let valueStillValid = false; + available.forEach(st => { + const opt = document.createElement('option'); + opt.value = String(st.id); + opt.textContent = st.name; + if (keepValue && String(st.id) === currentVal) { + opt.selected = true; + valueStillValid = true; + } + subtypeSelect.appendChild(opt); + }); + if (keepValue && !valueStillValid) { + subtypeSelect.value = ''; + } + } + } + + typeSelect.addEventListener('change', function() { + updateSubtype(this.value, false); + }); + } /** * Setup the dynamic link between provider and contract selects. @@ -130,6 +193,16 @@ } } + // Store original subtype options and visibility + const subtypeField = document.querySelector('.editable-field[data-field="sub_type"]'); + if (subtypeField) { + const subtypeSelect = subtypeField.querySelector('.field-edit select'); + if (subtypeSelect) { + originalSubtypeOptions = subtypeSelect.innerHTML; + } + originalSubtypeVisible = !subtypeField.classList.contains('d-none'); + } + // Store original values originalValues = {}; editableFields.forEach(field => { @@ -196,6 +269,22 @@ } } + // Restore original subtype options and visibility + if (originalSubtypeOptions !== null) { + const subtypeField = document.querySelector('.editable-field[data-field="sub_type"]'); + if (subtypeField) { + const subtypeSelect = subtypeField.querySelector('.field-edit select'); + if (subtypeSelect) { + subtypeSelect.innerHTML = originalSubtypeOptions; + } + if (originalSubtypeVisible) { + subtypeField.classList.remove('d-none'); + } else { + subtypeField.classList.add('d-none'); + } + } + } + // Restore original values editableFields.forEach(field => { const canEdit = field.dataset.canEdit === 'true'; @@ -234,6 +323,8 @@ originalValues = {}; originalContractOptions = null; + originalSubtypeOptions = null; + originalSubtypeVisible = null; } async function saveChanges(options = {}) { diff --git a/loko/interventions/templates/interventions/add/_intervention_add_form.html b/loko/interventions/templates/interventions/add/_intervention_add_form.html index 1f14bfd..4ad053b 100644 --- a/loko/interventions/templates/interventions/add/_intervention_add_form.html +++ b/loko/interventions/templates/interventions/add/_intervention_add_form.html @@ -335,6 +335,11 @@ {{ form.type }} {{ form.type.errors }} +
+ + {{ form.sub_type }} + {{ form.sub_type.errors }} +
{% endif %} diff --git a/loko/interventions/templates/interventions/add/_intervention_add_scripts.html b/loko/interventions/templates/interventions/add/_intervention_add_scripts.html index 7bd67f7..00d711a 100644 --- a/loko/interventions/templates/interventions/add/_intervention_add_scripts.html +++ b/loko/interventions/templates/interventions/add/_intervention_add_scripts.html @@ -14,6 +14,7 @@ const thematic = '{{ thematic.code }}'; const symptomsData = {{ symptoms_data|safe }}; const sourceCategoriesData = {{ source_categories_data|safe }}; + const subtypesData = {{ subtypes_data|default:"[]"|safe }}; const autocomplete_street_url = "{% url 'common:autocomplete_street' %}"; var autocomplete_locations_url = "{% url 'assets:locations_autocomplete' %}?thematic={{ thematic.code }}{% if inspection_config %}&prioritize={% if inspection_config.default_category %}{{ inspection_config.default_category.code|lower }}{% else %}giep{% endif %}{% endif %}"; var regional_roads_url = '{% url "common:get_regional_roads_geojson" %}'; @@ -54,6 +55,50 @@ {% endif %} + + + diff --git a/loko/interventions/templates/interventions/detail/card_info.html b/loko/interventions/templates/interventions/detail/card_info.html index bec750c..85a009e 100644 --- a/loko/interventions/templates/interventions/detail/card_info.html +++ b/loko/interventions/templates/interventions/detail/card_info.html @@ -498,6 +498,31 @@ + + +
+ + {% translate "Sous-type" %}: +
+ {{ intervention.sub_type.get_name|default:"-" }} +
+
+ +
+
diff --git a/loko/interventions/templates/interventions/detail/scripts.html b/loko/interventions/templates/interventions/detail/scripts.html index c397211..9a9422d 100644 --- a/loko/interventions/templates/interventions/detail/scripts.html +++ b/loko/interventions/templates/interventions/detail/scripts.html @@ -23,6 +23,7 @@ const recommendedNextStatus = '{{ recommended_next_status }}'; const interventionId = '{{ intervention.id }}'; const interventionThematicId = {% if intervention.thematic %}{{ intervention.thematic.id }}{% else %}null{% endif %}; + const interventionSubtypesData = {{ subtypes_data_json|default:"[]"|safe }}; const allowed_transitions = {{ allowed_transitions|safe }}; const canViewPrices = {{ can_view_prices|yesno:"true,false" }}; var contractId = {% if contract_id %} {{ contract_id }} {% else %} null {% endif %}; diff --git a/loko/interventions/tests/test_inspections.py b/loko/interventions/tests/test_inspections.py index 37e796d..ed39622 100644 --- a/loko/interventions/tests/test_inspections.py +++ b/loko/interventions/tests/test_inspections.py @@ -645,6 +645,165 @@ class InterventionSubTypeTests(TestCase): display_univ = admin_instance.types_display(self.subtype_universal) self.assertEqual(str(display_univ), "Tous") + def test_update_intervention_field_subtype(self): + from interventions.models import Intervention + from interventions.views.helpers import _update_intervention_field + + intervention = Intervention.objects.create( + title='Test SubType Update', + type='repair', + thematic=self.thematic, + status='in_preparation', + ) + + # 1. Setting compatible subtype succeeds + success, err = _update_intervention_field(intervention, 'sub_type', str(self.subtype_multi.id)) + self.assertTrue(success) + self.assertIsNone(err) + self.assertEqual(intervention.sub_type, self.subtype_multi) + + # 2. Setting universal subtype succeeds + success, err = _update_intervention_field(intervention, 'sub_type', str(self.subtype_universal.id)) + self.assertTrue(success) + self.assertEqual(intervention.sub_type, self.subtype_universal) + + # 3. Setting incompatible subtype fails + success, err = _update_intervention_field(intervention, 'sub_type', str(self.subtype_build_only.id)) + self.assertFalse(success) + self.assertIn("non compatible", err) + # Old value unchanged + self.assertEqual(intervention.sub_type, self.subtype_universal) + + # 4. Setting empty string clears subtype + success, err = _update_intervention_field(intervention, 'sub_type', "") + self.assertTrue(success) + self.assertIsNone(intervention.sub_type) + + def test_type_change_resets_incompatible_subtype(self): + from interventions.models import Intervention + from interventions.views.helpers import _update_intervention_field + + intervention = Intervention.objects.create( + title='Test SubType Incompatible Reset', + type='repair', + sub_type=self.subtype_multi, + thematic=self.thematic, + status='in_preparation', + ) + self.assertEqual(intervention.sub_type, self.subtype_multi) + + # Change type to 'build' (incompatible with subtype_multi ['repair', 'fixing']) + success, err = _update_intervention_field(intervention, 'type', 'build') + self.assertTrue(success) + self.assertEqual(intervention.type, 'build') + self.assertIsNone(intervention.sub_type) + + def test_permissions_includes_sub_type(self): + from interventions.models import Intervention + from interventions.permissions import get_allowed_update_fields + from common.models import UserConfig, Role + + admin_role, _ = Role.objects.get_or_create(name='admin') + user_config, _ = UserConfig.objects.get_or_create(user=self.user) + user_config.roles.add(admin_role) + user_config.is_intern = True + user_config.save() + + intervention = Intervention.objects.create( + title='Test Perms', + type='repair', + thematic=self.thematic, + status='in_preparation', + ) + allowed_fields = get_allowed_update_fields(self.user, intervention) + self.assertIn('type', allowed_fields) + self.assertIn('sub_type', allowed_fields) + + def test_detail_view_subtypes_context(self): + from interventions.models import Intervention + from common.models import UserConfig, Role + import json + + admin_role, _ = Role.objects.get_or_create(name='admin') + user_config, _ = UserConfig.objects.get_or_create(user=self.user) + user_config.roles.add(admin_role) + user_config.is_intern = True + user_config.save() + + # 1. Intervention with type 'repair' (has matching subtypes: sub_multi, sub_univ) + interv_repair = Intervention.objects.create( + title='Detail View Subtype Test', + type='repair', + thematic=self.thematic, + status='in_preparation', + created_by=self.user, + ) + + self.client.force_login(self.user) + response = self.client.get(reverse('interventions:intervention_detail', args=[interv_repair.id])) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context['has_subtypes_for_current_type']) + subtypes_in_json = json.loads(response.context['subtypes_data_json']) + self.assertTrue(any(s['id'] == self.subtype_multi.id for s in subtypes_in_json)) + + # 2. Deactivate universal subtype to test type without subtypes + self.subtype_universal.is_active = False + self.subtype_universal.save() + + interv_cleaning = Intervention.objects.create( + title='Detail View No Subtype Test', + type='cleaning', + thematic=self.thematic, + status='in_preparation', + created_by=self.user, + ) + response_cleaning = self.client.get(reverse('interventions:intervention_detail', args=[interv_cleaning.id])) + self.assertEqual(response_cleaning.status_code, 200) + self.assertFalse(response_cleaning.context['has_subtypes_for_current_type']) + + def test_update_intervention_endpoint_subtype(self): + from interventions.models import Intervention + from common.models import UserConfig, Role + import json + + admin_role, _ = Role.objects.get_or_create(name='admin') + user_config, _ = UserConfig.objects.get_or_create(user=self.user) + user_config.roles.add(admin_role) + user_config.is_intern = True + user_config.save() + + intervention = Intervention.objects.create( + title='API Update Subtype Test', + type='repair', + thematic=self.thematic, + status='in_preparation', + created_by=self.user, + ) + + self.client.force_login(self.user) + url = reverse('interventions:update_intervention', args=[intervention.id]) + + # Batch update sub_type + response = self.client.post( + url, + data=json.dumps({'sub_type': str(self.subtype_multi.id)}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + intervention.refresh_from_db() + self.assertEqual(intervention.sub_type, self.subtype_multi) + + # Batch update type and sub_type together: switch type to 'build' and sub_type to sub_build + response = self.client.post( + url, + data=json.dumps({'type': 'build', 'sub_type': str(self.subtype_build_only.id)}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + intervention.refresh_from_db() + self.assertEqual(intervention.type, 'build') + self.assertEqual(intervention.sub_type, self.subtype_build_only) + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 02928fe..f10c319 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -54,7 +54,7 @@ from common.private_files.helpers import build_private_url from common.utils import get_short_name from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, - InterventionLink, + InterventionLink, InterventionSubType, ContractPauseReasonAssignment, Symptom, SourceCategory, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, InterventionOccupation, @@ -632,6 +632,20 @@ def intervention_detail(request, intervention_id): sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes) + sub_types_qs = InterventionSubType.objects.filter(is_active=True, thematic=intervention.thematic).order_by('order_number', 'name_fr') + subtypes_data = [ + { + 'id': st.id, + 'name': st.get_name(), + 'types': st.intervention_types, + } + for st in sub_types_qs + ] + has_subtypes_for_current_type = any( + not st.intervention_types or intervention.type in st.intervention_types + for st in sub_types_qs + ) + context = { "intervention": intervention, "status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], @@ -663,6 +677,9 @@ def intervention_detail(request, intervention_id): 'contracts': contracts, 'maintain_choices': MAINTAIN_CHOICES, 'type_choices': get_type_choices(intervention.thematic), + 'sub_types': sub_types_qs, + 'subtypes_data_json': _json_mod.dumps(subtypes_data), + 'has_subtypes_for_current_type': has_subtypes_for_current_type, 'origin_type_choices': ORIGIN_TYPE_CHOICES, 'priority_choices': Intervention._meta.get_field('priority').choices, 'symptoms': Symptom.objects.filter(is_active=True, thematic=intervention.thematic).order_by('name_fr'), @@ -1694,6 +1711,16 @@ def interventions_add(request, thematic_code): for sc in SourceCategory.objects.filter(is_active=True) ] + subtypes_qs = InterventionSubType.objects.filter(thematic=thematic, is_active=True).order_by('order_number', 'name_fr') + subtypes_data = [ + { + "id": st.id, + "name": st.get_name(), + "types": st.intervention_types, + } + for st in subtypes_qs + ] + if request.method == 'POST': maintain_type = request.POST.get('maintain_type', 'corrective') form = InterventionForm(request.POST, request.FILES, thematic=thematic, user=request.user) @@ -2017,7 +2044,7 @@ def interventions_add(request, thematic_code): for key in [ 'title', 'lat', 'lon', 'address', 'asset_category', 'status', 'source_category', 'source_ref', 'source_detail', 'init_description', - 'type', 'origin_type', 'priority' + 'type', 'sub_type', 'origin_type', 'priority' ]: val = request.GET.get(key) if val not in (None, ""): @@ -2093,6 +2120,7 @@ def interventions_add(request, thematic_code): 'thematic': thematic, 'symptoms_data': json.dumps(symptoms_data), 'source_categories_data': json.dumps(source_categories_data), + 'subtypes_data': json.dumps(subtypes_data), 'maintain_type': maintain_type, 'observation': observation, 'observation_photos': observation_photos, @@ -2654,14 +2682,18 @@ def update_intervention(request, intervention_id): # Handle notes separately (special case) notes_data = data.pop('notes', None) - # Sort fields to process assigned_provider before contract + # Sort fields to process assigned_provider before contract, and type before sub_type def _field_priority(item): f_name = item[0] if f_name == 'assigned_provider': return 0 if f_name == 'contract': return 1 - return 2 + if f_name == 'type': + return 2 + if f_name == 'sub_type': + return 3 + return 4 for field, new_value in sorted(data.items(), key=_field_priority): if field not in allowed_update_fields: diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 7c91f27..b62dd00 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -54,7 +54,7 @@ from common.private_files.helpers import build_private_url from common.utils import get_short_name from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, - InterventionLink, + InterventionLink, InterventionSubType, ContractPauseReasonAssignment, Symptom, SourceCategory, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, InterventionOccupation, @@ -2604,9 +2604,32 @@ def _update_intervention_field(intervention, field, new_value, user_config=None) allowed_types = dict(get_type_choices(intervention.thematic)).keys() if new_value in allowed_types: intervention.type = new_value + if intervention.sub_type and intervention.sub_type.intervention_types: + if new_value not in intervention.sub_type.intervention_types: + intervention.sub_type = None return True, None return False, _('Type not found') + elif field == "sub_type": + if new_value in ("", None, "null"): + intervention.sub_type = None + return True, None + try: + sub_type_id = int(new_value) + sub_type = InterventionSubType.objects.filter( + id=sub_type_id, + is_active=True, + thematic=intervention.thematic, + ).first() + if not sub_type: + return False, _('Sous-type introuvable') + if sub_type.intervention_types and intervention.type not in sub_type.intervention_types: + return False, _("Sous-type non compatible avec le type d'intervention") + intervention.sub_type = sub_type + return True, None + except (ValueError, TypeError): + return False, _('Sous-type invalide') + elif field == "origin_type": if new_value in dict(ORIGIN_TYPE_CHOICES).keys(): intervention.origin_type = new_value