feat: add physical inspection tracking and UI component for asset inspections

This commit is contained in:
kdeterme 2026-08-08 09:40:56 +02:00
parent 66269d0462
commit ccc465bf5d
12 changed files with 311 additions and 7 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-08-08 07:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0014_alter_inspectiondocument_file'),
]
operations = [
migrations.AddField(
model_name='parkingspotinspection',
name='apply_physical_counts_to_asset',
field=models.BooleanField(default=False, verbose_name="Mettre à jour la capacité / comptages de l'asset"),
),
]

View file

@ -285,6 +285,10 @@ class ParkingSpotInspection(AssetInspection):
blank=True,
verbose_name=_("Qualité du marquage constatée")
)
apply_physical_counts_to_asset = models.BooleanField(
default=False,
verbose_name=_("Mettre à jour la capacité / comptages de l'asset")
)
class Meta:
verbose_name = _("Inspection d'emplacement de stationnement")
@ -292,6 +296,23 @@ class ParkingSpotInspection(AssetInspection):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.apply_physical_counts_to_asset and self.asset:
target_asset = self.asset
modified = False
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
modified = True
if self.verified_cargo_stand_count is not None and hasattr(target_asset, 'cargo_stand_count'):
target_asset.cargo_stand_count = self.verified_cargo_stand_count
modified = True
if self.verified_has_scooter_drop_zone is not None and hasattr(target_asset, 'has_scooter_drop_zone'):
target_asset.has_scooter_drop_zone = self.verified_has_scooter_drop_zone
modified = True
if self.verified_marking_quality and hasattr(target_asset, 'marking_quality'):
target_asset.marking_quality = self.verified_marking_quality
modified = True
if modified:
target_asset.save()
def inspection_document_upload_path(instance, filename):

View file

@ -216,6 +216,21 @@ def can_validate_asset(user, asset):
return False
def can_inspect_asset(user, asset):
"""Check if user has permission to perform an inspection on a specific asset."""
if not user or not user.is_authenticated:
return False
if not hasattr(user, 'config') or not user.config:
return False
if user.config.roles.filter(name='admin').exists():
return True
thematic = _get_asset_thematic(asset)
if not thematic:
return False
inspectable = user.config.get_inspectable_thematics()
return inspectable.filter(pk=thematic.pk).exists() if inspectable is not None else False
def can_view_location(user, location):
"""Check if user can view a specific location."""
def _fallback():

View file

