feat: add related inspections to intervention detail views and templates

This commit is contained in:
kdeterme 2026-09-18 10:44:58 +02:00
parent 6c80028906
commit 250a2b4f2f
17 changed files with 586 additions and 42 deletions

View file

@ -54,7 +54,7 @@
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span> <span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</td> </td>
<td> <td>
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }} <i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:"-" }}
</td> </td>
<td> <td>
<span class="small text-muted text-truncate d-inline-block" style="max-width: 220px;" title="{{ insp.details_summary }}"> <span class="small text-muted text-truncate d-inline-block" style="max-width: 220px;" title="{{ insp.details_summary }}">
@ -100,11 +100,31 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if insp.related_intervention %}
<div class="mb-3">
<a href="{% url 'interventions:intervention_detail' insp.related_intervention.id %}" class="btn btn-outline-primary w-100 fw-semibold" target="_blank">
<i class="bi bi-tools me-2"></i>{% translate "Consulter l'intervention" %} {{ insp.related_intervention.code }}
</a>
</div>
{% endif %}
<ul class="list-group list-group-flush mb-3"> <ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Équipement cible" %}</span> <span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Équipement cible" %}</span>
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span> <span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
</li> </li>
{% if insp.related_intervention %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
<span class="fw-semibold text-end ms-2">
<a href="{% url 'interventions:intervention_detail' insp.related_intervention.id %}" class="text-decoration-none fw-bold" target="_blank">
<i class="bi bi-box-arrow-up-right me-1"></i>{{ insp.related_intervention.code }}
</a>
{% if insp.related_intervention.sub_type %}
<span class="badge bg-light text-dark border ms-1">{{ insp.related_intervention.sub_type.get_name }}</span>
{% endif %}
</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span> <span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span> <span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
@ -119,7 +139,7 @@
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span> <span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}</span> <span class="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span> <span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>

View file

