feat: add inspection cancellation functionality with automatic asset compliance state recalculation
This commit is contained in:
parent
fb1ad643a9
commit
1fceffc293
10 changed files with 481 additions and 60 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue