feat: add HotBoxInspection model, views, templates, and admin configuration

This commit is contained in:
kdeterme 2026-09-18 12:02:05 +02:00
parent 250a2b4f2f
commit 010466e26f
10 changed files with 845 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,113 @@
{% load i18n %}
{% with leaf=insp.detailed_inspection %}
{% if leaf %}
<div class="card border-0 bg-light p-3 mb-3 rounded-3 shadow-sm">
<h6 class="fw-bold mb-3 text-primary border-bottom pb-2 small text-uppercase d-flex align-items-center">
<i class="bi bi-fire text-danger me-2"></i>{% translate "Contrôles visuels Hot Box (Enrobé à chaud)" %}
</h6>
<ul class="list-group list-group-flush bg-transparent">
<!-- 1. Planéité -->
{% if leaf.joint_level %}
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2.5 border-bottom-0">
<span class="text-secondary fw-medium">
<i class="bi bi-distribute-vertical me-2 text-primary fs-6"></i>{% translate "Planéité / Arasement" %}
</span>
<span>
{% if leaf.joint_level == 'flush' %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Bien à niveau / Arasé" %}
</span>
{% elif leaf.joint_level == 'slight_bump_hollow' %}
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-exclamation-circle-fill me-1"></i>{% translate "Léger décalage / Creux ou bosse" %}
</span>
{% elif leaf.joint_level == 'step' %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{% translate "Ressaut important (> 1-2 cm)" %}
</span>
{% else %}
{{ leaf.get_joint_level_display|default:leaf.joint_level }}
{% endif %}
</span>
</li>
{% endif %}
<!-- 2. Joints de raccordement -->
{% if leaf.joint_sealing %}
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2.5 border-bottom-0">
<span class="text-secondary fw-medium">
<i class="bi bi-bounding-box me-2 text-info fs-6"></i>{% translate "Joints de raccordement" %}
</span>
<span>
{% if leaf.joint_sealing == 'good' %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Joints bien fermés / scellés" %}
</span>
{% elif leaf.joint_sealing == 'cracked' %}
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-exclamation-circle-fill me-1"></i>{% translate "Joints fissurés ou abîmés" %}
</span>
{% elif leaf.joint_sealing == 'open' %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{% translate "Joints ouverts ou décollés" %}
</span>
{% else %}
{{ leaf.get_joint_sealing_display|default:leaf.joint_sealing }}
{% endif %}
</span>
</li>
{% endif %}
<!-- 3. Fissuration -->
{% if leaf.cracking %}
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2.5 border-bottom-0">
<span class="text-secondary fw-medium">
<i class="bi bi-activity me-2 text-warning fs-6"></i>{% translate "Fissuration" %}
</span>
<span>
{% if leaf.cracking == 'none' %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Aucune fissure" %}
</span>
{% elif leaf.cracking == 'minor' %}
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-exclamation-circle-fill me-1"></i>{% translate "Petites fissures / Faïençage" %}
</span>
{% elif leaf.cracking == 'major' %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-exclamation-triangle-fill me-1"></i>{% translate "Grosses fissures / Fissures ouvertes" %}
</span>
{% else %}
{{ leaf.get_cracking_display|default:leaf.cracking }}
{% endif %}
</span>
</li>
{% endif %}
<!-- 4. Évacuation des eaux pluviales -->
{% if leaf.water_drainage %}
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2.5 border-bottom-0">
<span class="text-secondary fw-medium">
<i class="bi bi-droplet-half me-2 text-primary fs-6"></i>{% translate "Évacuation des eaux pluviales" %}
</span>
<span>
{% if leaf.water_drainage == 'good' %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Bon écoulement / Pas de flaque" %}
</span>
{% elif leaf.water_drainage == 'stagnation' %}
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1.5 rounded-pill">
<i class="bi bi-droplet-fill me-1"></i>{% translate "Eau stagnante / Flaque d'eau" %}
</span>
{% else %}
{{ leaf.get_water_drainage_display|default:leaf.water_drainage }}
{% endif %}
</span>
</li>
{% endif %}
</ul>
</div>
{% endif %}
{% endwith %}

View file

@ -0,0 +1,182 @@
{% load i18n %}
<!-- Contrôles visuels Hot Box (Enrobé à chaud) -->
<div id="hotBoxSpecificFields" class="border rounded-3 p-3 mb-3 bg-white shadow-sm">
<div class="d-flex align-items-center justify-content-between mb-3 border-bottom pb-2">
<span class="small fw-bold text-primary text-uppercase d-flex align-items-center">
<i class="bi bi-fire text-danger me-2 fs-6"></i>{% translate "Contrôle visuel de réparation Hot Box" %}
</span>
<span id="hotboxRecommendedBadge" class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1 rounded-pill small">
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Suggéré : Conforme" %}
</span>
</div>
<p class="text-muted small mb-3">
<i class="bi bi-info-circle me-1"></i>
{% translate "Évaluez visuellement la zone réparée sur les 4 critères simples ci-dessous :" %}
</p>
<!-- 1. Planéité / Arasement -->
<div class="mb-3">
<label class="form-label small fw-bold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-distribute-vertical me-1 text-primary"></i>
1. {% translate "Planéité / Arasement" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="joint_level" id="hb_level_flush" value="flush" checked>
<label class="btn btn-outline-success btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_level_flush">
<i class="bi bi-check-circle"></i>
<span>{% translate "Bien à niveau / Arasé" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="joint_level" id="hb_level_slight" value="slight_bump_hollow">
<label class="btn btn-outline-warning btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_level_slight">
<i class="bi bi-exclamation-circle"></i>
<span>{% translate "Léger creux ou bosse" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="joint_level" id="hb_level_step" value="step">
<label class="btn btn-outline-danger btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_level_step">
<i class="bi bi-exclamation-triangle"></i>
<span>{% translate "Ressaut (> 1-2 cm)" %}</span>
</label>
</div>
</div>
</div>
<!-- 2. Joints de raccordement -->
<div class="mb-3">
<label class="form-label small fw-bold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-bounding-box me-1 text-info"></i>
2. {% translate "Joints de raccordement" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="joint_sealing" id="hb_sealing_good" value="good" checked>
<label class="btn btn-outline-success btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_sealing_good">
<i class="bi bi-check-circle"></i>
<span>{% translate "Bien fermés / scellés" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="joint_sealing" id="hb_sealing_cracked" value="cracked">
<label class="btn btn-outline-warning btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_sealing_cracked">
<i class="bi bi-exclamation-circle"></i>
<span>{% translate "Joints fissurés / abîmés" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="joint_sealing" id="hb_sealing_open" value="open">
<label class="btn btn-outline-danger btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_sealing_open">
<i class="bi bi-exclamation-triangle"></i>
<span>{% translate "Joints ouverts / décollés" %}</span>
</label>
</div>
</div>
</div>
<!-- 3. Fissuration -->
<div class="mb-3">
<label class="form-label small fw-bold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-activity me-1 text-warning"></i>
3. {% translate "Fissuration" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="cracking" id="hb_cracking_none" value="none" checked>
<label class="btn btn-outline-success btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_cracking_none">
<i class="bi bi-check-circle"></i>
<span>{% translate "Aucune fissure" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="cracking" id="hb_cracking_minor" value="minor">
<label class="btn btn-outline-warning btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_cracking_minor">
<i class="bi bi-exclamation-circle"></i>
<span>{% translate "Petites fissures / Faïençage" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check hotbox-input" name="cracking" id="hb_cracking_major" value="major">
<label class="btn btn-outline-danger btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_cracking_major">
<i class="bi bi-exclamation-triangle"></i>
<span>{% translate "Grosses fissures ouvertes" %}</span>
</label>
</div>
</div>
</div>
<!-- 4. Évacuation des eaux pluviales -->
<div class="mb-2">
<label class="form-label small fw-bold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-droplet-half me-1 text-primary"></i>
4. {% translate "Évacuation des eaux pluviales" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-6">
<input type="radio" class="btn-check hotbox-input" name="water_drainage" id="hb_drainage_good" value="good" checked>
<label class="btn btn-outline-success btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_drainage_good">
<i class="bi bi-check-circle"></i>
<span>{% translate "Bon écoulement / Pas de flaque" %}</span>
</label>
</div>
<div class="col-12 col-md-6">
<input type="radio" class="btn-check hotbox-input" name="water_drainage" id="hb_drainage_stagnation" value="stagnation">
<label class="btn btn-outline-warning btn-sm w-100 py-2 d-flex align-items-center justify-content-center gap-1 rounded-3" for="hb_drainage_stagnation">
<i class="bi bi-droplet-fill"></i>
<span>{% translate "Eau stagnante / Flaque d'eau" %}</span>
</label>
</div>
</div>
</div>
</div>
<script>
(function() {
function computeHotBoxStatus() {
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('hotboxRecommendedBadge');
if (badge) {
badge.className = `badge ${badgeClass} px-2.5 py-1 rounded-pill small`;
badge.innerHTML = `<i class="bi ${icon} me-1"></i>${badgeText}`;
}
// Auto-select the corresponding general inspection result status radio if present
const modal = document.getElementById('createInspectionModal') || document.getElementById('quickInspectionModal');
if (modal) {
const rad = modal.querySelector(`input[name="result_status"][value="${recommended}"]`);
if (rad) {
rad.checked = true;
}
}
}
document.querySelectorAll('.hotbox-input').forEach(input => {
input.addEventListener('change', computeHotBoxStatus);
});
})();
</script>

View file

@ -143,6 +143,135 @@
{% include "inspections/components/asset_fields/_naturerwiz.html" %}
</div>
<!-- Champs spécifiques aux réparations Hot Box (HotBoxInspection) -->
<div id="editHotBoxSpecificFields" class="border rounded-3 p-3 mb-3 bg-light d-none">
<div class="d-flex align-items-center justify-content-between mb-3 border-bottom pb-2">
<h6 class="fw-bold small text-primary mb-0">
<i class="bi bi-fire text-danger me-1"></i>{% translate "Contrôles visuels Hot Box" %}
</h6>
<span id="editHotboxRecommendedBadge" class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1 rounded-pill small">
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Suggéré : Conforme" %}
</span>
</div>
<!-- 1. Planéité -->
<div class="mb-3">
<label class="form-label small fw-semibold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-distribute-vertical me-1 text-primary"></i>
1. {% translate "Planéité / Arasement" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="joint_level" id="edit_hb_level_flush" value="flush">
<label class="btn btn-outline-success btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_level_flush">
<i class="bi bi-check-circle"></i>
<span>{% translate "Bien à niveau / Arasé" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="joint_level" id="edit_hb_level_slight" value="slight_bump_hollow">
<label class="btn btn-outline-warning btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_level_slight">
<i class="bi bi-exclamation-circle"></i>
<span>{% translate "Léger creux / bosse" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="joint_level" id="edit_hb_level_step" value="step">
<label class="btn btn-outline-danger btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_level_step">
<i class="bi bi-exclamation-triangle"></i>
<span>{% translate "Ressaut (> 1-2 cm)" %}</span>
</label>
</div>
</div>
</div>
<!-- 2. Joints de raccordement -->
<div class="mb-3">
<label class="form-label small fw-semibold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-bounding-box me-1 text-info"></i>
2. {% translate "Joints de raccordement" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="joint_sealing" id="edit_hb_sealing_good" value="good">
<label class="btn btn-outline-success btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_sealing_good">
<i class="bi bi-check-circle"></i>
<span>{% translate "Bien fermés / scellés" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="joint_sealing" id="edit_hb_sealing_cracked" value="cracked">
<label class="btn btn-outline-warning btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_sealing_cracked">
<i class="bi bi-exclamation-circle"></i>
<span>{% translate "Fissurés / abîmés" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="joint_sealing" id="edit_hb_sealing_open" value="open">
<label class="btn btn-outline-danger btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_sealing_open">
<i class="bi bi-exclamation-triangle"></i>
<span>{% translate "Ouverts / décollés" %}</span>
</label>
</div>
</div>
</div>
<!-- 3. Fissuration -->
<div class="mb-3">
<label class="form-label small fw-semibold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-activity me-1 text-warning"></i>
3. {% translate "Fissuration" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="cracking" id="edit_hb_cracking_none" value="none">
<label class="btn btn-outline-success btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_cracking_none">
<i class="bi bi-check-circle"></i>
<span>{% translate "Aucune fissure" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="cracking" id="edit_hb_cracking_minor" value="minor">
<label class="btn btn-outline-warning btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_cracking_minor">
<i class="bi bi-exclamation-circle"></i>
<span>{% translate "Petites fissures" %}</span>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check edit-hotbox-input" name="cracking" id="edit_hb_cracking_major" value="major">
<label class="btn btn-outline-danger btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_cracking_major">
<i class="bi bi-exclamation-triangle"></i>
<span>{% translate "Grosses fissures" %}</span>
</label>
</div>
</div>
</div>
<!-- 4. Évacuation des eaux pluviales -->
<div class="mb-2">
<label class="form-label small fw-semibold text-secondary mb-1 d-flex align-items-center">
<i class="bi bi-droplet-half me-1 text-primary"></i>
4. {% translate "Évacuation des eaux pluviales" %}
</label>
<div class="row g-2">
<div class="col-12 col-md-6">
<input type="radio" class="btn-check edit-hotbox-input" name="water_drainage" id="edit_hb_drainage_good" value="good">
<label class="btn btn-outline-success btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_drainage_good">
<i class="bi bi-check-circle"></i>
<span>{% translate "Bon écoulement / Pas de flaque" %}</span>
</label>
</div>
<div class="col-12 col-md-6">
<input type="radio" class="btn-check edit-hotbox-input" name="water_drainage" id="edit_hb_drainage_stagnation" value="stagnation">
<label class="btn btn-outline-warning btn-sm w-100 py-1.5 rounded-3 d-flex align-items-center justify-content-center gap-1" for="edit_hb_drainage_stagnation">
<i class="bi bi-droplet-fill"></i>
<span>{% translate "Eau stagnante / Flaque d'eau" %}</span>
</label>
</div>
</div>
</div>
</div>
<!-- Remarques -->
<div class="mb-3">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
@ -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 = `<i class="bi ${icon} me-1"></i>${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;

View file

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

View file

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