@ -24,6 +24,8 @@ class InspectionViewsTestCase(TestCase):
from common.models import UserConfig, UserThematics, Thematic from common.models import UserConfig, UserThematics, Thematic
user_config, _ = UserConfig.objects.get_or_create(user=self.user) user_config, _ = UserConfig.objects.get_or_create(user=self.user)
user_config.is_intern = True
user_config.save()
t_parking, _ = Thematic.objects.get_or_create(code='parking', defaults={'name_fr': 'Stationnement'}) t_parking, _ = Thematic.objects.get_or_create(code='parking', defaults={'name_fr': 'Stationnement'})
UserThematics.objects.update_or_create(user_config=user_config, thematic=t_parking, defaults={'can_inspect': True, 'can_view_assets': True}) UserThematics.objects.update_or_create(user_config=user_config, thematic=t_parking, defaults={'can_inspect': True, 'can_view_assets': True})
self.client.force_login(self.user) self.client.force_login(self.user)
@ -37,7 +39,7 @@ class InspectionViewsTestCase(TestCase):
'standard_stand_count': 5 'standard_stand_count': 5
} }
) )
self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326) self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812)
self.spot.save() self.spot.save()
self.spot_ct = ContentType.objects.get_for_model(ParkingSpot) self.spot_ct = ContentType.objects.get_for_model(ParkingSpot)
@ -52,7 +54,10 @@ class InspectionViewsTestCase(TestCase):
response = self.client.get(reverse('inspections:inspections_list')) response = self.client.get(reverse('inspections:inspections_list'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'PK-001')
response_mobile = self.client.get(reverse('mobile:inspections_list_mobile'))
self.assertEqual(response_mobile.status_code, 200)
self.assertContains(response_mobile, 'PK-001')
def test_quick_inspection_map_view(self): def test_quick_inspection_map_view(self):
response = self.client.get(reverse('inspections:quick_inspection_map')) response = self.client.get(reverse('inspections:quick_inspection_map'))
@ -162,7 +167,7 @@ class InspectionViewsTestCase(TestCase):
def test_inspections_geojson_view(self): def test_inspections_geojson_view(self):
from django.contrib.gis.geos import Polygon from django.contrib.gis.geos import Polygon
self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326) self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812)
self.spot.save() self.spot.save()
AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete() AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete()
@ -198,7 +203,7 @@ class InspectionViewsTestCase(TestCase):
nature='visual' nature='visual'
) )
response = self.client.get(reverse('inspections:inspections_list') + '?tab=all&q=SEARCH-99') response = self.client.get(reverse('mobile:inspections_list_mobile') + '?tab=all&q=SEARCH-99')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn(insp, response.context['inspections']) self.assertIn(insp, response.context['inspections'])
@ -209,11 +214,13 @@ class InspectionViewsTestCase(TestCase):
user_custom = User.objects.create_user(username='user_custom_perm', password='pwd') user_custom = User.objects.create_user(username='user_custom_perm', password='pwd')
user_config, _ = UserConfig.objects.get_or_create(user=user_custom) user_config, _ = UserConfig.objects.get_or_create(user=user_custom)
user_config.is_intern = True
user_config.save()
UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_view_assets=True, can_inspect=False) UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_view_assets=True, can_inspect=False)
UserThematics.objects.create(user_config=user_config, thematic=t_lighting, can_view_assets=False, can_inspect=False) UserThematics.objects.create(user_config=user_config, thematic=t_lighting, can_view_assets=False, can_inspect=False)
self.client.force_login(user_custom) self.client.force_login(user_custom)
resp = self.client.get(reverse('inspections:inspections_list')) resp = self.client.get(reverse('mobile:inspections_list_mobile'))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertIn(t_parking, resp.context['thematics']) self.assertIn(t_parking, resp.context['thematics'])
self.assertNotIn(t_lighting, resp.context['thematics']) self.assertNotIn(t_lighting, resp.context['thematics'])
@ -246,3 +253,54 @@ class InspectionViewsTestCase(TestCase):
self.assertEqual(data['total_count'], 1) self.assertEqual(data['total_count'], 1)
self.assertIn(insp, data['inspections']) self.assertIn(insp, data['inspections'])
self.assertEqual(data['asset_pk'], self.spot.pk) self.assertEqual(data['asset_pk'], self.spot.pk)
def test_asset_inspections_templatetag_renders_with_null_inspector(self):
"""Vérifie que le composant asset_inspections se rend sans erreur quand inspector est None."""
from assets.models import AssetInspection
from django.template import Template, Context
AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete()
insp = AssetInspection.objects.create(
asset=self.spot,
inspector=None,
result_status='pending',
nature='post_intervention'
)
request = self.client.get(reverse('inspections:inspections_list')).wsgi_request
t = Template('{% load asset_tags %}{% asset_inspections asset %}')
rendered = t.render(Context({'request': request, 'asset': self.spot}))
self.assertIn('assetInspectionDetailModal', rendered)
self.assertIn('-', rendered)
def test_roadway_detail_renders_with_inspection_without_inspector(self):
"""Vérifie que la page de détail d'une Roadway avec une inspection planifiée (sans inspecteur) ne plante pas (500)."""
from assets.models.roads import RoadStreet, Roadway
from common.models import UserThematics, Thematic, UserConfig
from inspections.models import Inspection
from django.contrib.gis.geos import MultiPolygon, Polygon
t_roads, _ = Thematic.objects.get_or_create(code='roads', defaults={'name_fr': 'Voiries'})
user_config = UserConfig.objects.get(user=self.user)
UserThematics.objects.update_or_create(user_config=user_config, thematic=t_roads, defaults={'can_view_assets': True})
street = RoadStreet.objects.create(code='WAT_R22_TEST_VIEW', name_fr='Chaussée Test')
p = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)))
roadway = Roadway.objects.create(
street=street,
geom=MultiPolygon(p, srid=3812)
)
# Créer une inspection ciblant la Roadway sans inspecteur (comme une inspection de durabilité automatique)
insp = Inspection.objects.create(
asset_content_type=ContentType.objects.get_for_model(Roadway),
asset_object_id=roadway.id,
inspector=None,
nature='post_intervention',
status='scheduled'
)
url = reverse('assets:roads_assets_detail', kwargs={'asset_model': 'roadway', 'asset_id': roadway.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View file

@ -435,7 +435,7 @@
<i class="bi {{ entry.status|status_icon }}"></i> <i class="bi {{ entry.status|status_icon }}"></i>
{{ entry.get_status_display }} {{ entry.get_status_display }}
</span> </span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span> <span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
</div> </div>
{% if entry.description %} {% if entry.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div> <div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
@ -486,7 +486,7 @@
<i class="bi {{ remark.status|status_icon }}"></i> <i class="bi {{ remark.status|status_icon }}"></i>
{{ remark.get_status_display }} {{ remark.get_status_display }}
</span> </span>
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }})</span> <span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }})</span>
</div> </div>
{% if remark.description %} {% if remark.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div> <div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>

View file

@ -467,7 +467,7 @@
<!-- ✅ Infos sur l’utilisateur et la date (dernière mise à jour) --> <!-- ✅ Infos sur l’utilisateur et la date (dernière mise à jour) -->
<p class="small text-muted mb-2"> <p class="small text-muted mb-2">
{% load short_name %} {% load short_name %}
{% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by|short_name|default:remark.updated_by.username }}</span> {% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by|short_name|default:"-" }}</span>
{% translate 'le' %} <span id="updated-at-{{ remark.id }}">{{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span> {% translate 'le' %} <span id="updated-at-{{ remark.id }}">{{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
</p> </p>
@ -483,7 +483,7 @@
<i class="bi {{ entry.status|status_icon }}"></i> <i class="bi {{ entry.status|status_icon }}"></i>
{{ entry.get_status_display }} {{ entry.get_status_display }}
</span> </span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span> <span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
</div> </div>
{% if entry.description %} {% if entry.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div> <div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
@ -534,7 +534,7 @@
<i class="bi {{ remark.status|status_icon }}"></i> <i class="bi {{ remark.status|status_icon }}"></i>
{{ remark.get_status_display }} {{ remark.get_status_display }}
</span> </span>
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }})</span> <span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }})</span>
</div> </div>
{% if remark.description %} {% if remark.description %}
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div> <div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>

View file

@ -36,7 +36,7 @@
<i class="bi {{ entry.status|status_icon }}"></i> <i class="bi {{ entry.status|status_icon }}"></i>
{{ entry.get_status_display }} {{ entry.get_status_display }}
</span> </span>
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span> <span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
</div> </div>
{% if entry.description %} {% if entry.description %}
<div class="small text-dark" style="white-space: pre-wrap;">{{ entry.description }}</div> <div class="small text-dark" style="white-space: pre-wrap;">{{ entry.description }}</div>

View file

@ -244,6 +244,38 @@ class Inspection(models.Model):
def is_cross_check(self): def is_cross_check(self):
return self.checked_intervention_id is not None return self.checked_intervention_id is not None
@property
def related_intervention(self):
"""
Renvoie l'intervention liée à cette inspection si elle existe :
- checked_intervention (intervention de travaux contrôlée, ex: durabilité)
- mission_intervention (mission d'inspection)
- asset si la cible de l'inspection est une intervention
"""
if self.checked_intervention_id:
return self.checked_intervention
if self.mission_intervention_id:
return self.mission_intervention
if self.asset_content_type and self.asset_content_type.model == 'intervention':
return self.asset
return None
@property
def related_intervention_url(self):
interv = self.related_intervention
if interv:
from django.urls import reverse
return reverse('interventions:intervention_detail', kwargs={'intervention_id': interv.id})
return ''
@property
def mobile_related_intervention_url(self):
interv = self.related_intervention
if interv:
from django.urls import reverse
return reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': interv.id})
return ''
@property @property
def is_cancelled(self): def is_cancelled(self):
return self.result_status == 'cancelled' or self.status == 'cancelled' return self.result_status == 'cancelled' or self.status == 'cancelled'

View file

@ -88,6 +88,11 @@ document.addEventListener('DOMContentLoaded', () => {
inspectionsgeojsonloader.loadGeojson(initialUrl); inspectionsgeojsonloader.loadGeojson(initialUrl);
_updateExportExcelUrl(query); _updateExportExcelUrl(query);
const urlInspectionId = new URLSearchParams(window.location.search).get('inspection_id');
if (urlInspectionId) {
openInspectionDetailModal(parseInt(urlInspectionId, 10));
}
}); });
inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => { inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => {
@ -923,6 +928,37 @@ window.openInspectionDetailModal = function(inspectionId) {
} }
} }
// Intervention link
const intervBtnContainer = document.getElementById('detail-intervention-btn-container');
const intervLink = document.getElementById('detail-intervention-link');
const intervBtnText = document.getElementById('detail-intervention-btn-text');
const intervRow = document.getElementById('detail-intervention-row');
const intervRowLink = document.getElementById('detail-intervention-row-link');
const intervCode = document.getElementById('detail-intervention-code');
const intervBadge = document.getElementById('detail-intervention-badge');
if (data.intervention_url && data.intervention_code) {
if (intervLink) intervLink.href = data.intervention_url;
if (intervBtnText) intervBtnText.textContent = `${gettext("Intervention")} ${data.intervention_code}`;
if (intervBtnContainer) intervBtnContainer.classList.remove('d-none');
if (intervRow) {
intervRow.classList.remove('d-none');
if (intervRowLink) intervRowLink.href = data.intervention_url;
if (intervCode) intervCode.textContent = data.intervention_code;
if (intervBadge) {
if (data.intervention_subtype) {
intervBadge.textContent = data.intervention_subtype;
intervBadge.classList.remove('d-none');
} else {
intervBadge.classList.add('d-none');
}
}
}
} else {
if (intervBtnContainer) intervBtnContainer.classList.add('d-none');
if (intervRow) intervRow.classList.add('d-none');
}
// Key fields // Key fields
document.getElementById('detail-target-display').textContent = data.target_display || '-'; document.getElementById('detail-target-display').textContent = data.target_display || '-';
document.getElementById('detail-asset-type').textContent = data.asset_type_name || '-'; document.getElementById('detail-asset-type').textContent = data.asset_type_name || '-';

View file

@ -186,11 +186,13 @@
<button class="nav-link active" id="inspections-table-tab" data-bs-toggle="tab" data-bs-target="#inspections-table-content" type="button" role="tab" aria-controls="inspections-table" aria-selected="true">{% translate "Inspections" %}</button> <button class="nav-link active" id="inspections-table-tab" data-bs-toggle="tab" data-bs-target="#inspections-table-content" type="button" role="tab" aria-controls="inspections-table" aria-selected="true">{% translate "Inspections" %}</button>
</li> </li>
{% if can_inspect %}
<div class="my-1 ms-2"> <div class="my-1 ms-2">
<a href="{% url 'inspections:quick_inspection_map' %}" class="btn btn-light" title="{% translate 'Nouvelle inspection sur carte' %}"> <a href="{% url 'inspections:quick_inspection_map' %}" class="btn btn-light" title="{% translate 'Nouvelle inspection sur carte' %}">
<i class="bi bi-plus-circle"></i> <i class="bi bi-plus-circle"></i>
</a> </a>
</div> </div>
{% endif %}
</ul> </ul>
<div class="tab-content my-2" id="tablesTabContent"> <div class="tab-content my-2" id="tablesTabContent">
@ -270,10 +272,17 @@
</div> </div>
</div> </div>
<div id="detail-asset-btn-container" class="mb-3 d-none"> <div class="row g-2 mb-3">
<a href="#" id="detail-asset-link" class="btn btn-primary w-100 fw-semibold"> <div id="detail-asset-btn-container" class="col-12 col-sm d-none">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %} <a href="#" id="detail-asset-link" class="btn btn-primary w-100 fw-semibold">
</a> <i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
<div id="detail-intervention-btn-container" class="col-12 col-sm d-none">
<a href="#" id="detail-intervention-link" class="btn btn-outline-primary w-100 fw-semibold">
<i class="bi bi-tools me-2"></i><span id="detail-intervention-btn-text">{% translate "Consulter l'intervention" %}</span>
</a>
</div>
</div> </div>
<ul class="list-group list-group-flush mb-3"> <ul class="list-group list-group-flush mb-3">
@ -285,6 +294,15 @@
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span> <span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary" id="detail-asset-type">-</span> <span class="badge bg-secondary" id="detail-asset-type">-</span>
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0 d-none" id="detail-intervention-row">
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
<span class="fw-semibold text-end ms-2">
<a href="#" id="detail-intervention-row-link" class="text-decoration-none fw-bold">
<i class="bi bi-box-arrow-up-right me-1"></i><span id="detail-intervention-code"></span>
</a>
<span id="detail-intervention-badge" class="badge bg-light text-dark border ms-1 d-none"></span>
</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span> <span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold" id="detail-date">-</span> <span class="fw-semibold" id="detail-date">-</span>

View file

@ -163,7 +163,7 @@
</span> </span>
{% endif %} {% endif %}
{% if insp.inspector %} {% if insp.inspector %}
<span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name|default:insp.inspector.username }}</span> <span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name }}</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -190,12 +190,23 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Lien vers la fiche de l'asset --> <!-- Boutons d'action : asset et/ou intervention -->
{% if insp.asset %} {% if insp.asset or insp.related_intervention %}
<div class="mb-3"> <div class="row g-2 mb-3">
<a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold"> {% if insp.asset %}
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %} <div class="col-12 {% if insp.related_intervention %}col-sm-6{% endif %}">
</a> <a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
{% endif %}
{% if insp.related_intervention %}
<div class="col-12 {% if insp.asset %}col-sm-6{% endif %}">
<a href="{% if is_mobile %}{{ insp.mobile_related_intervention_url }}{% else %}{{ insp.related_intervention_url }}{% endif %}" class="btn btn-outline-primary w-100 fw-semibold">
<i class="bi bi-tools me-2"></i>{% translate "Intervention" %} {{ insp.related_intervention.code }}
</a>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@ -208,6 +219,19 @@
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span> <span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span> <span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li> </li>
{% if insp.related_intervention %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
<span class="fw-semibold text-end ms-2">
<a href="{% if is_mobile %}{{ insp.mobile_related_intervention_url }}{% else %}{{ insp.related_intervention_url }}{% endif %}" class="text-decoration-none fw-bold">
<i class="bi bi-box-arrow-up-right me-1"></i>{{ insp.related_intervention.code }}
</a>
{% if insp.related_intervention.sub_type %}
<span class="badge bg-light text-dark border ms-1">{{ insp.related_intervention.sub_type.get_name }}</span>
{% endif %}
</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span> <span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span> <span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
@ -222,7 +246,7 @@
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span> <span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}</span> <span class="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span> <span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
@ -477,7 +501,7 @@ document.addEventListener('DOMContentLoaded', function() {
result: "{{ insp.get_result_status_display|escapejs }}", result: "{{ insp.get_result_status_display|escapejs }}",
result_code: "{{ insp.result_status|escapejs }}", result_code: "{{ insp.result_status|escapejs }}",
badge_class: "{{ insp.status_badge_class|escapejs }}", badge_class: "{{ insp.status_badge_class|escapejs }}",
inspector: "{{ insp.inspector|short_name|default:insp.inspector.username|escapejs }}", inspector: "{{ insp.inspector|short_name|escapejs }}",
nature: "{{ insp.get_nature_display|escapejs }}", nature: "{{ insp.get_nature_display|escapejs }}",
summary: "{{ insp.details_summary|escapejs }}" summary: "{{ insp.details_summary|escapejs }}"
}{% if not forloop.last %},{% endif %} }{% if not forloop.last %},{% endif %}

View file

@ -225,7 +225,7 @@
</span> </span>
{% endif %} {% endif %}
{% if insp.inspector %} {% if insp.inspector %}
<span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name|default:insp.inspector.username }}</span> <span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name }}</span>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@ -253,12 +253,23 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Lien vers la fiche de l'asset --> <!-- Boutons d'action : asset et/ou intervention -->
{% if insp.asset %} {% if insp.asset or insp.related_intervention %}
<div class="mb-3"> <div class="row g-2 mb-3">
<a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold"> {% if insp.asset %}
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %} <div class="col-12 {% if insp.related_intervention %}col-6{% endif %}">
</a> <a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Fiche de l'asset" %}
</a>
</div>
{% endif %}
{% if insp.related_intervention %}
<div class="col-12 {% if insp.asset %}col-6{% endif %}">
<a href="{% if is_mobile %}{{ insp.mobile_related_intervention_url }}{% else %}{{ insp.related_intervention_url }}{% endif %}" class="btn btn-outline-primary w-100 fw-semibold">
<i class="bi bi-tools me-2"></i>{% translate "Intervention" %} {{ insp.related_intervention.code }}
</a>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@ -271,6 +282,19 @@
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span> <span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span> <span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li> </li>
{% if insp.related_intervention %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
<span class="fw-semibold text-end ms-2">
<a href="{% if is_mobile %}{{ insp.mobile_related_intervention_url }}{% else %}{{ insp.related_intervention_url }}{% endif %}" class="text-decoration-none fw-bold">
<i class="bi bi-box-arrow-up-right me-1"></i>{{ insp.related_intervention.code }}
</a>
{% if insp.related_intervention.sub_type %}
<span class="badge bg-light text-dark border ms-1">{{ insp.related_intervention.sub_type.get_name }}</span>
{% endif %}
</span>
</li>
{% endif %}
{% if insp.is_scheduled %} {% if insp.is_scheduled %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-hourglass-split me-1"></i>{% translate "Statut" %}</span> <span class="text-muted"><i class="bi bi-hourglass-split me-1"></i>{% translate "Statut" %}</span>
@ -310,7 +334,7 @@
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span> <span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}</span> <span class="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
</li> </li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span> <span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
@ -570,7 +594,7 @@ document.addEventListener('DOMContentLoaded', function() {
result: "{{ insp.get_result_status_display|escapejs }}", result: "{{ insp.get_result_status_display|escapejs }}",
result_code: "{{ insp.result_status|escapejs }}", result_code: "{{ insp.result_status|escapejs }}",
badge_class: "{{ insp.status_badge_class|escapejs }}", badge_class: "{{ insp.status_badge_class|escapejs }}",
inspector: "{{ insp.inspector|short_name|default:insp.inspector.username|escapejs }}", inspector: "{{ insp.inspector|short_name|escapejs }}",
nature: "{{ insp.get_nature_display|escapejs }}", nature: "{{ insp.get_nature_display|escapejs }}",
summary: "{{ insp.details_summary|escapejs }}" summary: "{{ insp.details_summary|escapejs }}"
}{% if not forloop.last %},{% endif %} }{% if not forloop.last %},{% endif %}

View file

@ -1492,7 +1492,7 @@ class PostInterventionDurabilityInspectionTestCase(TestCase):
) )
from common.models import UserConfig, Role, UserThematics from common.models import UserConfig, Role, UserThematics
inspector_role, _ = Role.objects.get_or_create(name='inspector') inspector_role, _ = Role.objects.get_or_create(name='inspector')
config = UserConfig.objects.create(user=self.user) config = UserConfig.objects.create(user=self.user, is_intern=True)
config.roles.add(inspector_role) config.roles.add(inspector_role)
self.roads_thematic, _ = Thematic.objects.get_or_create( self.roads_thematic, _ = Thematic.objects.get_or_create(
@ -1535,6 +1535,18 @@ class PostInterventionDurabilityInspectionTestCase(TestCase):
requires_post_inspection=False requires_post_inspection=False
) )
def _create_finished_intervention(self, sub_type=None):
sub_type = sub_type or self.hotbox_subtype
return Intervention.objects.create(
thematic=self.roads_thematic,
type='repair',
sub_type=sub_type,
status='finished',
created_by=self.user,
assigned_team=self.team,
end_time=timezone.now()
)
def test_hotbox_intervention_finished_triggers_inspection(self): def test_hotbox_intervention_finished_triggers_inspection(self):
from datetime import timedelta from datetime import timedelta
now = timezone.now() now = timezone.now()
@ -1773,5 +1785,69 @@ class PostInterventionDurabilityInspectionTestCase(TestCase):
self.assertNotIn(cat_mobility, form.fields['concerned_categories'].queryset) self.assertNotIn(cat_mobility, form.fields['concerned_categories'].queryset)
self.assertIn('data-cat-models', form.fields['target_asset_model'].widget.attrs) self.assertIn('data-cat-models', form.fields['target_asset_model'].widget.attrs)
def test_inspection_related_intervention_properties_and_api(self):
"""Vérifie les propriétés related_intervention de l'inspection et les données renvoyées par get_inspection_detail_api."""
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)
# Propriétés du modèle
self.assertEqual(insp.related_intervention, intervention)
expected_url = reverse('interventions:intervention_detail', kwargs={'intervention_id': intervention.id})
expected_mobile_url = reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': intervention.id})
self.assertEqual(insp.related_intervention_url, expected_url)
self.assertEqual(insp.mobile_related_intervention_url, expected_mobile_url)
# API get_inspection_detail_api
self.client.force_login(self.user)
api_url = reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': insp.id})
resp = self.client.get(api_url)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertTrue(data['success'])
self.assertEqual(data['intervention_id'], intervention.id)
self.assertEqual(data['intervention_code'], intervention.code)
self.assertEqual(data['intervention_url'], expected_url)
self.assertEqual(data['mobile_intervention_url'], expected_mobile_url)
def test_intervention_related_inspections_and_detail_view(self):
"""Vérifie que l'intervention expose ses inspections liées et que la vue détail les affiche dans le bloc Contrôles."""
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)
# Méthode du modèle
related = intervention.get_related_inspections(user=self.user)
self.assertIn(insp, related)
# Page détail de l'intervention
self.client.force_login(self.user)
detail_url = reverse('interventions:intervention_detail', kwargs={'intervention_id': intervention.id})
resp = self.client.get(detail_url)
self.assertEqual(resp.status_code, 200)
self.assertIn('visible_inspections', resp.context)
self.assertIn(insp, resp.context['visible_inspections'])
# Vérifie la présence du lien vers l'inspection dans le HTML
self.assertContains(resp, f"inspection_id={insp.id}")
self.assertContains(resp, "Inspections de terrain / conformité")
def test_intervention_detail_mobile_renders_linked_inspections(self):
"""Vérifie que la vue mobile de l'intervention affiche aussi les inspections dans le bloc Contrôles."""
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.client.force_login(self.user)
mobile_url = reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': intervention.id})
resp = self.client.get(mobile_url)
self.assertEqual(resp.status_code, 200)
self.assertIn('visible_inspections', resp.context)
self.assertIn(insp, resp.context['visible_inspections'])
self.assertContains(resp, f"inspection_id={insp.id}")
self.assertContains(resp, "Inspections de terrain")

