feat: add physical inspection tracking and UI component for asset inspections
This commit is contained in:
parent
66269d0462
commit
ccc465bf5d
12 changed files with 311 additions and 7 deletions
|
|
@ -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"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }}">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue