feat: add support for dynamic intervention subtype filtering and validation

This commit is contained in:
kdeterme 2026-09-17 16:27:33 +02:00
parent 396d26414c
commit 368a167b9c
9 changed files with 387 additions and 6 deletions

View file

@ -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')

View file

@ -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 = '<option value="">-</option>';
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 = {}) {

View file

@ -335,6 +335,11 @@
{{ form.type }}
{{ form.type.errors }}
</div>
<div class="mb-3 d-none" id="sub_type_field_wrapper">
<label for="id_sub_type" class="form-label">{% translate "Sous-type d'intervention" %}</label>
{{ form.sub_type }}
{{ form.sub_type.errors }}
</div>
</div>
</div>
{% endif %}

View file

@ -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 %}
</script>
<!-- Dynamic subtype filtering for intervention add form -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const typeSelect = document.getElementById('id_type');
const subTypeSelect = document.getElementById('id_sub_type');
const subTypeWrapper = document.getElementById('sub_type_field_wrapper');
if (!typeSelect || !subTypeSelect || !subTypeWrapper) return;
function updateSubTypeVisibility() {
const selectedType = typeSelect.value;
const available = (typeof subtypesData !== 'undefined' ? subtypesData : []).filter(st =>
!st.types || st.types.length === 0 || st.types.includes(selectedType)
);
if (available.length === 0) {
subTypeWrapper.classList.add('d-none');
subTypeSelect.value = '';
} else {
subTypeWrapper.classList.remove('d-none');
const currentVal = subTypeSelect.value;
subTypeSelect.innerHTML = '<option value="">---------</option>';
let valueStillValid = false;
available.forEach(st => {
const opt = document.createElement('option');
opt.value = String(st.id);
opt.textContent = st.name;
if (String(st.id) === currentVal) {
opt.selected = true;
valueStillValid = true;
}
subTypeSelect.appendChild(opt);
});
if (!valueStillValid && currentVal) {
subTypeSelect.value = '';
}
}
}
typeSelect.addEventListener('change', updateSubTypeVisibility);
updateSubTypeVisibility();
});
</script>
<!-- intervention_add.js (split) -->
<script src="{% static 'interventions/add/map.js' %}"></script>
<script src="{% static 'interventions/add/source.js' %}"></script>

View file

@ -498,6 +498,31 @@
</select>
</div>
</div>
<!-- Sous-type d'intervention -->
<div class="d-flex align-items-center editable-field {% if not has_subtypes_for_current_type %}d-none{% endif %}"
id="intervention-subtype-field"
data-field="sub_type"
data-subtypes="{{ subtypes_data_json|escape }}"
data-can-edit="{% if 'sub_type' in allowed_update_fields %}true{% else %}false{% endif %}">
<button class="btn btn-sm border-0" style="cursor: default;"><i class="bi bi-tag"></i></button>
<span class="label mx-1">{% translate "Sous-type" %}:</span>
<div class="field-display">
<span class="field-value">{{ intervention.sub_type.get_name|default:"-" }}</span>
</div>
<div class="field-edit d-none" style="flex: 1;">
<select class="form-select form-select-sm" id="intervention-subtype-select">
<option value="">-</option>
{% for st in sub_types %}
{% if not st.intervention_types or intervention.type in st.intervention_types %}
<option value="{{ st.id }}" {% if st.id == intervention.sub_type_id %}selected{% endif %}>
{{ st.get_name }}
</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<!-- Catégorie -->
<div class="d-flex align-items-center editable-field" data-field="category" data-can-edit="{% if 'category' in allowed_update_fields %}true{% else %}false{% endif %}">
<button class="btn btn-sm border-0" style="cursor: default;"><i class="bi bi-tags"></i></button>

View file

@ -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 %};

View file

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

View file

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

View file

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