View file

@ -864,6 +864,11 @@ def get_inspection_detail_api(request, inspection_id):
'checked_intervention_code': leaf.checked_intervention.code if leaf.checked_intervention else '', 'checked_intervention_code': leaf.checked_intervention.code if leaf.checked_intervention else '',
'checked_intervention_id': leaf.checked_intervention_id, 'checked_intervention_id': leaf.checked_intervention_id,
'checked_intervention_subtype': leaf.checked_intervention.sub_type.get_name() if leaf.checked_intervention and leaf.checked_intervention.sub_type else '', 'checked_intervention_subtype': leaf.checked_intervention.sub_type.get_name() if leaf.checked_intervention and leaf.checked_intervention.sub_type else '',
'intervention_id': leaf.related_intervention.id if leaf.related_intervention else None,
'intervention_code': leaf.related_intervention.code if leaf.related_intervention else '',
'intervention_url': leaf.related_intervention_url,
'mobile_intervention_url': leaf.mobile_related_intervention_url,
'intervention_subtype': leaf.related_intervention.sub_type.get_name() if leaf.related_intervention and getattr(leaf.related_intervention, 'sub_type', None) else '',
'inspector_name': get_short_name(leaf.inspector) if leaf.inspector else '-', 'inspector_name': get_short_name(leaf.inspector) if leaf.inspector else '-',
'inspector_role': leaf.inspector_role, 'inspector_role': leaf.inspector_role,
'inspector_role_display': str(leaf.get_inspector_role_display()) if leaf.inspector_role else '', 'inspector_role_display': str(leaf.get_inspector_role_display()) if leaf.inspector_role else '',
@ -1173,8 +1178,8 @@ def inspections_geojson(request):
ct_ids.add(ContentType.objects.get_for_model(SignPole).id) ct_ids.add(ContentType.objects.get_for_model(SignPole).id)
elif thematic_code == 'parking': elif thematic_code == 'parking':
from assets.models import ParkingSpot, ParkingLocation from assets.models import ParkingSpot, ParkingLocation
ct_ids.add(ContentType.objects.get_for_model(ParkingSpot)) ct_ids.add(ContentType.objects.get_for_model(ParkingSpot).id)
ct_ids.add(ContentType.objects.get_for_model(ParkingLocation)) ct_ids.add(ContentType.objects.get_for_model(ParkingLocation).id)
except Thematic.DoesNotExist: except Thematic.DoesNotExist:
pass pass

View file

@ -849,6 +849,37 @@ class Intervention(models.Model):
), ),
] ]
def get_absolute_url(self):
from django.urls import reverse
return reverse('interventions:intervention_detail', kwargs={'intervention_id': self.pk})
def get_related_inspections(self, user=None):
"""
Retourne l'ensemble des inspections liées à cette intervention :
- quality_inspections (checked_intervention, ex: durabilité post-travaux)
- generated_inspections (mission_intervention, ex: inspection de contrôle)
- inspections ciblant directement cette intervention
"""
from inspections.models import Inspection
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(self.__class__)
qs = Inspection.objects.filter(
models.Q(checked_intervention=self) |
models.Q(mission_intervention=self) |
(models.Q(asset_content_type=ct) & models.Q(asset_object_id=self.id))
).distinct().select_related(
'asset_content_type', 'inspector', 'checked_intervention', 'mission_intervention'
).prefetch_related('documents').order_by('-inspection_date', '-created_at')
if user:
from inspections.permissions import can_view_inspection
return [insp for insp in qs if can_view_inspection(user, insp)]
return list(qs)
@property
def related_inspections(self):
return self.get_related_inspections()
@property @property
def intervention_sheet_filename(self): def intervention_sheet_filename(self):
if self.intervention_sheet: if self.intervention_sheet:

