feat: add inspection management system including models, views, API endpoints, and front-end modal components

This commit is contained in:
Karl Determe 2026-09-08 16:57:39 +02:00
parent 64e93d7ae4
commit 5417ca32b0
8 changed files with 226 additions and 8 deletions

View file

@ -1397,6 +1397,19 @@
document.getElementById('insp_object_id').value = objId;
let modelName = props.model_name || props.location_type || '';
const rawModel = String(props.model_name || props.location_type || '').toLowerCase();
const targetCode = String(props.code || props.target_display || '').toUpperCase();
const isZoneRwiz = (
rawModel === 'naturerwiz' ||
props.location_type === 'NatureRWIZ' ||
targetCode.startsWith('RWIZ') ||
Boolean(props.is_location && (props.thematic === 'water' || props.thematic === 'giep' || rawModel === 'naturerwiz'))
) && (props.model_name !== 'naturerwiasset' && props.asset_type !== 'naturerwiasset' && props.is_location !== false);
if (typeof window.toggleMissingResultChoice === 'function') {
window.toggleMissingResultChoice(isZoneRwiz);
}
if (modelName === 'NatureRWIZ' || modelName === 'NatureRWIAsset' || modelName === 'naturerwiasset' || props.thematic === 'water' || props.location_type === 'NatureRWIZ') {
modelName = 'naturerwiz';
window.currentGiepLocationId = props.location_id || (props.is_location ? objId : null) || objId;

View file

@ -15,6 +15,7 @@
data-bs-toggle="modal"
data-bs-target="#quickInspectionModal"
data-content-type-id="{{ asset_ct_id }}"
data-model-name="{{ asset_ct_model }}"
data-object-id="{{ asset_pk }}"
data-target-code="{{ asset.code|default:asset.pk }}"
data-target-name="{{ asset.name_fr|default:asset.name_nl|default:asset }}"
@ -206,6 +207,13 @@ document.addEventListener('DOMContentLoaded', function() {
if (modalTypeName) modalTypeName.textContent = name;
if (modalTypeBadge) modalTypeBadge.textContent = type;
if (modalStatusBadge) modalStatusBadge.textContent = status;
const modelName = (this.getAttribute('data-model-name') || '').toLowerCase();
const codeUpper = (code || '').toUpperCase();
const isRwiz = (modelName === 'naturerwiz' || codeUpper.startsWith('RWIZ'));
if (typeof window.toggleMissingResultChoice === 'function') {
window.toggleMissingResultChoice(isRwiz);
}
});
});

View file

@ -64,9 +64,9 @@
<span>{% translate "Endommagé" %}</span>
</label>
</div>
<div class="col-4">
<div class="col-4" id="res_container_missing">
<input type="radio" class="btn-check" name="result_status" id="res_missing" value="missing">
<label class="btn btn-outline-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_missing">
<label class="btn btn-outline-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_missing" id="res_label_missing">
<i class="bi bi-dash-circle-fill"></i>
<span>{% translate "Absent" %}</span>
</label>

View file

@ -153,6 +153,14 @@
<script>
(function() {
if (typeof window.toggleMissingResultChoice === 'function') {
const container = document.getElementById('giepSpecificFields');
const isAssetSpecific = container && container.dataset.assetId && container.dataset.assetId !== '';
if (!isAssetSpecific) {
window.toggleMissingResultChoice(true);
}
}
function createEmptyOuvrage(idx, deviceType = "evc", asset = null) {
return {
local_id: String(idx),

View file

@ -39,9 +39,9 @@
<label class="form-label fw-semibold d-block">{% translate "Résultat de l'inspection" %} <span class="text-danger">*</span></label>
<div class="row g-2">
{% for choice in INSPECTION_RESULT_CHOICES_CONFIG %}
<div class="col-6 mb-1">
<div class="col-6 mb-1" id="edit_res_container_{{ choice.code }}">
<input type="radio" class="btn-check" name="result_status" id="edit_res_{{ choice.code }}" value="{{ choice.code }}">
<label class="btn {{ choice.btn_class }} w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="edit_res_{{ choice.code }}">
<label class="btn {{ choice.btn_class }} w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="edit_res_{{ choice.code }}" id="edit_res_label_{{ choice.code }}">
<i class="bi {{ choice.icon }}"></i>
<span>{{ choice.label }}</span>
</label>
@ -453,6 +453,45 @@ document.addEventListener('DOMContentLoaded', function() {
initEditInspectionPhotoHandler();
window.toggleEditMissingResultChoice = function(isDisabled, tooltipText) {
const radio = document.getElementById('edit_res_missing');
const label = document.getElementById('edit_res_label_missing') || document.querySelector('label[for="edit_res_missing"]');
const container = document.getElementById('edit_res_container_missing') || (radio ? radio.closest('.col-6') : null);
const defaultMsg = "{% filter escapejs %}{% translate 'Non applicable pour une zone (RWIZ)' %}{% endfilter %}";
const msg = tooltipText || defaultMsg;
if (radio) {
radio.disabled = Boolean(isDisabled);
if (isDisabled && radio.checked) {
const compliantRadio = document.getElementById('edit_res_compliant');
if (compliantRadio) {
compliantRadio.checked = true;
compliantRadio.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}
if (label) {
if (isDisabled) {
label.classList.add('disabled');
label.style.pointerEvents = 'none';
label.style.opacity = '0.35';
} else {
label.classList.remove('disabled');
label.style.pointerEvents = '';
label.style.opacity = '';
}
}
if (container) {
if (isDisabled) {
container.style.cursor = 'not-allowed';
container.setAttribute('title', msg);
} else {
container.style.cursor = '';
container.removeAttribute('title');
}
}
};
window.openEditInspectionModal = function(inspectionId) {
if (!inspectionId) return;
@ -498,9 +537,20 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('edit_insp_dates_info').textContent = datesText;
document.getElementById('edit_insp_current_status').textContent = data.result_status || '';
if (typeof window.toggleEditMissingResultChoice === 'function') {
window.toggleEditMissingResultChoice(Boolean(data.is_zone_rwiz));
}
// Pre-check result_status radio
const rad = document.getElementById('edit_res_' + data.result_status);
if (rad) rad.checked = true;
if (rad) {
if (data.is_zone_rwiz && data.result_status === 'missing') {
const compRad = document.getElementById('edit_res_compliant');
if (compRad) compRad.checked = true;
} else {
rad.checked = true;
}
}
if (document.getElementById('edit_insp_nature')) {
document.getElementById('edit_insp_nature').value = data.nature || 'visual';
@ -683,5 +733,14 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
}
const editModalEl = document.getElementById('editInspectionModal');
if (editModalEl) {
editModalEl.addEventListener('hidden.bs.modal', function() {
if (typeof window.toggleEditMissingResultChoice === 'function') {
window.toggleEditMissingResultChoice(false);
}
});
}
});
</script>

View file

@ -43,9 +43,9 @@
<div class="row g-2">
{% for choice in INSPECTION_RESULT_CHOICES_CONFIG %}
{% if choice.code != 'cancelled' %}
<div class="col-6 mb-1">
<div class="col-6 mb-1" id="res_container_{{ choice.code }}">
<input type="radio" class="btn-check" name="result_status" id="res_{{ choice.code }}" value="{{ choice.code }}" {% if forloop.first %}checked{% endif %}>
<label class="btn {{ choice.btn_class }} w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_{{ choice.code }}">
<label class="btn {{ choice.btn_class }} w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_{{ choice.code }}" id="res_label_{{ choice.code }}">
<i class="bi {{ choice.icon }}"></i>
<span>{{ choice.label }}</span>
</label>
@ -168,3 +168,55 @@
</div>
</div>
</div>
<script>
window.toggleMissingResultChoice = function(isDisabled, tooltipText) {
const radio = document.getElementById('res_missing');
const label = document.getElementById('res_label_missing') || document.querySelector('label[for="res_missing"]');
const container = document.getElementById('res_container_missing') || (radio ? radio.closest('.col-6, .col-4') : null);
const defaultMsg = "{% filter escapejs %}{% translate 'Non applicable pour une zone (RWIZ)' %}{% endfilter %}";
const msg = tooltipText || defaultMsg;
if (radio) {
radio.disabled = Boolean(isDisabled);
if (isDisabled && radio.checked) {
const compliantRadio = document.getElementById('res_compliant');
if (compliantRadio) {
compliantRadio.checked = true;
compliantRadio.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}
if (label) {
if (isDisabled) {
label.classList.add('disabled');
label.style.pointerEvents = 'none';
label.style.opacity = '0.35';
} else {
label.classList.remove('disabled');
label.style.pointerEvents = '';
label.style.opacity = '';
}
}
if (container) {
if (isDisabled) {
container.style.cursor = 'not-allowed';
container.setAttribute('title', msg);
} else {
container.style.cursor = '';
container.removeAttribute('title');
}
}
};
document.addEventListener('DOMContentLoaded', function() {
const modalEl = document.getElementById('quickInspectionModal');
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', function() {
if (typeof window.toggleMissingResultChoice === 'function') {
window.toggleMissingResultChoice(false);
}
});
}
});
</script>

View file

@ -189,6 +189,62 @@ class InspectionModelAndViewsTestCase(TestCase):
self.assertFalse(insp.orage_moins_une_heure)
self.assertEqual(len(insp.data.get('ouvrages', [])), 1)
def test_create_giep_inspection_api_rejects_missing(self):
self.client.login(username='inspector_user', password='password123')
url = reverse('inspections:create_inspection_api')
response = self.client.post(url, {
'content_type_id': self.rwiz_ct.id,
'object_id': self.rwiz.id,
'result_status': 'missing',
'nature': 'visual',
'inspector_role': 'observer',
})
self.assertEqual(response.status_code, 400)
data = response.json()
self.assertFalse(data['success'])
self.assertIn("Absent / Retiré", data['error'])
def test_update_giep_inspection_api_rejects_missing(self):
self.client.login(username='inspector_user', password='password123')
insp = GiepInspection.objects.create(
asset_content_type=self.rwiz_ct,
asset_object_id=self.rwiz.id,
inspector=self.user,
result_status='compliant'
)
url = reverse('inspections:update_inspection_api', kwargs={'inspection_id': insp.id})
response = self.client.post(url, {
'result_status': 'missing'
})
self.assertEqual(response.status_code, 400)
data = response.json()
self.assertFalse(data['success'])
self.assertIn("Absent / Retiré", data['error'])
def test_get_inspection_detail_api_returns_is_zone_rwiz(self):
self.client.login(username='inspector_user', password='password123')
rwiz_insp = GiepInspection.objects.create(
asset_content_type=self.rwiz_ct,
asset_object_id=self.rwiz.id,
inspector=self.user,
result_status='compliant'
)
url_rwiz = reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': rwiz_insp.id})
resp_rwiz = self.client.get(url_rwiz)
self.assertEqual(resp_rwiz.status_code, 200)
self.assertTrue(resp_rwiz.json()['is_zone_rwiz'])
spot_insp = ParkingSpotInspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
result_status='compliant'
)
url_spot = reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': spot_insp.id})
resp_spot = self.client.get(url_spot)
self.assertEqual(resp_spot.status_code, 200)
self.assertFalse(resp_spot.json()['is_zone_rwiz'])
def test_registry_giep_template(self):
tmpl_rwiz = get_inspection_partial_template('naturerwiz')
tmpl_asset = get_inspection_partial_template('naturerwiasset')

View file

@ -506,6 +506,12 @@ def create_inspection_api(request):
is_giep = (content_type.model == 'naturerwiz')
is_signpanel = (content_type.model == 'signpanel')
if is_giep and result_status == 'missing':
return JsonResponse({
'success': False,
'error': _("Le résultat 'Absent / Retiré' n'est pas applicable à une zone GIEP (RWIZ).")
}, status=400)
try:
if is_parking_spot:
inspection = ParkingSpotInspection(
@ -748,6 +754,11 @@ def get_inspection_detail_api(request, inspection_id):
except Exception:
rendered_detail_html = ''
is_zone_rwiz = bool(
(leaf.asset_content_type and leaf.asset_content_type.model == 'naturerwiz') or
(asset and asset.__class__.__name__ == 'NatureRWIZ')
)
data = {
'success': True,
'id': leaf.id,
@ -759,6 +770,7 @@ def get_inspection_detail_api(request, inspection_id):
'thematic_name': thematic_name,
'thematic_icon': thematic_icon,
'asset_type_name': leaf.asset_content_type.name if leaf.asset_content_type else '',
'is_zone_rwiz': is_zone_rwiz,
'result_status': leaf.result_status,
'result_status_display': str(leaf.get_result_status_display()),
'status_badge_class': leaf.status_badge_class,
@ -833,7 +845,17 @@ def update_inspection_api(request, inspection_id):
try:
if 'result_status' in request.POST:
inspection.result_status = request.POST['result_status']
new_status = request.POST['result_status']
is_zone_rwiz = (
isinstance(inspection, GiepInspection) or
(inspection.asset_content_type and inspection.asset_content_type.model == 'naturerwiz')
)
if is_zone_rwiz and new_status == 'missing':
return JsonResponse({
'success': False,
'error': _("Le résultat 'Absent / Retiré' n'est pas applicable à une zone GIEP (RWIZ).")
}, status=400)
inspection.result_status = new_status
if 'nature' in request.POST:
inspection.nature = request.POST['nature']
if 'inspector_role' in request.POST: