feat: add inspection cancellation functionality with automatic asset compliance state recalculation

This commit is contained in:
kdeterme 2026-08-17 14:57:28 +02:00
parent fb1ad643a9
commit 1fceffc293
10 changed files with 481 additions and 60 deletions

View file

@ -322,6 +322,7 @@
'non_compliant', '#fd7e14', 'non_compliant', '#fd7e14',
'damaged', '#dc3545', 'damaged', '#dc3545',
'missing', '#212529', 'missing', '#212529',
'cancelled', '#6c757d',
'#6c757d' '#6c757d'
], ],
'circle-stroke-width': 2, 'circle-stroke-width': 2,
@ -380,10 +381,11 @@
`; `;
inspectionsList.forEach((insp, index) => { inspectionsList.forEach((insp, index) => {
const isCancelled = insp.is_cancelled || (insp.result_status === 'cancelled');
html += ` html += `
<div class="list-group-item px-2 py-1.5 border-bottom bg-light rounded mb-1"> <div class="list-group-item px-2 py-1.5 border-bottom rounded mb-1 ${isCancelled ? 'bg-light text-muted opacity-75 fst-italic' : 'bg-light'}">
<div class="d-flex justify-content-between align-items-center mb-1 gap-2"> <div class="d-flex justify-content-between align-items-center mb-1 gap-2">
<span class="badge ${insp.status_badge_class || 'bg-primary'} extra-small text-truncate flex-shrink-1">${escapeHtml(insp.result_status_display || '')}</span> <span class="badge ${insp.status_badge_class || 'bg-secondary'} extra-small text-truncate flex-shrink-1">${escapeHtml(insp.result_status_display || '')}</span>
<a href="#" class="insp-date-modal-link text-primary text-decoration-underline fw-bold extra-small text-nowrap flex-shrink-0" style="font-size:0.75rem;" data-insp-index="${index}"> <a href="#" class="insp-date-modal-link text-primary text-decoration-underline fw-bold extra-small text-nowrap flex-shrink-0" style="font-size:0.75rem;" data-insp-index="${index}">
<i class="bi bi-calendar-event me-1"></i>${escapeHtml(insp.date || '')} <i class="bi bi-calendar-event me-1"></i>${escapeHtml(insp.date || '')}
</a> </a>

View file

@ -40,7 +40,7 @@
</thead> </thead>
<tbody> <tbody>
{% for insp in inspections %} {% for insp in inspections %}
<tr> <tr class="{% if insp.is_cancelled %}table-light text-muted opacity-75 fst-italic{% endif %}">
<td class="ps-3 fw-semibold"> <td class="ps-3 fw-semibold">
{{ insp.inspection_date|date:"d/m/Y H:i" }} {{ insp.inspection_date|date:"d/m/Y H:i" }}
</td> </td>
@ -91,6 +91,14 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body p-3"> <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 %}
<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>
@ -149,6 +157,15 @@
<div class="modal-footer py-2 bg-light"> <div class="modal-footer py-2 bg-light">
{% can_edit_inspection insp request.user as user_can_edit %} {% can_edit_inspection insp request.user as user_can_edit %}
{% if user_can_edit %} {% if user_can_edit %}
{% if insp.is_cancelled %}
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelAssetInspection({{ insp.id }}, 'restore')">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
</button>
{% else %}
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold" onclick="handleToggleCancelAssetInspection({{ insp.id }}, 'cancel')">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal"> <button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %} <i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button> </button>
@ -253,5 +270,49 @@ document.addEventListener('DOMContentLoaded', function() {
</script> </script>
{% endif %} {% endif %}
<script>
window.handleToggleCancelAssetInspection = function(inspectionId, action) {
const confirmMsg = action === 'cancel'
? "{% filter escapejs %}{% translate 'Êtes-vous sûr de vouloir annuler cette inspection ?' %}{% endfilter %}"
: "{% filter escapejs %}{% translate 'Êtes-vous sûr de vouloir rétablir cette inspection ?' %}{% endfilter %}";
if (!confirm(confirmMsg)) return;
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, 10) === 'csrftoken=') {
cookieValue = decodeURIComponent(cookie.substring(10));
break;
}
}
}
const formData = new FormData();
formData.append('action', action);
fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
method: 'POST',
headers: {
'X-CSRFToken': cookieValue
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.error || "{% filter escapejs %}{% translate 'Une erreur est survenue.' %}{% endfilter %}");
}
})
.catch(err => {
console.error(err);
alert("{% filter escapejs %}{% translate 'Erreur de communication avec le serveur.' %}{% endfilter %}");
});
};
</script>
{% include "inspections/components/edit_inspection_modal.html" %} {% include "inspections/components/edit_inspection_modal.html" %}

View file

@ -7,6 +7,20 @@ class InspectionDocumentInline(admin.TabularInline):
extra = 0 extra = 0
@admin.action(description="Marquer comme annulée(s)")
def make_cancelled(modeladmin, request, queryset):
for obj in queryset:
obj.result_status = 'cancelled'
obj.save()
@admin.action(description="Rétablir comme conforme(s)")
def make_compliant(modeladmin, request, queryset):
for obj in queryset:
obj.result_status = 'compliant'
obj.save()
@admin.register(Inspection) @admin.register(Inspection)
class InspectionAdmin(admin.ModelAdmin): class InspectionAdmin(admin.ModelAdmin):
list_display = ( list_display = (
@ -25,6 +39,7 @@ class InspectionAdmin(admin.ModelAdmin):
) )
raw_id_fields = ("inspector", "mission_intervention", "checked_intervention") raw_id_fields = ("inspector", "mission_intervention", "checked_intervention")
inlines = [InspectionDocumentInline] inlines = [InspectionDocumentInline]
actions = [make_cancelled, make_compliant]
@admin.register(ParkingSpotInspection) @admin.register(ParkingSpotInspection)
@ -39,6 +54,7 @@ class ParkingSpotInspectionAdmin(admin.ModelAdmin):
search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name") search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name")
raw_id_fields = ("inspector", "mission_intervention", "checked_intervention") raw_id_fields = ("inspector", "mission_intervention", "checked_intervention")
inlines = [InspectionDocumentInline] inlines = [InspectionDocumentInline]
actions = [make_cancelled, make_compliant]
@admin.register(GiepInspection) @admin.register(GiepInspection)
@ -52,6 +68,7 @@ class GiepInspectionAdmin(admin.ModelAdmin):
search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name") search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name")
raw_id_fields = ("inspector", "mission_intervention", "checked_intervention") raw_id_fields = ("inspector", "mission_intervention", "checked_intervention")
inlines = [InspectionDocumentInline] inlines = [InspectionDocumentInline]
actions = [make_cancelled, make_compliant]
@admin.register(InspectionDocument) @admin.register(InspectionDocument)

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-08-17 12:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inspections', '0006_alter_inspection_apply_changes_to_asset'),
]
operations = [
migrations.AlterField(
model_name='inspection',
name='result_status',
field=models.CharField(choices=[('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('damaged', 'Endommagé'), ('missing', 'Absent / Retiré'), ('cancelled', 'Annulé')], default='compliant', max_length=50, verbose_name='Résultat du contrôle'),
),
]

View file

@ -37,6 +37,13 @@ INSPECTION_RESULT_CONFIG = {
'icon': 'bi-dash-circle-fill', 'icon': 'bi-dash-circle-fill',
'color_hex': '#212529', 'color_hex': '#212529',
}, },
'cancelled': {
'label': _('Annulé'),
'badge_class': 'bg-secondary text-white',
'btn_class': 'btn-outline-secondary',
'icon': 'bi-slash-circle',
'color_hex': '#6c757d',
},
} }
INSPECTION_RESULT_CHOICES = tuple( INSPECTION_RESULT_CHOICES = tuple(
@ -147,6 +154,10 @@ 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 is_cancelled(self):
return self.result_status == 'cancelled'
def can_edit(self, user): def can_edit(self, user):
from .permissions import can_edit_inspection from .permissions import can_edit_inspection
return can_edit_inspection(user, self) return can_edit_inspection(user, self)
@ -158,6 +169,64 @@ class Inspection(models.Model):
target_asset = self.asset target_asset = self.asset
modified = False modified = False
if self.is_cancelled:
# Recalculer l'état de l'asset à partir de la dernière inspection active restante
latest_active = Inspection.objects.filter(
asset_content_type=self.asset_content_type,
asset_object_id=self.asset_object_id
).exclude(result_status='cancelled').exclude(pk=self.pk).order_by('-inspection_date').first()
if latest_active:
if hasattr(target_asset, 'last_inspection_date'):
target_asset.last_inspection_date = latest_active.inspection_date
modified = True
if hasattr(target_asset, 'inspection_status'):
try:
setattr(target_asset, 'inspection_status', latest_active.result_status)
modified = True
except AttributeError:
pass
if hasattr(target_asset, 'validation_status'):
target_asset.validation_status = 'validated'
modified = True
if hasattr(target_asset, 'is_compliant'):
if latest_active.result_status in ('non_compliant', 'damaged', 'missing'):
target_asset.is_compliant = False
if hasattr(target_asset, 'non_compliance_reason'):
target_asset.non_compliance_reason = latest_active.notes or ''
else:
target_asset.is_compliant = True
if hasattr(target_asset, 'non_compliance_reason'):
target_asset.non_compliance_reason = None
modified = True
if latest_active.verified_obsolescence and hasattr(target_asset, 'obsolescence'):
target_asset.obsolescence = latest_active.verified_obsolescence
modified = True
else:
if hasattr(target_asset, 'last_inspection_date'):
target_asset.last_inspection_date = None
modified = True
if hasattr(target_asset, 'inspection_status'):
try:
setattr(target_asset, 'inspection_status', 'unknown')
modified = True
except AttributeError:
pass
if hasattr(target_asset, 'is_compliant'):
target_asset.is_compliant = True
if hasattr(target_asset, 'non_compliance_reason'):
target_asset.non_compliance_reason = None
modified = True
if modified:
target_asset.save()
else:
if hasattr(target_asset, 'last_inspection_date'): if hasattr(target_asset, 'last_inspection_date'):
target_asset.last_inspection_date = self.inspection_date target_asset.last_inspection_date = self.inspection_date
modified = True modified = True
@ -391,6 +460,7 @@ class ParkingSpotInspection(Inspection):
if self.apply_physical_counts_to_asset and self.asset: if self.apply_physical_counts_to_asset and self.asset:
target_asset = self.asset target_asset = self.asset
modified = False modified = False
if not self.is_cancelled:
if self.verified_standard_stand_count is not None and hasattr(target_asset, 'standard_stand_count'): if self.verified_standard_stand_count is not None and hasattr(target_asset, 'standard_stand_count'):
target_asset.standard_stand_count = self.verified_standard_stand_count target_asset.standard_stand_count = self.verified_standard_stand_count
modified = True modified = True
@ -408,6 +478,29 @@ class ParkingSpotInspection(Inspection):
modified = True modified = True
if modified: if modified:
target_asset.save() target_asset.save()
else:
latest_active_ps = ParkingSpotInspection.objects.filter(
asset_content_type=self.asset_content_type,
asset_object_id=self.asset_object_id
).exclude(result_status='cancelled').exclude(pk=self.pk).order_by('-inspection_date').first()
if latest_active_ps and latest_active_ps.apply_physical_counts_to_asset:
if latest_active_ps.verified_standard_stand_count is not None and hasattr(target_asset, 'standard_stand_count'):
target_asset.standard_stand_count = latest_active_ps.verified_standard_stand_count
modified = True
if latest_active_ps.verified_cargo_stand_count is not None and hasattr(target_asset, 'cargo_stand_count'):
target_asset.cargo_stand_count = latest_active_ps.verified_cargo_stand_count
modified = True
if latest_active_ps.verified_drop_off_stand_count is not None and hasattr(target_asset, 'drop_off_stand_count'):
target_asset.drop_off_stand_count = latest_active_ps.verified_drop_off_stand_count
modified = True
if latest_active_ps.verified_has_scooter_drop_zone is not None and hasattr(target_asset, 'has_scooter_drop_zone'):
target_asset.has_scooter_drop_zone = latest_active_ps.verified_has_scooter_drop_zone
modified = True
if latest_active_ps.verified_marking_quality and hasattr(target_asset, 'marking_quality'):
target_asset.marking_quality = latest_active_ps.verified_marking_quality
modified = True
if modified:
target_asset.save()
class GiepInspection(Inspection): class GiepInspection(Inspection):

View file

@ -42,6 +42,7 @@
<div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}"> <div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}">
<div class="row g-2"> <div class="row g-2">
{% for choice in INSPECTION_RESULT_CHOICES_CONFIG %} {% for choice in INSPECTION_RESULT_CHOICES_CONFIG %}
{% if choice.code != 'cancelled' %}
<div class="col-6 mb-1"> <div class="col-6 mb-1">
<input type="radio" class="btn-check" name="result_status" id="res_{{ choice.code }}" value="{{ choice.code }}" {% if forloop.first %}checked{% endif %}> <input type="radio" class="btn-check" name="result_status" id="res_{{ choice.code }}" value="{{ choice.code }}" {% if forloop.first %}checked{% endif %}>
<label class="btn {{ choice.btn_class }} w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_{{ choice.code }}"> <label class="btn {{ choice.btn_class }} w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_{{ choice.code }}">
@ -49,6 +50,7 @@
<span>{{ choice.label }}</span> <span>{{ choice.label }}</span>
</label> </label>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View file

@ -5,6 +5,17 @@
{% block extra_css %} {% block extra_css %}
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet"> <link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<style>
.inspection-card.is-cancelled {
background-color: #f8f9fa !important;
border: 1px dashed #adb5bd !important;
opacity: 0.7;
}
.inspection-card.is-cancelled .card-title,
.inspection-card.is-cancelled h6 {
color: #6c757d !important;
}
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -112,7 +123,7 @@
{% for insp in inspections %} {% for insp in inspections %}
<div class="col-12"> <div class="col-12">
<div class="card shadow-sm border-0 h-100 inspection-card cursor-pointer" <div class="card shadow-sm border-0 h-100 inspection-card cursor-pointer {% if insp.is_cancelled %}is-cancelled{% endif %}"
style="cursor: pointer; transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;" style="cursor: pointer; transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#inspectionDetailModal{{ insp.id }}"> data-bs-target="#inspectionDetailModal{{ insp.id }}">
@ -171,6 +182,14 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body p-3"> <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 %}
<!-- Lien vers la fiche de l'asset --> <!-- Lien vers la fiche de l'asset -->
{% if insp.asset %} {% if insp.asset %}
<div class="mb-3"> <div class="mb-3">
@ -292,6 +311,15 @@
<div class="modal-footer bg-light py-2"> <div class="modal-footer bg-light py-2">
{% can_edit_inspection insp request.user as user_can_edit %} {% can_edit_inspection insp request.user as user_can_edit %}
{% if user_can_edit %} {% if user_can_edit %}
{% if insp.is_cancelled %}
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'restore')">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
</button>
{% else %}
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'cancel')">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal"> <button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %} <i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button> </button>
@ -678,6 +706,48 @@ document.addEventListener('DOMContentLoaded', function() {
}, 150); }, 150);
} }
}); });
window.handleToggleCancelInspection = function(inspectionId, action) {
const confirmMsg = action === 'cancel'
? "{% translate 'Êtes-vous sûr de vouloir annuler cette inspection ?' %}"
: "{% translate 'Êtes-vous sûr de vouloir rétablir cette inspection ?' %}";
if (!confirm(confirmMsg)) return;
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, 10) === 'csrftoken=') {
cookieValue = decodeURIComponent(cookie.substring(10));
break;
}
}
}
const formData = new FormData();
formData.append('action', action);
fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
method: 'POST',
headers: {
'X-CSRFToken': cookieValue
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.error || "{% translate 'Une erreur est survenue.' %}");
}
})
.catch(err => {
console.error(err);
alert("{% translate 'Erreur de communication avec le serveur.' %}");
});
};
}); });
</script> </script>

