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