From 676e781943434037fda91242b5d5e8ca8297486d Mon Sep 17 00:00:00 2001 From: Karl Determe Date: Thu, 17 Sep 2026 08:55:47 +0200 Subject: [PATCH] feat: add inspections application and update interventions module with new models, views, and migrations --- loko/inspections/admin.py | 24 ++- ...ection_non_compliance_notified_and_more.py | 69 ++++++ loko/inspections/models.py | 197 +++++++++++++++++- loko/inspections/permissions.py | 18 +- loko/inspections/services.py | 103 +++++++++ .../components/edit_inspection_modal.html | 40 +++- .../inspections/inspections_list_mobile.html | 119 ++++++++++- loko/inspections/tests.py | 150 +++++++++++++ loko/inspections/views.py | 121 +++++++++-- loko/interventions/admin.py | 20 +- ...terventionsubtype_intervention_sub_type.py | 44 ++++ .../0010_seed_roads_hotbox_subtype.py | 40 ++++ loko/interventions/models.py | 90 ++++++++ loko/interventions/views/forms.py | 11 +- loko/loko/settings.py | 8 + 15 files changed, 1008 insertions(+), 46 deletions(-) create mode 100644 loko/inspections/migrations/0009_inspection_non_compliance_notified_and_more.py create mode 100644 loko/inspections/services.py create mode 100644 loko/interventions/migrations/0009_interventionsubtype_intervention_sub_type.py create mode 100644 loko/interventions/migrations/0010_seed_roads_hotbox_subtype.py diff --git a/loko/inspections/admin.py b/loko/inspections/admin.py index 86ee8dd..68a764e 100644 --- a/loko/inspections/admin.py +++ b/loko/inspections/admin.py @@ -1,5 +1,14 @@ from django.contrib import admin -from .models import Inspection, ParkingSpotInspection, GiepInspection, InspectionDocument +from django.contrib.gis import admin as gis_admin +from .models import Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, InspectionDocument + + +@admin.register(InspectionZone) +class InspectionZoneAdmin(gis_admin.GISModelAdmin): + list_display = ("code", "name_fr", "thematic", "is_active", "created_at") + list_filter = ("thematic", "is_active") + search_fields = ("code", "name_fr", "name_nl") + filter_horizontal = ("municipalities", "inspectors") class InspectionDocumentInline(admin.TabularInline): @@ -11,6 +20,7 @@ class InspectionDocumentInline(admin.TabularInline): def make_cancelled(modeladmin, request, queryset): for obj in queryset: obj.result_status = 'cancelled' + obj.status = 'cancelled' obj.save() @@ -18,26 +28,28 @@ def make_cancelled(modeladmin, request, queryset): def make_compliant(modeladmin, request, queryset): for obj in queryset: obj.result_status = 'compliant' + obj.status = 'completed' obj.save() @admin.register(Inspection) class InspectionAdmin(admin.ModelAdmin): list_display = ( - "id", "asset", "inspector", "inspection_date", + "id", "asset", "status", "zone", "scheduled_start_date", "scheduled_due_date", + "inspector", "inspection_date", "result_status", "mission_intervention", "checked_intervention", "apply_changes_to_asset" ) list_filter = ( - "result_status", "nature", "inspector_role", + "status", "result_status", "nature", "zone", "inspector_role", "apply_changes_to_asset", "inspection_date" ) search_fields = ( "notes", "inspector__username", "inspector__first_name", - "inspector__last_name", "mission_intervention__name", - "checked_intervention__name" + "inspector__last_name", "mission_intervention__code", + "checked_intervention__code" ) - raw_id_fields = ("inspector", "mission_intervention", "checked_intervention") + raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "zone") inlines = [InspectionDocumentInline] actions = [make_cancelled, make_compliant] diff --git a/loko/inspections/migrations/0009_inspection_non_compliance_notified_and_more.py b/loko/inspections/migrations/0009_inspection_non_compliance_notified_and_more.py new file mode 100644 index 0000000..69121af --- /dev/null +++ b/loko/inspections/migrations/0009_inspection_non_compliance_notified_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 6.0.7 on 2026-09-16 14:19 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0014_userconfig_inspection_list_filters_and_more'), + ('inspections', '0008_signpanelinspection'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='inspection', + name='non_compliance_notified', + field=models.BooleanField(default=False, verbose_name='Notification de non-conformité envoyée'), + ), + migrations.AddField( + model_name='inspection', + name='scheduled_due_date', + field=models.DateField(blank=True, null=True, verbose_name="Échéance limite d'inspection"), + ), + migrations.AddField( + model_name='inspection', + name='scheduled_start_date', + field=models.DateField(blank=True, null=True, verbose_name="Début de fenêtre d'inspection"), + ), + migrations.AddField( + model_name='inspection', + name='status', + field=models.CharField(choices=[('scheduled', 'Planifiée / À réaliser'), ('in_progress', 'En cours'), ('completed', 'Réalisée'), ('cancelled', 'Annulée')], default='completed', max_length=50, verbose_name="Statut d'avancement"), + ), + 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é'), ('pending', 'En attente')], default='compliant', max_length=50, verbose_name='Résultat du contrôle'), + ), + migrations.CreateModel( + name='InspectionZone', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=50, unique=True, verbose_name='Code du secteur / zone')), + ('name_fr', models.CharField(max_length=200, verbose_name='Nom (FR)')), + ('name_nl', models.CharField(blank=True, max_length=200, verbose_name='Nom (NL)')), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise géographique')), + ('is_active', models.BooleanField(default=True, verbose_name='Actif')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('inspectors', models.ManyToManyField(blank=True, related_name='assigned_inspection_zones', to=settings.AUTH_USER_MODEL, verbose_name='Inspecteurs assignés')), + ('municipalities', models.ManyToManyField(blank=True, related_name='inspection_zones', to='common.municipality', verbose_name='Communes associées')), + ('thematic', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='inspection_zones', to='common.thematic', verbose_name='Thématique')), + ], + options={ + 'verbose_name': "Zone / District d'inspection", + 'verbose_name_plural': "Zones / Districts d'inspection", + 'ordering': ['thematic', 'name_fr'], + }, + ), + migrations.AddField( + model_name='inspection', + name='zone', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inspections', to='inspections.inspectionzone', verbose_name="Zone / District d'inspection"), + ), + ] diff --git a/loko/inspections/models.py b/loko/inspections/models.py index dbf6c62..6f21e66 100644 --- a/loko/inspections/models.py +++ b/loko/inspections/models.py @@ -1,4 +1,4 @@ -from django.db import models +from django.contrib.gis.db import models from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey @@ -44,12 +44,26 @@ INSPECTION_RESULT_CONFIG = { 'icon': 'bi-slash-circle', 'color_hex': '#6c757d', }, + 'pending': { + 'label': _('En attente'), + 'badge_class': 'bg-light text-dark border', + 'btn_class': 'btn-outline-secondary', + 'icon': 'bi-clock', + 'color_hex': '#6c757d', + }, } INSPECTION_RESULT_CHOICES = tuple( (code, cfg['label']) for code, cfg in INSPECTION_RESULT_CONFIG.items() ) +INSPECTION_STATUS_CHOICES = ( + ('scheduled', _('Planifiée / À réaliser')), + ('in_progress', _('En cours')), + ('completed', _('Réalisée')), + ('cancelled', _('Annulée')), +) + INSPECTION_NATURE_CHOICES = ( ('visual', _('Inspection visuelle')), ('technical', _('Inspection technique')), @@ -63,6 +77,53 @@ INSPECTOR_ROLE_CHOICES = ( ) +class InspectionZone(models.Model): + """ + Zone / District géographique d'inspection pour une thématique donnée. + Permet d'affecter automatiquement les inspections de durabilité et de filtrer les tournées des inspecteurs. + """ + code = models.CharField(max_length=50, unique=True, verbose_name=_("Code du secteur / zone")) + name_fr = models.CharField(max_length=200, verbose_name=_("Nom (FR)")) + name_nl = models.CharField(max_length=200, blank=True, verbose_name=_("Nom (NL)")) + thematic = models.ForeignKey( + 'common.Thematic', + on_delete=models.PROTECT, + related_name='inspection_zones', + verbose_name=_("Thématique") + ) + geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise géographique")) + municipalities = models.ManyToManyField( + 'common.Municipality', + blank=True, + related_name='inspection_zones', + verbose_name=_("Communes associées") + ) + inspectors = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name='assigned_inspection_zones', + verbose_name=_("Inspecteurs assignés") + ) + is_active = models.BooleanField(default=True, verbose_name=_("Actif")) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Zone / District d'inspection") + verbose_name_plural = _("Zones / Districts d'inspection") + ordering = ['thematic', 'name_fr'] + + def __str__(self): + return f"{self.name_fr} ({self.thematic.name})" + + def get_name(self): + from django.utils.translation import get_language + lang = get_language() + if lang == 'nl' and self.name_nl: + return self.name_nl + return self.name_fr + + class Inspection(models.Model): """ Modèle d'enregistrement générique d'une vérification/inspection sur le terrain d'une cible (Asset, Localisation, Intervention). @@ -124,6 +185,35 @@ class Inspection(models.Model): verbose_name=_("Résultat du contrôle") ) + status = models.CharField( + max_length=50, + choices=INSPECTION_STATUS_CHOICES, + default='completed', + verbose_name=_("Statut d'avancement") + ) + scheduled_start_date = models.DateField( + null=True, + blank=True, + verbose_name=_("Début de fenêtre d'inspection") + ) + scheduled_due_date = models.DateField( + null=True, + blank=True, + verbose_name=_("Échéance limite d'inspection") + ) + zone = models.ForeignKey( + InspectionZone, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='inspections', + verbose_name=_("Zone / District d'inspection") + ) + non_compliance_notified = models.BooleanField( + default=False, + verbose_name=_("Notification de non-conformité envoyée") + ) + verified_obsolescence = models.CharField( max_length=50, null=True, @@ -156,7 +246,104 @@ class Inspection(models.Model): @property def is_cancelled(self): - return self.result_status == 'cancelled' + return self.result_status == 'cancelled' or self.status == 'cancelled' + + @property + def is_scheduled(self): + return self.status == 'scheduled' + + @property + def timeliness(self): + """ + Niveau d'urgence temporelle pour une inspection planifiée: + 'future', 'current', 'urgent', 'overdue', ou None. + """ + if self.status != 'scheduled' or not self.scheduled_due_date: + return None + today = timezone.now().date() + if self.scheduled_start_date and today < self.scheduled_start_date: + return 'future' + if today > self.scheduled_due_date: + return 'overdue' + days_left = (self.scheduled_due_date - today).days + if days_left <= 7: + return 'urgent' + return 'current' + + @property + def timeliness_badge_class(self): + mapping = { + 'future': 'bg-secondary text-white', + 'current': 'bg-success text-white', + 'urgent': 'bg-warning text-dark', + 'overdue': 'bg-danger text-white', + } + return mapping.get(self.timeliness, 'bg-light text-dark') + + @property + def timeliness_label(self): + mapping = { + 'future': _('À venir'), + 'current': _('À réaliser'), + 'urgent': _('Échéance proche'), + 'overdue': _('En retard'), + } + return mapping.get(self.timeliness, '') + + def notify_sub_type_teams_if_needed(self): + """ + Notifie les équipes configurées sur le sous-type d'intervention + si le résultat du contrôle de durabilité est non-conforme. + """ + if not self.checked_intervention or not getattr(self.checked_intervention, 'sub_type_id', None): + return + sub_type = self.checked_intervention.sub_type + if not sub_type or not sub_type.requires_post_inspection: + return + teams = list(sub_type.notification_teams.all()) + if not teams: + return + + from notifications.services import notify_team + status_label = self.get_result_status_display() + title = _("Non-conformité durabilité (%(status)s) : %(code)s") % { + 'status': status_label, + 'code': self.checked_intervention.code, + } + inspector_name = self.inspector.get_full_name() if self.inspector else str(_("Non assigné")) + content = _( + "L'inspection de durabilité sur l'intervention %(code)s (%(subtype)s) " + "a révélé un constat non conforme : %(status)s.\n" + "Inspecteur : %(inspector)s\n" + "Remarques : %(notes)s" + ) % { + 'code': self.checked_intervention.code, + 'subtype': sub_type.get_name(), + 'status': status_label, + 'inspector': inspector_name, + 'notes': self.notes or '-', + } + action_url = f"/interventions/{self.checked_intervention.id}/" + + for team in teams: + try: + notify_team( + team=team, + title=str(title), + content=str(content), + notification_type='alert', + action_url=action_url, + action_label=str(_("Consulter l'intervention")), + source_app='inspections', + ) + except Exception: + import logging + logging.getLogger(__name__).exception( + f"Error sending non-compliance notification for inspection {self.pk} to team {team.pk}" + ) + + self.non_compliance_notified = True + Inspection.objects.filter(pk=self.pk).update(non_compliance_notified=True) def can_edit(self, user): from .permissions import can_edit_inspection @@ -165,7 +352,11 @@ class Inspection(models.Model): def save(self, *args, **kwargs): super().save(*args, **kwargs) - if self.apply_changes_to_asset and self.asset: + # Notification automatique en cas de non-conformité post-travaux + if self.status == 'completed' and self.result_status in ('non_compliant', 'damaged', 'missing') and not self.non_compliance_notified: + self.notify_sub_type_teams_if_needed() + + if self.status == 'completed' and self.apply_changes_to_asset and self.asset: target_asset = self.asset modified = False diff --git a/loko/inspections/permissions.py b/loko/inspections/permissions.py index 9ed3758..b296218 100644 --- a/loko/inspections/permissions.py +++ b/loko/inspections/permissions.py @@ -42,10 +42,23 @@ def can_edit_inspection(user, inspection): if user_config.has_role('admin') or user_config.has_role('top_manager'): return True - # Auteur de l'inspection (l'inspecteur créateur) + # Auteur de l'inspection (l'inspecteur créateur ou assigné) if inspection and inspection.inspector_id == user.id: return True + # Pour une inspection planifiée / à réaliser : + if inspection and getattr(inspection, 'status', None) == 'scheduled': + # Inspecteur faisant partie de la zone d'inspection + if inspection.zone_id and inspection.zone.inspectors.filter(id=user.id).exists(): + return True + # Inspecteur ayant la permission can_inspect sur la thématique + insp_thematic = get_inspection_thematic(inspection) + if user_config and insp_thematic: + from common.models import UserThematics + ut = UserThematics.objects.filter(user_config=user_config, thematic=insp_thematic).first() + if ut and ut.can_inspect: + return True + return False @@ -316,8 +329,9 @@ def filter_viewable_inspections_for_user(user, qs=None): conditions = [] - # 1. Auteur / Inspecteur de l'inspection + # 1. Auteur / Inspecteur de l'inspection ou inspecteur de la zone conditions.append(Q(inspector=user)) + conditions.append(Q(zone__inspectors=user)) # 2. Interventions liées visibles (missions d'intervention, contrôles post-travaux, ou cible intervention) from interventions.permissions import filter_viewable_interventions_for_user diff --git a/loko/inspections/services.py b/loko/inspections/services.py new file mode 100644 index 0000000..808671b --- /dev/null +++ b/loko/inspections/services.py @@ -0,0 +1,103 @@ +import logging +from datetime import timedelta +from django.utils import timezone +from django.contrib.contenttypes.models import ContentType + +logger = logging.getLogger(__name__) + + +def schedule_post_intervention_inspection(intervention): + """ + Planifie automatiquement une inspection de durabilité pour une intervention + lorsque son sous-type requiert une inspection post-travaux (ex: Hot Box). + """ + from .models import Inspection, InspectionZone + + sub_type = getattr(intervention, 'sub_type', None) + if not sub_type or not getattr(sub_type, 'requires_post_inspection', False): + return None + + # Éviter les doublons : vérifier si une inspection post-intervention non annulée existe déjà + existing = Inspection.objects.filter( + checked_intervention=intervention, + nature=sub_type.post_inspection_nature + ).exclude(status='cancelled').first() + if existing: + return existing + + # Calcul des dates de la fenêtre d'inspection [fin + x, fin + x + y] + ref_date = intervention.end_time or timezone.now() + min_days = sub_type.post_inspection_delay_min_days or 30 + max_days = sub_type.post_inspection_delay_max_days or 60 + scheduled_start_date = ref_date.date() + timedelta(days=min_days) + scheduled_due_date = ref_date.date() + timedelta(days=max_days) + + # Détermination de la zone d'inspection + thematic = intervention.thematic + zones = InspectionZone.objects.filter(thematic=thematic, is_active=True) if thematic else InspectionZone.objects.none() + zone = None + + # 1. Résolution spatiale par intersection / inclusion + if zones.exists(): + if intervention.geom: + zone = zones.filter(geom__intersects=intervention.geom).first() + + if not zone and intervention.lon and intervention.lat: + try: + from django.contrib.gis.geos import Point + p = Point(float(intervention.lon), float(intervention.lat), srid=4326) + p.transform(3812) + zone = zones.filter(geom__contains=p).first() + except Exception as e: + logger.debug(f"Error resolving inspection zone via coordinates: {e}") + + # 2. Résolution administrative par commune associée + if not zone: + for loc in intervention.locations.all(): + loc_obj = loc.location + if loc_obj and hasattr(loc_obj, 'municipality') and loc_obj.municipality: + zone = zones.filter(municipalities=loc_obj.municipality).first() + if zone: + break + + # Détermination de la cible (Asset, Localisation ou Intervention elle-même) + target_ct = None + target_id = None + + first_asset = intervention.interventionasset_set.first() if hasattr(intervention, 'interventionasset_set') else None + if first_asset and first_asset.content_type_id and first_asset.object_id: + target_ct = first_asset.content_type + target_id = first_asset.object_id + else: + first_loc = intervention.locations.first() if hasattr(intervention, 'locations') else None + if first_loc and first_loc.content_type_id and first_loc.object_id: + target_ct = first_loc.content_type + target_id = first_loc.object_id + else: + target_ct = ContentType.objects.get_for_model(intervention) + target_id = intervention.pk + + # Pré-assignation si un seul inspecteur est assigné à la zone + assigned_inspector = None + if zone: + zone_inspectors = list(zone.inspectors.all()[:2]) + if len(zone_inspectors) == 1: + assigned_inspector = zone_inspectors[0] + + # Création de l'inspection planifiée + notes = f"Inspection de durabilité planifiée pour {sub_type.get_name()} (Intervention {intervention.code})." + inspection = Inspection.objects.create( + asset_content_type=target_ct, + asset_object_id=target_id, + checked_intervention=intervention, + nature=sub_type.post_inspection_nature, + status='scheduled', + result_status='pending', + scheduled_start_date=scheduled_start_date, + scheduled_due_date=scheduled_due_date, + zone=zone, + inspector=assigned_inspector, + notes=notes, + ) + logger.info(f"Scheduled post-intervention inspection {inspection.pk} created for intervention {intervention.code}") + return inspection diff --git a/loko/inspections/templates/inspections/components/edit_inspection_modal.html b/loko/inspections/templates/inspections/components/edit_inspection_modal.html index 2b0ab86..7794144 100644 --- a/loko/inspections/templates/inspections/components/edit_inspection_modal.html +++ b/loko/inspections/templates/inspections/components/edit_inspection_modal.html @@ -530,21 +530,47 @@ document.addEventListener('DOMContentLoaded', function() { document.getElementById('edit_insp_object_id').value = data.object_id; document.getElementById('edit_insp_target_display').textContent = data.target_display || ''; - let datesText = "{% translate 'Créée le' %} " + (data.created_at || data.inspection_date); - if (data.updated_at) { - datesText += " | {% translate 'Dernière modification :' %} " + data.updated_at; + if (data.is_scheduled) { + const titleModal = document.getElementById('editInspectionModalLabel'); + if (titleModal) { + titleModal.innerHTML = `{% translate "Réaliser l'inspection" %}`; + } + let schedInfo = `{% translate "Fenêtre" %} : ${data.scheduled_start_date} → ${data.scheduled_due_date}`; + if (data.zone_name) { + schedInfo += ` | {% translate "Zone" %} : ${data.zone_name}`; + } + if (data.checked_intervention_code) { + schedInfo += ` | ${data.checked_intervention_code}`; + } + document.getElementById('edit_insp_dates_info').textContent = schedInfo; + document.getElementById('edit_insp_current_status').textContent = data.timeliness_label ? `${data.status_display} (${data.timeliness_label})` : data.status_display; + document.getElementById('edit_insp_current_status').className = `badge ${data.timeliness_badge_class || 'bg-primary'}`; + } else { + const titleModal = document.getElementById('editInspectionModalLabel'); + if (titleModal) { + titleModal.innerHTML = `{% translate "Modifier l'inspection" %}`; + } + let datesText = "{% translate 'Créée le' %} " + (data.created_at || data.inspection_date); + if (data.updated_at) { + datesText += " | {% translate 'Dernière modification :' %} " + data.updated_at; + } + document.getElementById('edit_insp_dates_info').textContent = datesText; + document.getElementById('edit_insp_current_status').textContent = data.result_status_display || data.result_status || ''; + document.getElementById('edit_insp_current_status').className = 'badge bg-secondary'; } - document.getElementById('edit_insp_dates_info').textContent = datesText; - document.getElementById('edit_insp_current_status').textContent = data.result_status || ''; if (typeof window.toggleEditMissingResultChoice === 'function') { window.toggleEditMissingResultChoice(Boolean(data.is_zone_rwiz)); } // Pre-check result_status radio - const rad = document.getElementById('edit_res_' + data.result_status); + let targetRadioCode = data.result_status; + if (data.is_scheduled && targetRadioCode === 'pending') { + targetRadioCode = 'compliant'; + } + const rad = document.getElementById('edit_res_' + targetRadioCode); if (rad) { - if (data.is_zone_rwiz && data.result_status === 'missing') { + if (data.is_zone_rwiz && targetRadioCode === 'missing') { const compRad = document.getElementById('edit_res_compliant'); if (compRad) compRad.checked = true; } else { diff --git a/loko/inspections/templates/inspections/inspections_list_mobile.html b/loko/inspections/templates/inspections/inspections_list_mobile.html index f711579..efdea91 100644 --- a/loko/inspections/templates/inspections/inspections_list_mobile.html +++ b/loko/inspections/templates/inspections/inspections_list_mobile.html @@ -38,9 +38,22 @@