View file

@ -379,3 +379,120 @@ class InspectionModelAndViewsTestCase(TestCase):
self.assertIn(insp_parking, inspections_parking) self.assertIn(insp_parking, inspections_parking)
self.assertNotIn(insp_water, inspections_parking) self.assertNotIn(insp_water, inspections_parking)
def test_toggle_cancel_inspection_api(self):
self.client.login(username='inspector_user', password='password123')
insp = Inspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
result_status='compliant'
)
self.assertFalse(insp.is_cancelled)
# Cancel inspection
url = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp.id})
resp = self.client.post(url, {'action': 'cancel'})
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertTrue(data['success'])
self.assertTrue(data['is_cancelled'])
self.assertEqual(data['result_status'], 'cancelled')
insp.refresh_from_db()
self.assertTrue(insp.is_cancelled)
self.assertEqual(insp.result_status, 'cancelled')
self.assertEqual(insp.status_badge_class, 'bg-secondary text-white')
# Restore inspection
resp_restore = self.client.post(url, {'action': 'restore'})
self.assertEqual(resp_restore.status_code, 200)
data_restore = resp_restore.json()
self.assertTrue(data_restore['success'])
self.assertFalse(data_restore['is_cancelled'])
self.assertEqual(data_restore['result_status'], 'compliant')
insp.refresh_from_db()
self.assertFalse(insp.is_cancelled)
self.assertEqual(insp.result_status, 'compliant')
def test_toggle_cancel_inspection_permissions(self):
other_user = User.objects.create_user(
username='stranger_user',
password='password123'
)
insp = Inspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
result_status='compliant'
)
url = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp.id})
# Stranger cannot cancel
self.client.login(username='stranger_user', password='password123')
resp = self.client.post(url, {'action': 'cancel'})
self.assertEqual(resp.status_code, 403)
insp.refresh_from_db()
self.assertEqual(insp.result_status, 'compliant')
# Superuser can cancel
other_user.is_superuser = True
other_user.save()
resp_admin = self.client.post(url, {'action': 'cancel'})
self.assertEqual(resp_admin.status_code, 200)
insp.refresh_from_db()
self.assertEqual(insp.result_status, 'cancelled')
def test_asset_compliance_recalculation_on_cancellation(self):
self.client.login(username='inspector_user', password='password123')
# 1st inspection (compliant)
date1 = timezone.now() - timezone.timedelta(days=2)
insp1 = Inspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
inspection_date=date1,
result_status='compliant',
apply_changes_to_asset=True
)
self.spot.refresh_from_db()
self.assertEqual(self.spot.inspection_status, 'compliant')
self.assertTrue(self.spot.is_compliant)
# 2nd inspection (damaged)
date2 = timezone.now()
insp2 = Inspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
inspection_date=date2,
result_status='damaged',
notes="Arceau cassé",
apply_changes_to_asset=True
)
self.spot.refresh_from_db()
self.assertEqual(self.spot.inspection_status, 'damaged')
self.assertFalse(self.spot.is_compliant)
self.assertEqual(self.spot.non_compliance_reason, "Arceau cassé")
# Cancel 2nd inspection -> asset should fall back to 1st inspection (compliant)
url2 = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp2.id})
self.client.post(url2, {'action': 'cancel'})
self.spot.refresh_from_db()
self.assertEqual(self.spot.inspection_status, 'compliant')
self.assertTrue(self.spot.is_compliant)
self.assertIsNone(self.spot.non_compliance_reason)
self.assertEqual(self.spot.last_inspection_date, date1)
# Cancel 1st inspection -> no active inspections remain, reset to default
url1 = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp1.id})
self.client.post(url1, {'action': 'cancel'})
self.spot.refresh_from_db()
self.assertEqual(self.spot.inspection_status, 'unknown')
self.assertIsNone(self.spot.last_inspection_date)