@ -0,0 +1,151 @@
{% load i18n static %}
{# Inspections et contrôles liés à l'asset #}
<div class="card mb-3" id="asset-inspections-card">
<div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold">
<i class="bi bi-patch-check me-2 text-primary"></i>{% translate "Inspections et contrôles terrain" %}
{% if inspections %}
<span class="badge bg-secondary rounded-pill ms-1">{{ total_count }}</span>
{% endif %}
</h5>
{% if can_inspect %}
<a href="{% url 'assets:quick_inspection_map' %}?content_type_id={{ asset_ct_id }}&object_id={{ asset_pk }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>{% translate "Réaliser une inspection" %}
</a>
{% endif %}
</div>
<div class="card-body p-0">
{% if inspections %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 15%;" class="ps-3">{% translate "Date & Heure" %}</th>
<th style="width: 18%;">{% translate "Statut du contrôle" %}</th>
<th style="width: 15%;">{% translate "Nature" %}</th>
<th style="width: 20%;">{% translate "Inspecteur" %}</th>
<th style="width: 22%;">{% translate "Remarques" %}</th>
<th style="width: 10%;" class="pe-3 text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for insp in inspections %}
<tr>
<td class="ps-3 fw-semibold">
{{ insp.inspection_date|date:"d/m/Y H:i" }}
</td>
<td>
<span class="badge {{ insp.status_badge_class }}">
{{ insp.get_result_status_display }}
</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</td>
<td>
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector.get_full_name|default:insp.inspector.username|default:"-" }}
</td>
<td>
<span class="small text-muted text-truncate d-inline-block" style="max-width: 220px;" title="{{ insp.details_summary }}">
{{ insp.details_summary }}
</span>
</td>
<td class="pe-3 text-end">
<button type="button" class="btn btn-xs btn-outline-primary px-2 py-1 rounded" data-bs-toggle="modal" data-bs-target="#assetInspectionDetailModal{{ insp.id }}">
<i class="bi bi-eye me-1"></i>{% translate "Détail" %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-center text-muted small">
<i class="bi bi-info-circle fs-5 d-block mb-1 text-secondary"></i>
{% translate "Aucune inspection ni vérification enregistrée pour cet équipement." %}
</div>
{% endif %}
</div>
</div>
{% for insp in inspections %}
<!-- Modale de détail de l'inspection pour l'asset -->
<div class="modal fade" id="assetInspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="assetInspectionDetailModalLabel{{ 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="assetInspectionDetailModalLabel{{ insp.id }}">
<i class="bi bi-patch-check me-2 text-primary"></i>{% translate "Détail du contrôle" %} - {{ insp.inspection_date|date:"d/m/Y H:i" }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-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">
<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>
</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" }}</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 }}">{{ 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.get_full_name|default:insp.inspector.username|default:"-" }}</span>
</li>
{% with leaf=insp.detailed_inspection %}
{% if leaf and leaf.verified_standard_stand_count is not None %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-bicycle me-1 text-primary"></i>{% translate "Arceaux classiques constatés" %}</span>
<span class="badge bg-primary rounded-pill fs-6">{{ leaf.verified_standard_stand_count }}</span>
</li>
{% endif %}
{% if leaf and leaf.verified_cargo_stand_count is not None %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-box-seam me-1 text-info"></i>{% translate "Arceaux cargo constatés" %}</span>
<span class="badge bg-info text-dark rounded-pill fs-6">{{ leaf.verified_cargo_stand_count }}</span>
</li>
{% endif %}
{% endwith %}
</ul>
{% if insp.notes %}
<div class="mb-3">
<label class="form-label text-muted small fw-semibold"><i class="bi bi-journal-text me-1"></i>{% translate "Remarques / Observations" %}:</label>
<div class="p-2 bg-light rounded border text-secondary small">
{{ insp.notes|linebreaksbr }}
</div>
</div>
{% endif %}
{% if insp.documents.all %}
<div>
<label class="form-label text-muted small fw-semibold mb-2"><i class="bi bi-camera me-1"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}):</label>
<div class="d-flex flex-wrap gap-2">
{% for doc in insp.documents.all %}
<a href="{{ doc.file_url }}" target="_blank" class="d-inline-block border rounded p-1 bg-light">
<img src="{{ doc.file_url }}" alt="Photo" class="rounded" style="width: 75px; height: 75px; object-fit: cover;">
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="modal-footer py-2 bg-light">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endfor %}

View file

@ -2,7 +2,7 @@
<!-- Modale de Saisie Rapide d'Inspection -->
<div class="modal fade" id="quickInspectionModal" tabindex="-1" aria-labelledby="quickInspectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="quickInspectionModalLabel">

View file

@ -737,6 +737,13 @@
</div>
</div>
<!-- Inspections et contrôles liés (pleine largeur) -->
<div class="row mt-3">
<div class="col-12">
{% asset_inspections asset %}
</div>
</div>
<!-- Historique des modifications (plein largeur, en bas) -->
<div class="row mt-3">
<div class="col-12">

View file

@ -160,7 +160,7 @@
<!-- Modal de détail de l'inspection -->
<div class="modal fade" id="inspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="inspectionDetailModalLabel{{ insp.id }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="inspectionDetailModalLabel{{ insp.id }}">

View file

@ -326,7 +326,7 @@
<!-- Modal Détail d'une Inspection depuis la carte -->
<div class="modal fade" id="mapInspectionDetailModal" tabindex="-1" aria-labelledby="mapInspectionDetailModalLabel" aria-hidden="true" style="z-index: 1065;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="mapInspectionDetailModalLabel">

View file

@ -496,3 +496,46 @@ def asset_interventions(context, asset):
}
@register.inclusion_tag('assets/components/asset_inspections.html', takes_context=True)
def asset_inspections(context, asset):
"""
Display all inspections associated with an asset.
Also provides context for triggering an inspection directly from the asset page.
"""
if asset is None or not hasattr(asset, 'pk') or asset.pk is None:
return {
'asset': asset,
'inspections': [],
'can_inspect': False,
'asset_ct_id': None,
'asset_pk': None,
'total_count': 0,
}
from django.contrib.contenttypes.models import ContentType
from assets.models import AssetInspection
from assets.permissions import can_inspect_asset
asset_ct = ContentType.objects.get_for_model(asset)
inspections = list(AssetInspection.objects.filter(
asset_content_type=asset_ct,
asset_object_id=asset.pk
).select_related('inspector').prefetch_related('documents').order_by('-inspection_date'))
request = context.get('request')
user = request.user if request else None
can_insp = False
if user and user.is_authenticated:
can_insp = can_inspect_asset(user, asset)
return {
'asset': asset,
'asset_ct_id': asset_ct.id,
'asset_pk': asset.pk,
'inspections': inspections,
'can_inspect': can_insp,
'total_count': len(inspections),
}

View file

@ -207,3 +207,20 @@ class InspectionViewsTestCase(TestCase):
user_no_config = User.objects.create_user(username='no_config_user', password='pwd')
self.assertIsNone(get_user_viewable_thematics(user_no_config))
self.assertIsNone(get_user_inspectable_thematics(user_no_config))
def test_asset_inspections_templatetag(self):
from assets.templatetags.asset_tags import asset_inspections
from assets.models import AssetInspection
insp = AssetInspection.objects.create(
asset=self.spot,
inspector=self.user,
result_status='compliant',
nature='visual'
)
dummy_context = {'request': self.client.get('/').wsgi_request}
data = asset_inspections(dummy_context, self.spot)
self.assertEqual(data['total_count'], 1)
self.assertIn(insp, data['inspections'])
self.assertEqual(data['asset_pk'], self.spot.pk)

View file

@ -61,7 +61,8 @@ class ParkingSpotAndInspectionTest(TestCase):
self.assertEqual(self.spot.inspection_status, "unknown")
self.assertIsNotNone(self.spot.geojson)
def test_parking_spot_inspection_updates_asset(self):
def test_parking_spot_inspection_default_does_not_update_counts(self):
# Par défaut, apply_physical_counts_to_asset est False
inspection = ParkingSpotInspection.objects.create(
asset=self.spot,
inspector=self.user,
@ -72,12 +73,34 @@ class ParkingSpotAndInspectionTest(TestCase):
verified_has_scooter_drop_zone=True,
verified_marking_quality="good",
apply_changes_to_asset=True,
apply_physical_counts_to_asset=False,
notes="Arceaux constatés"
)
self.spot.refresh_from_db()
self.assertEqual(self.spot.standard_stand_count, 3) # Non modifié
self.assertEqual(self.spot.cargo_stand_count, 2) # Non modifié
self.assertEqual(self.spot.validation_status, "validated") # Statut génerique mis à jour
self.assertEqual(self.spot.inspection_status, "updated")
def test_parking_spot_inspection_with_counts_opt_in_updates_asset(self):
inspection = ParkingSpotInspection.objects.create(
asset=self.spot,
inspector=self.user,
inspection_date=timezone.now(),
result_status="updated",
verified_standard_stand_count=4,
verified_cargo_stand_count=3,
verified_has_scooter_drop_zone=True,
verified_marking_quality="good",
apply_changes_to_asset=True,
apply_physical_counts_to_asset=True,
notes="Ajout récent de 1 arceau classique et 1 arceau cargo"
)
self.spot.refresh_from_db()
self.assertEqual(self.spot.standard_stand_count, 4)
self.assertEqual(self.spot.cargo_stand_count, 3)
self.assertEqual(self.spot.standard_stand_count, 4) # Modifié avec l'opt-in
self.assertEqual(self.spot.cargo_stand_count, 3) # Modifié avec l'opt-in
self.assertEqual(self.spot.total_stand_count, 7)
self.assertEqual(self.spot.validation_status, "validated")
self.assertEqual(self.spot.inspection_status, "updated")

View file

@ -99,6 +99,8 @@
{% block head %}
<title>{{ product_name }}</title>
{% endblock head %}
{% block extra_head %}{% endblock %}
{% block extra_css %}{% endblock %}
<style>
:root {
--brand-color: {{ organization_primary_color|default:'#2f3e8b' }};
@ -340,10 +342,11 @@
<i class="bi bi-house-fill" data-bs-toggle="tooltip" data-bs-placement="right" title="Accueil"></i>
<span class="ms-2 text d-none d-md-inline">{% translate "Accueil" %}</span>
</a>
<a href="/assets/" class="nav-link d-flex align-items-center p-2 {% if request.path|slice:':8' == '/assets/' %}active{% endif %}">
<a href="/assets/" class="nav-link d-flex align-items-center p-2 {% if request.path == '/assets/' or request.path|slice:':14' == '/assets/maps/' %}active{% endif %}">
<i class="bi bi-layers" data-bs-toggle="tooltip" data-bs-placement="right" title="Assets"></i>
<span class="ms-2 text d-none d-md-inline">{% translate "Assets" %}</span>
</a>
{% if user.config|has_any_role:"admin,manager,external_manager,controller,observer,operator,editor,top_manager" %}
<a href="/observations/" class="nav-link d-flex align-items-center p-2 {% if request.path|slice:':13' == '/observations/' %}active{% endif %}">
<i class="bi bi-eye" data-bs-toggle="tooltip" data-bs-placement="right" title="observations"></i>
@ -369,6 +372,11 @@
</a>
{% endif %}
<a href="{% url 'assets:inspections_list' %}" class="nav-link d-flex align-items-center p-2 {% if request.path|slice:':19' == '/assets/inspections' %}active{% endif %}">
<i class="bi bi-search" data-bs-toggle="tooltip" data-bs-placement="right" title="{% translate 'Inspections' %}"></i>
<span class="ms-2 text d-none d-md-inline">{% translate "Inspections" %}</span>
</a>
<hr class="my-1">
<a href="/interventions/calendar/" class="nav-link d-flex align-items-center p-2 {% if request.path|slice:':24' == '/interventions/calendar/' %}active{% endif %}">
@ -651,5 +659,6 @@ document.addEventListener('DOMContentLoaded', () => {
</script>
<script src="{% static 'common/help_panel.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>