diff --git a/loko/inspections/admin.py b/loko/inspections/admin.py
index 68a764e..ecad04a 100644
--- a/loko/inspections/admin.py
+++ b/loko/inspections/admin.py
@@ -1,6 +1,6 @@
from django.contrib import admin
from django.contrib.gis import admin as gis_admin
-from .models import Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, InspectionDocument
+from .models import Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, HotBoxInspection, InspectionDocument
@admin.register(InspectionZone)
@@ -83,6 +83,23 @@ class GiepInspectionAdmin(admin.ModelAdmin):
actions = [make_cancelled, make_compliant]
+@admin.register(HotBoxInspection)
+class HotBoxInspectionAdmin(admin.ModelAdmin):
+ list_display = (
+ "id", "asset", "inspector", "inspection_date", "result_status",
+ "joint_level", "joint_sealing", "cracking", "water_drainage",
+ "checked_intervention"
+ )
+ list_filter = (
+ "result_status", "joint_level", "joint_sealing",
+ "cracking", "water_drainage", "inspection_date"
+ )
+ search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name", "checked_intervention__code")
+ raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "zone")
+ inlines = [InspectionDocumentInline]
+ actions = [make_cancelled, make_compliant]
+
+
@admin.register(InspectionDocument)
class InspectionDocumentAdmin(admin.ModelAdmin):
list_display = ("id", "inspection", "field_key", "filename", "created_at")
diff --git a/loko/inspections/migrations/0010_hotboxinspection.py b/loko/inspections/migrations/0010_hotboxinspection.py
new file mode 100644
index 0000000..8bca0fb
--- /dev/null
+++ b/loko/inspections/migrations/0010_hotboxinspection.py
@@ -0,0 +1,29 @@
+# Generated by Django 6.0.7 on 2026-09-18 09:24
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('inspections', '0009_inspection_non_compliance_notified_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='HotBoxInspection',
+ fields=[
+ ('inspection_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='inspections.inspection')),
+ ('joint_level', models.CharField(blank=True, choices=[('flush', 'Bien à niveau / Arasé'), ('slight_bump_hollow', 'Léger décalage / Creux ou bosse'), ('step', 'Ressaut important (> 1-2 cm)')], default='flush', max_length=50, null=True, verbose_name='Planéité / Arasement')),
+ ('joint_sealing', models.CharField(blank=True, choices=[('good', 'Joints bien fermés / scellés'), ('cracked', 'Joints fissurés ou abîmés'), ('open', 'Joints ouverts ou décollés')], default='good', max_length=50, null=True, verbose_name='Joints de raccordement')),
+ ('cracking', models.CharField(blank=True, choices=[('none', 'Aucune fissure'), ('minor', 'Petites fissures / Faïençage'), ('major', 'Grosses fissures / Fissures ouvertes')], default='none', max_length=50, null=True, verbose_name='Fissuration')),
+ ('water_drainage', models.CharField(blank=True, choices=[('good', 'Bon écoulement / Pas de flaque'), ('stagnation', "Eau stagnante / Flaque d'eau")], default='good', max_length=50, null=True, verbose_name='Évacuation des eaux pluviales')),
+ ],
+ options={
+ 'verbose_name': 'Inspection de réparation Hot Box',
+ 'verbose_name_plural': 'Inspections de réparations Hot Box',
+ },
+ bases=('inspections.inspection',),
+ ),
+ ]
diff --git a/loko/inspections/models.py b/loko/inspections/models.py
index efc0b7d..69c76df 100644
--- a/loko/inspections/models.py
+++ b/loko/inspections/models.py
@@ -567,15 +567,39 @@ class Inspection(models.Model):
return th.icon
return 'bi-geo-alt'
+ @property
+ def is_hotbox_related(self):
+ """Détermine si l'inspection concerne une réparation Hot Box (asphalte à chaud)."""
+ if hasattr(self, 'hotboxinspection'):
+ return True
+ interv = self.related_intervention
+ if interv and getattr(interv, 'sub_type', None):
+ st = interv.sub_type
+ code = (st.code or '').lower()
+ name = (st.name_fr or '').lower()
+ if any(k in code or k in name for k in ('hotbox', 'hot_box', 'hot box')):
+ return True
+ if self.nature == 'post_intervention' and 'hot box' in (self.notes or '').lower():
+ return True
+ return False
+
@property
def detailed_inspection(self):
- """Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection, GiepInspection, SignPanelInspection)."""
+ """Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection, GiepInspection, SignPanelInspection, HotBoxInspection)."""
if hasattr(self, 'parkingspotinspection'):
return self.parkingspotinspection
if hasattr(self, 'giepinspection'):
return self.giepinspection
if hasattr(self, 'signpanelinspection'):
return self.signpanelinspection
+ if hasattr(self, 'hotboxinspection'):
+ return self.hotboxinspection
+ if self.is_hotbox_related and self.pk:
+ try:
+ hb, _ = HotBoxInspection.objects.get_or_create(inspection_ptr=self)
+ return hb
+ except Exception:
+ pass
return self
@property
@@ -615,6 +639,17 @@ class Inspection(models.Model):
if getattr(leaf, 'date_derniere_pluie', None) and leaf.date_derniere_pluie != 'null':
parts.append(str(_("Pluie : %s") % leaf.get_date_derniere_pluie_display()))
+ # Hot Box summary
+ if hasattr(leaf, 'joint_level') and hasattr(leaf, 'joint_sealing'):
+ if leaf.joint_level:
+ parts.append(f"{_('Planéité')} : {leaf.get_joint_level_display()}")
+ if leaf.joint_sealing:
+ parts.append(f"{_('Joints')} : {leaf.get_joint_sealing_display()}")
+ if leaf.cracking:
+ parts.append(f"{_('Fissuration')} : {leaf.get_cracking_display()}")
+ if leaf.water_drainage:
+ parts.append(f"{_('Évacuation')} : {leaf.get_water_drainage_display()}")
+
if leaf.verified_obsolescence:
parts.append(str(_("Obsolescence : %s") % leaf.verified_obsolescence))
@@ -1010,6 +1045,91 @@ class SignPanelInspection(Inspection):
target_asset.save()
+class HotBoxInspection(Inspection):
+ """
+ Spécialisation de l'inspection pour les réparations d'enrobé à chaud (Hot Box).
+ Formulaire visuel simplifié adapté aux observateurs non experts :
+ - Planéité (Arasement)
+ - Joints (Raccordement)
+ - Fissuration
+ - Évacuation des eaux pluviales
+ """
+ JOINT_LEVEL_CHOICES = (
+ ('flush', _("Bien à niveau / Arasé")),
+ ('slight_bump_hollow', _("Léger décalage / Creux ou bosse")),
+ ('step', _("Ressaut important (> 1-2 cm)")),
+ )
+ joint_level = models.CharField(
+ max_length=50,
+ choices=JOINT_LEVEL_CHOICES,
+ default='flush',
+ null=True,
+ blank=True,
+ verbose_name=_("Planéité / Arasement")
+ )
+
+ JOINT_SEALING_CHOICES = (
+ ('good', _("Joints bien fermés / scellés")),
+ ('cracked', _("Joints fissurés ou abîmés")),
+ ('open', _("Joints ouverts ou décollés")),
+ )
+ joint_sealing = models.CharField(
+ max_length=50,
+ choices=JOINT_SEALING_CHOICES,
+ default='good',
+ null=True,
+ blank=True,
+ verbose_name=_("Joints de raccordement")
+ )
+
+ CRACKING_CHOICES = (
+ ('none', _("Aucune fissure")),
+ ('minor', _("Petites fissures / Faïençage")),
+ ('major', _("Grosses fissures / Fissures ouvertes")),
+ )
+ cracking = models.CharField(
+ max_length=50,
+ choices=CRACKING_CHOICES,
+ default='none',
+ null=True,
+ blank=True,
+ verbose_name=_("Fissuration")
+ )
+
+ WATER_DRAINAGE_CHOICES = (
+ ('good', _("Bon écoulement / Pas de flaque")),
+ ('stagnation', _("Eau stagnante / Flaque d'eau")),
+ )
+ water_drainage = models.CharField(
+ max_length=50,
+ choices=WATER_DRAINAGE_CHOICES,
+ default='good',
+ null=True,
+ blank=True,
+ verbose_name=_("Évacuation des eaux pluviales")
+ )
+
+ class Meta:
+ verbose_name = _("Inspection de réparation Hot Box")
+ verbose_name_plural = _("Inspections de réparations Hot Box")
+
+ def compute_status(self):
+ """
+ Calcule automatiquement le statut de conformité ('compliant', 'non_compliant', 'damaged')
+ selon la sévérité des constats visuels.
+ """
+ if self.joint_level == 'step' or self.joint_sealing == 'open' or self.cracking == 'major':
+ return 'damaged'
+ if (
+ self.joint_level == 'slight_bump_hollow' or
+ self.joint_sealing == 'cracked' or
+ self.cracking == 'minor' or
+ self.water_drainage == 'stagnation'
+ ):
+ return 'non_compliant'
+ return 'compliant'
+
+
def inspection_document_upload_path(instance, filename):
now = timezone.now()
year = now.strftime("%Y")
diff --git a/loko/inspections/registry.py b/loko/inspections/registry.py
index 66739ca..17ea211 100644
--- a/loko/inspections/registry.py
+++ b/loko/inspections/registry.py
@@ -38,7 +38,9 @@ def get_inspection_detail_partial_template(insp_or_model_or_name):
if not insp_or_model_or_name:
return None
- if hasattr(insp_or_model_or_name, 'asset') and insp_or_model_or_name.asset:
+ if hasattr(insp_or_model_or_name, 'is_hotbox_related') and insp_or_model_or_name.is_hotbox_related:
+ model_name = 'hotbox'
+ elif hasattr(insp_or_model_or_name, 'asset') and insp_or_model_or_name.asset:
model_name = insp_or_model_or_name.asset._meta.model_name.lower()
elif hasattr(insp_or_model_or_name, '_meta'):
model_name = insp_or_model_or_name._meta.model_name.lower()
@@ -51,6 +53,8 @@ def get_inspection_detail_partial_template(insp_or_model_or_name):
model_name = 'parkingspot'
elif model_name in ('signpanel', 'signpanelinspection'):
model_name = 'signpanel'
+ elif model_name in ('hotbox', 'hotboxinspection'):
+ model_name = 'hotbox'
template_path = f"inspections/components/asset_details/_{model_name}.html"
try:
diff --git a/loko/inspections/services.py b/loko/inspections/services.py
index 8a13218..4438525 100644
--- a/loko/inspections/services.py
+++ b/loko/inspections/services.py
@@ -204,9 +204,14 @@ def schedule_post_intervention_inspection(intervention):
if len(zone_inspectors) == 1:
assigned_inspector = zone_inspectors[0]
+ from .models import Inspection, InspectionZone, HotBoxInspection
+
+ is_hotbox = any(k in (sub_type.code or '').lower() or k in (sub_type.name_fr or '').lower() for k in ('hotbox', 'hot_box', 'hot box'))
+ inspection_cls = HotBoxInspection if is_hotbox else Inspection
+
# Création de l'inspection planifiée
notes = f"Inspection de durabilité planifiée pour {sub_type.get_name()} (Intervention {intervention.code})."
- inspection = Inspection.objects.create(
+ inspection = inspection_cls.objects.create(
asset_content_type=target_ct,
asset_object_id=target_id,
checked_intervention=intervention,
diff --git a/loko/inspections/templates/inspections/components/asset_details/_hotbox.html b/loko/inspections/templates/inspections/components/asset_details/_hotbox.html
new file mode 100644
index 0000000..6f5049f
--- /dev/null
+++ b/loko/inspections/templates/inspections/components/asset_details/_hotbox.html
@@ -0,0 +1,113 @@
+{% load i18n %}
+
+{% with leaf=insp.detailed_inspection %}
+{% if leaf %}
+
+
+ {% translate "Contrôles visuels Hot Box (Enrobé à chaud)" %}
+
+
+
+
+ {% if leaf.joint_level %}
+ -
+
+ {% translate "Planéité / Arasement" %}
+
+
+ {% if leaf.joint_level == 'flush' %}
+
+ {% translate "Bien à niveau / Arasé" %}
+
+ {% elif leaf.joint_level == 'slight_bump_hollow' %}
+
+ {% translate "Léger décalage / Creux ou bosse" %}
+
+ {% elif leaf.joint_level == 'step' %}
+
+ {% translate "Ressaut important (> 1-2 cm)" %}
+
+ {% else %}
+ {{ leaf.get_joint_level_display|default:leaf.joint_level }}
+ {% endif %}
+
+
+ {% endif %}
+
+
+ {% if leaf.joint_sealing %}
+ -
+
+ {% translate "Joints de raccordement" %}
+
+
+ {% if leaf.joint_sealing == 'good' %}
+
+ {% translate "Joints bien fermés / scellés" %}
+
+ {% elif leaf.joint_sealing == 'cracked' %}
+
+ {% translate "Joints fissurés ou abîmés" %}
+
+ {% elif leaf.joint_sealing == 'open' %}
+
+ {% translate "Joints ouverts ou décollés" %}
+
+ {% else %}
+ {{ leaf.get_joint_sealing_display|default:leaf.joint_sealing }}
+ {% endif %}
+
+
+ {% endif %}
+
+
+ {% if leaf.cracking %}
+ -
+
+ {% translate "Fissuration" %}
+
+
+ {% if leaf.cracking == 'none' %}
+
+ {% translate "Aucune fissure" %}
+
+ {% elif leaf.cracking == 'minor' %}
+
+ {% translate "Petites fissures / Faïençage" %}
+
+ {% elif leaf.cracking == 'major' %}
+
+ {% translate "Grosses fissures / Fissures ouvertes" %}
+
+ {% else %}
+ {{ leaf.get_cracking_display|default:leaf.cracking }}
+ {% endif %}
+
+
+ {% endif %}
+
+
+ {% if leaf.water_drainage %}
+ -
+
+ {% translate "Évacuation des eaux pluviales" %}
+
+
+ {% if leaf.water_drainage == 'good' %}
+
+ {% translate "Bon écoulement / Pas de flaque" %}
+
+ {% elif leaf.water_drainage == 'stagnation' %}
+
+ {% translate "Eau stagnante / Flaque d'eau" %}
+
+ {% else %}
+ {{ leaf.get_water_drainage_display|default:leaf.water_drainage }}
+ {% endif %}
+
+
+ {% endif %}
+
+
+{% endif %}
+{% endwith %}
diff --git a/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html b/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html
new file mode 100644
index 0000000..6715b47
--- /dev/null
+++ b/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html
@@ -0,0 +1,182 @@
+{% load i18n %}
+
+
+
+
+
+ {% translate "Contrôle visuel de réparation Hot Box" %}
+
+
+ {% translate "Suggéré : Conforme" %}
+
+
+
+
+
+ {% translate "Évaluez visuellement la zone réparée sur les 4 critères simples ci-dessous :" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/loko/inspections/templates/inspections/components/edit_inspection_modal.html b/loko/inspections/templates/inspections/components/edit_inspection_modal.html
index 7794144..91cffa2 100644
--- a/loko/inspections/templates/inspections/components/edit_inspection_modal.html
+++ b/loko/inspections/templates/inspections/components/edit_inspection_modal.html
@@ -143,6 +143,135 @@
{% include "inspections/components/asset_fields/_naturerwiz.html" %}
+
+
+
+
+ {% translate "Contrôles visuels Hot Box" %}
+
+
+ {% translate "Suggéré : Conforme" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -630,6 +759,34 @@ document.addEventListener('DOMContentLoaded', function() {
if (giepFieldsContainer) giepFieldsContainer.classList.add('d-none');
}
+ // Affichage et préremplissage des champs spécifiques Hot Box (HotBoxInspection)
+ const hotboxFieldsContainer = document.getElementById('editHotBoxSpecificFields');
+ if (data.is_hotbox) {
+ if (hotboxFieldsContainer) hotboxFieldsContainer.classList.remove('d-none');
+ const jl = data.joint_level || 'flush';
+ const js = data.joint_sealing || 'good';
+ const cr = data.cracking || 'none';
+ const wd = data.water_drainage || 'good';
+
+ const radLevel = document.getElementById('edit_hb_level_' + (jl === 'slight_bump_hollow' ? 'slight' : jl));
+ if (radLevel) radLevel.checked = true;
+
+ const radSealing = document.getElementById('edit_hb_sealing_' + js);
+ if (radSealing) radSealing.checked = true;
+
+ const radCracking = document.getElementById('edit_hb_cracking_' + cr);
+ if (radCracking) radCracking.checked = true;
+
+ const radDrainage = document.getElementById('edit_hb_drainage_' + wd);
+ if (radDrainage) radDrainage.checked = true;
+
+ if (typeof updateEditHotBoxRecommendation === 'function') {
+ updateEditHotBoxRecommendation(false);
+ }
+ } else {
+ if (hotboxFieldsContainer) hotboxFieldsContainer.classList.add('d-none');
+ }
+
// Rendu des photos / documents existants
const existingDocsContainer = document.getElementById('editInspectionExistingDocsContainer');
@@ -678,6 +835,47 @@ document.addEventListener('DOMContentLoaded', function() {
});
};
+ function updateEditHotBoxRecommendation(syncRadio = true) {
+ const level = document.querySelector('input[name="joint_level"]:checked')?.value || 'flush';
+ const sealing = document.querySelector('input[name="joint_sealing"]:checked')?.value || 'good';
+ const cracking = document.querySelector('input[name="cracking"]:checked')?.value || 'none';
+ const drainage = document.querySelector('input[name="water_drainage"]:checked')?.value || 'good';
+
+ let recommended = 'compliant';
+ let badgeText = '{% translate "Suggéré : Conforme" %}';
+ let badgeClass = 'bg-success-subtle text-success border border-success-subtle';
+ let icon = 'bi-check-circle-fill';
+
+ if (level === 'step' || sealing === 'open' || cracking === 'major') {
+ recommended = 'damaged';
+ badgeText = '{% translate "Suggéré : Dégradé" %}';
+ badgeClass = 'bg-danger-subtle text-danger border border-danger-subtle';
+ icon = 'bi-exclamation-triangle-fill';
+ } else if (level === 'slight_bump_hollow' || sealing === 'cracked' || cracking === 'minor' || drainage === 'stagnation') {
+ recommended = 'non_compliant';
+ badgeText = '{% translate "Suggéré : Non conforme" %}';
+ badgeClass = 'bg-warning-subtle text-warning-emphasis border border-warning-subtle';
+ icon = 'bi-exclamation-circle-fill';
+ }
+
+ const badge = document.getElementById('editHotboxRecommendedBadge');
+ if (badge) {
+ badge.className = `badge ${badgeClass} px-2.5 py-1 rounded-pill small`;
+ badge.innerHTML = `${badgeText}`;
+ }
+
+ if (syncRadio) {
+ const rad = document.getElementById('edit_res_' + recommended);
+ if (rad) {
+ rad.checked = true;
+ }
+ }
+ }
+
+ document.querySelectorAll('.edit-hotbox-input').forEach(input => {
+ input.addEventListener('change', () => updateEditHotBoxRecommendation(true));
+ });
+
window.deleteInspectionDoc = function(docId) {
if (!docId) return;
if (!confirm("{% translate 'Êtes-vous sûr de vouloir supprimer ce document ?' %}")) return;
diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py
index 4d9866f..eb267a3 100644
--- a/loko/inspections/tests.py
+++ b/loko/inspections/tests.py
@@ -7,7 +7,7 @@ from django.utils import timezone
from assets.models import NatureRWIZ, NatureRWIAsset, ParkingSpot
from interventions.models import Intervention
from common.models import Thematic
-from .models import Inspection, ParkingSpotInspection, GiepInspection, InspectionDocument
+from .models import Inspection, ParkingSpotInspection, GiepInspection, HotBoxInspection, InspectionDocument
from .registry import get_inspection_partial_template
User = get_user_model()
@@ -1849,5 +1849,120 @@ class PostInterventionDurabilityInspectionTestCase(TestCase):
self.assertContains(resp, f"inspection_id={insp.id}")
self.assertContains(resp, "Inspections de terrain")
+ def test_hotbox_inspection_model_and_compute_status(self):
+ """Vérifie le modèle HotBoxInspection, le calcul de statut automatique et details_summary."""
+ intervention = self._create_finished_intervention()
+ interv_ct = ContentType.objects.get_for_model(Intervention)
+ hb = HotBoxInspection.objects.create(
+ asset_content_type=interv_ct,
+ asset_object_id=intervention.pk,
+ inspector=self.user,
+ inspection_date=timezone.now(),
+ joint_level='flush',
+ joint_sealing='good',
+ cracking='none',
+ water_drainage='good',
+ )
+ self.assertEqual(hb.compute_status(), 'compliant')
+ self.assertTrue(hb.is_hotbox_related)
+ self.assertEqual(hb.detailed_inspection, hb)
+
+ # Vérification details_summary
+ summary = hb.details_summary
+ self.assertIn("Planéité", summary)
+ self.assertIn("Joints", summary)
+ self.assertIn("Fissuration", summary)
+ self.assertIn("Évacuation", summary)
+
+ # Problème mineur -> non_compliant
+ hb.water_drainage = 'stagnation'
+ self.assertEqual(hb.compute_status(), 'non_compliant')
+ hb.water_drainage = 'good'
+ hb.cracking = 'minor'
+ self.assertEqual(hb.compute_status(), 'non_compliant')
+
+ # Problème majeur -> damaged
+ hb.cracking = 'major'
+ self.assertEqual(hb.compute_status(), 'damaged')
+ hb.cracking = 'none'
+ hb.joint_level = 'step'
+ self.assertEqual(hb.compute_status(), 'damaged')
+ hb.joint_level = 'flush'
+ hb.joint_sealing = 'open'
+ self.assertEqual(hb.compute_status(), 'damaged')
+
+ def test_hotbox_inspection_apis(self):
+ """Vérifie la création, la lecture des détails et la mise à jour via les endpoints API."""
+ self.client.force_login(self.user)
+ intervention = self._create_finished_intervention()
+ interv_ct = ContentType.objects.get_for_model(Intervention)
+
+ # 1. Création via create_inspection_api
+ create_url = reverse('inspections:create_inspection_api')
+ post_data = {
+ 'content_type_id': interv_ct.id,
+ 'object_id': intervention.pk,
+ 'result_status': 'compliant',
+ 'nature': 'post_intervention',
+ 'is_hotbox': 'true',
+ 'joint_level': 'flush',
+ 'joint_sealing': 'good',
+ 'cracking': 'minor',
+ 'water_drainage': 'good',
+ 'notes': 'Contrôle réparation enrobé',
+ }
+ resp = self.client.post(create_url, post_data)
+ self.assertEqual(resp.status_code, 200)
+ data = resp.json()
+ self.assertTrue(data['success'])
+ insp_id = data['inspection_id']
+
+ # Vérifier que l'instance créée est bien un HotBoxInspection
+ hb_obj = HotBoxInspection.objects.get(pk=insp_id)
+ self.assertEqual(hb_obj.joint_level, 'flush')
+ self.assertEqual(hb_obj.cracking, 'minor')
+ self.assertEqual(hb_obj.water_drainage, 'good')
+
+ # 2. Détail via get_inspection_detail_api
+ detail_url = reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': insp_id})
+ resp_detail = self.client.get(detail_url)
+ self.assertEqual(resp_detail.status_code, 200)
+ detail_data = resp_detail.json()
+ self.assertTrue(detail_data['success'])
+ self.assertTrue(detail_data.get('is_hotbox'))
+ self.assertEqual(detail_data['joint_level'], 'flush')
+ self.assertEqual(detail_data['cracking'], 'minor')
+ self.assertIn("Contrôles visuels Hot Box", detail_data.get('details_html', ''))
+
+ # 3. Mise à jour via update_inspection_api
+ update_url = reverse('inspections:update_inspection_api', kwargs={'inspection_id': insp_id})
+ update_data = {
+ 'result_status': 'non_compliant',
+ 'joint_level': 'slight_bump_hollow',
+ 'joint_sealing': 'cracked',
+ 'cracking': 'minor',
+ 'water_drainage': 'stagnation',
+ 'notes': 'Flaque résiduelle observée après pluie',
+ }
+ resp_update = self.client.post(update_url, update_data)
+ self.assertEqual(resp_update.status_code, 200)
+ self.assertTrue(resp_update.json()['success'])
+
+ hb_obj.refresh_from_db()
+ self.assertEqual(hb_obj.result_status, 'non_compliant')
+ self.assertEqual(hb_obj.joint_level, 'slight_bump_hollow')
+ self.assertEqual(hb_obj.joint_sealing, 'cracked')
+ self.assertEqual(hb_obj.water_drainage, 'stagnation')
+ self.assertEqual(hb_obj.notes, 'Flaque résiduelle observée après pluie')
+
+ def test_schedule_post_intervention_inspection_creates_hotbox_inspection(self):
+ """Vérifie que schedule_post_intervention_inspection pour un sous-type Hot Box instancie directement un HotBoxInspection."""
+ from inspections.services import schedule_post_intervention_inspection
+ intervention = self._create_finished_intervention(sub_type=self.hotbox_subtype)
+ insp = schedule_post_intervention_inspection(intervention)
+ self.assertIsNotNone(insp)
+ self.assertIsInstance(insp.detailed_inspection, HotBoxInspection)
+ self.assertTrue(insp.is_hotbox_related)
+
diff --git a/loko/inspections/views.py b/loko/inspections/views.py
index 2055bc1..8a02273 100644
--- a/loko/inspections/views.py
+++ b/loko/inspections/views.py
@@ -12,7 +12,7 @@ from common.models import Thematic
from common.utils import get_short_name
from assets.models import AssetCategory
from .models import (
- Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument,
+ Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, SignPanelInspection, HotBoxInspection, InspectionDocument,
INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES
)
@@ -579,6 +579,18 @@ def create_inspection_api(request):
is_parking_spot = (content_type.model == 'parkingspot')
is_giep = (content_type.model == 'naturerwiz')
is_signpanel = (content_type.model == 'signpanel')
+ is_hotbox = (
+ request.POST.get('is_hotbox') == 'true' or
+ request.POST.get('inspection_type') == 'hotbox' or
+ content_type.model in ('hotbox', 'hotboxinspection')
+ )
+ if not is_hotbox and checked_intervention_id:
+ from interventions.models import Intervention
+ interv = Intervention.objects.filter(pk=checked_intervention_id).first()
+ if interv and getattr(interv, 'sub_type', None):
+ st = interv.sub_type
+ if any(k in (st.code or '').lower() or k in (st.name_fr or '').lower() for k in ('hotbox', 'hot_box', 'hot box')):
+ is_hotbox = True
if is_giep and result_status == 'missing':
return JsonResponse({
@@ -715,6 +727,30 @@ def create_inspection_api(request):
ai_confidence=ai_conf,
apply_sign_updates_to_asset=apply_changes,
)
+ elif is_hotbox:
+ joint_level = request.POST.get('joint_level') or 'flush'
+ joint_sealing = request.POST.get('joint_sealing') or 'good'
+ cracking = request.POST.get('cracking') or 'none'
+ water_drainage = request.POST.get('water_drainage') or 'good'
+
+ inspection = HotBoxInspection(
+ asset_content_type=content_type,
+ asset_object_id=target_obj.pk,
+ inspector=request.user,
+ inspection_date=timezone.now(),
+ result_status=result_status,
+ nature=nature,
+ inspector_role=inspector_role,
+ mission_intervention_id=mission_intervention_id,
+ checked_intervention_id=checked_intervention_id,
+ verified_obsolescence=verified_obsolescence,
+ notes=notes,
+ apply_changes_to_asset=apply_changes,
+ joint_level=joint_level,
+ joint_sealing=joint_sealing,
+ cracking=cracking,
+ water_drainage=water_drainage,
+ )
inspection.save()
else:
inspection = Inspection.objects.create(
@@ -916,6 +952,17 @@ def get_inspection_detail_api(request, inspection_id):
data['ai_confidence'] = leaf.ai_confidence
data['apply_sign_updates_to_asset'] = leaf.apply_sign_updates_to_asset
+ elif isinstance(leaf, HotBoxInspection) or getattr(leaf, 'is_hotbox_related', False):
+ data['is_hotbox'] = True
+ data['joint_level'] = getattr(leaf, 'joint_level', 'flush')
+ data['joint_level_display'] = str(leaf.get_joint_level_display()) if hasattr(leaf, 'get_joint_level_display') else ''
+ data['joint_sealing'] = getattr(leaf, 'joint_sealing', 'good')
+ data['joint_sealing_display'] = str(leaf.get_joint_sealing_display()) if hasattr(leaf, 'get_joint_sealing_display') else ''
+ data['cracking'] = getattr(leaf, 'cracking', 'none')
+ data['cracking_display'] = str(leaf.get_cracking_display()) if hasattr(leaf, 'get_cracking_display') else ''
+ data['water_drainage'] = getattr(leaf, 'water_drainage', 'good')
+ data['water_drainage_display'] = str(leaf.get_water_drainage_display()) if hasattr(leaf, 'get_water_drainage_display') else ''
+
return JsonResponse(data)
@@ -1015,6 +1062,15 @@ def update_inspection_api(request, inspection_id):
inspection.verified_cleanliness = request.POST.get('verified_cleanliness')
if 'apply_sign_updates_to_asset' in request.POST:
inspection.apply_sign_updates_to_asset = (request.POST.get('apply_sign_updates_to_asset') == 'true')
+ elif isinstance(inspection, HotBoxInspection):
+ if 'joint_level' in request.POST:
+ inspection.joint_level = request.POST['joint_level']
+ if 'joint_sealing' in request.POST:
+ inspection.joint_sealing = request.POST['joint_sealing']
+ if 'cracking' in request.POST:
+ inspection.cracking = request.POST['cracking']
+ if 'water_drainage' in request.POST:
+ inspection.water_drainage = request.POST['water_drainage']
# Traitement des nouvelles pièces jointes / photos
uploaded_files = request.FILES.getlist('photos') + request.FILES.getlist('documents') + request.FILES.getlist('files')