View file

@ -9,6 +9,7 @@ urlpatterns = [
path('api/create/', views.create_inspection_api, name='create_inspection_api'), path('api/create/', views.create_inspection_api, name='create_inspection_api'),
path('api/<int:inspection_id>/detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'), path('api/<int:inspection_id>/detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'),
path('api/<int:inspection_id>/update/', views.update_inspection_api, name='update_inspection_api'), path('api/<int:inspection_id>/update/', views.update_inspection_api, name='update_inspection_api'),
path('api/<int:inspection_id>/toggle-cancel/', views.toggle_cancel_inspection_api, name='toggle_cancel_inspection_api'),
path('api/document/<int:document_id>/delete/', views.delete_inspection_document_api, name='delete_inspection_document_api'), path('api/document/<int:document_id>/delete/', views.delete_inspection_document_api, name='delete_inspection_document_api'),
path('api/geojson/', views.inspections_geojson, name='inspections_geojson'), path('api/geojson/', views.inspections_geojson, name='inspections_geojson'),

View file

@ -460,6 +460,7 @@ def get_inspection_detail_api(request, inspection_id):
'notes': leaf.notes or '', 'notes': leaf.notes or '',
'verified_obsolescence': leaf.verified_obsolescence or '', 'verified_obsolescence': leaf.verified_obsolescence or '',
'apply_changes_to_asset': leaf.apply_changes_to_asset, 'apply_changes_to_asset': leaf.apply_changes_to_asset,
'is_cancelled': leaf.is_cancelled,
'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '', 'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '',
'created_at': leaf.created_at.strftime('%d/%m/%Y %H:%M') if leaf.created_at else '', 'created_at': leaf.created_at.strftime('%d/%m/%Y %H:%M') if leaf.created_at else '',
'updated_at': leaf.updated_at.strftime('%d/%m/%Y %H:%M') if leaf.updated_at else '', 'updated_at': leaf.updated_at.strftime('%d/%m/%Y %H:%M') if leaf.updated_at else '',
@ -600,6 +601,42 @@ def delete_inspection_document_api(request, document_id):
return JsonResponse({'success': True, 'document_id': document_id}) return JsonResponse({'success': True, 'document_id': document_id})
@login_required
@require_POST
def toggle_cancel_inspection_api(request, inspection_id):
"""
API endpoint pour annuler ou rétablir une inspection.
Seuls l'auteur et les administrateurs ont le droit d'annuler/rétablir une inspection.
"""
inspection_obj = get_object_or_404(Inspection, pk=inspection_id)
inspection = inspection_obj.detailed_inspection
if not inspection.can_edit(request.user):
return JsonResponse({
'success': False,
'error': str(_("Permission refusée. Seuls l'auteur de l'inspection et les administrateurs peuvent la modifier."))
}, status=403)
action = request.POST.get('action') # 'cancel', 'restore', ou toggle automatique si non spécifié
if action == 'cancel' or (action is None and not inspection.is_cancelled):
inspection.result_status = 'cancelled'
elif action == 'restore' or (action is None and inspection.is_cancelled):
target_status = request.POST.get('result_status', 'compliant')
inspection.result_status = target_status if target_status != 'cancelled' else 'compliant'
inspection.save()
return JsonResponse({
'success': True,
'inspection_id': inspection.id,
'is_cancelled': inspection.is_cancelled,
'result_status': inspection.result_status,
'result_status_display': str(inspection.get_result_status_display()),
'status_badge_class': inspection.status_badge_class,
'status_icon': inspection.status_icon,
'status_btn_class': inspection.status_btn_class,
'message': str(_("L'inspection a été annulée avec succès.")) if inspection.is_cancelled else str(_("L'inspection a été rétablie avec succès."))
})
@login_required @login_required
@ -709,8 +746,9 @@ def inspections_geojson(request):
if not geojson_str or not insp_list: if not geojson_str or not insp_list:
continue continue
latest_insp = insp_list[0] active_insps = [i for i in insp_list if i.result_status != 'cancelled']
target_obj = latest_insp.asset top_insp = active_insps[0] if active_insps else insp_list[0]
target_obj = top_insp.asset
inspections_data = [] inspections_data = []
for insp in insp_list: for insp in insp_list:
@ -729,6 +767,8 @@ def inspections_geojson(request):
'result_status': insp.result_status, 'result_status': insp.result_status,
'result_status_display': insp.get_result_status_display(), 'result_status_display': insp.get_result_status_display(),
'status_badge_class': insp.status_badge_class, 'status_badge_class': insp.status_badge_class,
'status_icon': insp.status_icon,
'is_cancelled': insp.is_cancelled,
'nature_display': insp.get_nature_display(), 'nature_display': insp.get_nature_display(),
'inspector_name': get_short_name(insp.inspector) if insp.inspector else '-', 'inspector_name': get_short_name(insp.inspector) if insp.inspector else '-',
'inspector_role_display': insp.get_inspector_role_display() if insp.inspector_role else '', 'inspector_role_display': insp.get_inspector_role_display() if insp.inspector_role else '',
@ -740,7 +780,6 @@ def inspections_geojson(request):
'documents': docs_data, 'documents': docs_data,
}) })
feature = { feature = {
'type': 'Feature', 'type': 'Feature',
'geometry': json.loads(geojson_str), 'geometry': json.loads(geojson_str),
@ -749,10 +788,11 @@ def inspections_geojson(request):
'object_id': asset_id, 'object_id': asset_id,
'model_name': ct.model, 'model_name': ct.model,
'spot_type': getattr(target_obj, 'spot_type', None) if target_obj else None, 'spot_type': getattr(target_obj, 'spot_type', None) if target_obj else None,
'target_display': latest_insp.target_display, 'target_display': top_insp.target_display,
'latest_result_status': latest_insp.result_status, 'latest_result_status': top_insp.result_status,
'latest_result_status_display': latest_insp.get_result_status_display(), 'latest_result_status_display': top_insp.get_result_status_display(),
'latest_status_badge_class': latest_insp.status_badge_class, 'latest_status_badge_class': top_insp.status_badge_class,
'is_cancelled': top_insp.is_cancelled,
'inspections_count': len(inspections_data), 'inspections_count': len(inspections_data),
'inspections': inspections_data, 'inspections': inspections_data,
} }