View file

@ -1,5 +1,5 @@
{% load i18n %} {% load i18n short_name %}
<div id="section-controles" class="card mb-4 {% if intervention.status == 'in_preparation' %} d-none {% endif %}"> <div id="section-controles" class="card mb-4 {% if intervention.status == 'in_preparation' and not visible_controls and not visible_inspections %} d-none {% endif %}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h4>{% translate "Contrôles / Rapports de suivi" %}</h4> <h4>{% translate "Contrôles / Rapports de suivi" %}</h4>
{% if 'follow_up_reports' in allowed_update_fields %} {% if 'follow_up_reports' in allowed_update_fields %}
@ -43,7 +43,11 @@
</div> </div>
{% endif %} {% endif %}
<table class="table"> {% if visible_controls %}
<h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-file-earmark-text text-primary me-1"></i>{% translate "Rapports de contrôle" %}
</h6>
<table class="table mb-3">
<thead> <thead>
<tr> <tr>
<th>{% translate "Date" %}</th> <th>{% translate "Date" %}</th>
@ -63,9 +67,188 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% elif not visible_inspections %}
<p class="text-muted small mb-0">{% translate "Aucun contrôle ou rapport pour le moment." %}</p>
{% endif %}
{% if visible_inspections %}
<div class="{% if visible_controls %}mt-4 pt-3 border-top{% endif %}">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-bold text-secondary mb-0">
<i class="bi bi-patch-check text-primary me-1"></i>{% translate "Inspections de terrain / conformité" %}
<span class="badge bg-secondary-subtle text-secondary rounded-pill ms-1">{{ visible_inspections|length }}</span>
</h6>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Inspection" %}</th>
<th>{% translate "Date / Échéance" %}</th>
<th>{% translate "Nature" %}</th>
<th>{% translate "Cible" %}</th>
<th>{% translate "Inspecteur" %}</th>
<th>{% translate "Statut / Résultat" %}</th>
<th class="text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for insp in visible_inspections %}
<tr>
<td>
<a href="{% url 'inspections:inspections_list' %}?inspection_id={{ insp.id }}" class="fw-bold text-decoration-none">
<i class="bi bi-patch-check me-1 text-primary"></i>#{{ insp.id }}
</a>
</td>
<td>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i"|default:"-" }}</span>
{% if insp.is_scheduled and insp.scheduled_start_date %}
<div class="small text-muted">
{% translate "Prévue du" %} {{ insp.scheduled_start_date|date:"d/m/Y" }} {% translate "au" %} {{ insp.scheduled_due_date|date:"d/m/Y" }}
</div>
{% endif %}
</td>
<td>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</td>
<td>
{% if insp.asset and insp.asset.get_absolute_url %}
<a href="{{ insp.asset.get_absolute_url }}" class="text-decoration-none" title="{% translate "Consulter l'asset" %}">
<i class="bi bi-geo-alt me-1"></i>{{ insp.target_display }}
</a>
{% else %}
<span><i class="bi bi-geo-alt me-1"></i>{{ insp.target_display }}</span>
{% endif %}
</td>
<td>
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:"-" }}
</td>
<td>
<span class="badge {{ insp.status_badge_class }}">
<i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}
</span>
{% if insp.is_scheduled %}
<span class="badge bg-info text-dark ms-1">{% translate "Planifiée" %}</span>
{% endif %}
</td>
<td class="text-end">
<button type="button" class="btn btn-xs btn-outline-primary py-1 px-2 me-1" data-bs-toggle="modal" data-bs-target="#interventionInspectionDetailModal{{ insp.id }}" title="{% translate "Détail" %}">
<i class="bi bi-eye me-1"></i>{% translate "Détail" %}
</button>
<a href="{% url 'inspections:inspections_list' %}?inspection_id={{ insp.id }}" class="btn btn-xs btn-outline-secondary py-1 px-2" title="{% translate "Consulter dans la carte des inspections" %}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
{% for insp in visible_inspections %}
<!-- Modale de détail de l'inspection liée -->
<div class="modal fade" id="interventionInspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="interventionInspectionDetailModalLabel{{ insp.id }}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light py-2.5">
<h5 class="modal-title fs-6 fw-bold" id="interventionInspectionDetailModalLabel{{ insp.id }}">
<i class="bi bi-patch-check me-2 text-primary"></i>{% translate "Détail de l'inspection" %} #{{ insp.id }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
{% if insp.is_cancelled %}
<div class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3">
<i class="bi bi-slash-circle me-2 fs-6"></i>
<div>
<strong>{% translate "Inspection annulée" %}</strong> — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
</div>
</div>
{% endif %}
{% if insp.asset and insp.asset.get_absolute_url %}
<div class="mb-3">
<a href="{{ insp.asset.get_absolute_url }}" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
{% endif %}
<ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i"|default:"-" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
<span class="badge {{ insp.status_badge_class }}"><i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
<span class="fw-semibold">{{ insp.updated_at|date:"d/m/Y H:i"|default:"-" }}</span>
</li>
</ul>
{% if insp.notes %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
<div class="text-dark small">{{ insp.notes }}</div>
</div>
{% endif %}
{% if insp.documents.all %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
</h6>
<div class="d-flex flex-wrap gap-2 align-items-center">
{% for doc in insp.documents.all %}
{% if doc.is_image %}
<a href="{{ doc.file_url }}" target="_blank" class="d-inline-block border rounded p-1 bg-white" title="{{ doc.filename }}">
<img src="{{ doc.file_url }}" alt="{{ doc.filename }}" class="rounded" style="width: 75px; height: 75px; object-fit: cover;">
</a>
{% else %}
<a href="{{ doc.file_url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1" title="{{ doc.filename }}">
<i class="bi bi-file-earmark-arrow-down text-primary"></i>
<span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span>
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="modal-footer bg-light py-2">
<a href="{% url 'inspections:inspections_list' %}?inspection_id={{ insp.id }}" class="btn btn-primary btn-sm">
<i class="bi bi-box-arrow-up-right me-1"></i>{% translate "Ouvrir dans Inspections" %}
</a>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endfor %}

View file

@ -1398,9 +1398,42 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% elif not visible_inspections %}
<p class="text-muted mb-0 small">{% translate "Aucun contrôle pour le moment." %}</p> <p class="text-muted mb-0 small">{% translate "Aucun contrôle pour le moment." %}</p>
{% endif %} {% endif %}
{% if visible_inspections %}
<div class="{% if visible_controls %}mt-3 pt-3 border-top{% endif %}">
<div class="fw-semibold text-secondary mb-2 small text-uppercase">
<i class="bi bi-patch-check text-primary me-1"></i>{% translate "Inspections de terrain" %} ({{ visible_inspections|length }})
</div>
{% for insp in visible_inspections %}
<div class="card mb-2 bg-light border p-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<a href="{% url 'inspections:inspections_list' %}?inspection_id={{ insp.id }}" class="fw-bold small text-decoration-none">
<i class="bi bi-patch-check me-1 text-primary"></i>{% translate "Inspection" %} #{{ insp.id }}
</a>
<span class="badge {{ insp.status_badge_class }}">
{{ insp.get_result_status_display }}
</span>
</div>
<div class="small text-muted mb-1">
<span class="badge bg-light text-dark border me-1">{{ insp.get_nature_display }}</span>
{% if insp.is_scheduled %}
<span class="badge bg-info text-dark">{% translate "Planifiée" %}</span>
{% endif %}
</div>
<div class="small text-muted mb-1">
<i class="bi bi-geo-alt me-1"></i>{{ insp.target_display }}
</div>
<div class="d-flex justify-content-between align-items-center small text-muted">
<span><i class="bi bi-calendar-event me-1"></i>{{ insp.inspection_date|date:"d/m/Y H:i"|default:"-" }}</span>
<span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name|default:"-" }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -502,6 +502,7 @@ def intervention_detail(request, intervention_id):
control for control in intervention.related_controls.all() control for control in intervention.related_controls.all()
if user_can_view_control(request.user, control) if user_can_view_control(request.user, control)
] ]
visible_inspections = intervention.get_related_inspections(user=request.user)
# Bons de préparation liés à l'intervention: # Bons de préparation liés à l'intervention:
# - admin # - admin
@ -741,6 +742,7 @@ def intervention_detail(request, intervention_id):
'can_take_over': bool(contract and user_contract_access), 'can_take_over': bool(contract and user_contract_access),
# Contrôles visibles par l'utilisateur # Contrôles visibles par l'utilisateur
'visible_controls': visible_controls, 'visible_controls': visible_controls,
'visible_inspections': visible_inspections,
# Équipes filtrées par thématique # Équipes filtrées par thématique
'available_teams': available_teams, 'available_teams': available_teams,
# Permission de voir les messages de correction # Permission de voir les messages de correction

View file

@ -912,6 +912,7 @@ def intervention_detail_mobile(request, intervention_id):
control for control in intervention.related_controls.all() control for control in intervention.related_controls.all()
if user_can_view_control(request.user, control) if user_can_view_control(request.user, control)
] ]
visible_inspections = intervention.get_related_inspections(user=request.user)
# Historique des interventions liées aux mêmes localisations (3 derniers mois) # Historique des interventions liées aux mêmes localisations (3 derniers mois)
location_history_interventions = [] location_history_interventions = []
@ -1151,6 +1152,7 @@ def intervention_detail_mobile(request, intervention_id):
'is_restricted_team': is_restricted_team, 'is_restricted_team': is_restricted_team,
# Contrôles visibles par l'utilisateur # Contrôles visibles par l'utilisateur
'visible_controls': visible_controls, 'visible_controls': visible_controls,
'visible_inspections': visible_inspections,
'tl_intersection_ids': tl_intersection_ids, 'tl_intersection_ids': tl_intersection_ids,
# --- Timeline mobile --- # --- Timeline mobile ---
'status_choices': [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], 'status_choices': [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()],