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 @@
+ {% if can_inspect %}
+ -
+
+ {% translate "À réaliser" %}
+ {% if scheduled_count > 0 %}
+ {{ scheduled_count }}
+ {% endif %}
+
+
+ {% endif %}
-
{% translate "Mes inspections" %}
+ {% if my_count > 0 and filter_type != 'my' %}
+ {{ my_count }}
+ {% endif %}
-
@@ -72,6 +85,17 @@
+ {% if zones %}
+
+
+
+ {% endif %}
+
-
+
-
+
@@ -128,14 +152,29 @@
-
-
-
- {{ insp.asset_content_type.name }}
-
-
- {{ insp.get_result_status_display }}
-
+
+
+
+
+ {{ insp.asset_content_type.name }}
+
+ {% if insp.zone %}
+
+ {{ insp.zone.name_fr }}
+
+ {% endif %}
+
+
+ {% if insp.is_scheduled %}
+
+ {{ insp.timeliness_label }}
+
+ {% else %}
+
+ {{ insp.get_result_status_display }}
+
+ {% endif %}
+
@@ -143,6 +182,20 @@
{{ insp.target_display|truncatechars:100 }}
+ {% if insp.is_scheduled %}
+
+
+
+ {% translate "Fenêtre" %} : {{ insp.scheduled_start_date|date:"d/m/Y" }} → {{ insp.scheduled_due_date|date:"d/m/Y" }}
+
+ {% if insp.checked_intervention %}
+
+ {{ insp.checked_intervention.code }}{% if insp.checked_intervention.sub_type %} ({{ insp.checked_intervention.sub_type.get_name }}){% endif %}
+
+ {% endif %}
+
+ {% endif %}
+
{% if insp.details_summary and insp.details_summary != "-" %}
@@ -151,8 +204,19 @@
{% endif %}
-
+
@@ -206,6 +271,30 @@
{% translate "Type d'asset" %}
{{ insp.asset_content_type.name }}
+ {% if insp.is_scheduled %}
+
-
+ {% translate "Statut" %}
+ {{ insp.get_status_display }} ({{ insp.timeliness_label }})
+
+
-
+ {% translate "Fenêtre d'inspection" %}
+ {{ insp.scheduled_start_date|date:"d/m/Y" }} → {{ insp.scheduled_due_date|date:"d/m/Y" }}
+
+ {% if insp.zone %}
+
-
+ {% translate "District / Zone" %}
+ {{ insp.zone.name_fr }}
+
+ {% endif %}
+ {% if insp.checked_intervention %}
+
-
+ {% translate "Intervention liée" %}
+
+ {{ insp.checked_intervention.code }} {% if insp.checked_intervention.sub_type %}({{ insp.checked_intervention.sub_type.get_name }}){% endif %}
+
+
+ {% endif %}
+ {% else %}
-
{% translate "Date & Heure" %}
{{ insp.inspection_date|date:"d/m/Y H:i" }}
@@ -214,6 +303,7 @@
{% translate "Résultat du contrôle" %}
{{ insp.get_result_status_display }}
+ {% endif %}
-
{% translate "Nature" %}
{{ insp.get_nature_display }}
@@ -307,6 +397,11 @@
diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py
index bc6f712..688c34e 100644
--- a/loko/inspections/tests.py
+++ b/loko/inspections/tests.py
@@ -1481,3 +1481,153 @@ class GiepInspectionStatusCalculationTestCase(TestCase):
self.assertEqual(GiepInspection.compute_status_from_data(data_mix2), 'damaged')
+class PostInterventionDurabilityInspectionTestCase(TestCase):
+ def setUp(self):
+ from datetime import timedelta
+ self.user = User.objects.create_user(
+ username='road_inspector',
+ password='password123',
+ first_name='Pierre',
+ last_name='Dupont'
+ )
+ from common.models import UserConfig, Role, UserThematics
+ inspector_role, _ = Role.objects.get_or_create(name='inspector')
+ config = UserConfig.objects.create(user=self.user)
+ config.roles.add(inspector_role)
+
+ self.roads_thematic, _ = Thematic.objects.get_or_create(
+ code='roads',
+ defaults={'name_fr': 'Voirie', 'name_nl': 'Wegen'}
+ )
+ UserThematics.objects.create(
+ user_config=config,
+ thematic=self.roads_thematic,
+ can_view_assets=True,
+ can_edit_assets=True,
+ can_inspect=True
+ )
+
+ from notifications.models import Team
+ self.team = Team.objects.create(name='Équipe Contrôle Enrobés')
+ self.team.members.add(self.user)
+
+ from interventions.models import InterventionSubType
+ self.hotbox_subtype = InterventionSubType.objects.create(
+ code='test_roads_repair_hotbox',
+ name_fr='Hot Box (asphalte à chaud)',
+ name_nl='Hot Box (warm asfalt)',
+ thematic=self.roads_thematic,
+ intervention_type='repair',
+ requires_post_inspection=True,
+ post_inspection_delay_min_days=30,
+ post_inspection_delay_max_days=60,
+ post_inspection_nature='post_intervention'
+ )
+ self.hotbox_subtype.notification_teams.add(self.team)
+
+ self.standard_subtype = InterventionSubType.objects.create(
+ code='test_roads_repair_standard',
+ name_fr='Réparation standard',
+ thematic=self.roads_thematic,
+ intervention_type='repair',
+ requires_post_inspection=False
+ )
+
+ def test_hotbox_intervention_finished_triggers_inspection(self):
+ from datetime import timedelta
+ now = timezone.now()
+ intervention = Intervention.objects.create(
+ thematic=self.roads_thematic,
+ type='repair',
+ sub_type=self.hotbox_subtype,
+ status='in_progress',
+ end_time=now
+ )
+ self.assertEqual(Inspection.objects.filter(checked_intervention=intervention).count(), 0)
+
+ # Passage au statut finished
+ intervention.status = 'finished'
+ intervention.save()
+
+ self.assertEqual(Inspection.objects.filter(checked_intervention=intervention).count(), 1)
+ insp = Inspection.objects.get(checked_intervention=intervention)
+ self.assertEqual(insp.status, 'scheduled')
+ self.assertEqual(insp.result_status, 'pending')
+ self.assertEqual(insp.nature, 'post_intervention')
+ self.assertEqual(insp.scheduled_start_date, now.date() + timedelta(days=30))
+ self.assertEqual(insp.scheduled_due_date, now.date() + timedelta(days=60))
+ self.assertTrue(insp.is_scheduled)
+ self.assertEqual(insp.timeliness, 'future')
+
+ def test_zone_and_single_inspector_auto_assignment(self):
+ from common.models import Municipality
+ from interventions.models import InterventionLocation
+ from .models import InspectionZone
+
+ zone = InspectionZone.objects.create(
+ code='DIST_NORD_TEST',
+ name_fr='District Nord Test',
+ thematic=self.roads_thematic
+ )
+ zone.inspectors.add(self.user)
+ muni = Municipality.objects.create(name_fr='Schaerbeek Test', code='21099')
+ zone.municipalities.add(muni)
+
+ intervention = Intervention.objects.create(
+ thematic=self.roads_thematic,
+ type='repair',
+ sub_type=self.hotbox_subtype,
+ status='in_progress',
+ end_time=timezone.now()
+ )
+ InterventionLocation.objects.create(
+ intervention=intervention,
+ content_type=ContentType.objects.get_for_model(muni),
+ object_id=muni.pk
+ )
+
+ intervention.status = 'finished'
+ intervention.save()
+
+ insp = Inspection.objects.get(checked_intervention=intervention)
+ self.assertEqual(insp.zone, zone)
+ # Inspecteur unique pré-assigné
+ self.assertEqual(insp.inspector, self.user)
+ self.assertTrue(insp.can_edit(self.user))
+
+ def test_non_compliance_triggers_team_notification(self):
+ from notifications.models import TeamNotificationTarget
+ intervention = Intervention.objects.create(
+ thematic=self.roads_thematic,
+ type='repair',
+ sub_type=self.hotbox_subtype,
+ status='finished',
+ end_time=timezone.now()
+ )
+ insp = Inspection.objects.get(checked_intervention=intervention)
+ self.assertFalse(insp.non_compliance_notified)
+
+ # Réalisation avec non-conformité
+ insp.status = 'completed'
+ insp.result_status = 'non_compliant'
+ insp.notes = 'Fissures apparues sur le joint de raccordement.'
+ insp.save()
+
+ insp.refresh_from_db()
+ self.assertTrue(insp.non_compliance_notified)
+ self.assertTrue(TeamNotificationTarget.objects.filter(team=self.team).exists())
+
+ def test_subtype_without_post_inspection_does_not_trigger(self):
+ intervention = Intervention.objects.create(
+ thematic=self.roads_thematic,
+ type='repair',
+ sub_type=self.standard_subtype,
+ status='in_progress',
+ end_time=timezone.now()
+ )
+ intervention.status = 'finished'
+ intervention.save()
+
+ self.assertEqual(Inspection.objects.filter(checked_intervention=intervention).count(), 0)
+
+
diff --git a/loko/inspections/views.py b/loko/inspections/views.py
index f7486ea..2f7e191 100644
--- a/loko/inspections/views.py
+++ b/loko/inspections/views.py
@@ -12,7 +12,7 @@ from common.models import Thematic
from common.utils import get_short_name
from assets.models import AssetCategory
from .models import (
- Inspection, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument,
+ Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument,
INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES
)
@@ -139,18 +139,52 @@ def _filter_inspections_queryset(request):
qs = qs.filter(combined)
- statuses = request.GET.getlist('status') or request.GET.getlist('result_status')
- if statuses:
- qs = qs.filter(result_status__in=statuses)
+ lifecycle_statuses = request.GET.getlist('lifecycle_status')
+ result_statuses = request.GET.getlist('result_status')
+ raw_statuses = request.GET.getlist('status')
+ for s in raw_statuses:
+ if s in ('scheduled', 'in_progress', 'completed', 'cancelled'):
+ if s not in lifecycle_statuses:
+ lifecycle_statuses.append(s)
+ else:
+ if s not in result_statuses:
+ result_statuses.append(s)
+
+ if lifecycle_statuses:
+ qs = qs.filter(status__in=lifecycle_statuses)
+ if result_statuses:
+ qs = qs.filter(result_status__in=result_statuses)
natures = request.GET.getlist('nature')
if natures:
qs = qs.filter(nature__in=natures)
+ zone_id = request.GET.get('zone') or request.GET.get('zone_id')
+ if zone_id:
+ qs = qs.filter(zone_id=zone_id)
+
show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
if show_mine_only:
qs = qs.filter(inspector=request.user)
+ if request.GET.get('mine_zones') == '1':
+ qs = qs.filter(Q(zone__inspectors=request.user) | Q(inspector=request.user))
+
+ timeliness = request.GET.get('timeliness')
+ if timeliness:
+ today = timezone.now().date()
+ from datetime import timedelta
+ if timeliness == 'overdue':
+ qs = qs.filter(status='scheduled', scheduled_due_date__lt=today)
+ elif timeliness == 'urgent':
+ qs = qs.filter(status='scheduled', scheduled_due_date__gte=today, scheduled_due_date__lte=today + timedelta(days=7))
+ elif timeliness == 'current':
+ qs = qs.filter(status='scheduled', scheduled_due_date__gte=today).filter(
+ Q(scheduled_start_date__isnull=True) | Q(scheduled_start_date__lte=today)
+ )
+ elif timeliness == 'future':
+ qs = qs.filter(status='scheduled', scheduled_start_date__gt=today)
+
date_from = request.GET.get('date_from', '').strip()
date_to = request.GET.get('date_to', '').strip()
if date_from:
@@ -166,7 +200,9 @@ def _filter_inspections_queryset(request):
if checked_interv_id:
qs = qs.filter(checked_intervention_id=checked_interv_id)
- return qs.order_by('-inspection_date')
+ if lifecycle_statuses == ['scheduled']:
+ return qs.order_by('scheduled_due_date', 'scheduled_start_date', '-id')
+ return qs.order_by('-inspection_date', '-id')
@login_required
@@ -183,24 +219,42 @@ def inspections_list(request):
can_inspect = bool(insp_thematics and insp_thematics.exists())
if is_mobile:
- default_tab = 'my' if can_inspect else 'all'
+ from .permissions import filter_viewable_inspections_for_user
+ base_viewable = filter_viewable_inspections_for_user(request.user)
+
+ # Compteurs rapides pour les onglets
+ scheduled_count = base_viewable.filter(status='scheduled').filter(
+ Q(inspector=request.user) | Q(zone__inspectors=request.user) | Q(inspector__isnull=True)
+ ).count()
+ my_count = base_viewable.filter(inspector=request.user).exclude(status='scheduled').count()
+
+ default_tab = 'scheduled' if (can_inspect and scheduled_count > 0) else ('my' if can_inspect else 'all')
filter_type = request.GET.get('tab', default_tab)
thematic_code = request.GET.get('thematic', '')
status_filter = request.GET.get('status', '')
nature_filter = request.GET.get('nature', '')
+ zone_filter = request.GET.get('zone', '')
mission_id = request.GET.get('mission_id', '')
checked_interv_id = request.GET.get('checked_interv_id', '')
search_q = request.GET.get('q', '').strip()
- from .permissions import filter_viewable_inspections_for_user
- qs = filter_viewable_inspections_for_user(request.user)
- qs = qs.select_related(
+ qs = base_viewable.select_related(
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention',
- 'parkingspotinspection', 'giepinspection', 'signpanelinspection'
- ).prefetch_related('documents').order_by('-inspection_date')
+ 'zone', 'parkingspotinspection', 'giepinspection', 'signpanelinspection'
+ ).prefetch_related('documents')
- if filter_type == 'my':
- qs = qs.filter(inspector=request.user)
+ if filter_type == 'scheduled':
+ qs = qs.filter(status='scheduled')
+ if request.GET.get('all_zones') != '1':
+ qs = qs.filter(Q(inspector=request.user) | Q(zone__inspectors=request.user) | Q(inspector__isnull=True))
+ qs = qs.order_by('scheduled_due_date', 'scheduled_start_date', '-id')
+ elif filter_type == 'my':
+ qs = qs.filter(inspector=request.user).exclude(status='scheduled').order_by('-inspection_date', '-id')
+ else:
+ qs = qs.order_by('-inspection_date', '-id')
+
+ if zone_filter:
+ qs = qs.filter(zone_id=zone_filter)
if thematic_code:
try:
@@ -224,7 +278,10 @@ def inspections_list(request):
pass
if status_filter:
- qs = qs.filter(result_status=status_filter)
+ if status_filter in ('scheduled', 'in_progress', 'completed', 'cancelled'):
+ qs = qs.filter(status=status_filter)
+ else:
+ qs = qs.filter(result_status=status_filter)
if nature_filter:
qs = qs.filter(nature=nature_filter)
@@ -270,15 +327,23 @@ def inspections_list(request):
qs = qs.filter(search_filter)
+ zones = InspectionZone.objects.filter(is_active=True).order_by('thematic__name_fr', 'name_fr')
+ if thematics.exists():
+ zones = zones.filter(thematic__in=thematics)
+
context = {
'base_template': "mobile/mobile_base.html",
'is_mobile': True,
'inspections': qs[:100],
'total_count': qs.count(),
'filter_type': filter_type,
+ 'scheduled_count': scheduled_count,
+ 'my_count': my_count,
'selected_thematic': thematic_code,
'selected_status': status_filter,
'selected_nature': nature_filter,
+ 'selected_zone': zone_filter,
+ 'zones': zones,
'selected_mission_id': mission_id,
'selected_checked_interv_id': checked_interv_id,
'search_q': search_q,
@@ -314,6 +379,7 @@ def inspections_list(request):
selected_categories = request.GET.getlist('category')
selected_statuses = request.GET.getlist('status') or request.GET.getlist('result_status')
selected_natures = request.GET.getlist('nature')
+ selected_zone = request.GET.get('zone', '')
search_query = request.GET.get('q', '')
show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
date_from = request.GET.get('date_from', '')
@@ -337,6 +403,10 @@ def inspections_list(request):
for cfg in result_choices_config
]
+ zones = InspectionZone.objects.filter(is_active=True).order_by('thematic__name_fr', 'name_fr')
+ if thematics.exists():
+ zones = zones.filter(thematic__in=thematics)
+
context = {
'thematic_choices': [(t.code, t.name, t.icon or 'bi-geo-alt') for t in thematics],
'selected_thematics': selected_thematics,
@@ -348,6 +418,8 @@ def inspections_list(request):
'selected_result_statuses': selected_statuses,
'nature_choices': INSPECTION_NATURE_CHOICES,
'selected_natures': selected_natures,
+ 'zones': zones,
+ 'selected_zone': selected_zone,
'search_query': search_query,
'show_mine_only': show_mine_only,
'date_from': date_from,
@@ -779,6 +851,19 @@ def get_inspection_detail_api(request, inspection_id):
'status_icon': leaf.status_icon,
'nature': leaf.nature,
'nature_display': str(leaf.get_nature_display()),
+ 'status': leaf.status,
+ 'status_display': str(leaf.get_status_display()),
+ 'is_scheduled': leaf.is_scheduled,
+ 'scheduled_start_date': leaf.scheduled_start_date.strftime('%d/%m/%Y') if leaf.scheduled_start_date else '',
+ 'scheduled_due_date': leaf.scheduled_due_date.strftime('%d/%m/%Y') if leaf.scheduled_due_date else '',
+ 'zone_id': leaf.zone_id,
+ 'zone_name': leaf.zone.get_name() if leaf.zone else '',
+ 'timeliness': leaf.timeliness,
+ 'timeliness_label': str(leaf.timeliness_label) if leaf.timeliness_label else '',
+ 'timeliness_badge_class': leaf.timeliness_badge_class,
+ 'checked_intervention_code': leaf.checked_intervention.code if leaf.checked_intervention else '',
+ 'checked_intervention_id': leaf.checked_intervention_id,
+ 'checked_intervention_subtype': leaf.checked_intervention.sub_type.get_name() if leaf.checked_intervention and leaf.checked_intervention.sub_type else '',
'inspector_name': get_short_name(leaf.inspector) if leaf.inspector else '-',
'inspector_role': leaf.inspector_role,
'inspector_role_display': str(leaf.get_inspector_role_display()) if leaf.inspector_role else '',
@@ -846,6 +931,14 @@ def update_inspection_api(request, inspection_id):
}, status=403)
try:
+ if 'status' in request.POST:
+ inspection.status = request.POST['status']
+ elif inspection.status == 'scheduled' and 'result_status' in request.POST and request.POST['result_status'] != 'pending':
+ inspection.status = 'completed'
+ if not inspection.inspector_id or inspection.inspector != request.user:
+ inspection.inspector = request.user
+ inspection.inspection_date = timezone.now()
+
if 'result_status' in request.POST:
new_status = request.POST['result_status']
is_zone_rwiz = (
diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py
index e731d8d..5513f4d 100644
--- a/loko/interventions/admin.py
+++ b/loko/interventions/admin.py
@@ -21,7 +21,10 @@ try:
except ImportError:
openpyxl = None
-from .models import Intervention, InterventionDocument, InterventionNote, InterventionLocation, InterventionOccupation
+from .models import (
+ Intervention, InterventionSubType, InterventionDocument, InterventionNote,
+ InterventionLocation, InterventionOccupation
+)
from. models import InterventionPlanificationTimeLine, InterventionRealizationTimeLine
from .models import Symptom, SymptomRecommendation, ReflexSheet, SourceCategory, SymptomContractAutoClose
from .models import (
@@ -42,10 +45,23 @@ from interventions.views.preventive_import import process_worksheet as _process_
from interventions.models import STATUS_CHOICES, DirectContractRouting
+@admin.register(InterventionSubType)
+class InterventionSubTypeAdmin(admin.ModelAdmin):
+ list_display = (
+ "code", "name_fr", "thematic", "intervention_type",
+ "requires_post_inspection", "post_inspection_delay_min_days",
+ "post_inspection_delay_max_days", "is_active"
+ )
+ list_filter = ("thematic", "intervention_type", "requires_post_inspection", "is_active")
+ search_fields = ("code", "name_fr", "name_nl")
+ filter_horizontal = ("notification_teams",)
+
+
@admin.register(Intervention)
class InterventionAdmin(admin.ModelAdmin):
search_fields = ("code", "title")
- raw_id_fields = ("intervention_manager",)
+ list_filter = ("thematic", "type", "sub_type", "status", "maintain_type")
+ raw_id_fields = ("intervention_manager", "sub_type")
change_list_template = "admin/interventions/intervention_changelist.html"
diff --git a/loko/interventions/migrations/0009_interventionsubtype_intervention_sub_type.py b/loko/interventions/migrations/0009_interventionsubtype_intervention_sub_type.py
new file mode 100644
index 0000000..8b4d99a
--- /dev/null
+++ b/loko/interventions/migrations/0009_interventionsubtype_intervention_sub_type.py
@@ -0,0 +1,44 @@
+# Generated by Django 6.0.7 on 2026-09-16 14:17
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('common', '0014_userconfig_inspection_list_filters_and_more'),
+ ('interventions', '0008_symptom_notification_recipients'),
+ ('notifications', '0004_discussion_flag_discussion_flag_set_at_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='InterventionSubType',
+ 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')),
+ ('name_fr', models.CharField(max_length=200, verbose_name='Nom (FR)')),
+ ('name_nl', models.CharField(blank=True, max_length=200, verbose_name='Nom (NL)')),
+ ('intervention_type', models.CharField(choices=[('fixing', 'Dépannage'), ('repair', 'Réparation'), ('repair_post_preventive', 'Réparation post préventif'), ('replace', 'Remplacement'), ('care', 'Entretien'), ('control', 'Contrôle'), ('inspection', 'Inspection'), ('build', 'Construction'), ('collection', 'Ramassage'), ('reprogramming', 'Reprogrammation')], max_length=50, verbose_name="Type d'intervention")),
+ ('requires_post_inspection', models.BooleanField(default=False, help_text="Si coché, une inspection de durabilité est automatiquement planifiée quand l'intervention passe à 'Terminé'.", verbose_name='Déclenche une inspection de durabilité')),
+ ('post_inspection_delay_min_days', models.PositiveIntegerField(default=30, help_text="Nombre de jours minimum après fin d'exécution pour réaliser l'inspection (ex: 30 jours).", verbose_name='Délai début fenêtre (jours)')),
+ ('post_inspection_delay_max_days', models.PositiveIntegerField(default=60, help_text="Nombre de jours maximum après fin d'exécution marquant la date limite d'inspection (ex: 60 jours).", verbose_name='Délai fin fenêtre / échéance (jours)')),
+ ('post_inspection_nature', models.CharField(choices=[('visual', 'Inspection visuelle'), ('technical', 'Inspection technique'), ('post_intervention', 'Suivi / Qualité post-intervention')], default='post_intervention', max_length=50, verbose_name="Nature de l'inspection")),
+ ('order_number', models.PositiveIntegerField(blank=True, null=True, verbose_name="Ordre d'affichage")),
+ ('is_active', models.BooleanField(default=True, verbose_name='Actif')),
+ ('notification_teams', models.ManyToManyField(blank=True, help_text="Équipes notifiées si l'inspection de durabilité révèle un constat non-conforme.", related_name='intervention_subtypes', to='notifications.team', verbose_name='Équipes à notifier')),
+ ('thematic', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='intervention_subtypes', to='common.thematic', verbose_name='Thématique')),
+ ],
+ options={
+ 'verbose_name': "Sous-type d'intervention",
+ 'verbose_name_plural': "Sous-types d'interventions",
+ 'ordering': ['thematic', 'intervention_type', 'order_number', 'name_fr'],
+ },
+ ),
+ migrations.AddField(
+ model_name='intervention',
+ name='sub_type',
+ field=models.ForeignKey(blank=True, help_text='Précise la technique ou le sous-type de travail (ex: Hot Box pour une réparation voirie).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interventions', to='interventions.interventionsubtype', verbose_name="Sous-type d'intervention"),
+ ),
+ ]
diff --git a/loko/interventions/migrations/0010_seed_roads_hotbox_subtype.py b/loko/interventions/migrations/0010_seed_roads_hotbox_subtype.py
new file mode 100644
index 0000000..d492974
--- /dev/null
+++ b/loko/interventions/migrations/0010_seed_roads_hotbox_subtype.py
@@ -0,0 +1,40 @@
+from django.db import migrations
+
+
+def seed_hotbox_subtype(apps, schema_editor):
+ Thematic = apps.get_model('common', 'Thematic')
+ InterventionSubType = apps.get_model('interventions', 'InterventionSubType')
+
+ roads_thematic = Thematic.objects.filter(code='roads').first()
+ if roads_thematic:
+ InterventionSubType.objects.update_or_create(
+ code='roads_repair_hotbox',
+ defaults={
+ 'name_fr': 'Hot Box (asphalte à chaud)',
+ 'name_nl': 'Hot Box (warm asfalt)',
+ 'thematic': roads_thematic,
+ 'intervention_type': 'repair',
+ 'requires_post_inspection': True,
+ 'post_inspection_delay_min_days': 30,
+ 'post_inspection_delay_max_days': 60,
+ 'post_inspection_nature': 'post_intervention',
+ 'order_number': 10,
+ 'is_active': True,
+ }
+ )
+
+
+def unseed_hotbox_subtype(apps, schema_editor):
+ InterventionSubType = apps.get_model('interventions', 'InterventionSubType')
+ InterventionSubType.objects.filter(code='roads_repair_hotbox').delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('interventions', '0009_interventionsubtype_intervention_sub_type'),
+ ]
+
+ operations = [
+ migrations.RunPython(seed_hotbox_subtype, reverse_code=unseed_hotbox_subtype),
+ ]
diff --git a/loko/interventions/models.py b/loko/interventions/models.py
index c16b106..81fa062 100644
--- a/loko/interventions/models.py
+++ b/loko/interventions/models.py
@@ -185,6 +185,71 @@ SYMPTOM_MAINTENANCE_TYPE_CHOICES = {
}
+INSPECTION_NATURE_CHOICES = (
+ ('visual', _('Inspection visuelle')),
+ ('technical', _('Inspection technique')),
+ ('post_intervention', _('Suivi / Qualité post-intervention')),
+)
+
+
+class InterventionSubType(models.Model):
+ """
+ Sous-type de travaux d'intervention (ex: Roads -> Réparation -> Hot Box).
+ Permet de configurer les règles d'inspection de durabilité post-travaux et les équipes à notifier en cas de non-conformité.
+ """
+ code = models.CharField(max_length=50, unique=True, verbose_name=_("Code"))
+ 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(Thematic, on_delete=models.PROTECT, related_name='intervention_subtypes', verbose_name=_("Thématique"))
+ intervention_type = models.CharField(max_length=50, choices=TYPE_CHOICES, verbose_name=_("Type d'intervention"))
+
+ # Paramètres de durabilité / inspection post-travaux
+ requires_post_inspection = models.BooleanField(
+ default=False,
+ verbose_name=_("Déclenche une inspection de durabilité"),
+ help_text=_("Si coché, une inspection de durabilité est automatiquement planifiée quand l'intervention passe à 'Terminé'.")
+ )
+ post_inspection_delay_min_days = models.PositiveIntegerField(
+ default=30,
+ verbose_name=_("Délai début fenêtre (jours)"),
+ help_text=_("Nombre de jours minimum après fin d'exécution pour réaliser l'inspection (ex: 30 jours).")
+ )
+ post_inspection_delay_max_days = models.PositiveIntegerField(
+ default=60,
+ verbose_name=_("Délai fin fenêtre / échéance (jours)"),
+ help_text=_("Nombre de jours maximum après fin d'exécution marquant la date limite d'inspection (ex: 60 jours).")
+ )
+ post_inspection_nature = models.CharField(
+ max_length=50,
+ choices=INSPECTION_NATURE_CHOICES,
+ default='post_intervention',
+ verbose_name=_("Nature de l'inspection")
+ )
+ notification_teams = models.ManyToManyField(
+ 'notifications.Team',
+ blank=True,
+ related_name='intervention_subtypes',
+ verbose_name=_("Équipes à notifier"),
+ help_text=_("Équipes notifiées si l'inspection de durabilité révèle un constat non-conforme.")
+ )
+ order_number = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Ordre d'affichage"))
+ is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
+
+ class Meta:
+ verbose_name = _("Sous-type d'intervention")
+ verbose_name_plural = _("Sous-types d'interventions")
+ ordering = ['thematic', 'intervention_type', 'order_number', 'name_fr']
+
+ def __str__(self):
+ return f"{self.get_name()} ({self.thematic.name})"
+
+ def get_name(self):
+ lang = get_language()
+ if lang == 'nl' and self.name_nl:
+ return self.name_nl
+ return self.name_fr
+
+
class Symptom(models.Model):
code = models.CharField(max_length=50, unique=True, null=True, blank=True, help_text=_("Code unique optionnel pour identifier le symptôme (ex: pour les raccourcis)."))
name_fr = models.CharField(max_length=255)
@@ -381,6 +446,15 @@ class Intervention(models.Model):
description = models.TextField(null=True,blank=True)
maintain_type = models.CharField(choices=MAINTAIN_CHOICES, default='corrective')
type = models.CharField(choices=TYPE_CHOICES,null=True,blank=True)
+ sub_type = models.ForeignKey(
+ 'interventions.InterventionSubType',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='interventions',
+ verbose_name=_("Sous-type d'intervention"),
+ help_text=_("Précise la technique ou le sous-type de travail (ex: Hot Box pour une réparation voirie).")
+ )
origin_type = models.CharField(choices=ORIGIN_TYPE_CHOICES, default='damage')
begin_time = models.DateTimeField(null=True,blank=True)
end_time = models.DateTimeField(null=True,blank=True)
@@ -498,6 +572,7 @@ class Intervention(models.Model):
is_new = self._state.adding # True uniquement lors de la création
status_changed_to_closed = False
status_changed_to_validated = False
+ status_changed_to_finished = False
old_status = None
if not is_new:
old_status = Intervention.objects.filter(pk=self.pk).values_list('status', flat=True).first()
@@ -505,9 +580,13 @@ class Intervention(models.Model):
status_changed_to_closed = True
if old_status and old_status != 'validated' and self.status == 'validated':
status_changed_to_validated = True
+ if old_status and old_status != 'finished' and self.status == 'finished':
+ status_changed_to_finished = True
else:
if self.status == 'validated':
status_changed_to_validated = True
+ if self.status == 'finished':
+ status_changed_to_finished = True
if not self.code: # Generate code only if not defined
next_number = InterventionSequence.get_next_number()
@@ -546,6 +625,17 @@ class Intervention(models.Model):
f"Error automatically generating preventive report for intervention {self.code}"
)
+ if status_changed_to_finished:
+ if self.sub_type and getattr(self.sub_type, 'requires_post_inspection', False):
+ from inspections.services import schedule_post_intervention_inspection
+ import logging
+ try:
+ schedule_post_intervention_inspection(self)
+ except Exception:
+ logging.getLogger(__name__).exception(
+ f"Error automatically scheduling post-intervention inspection for intervention {self.code}"
+ )
+
if status_changed_to_closed:
from observations.models import Observation, ObservationComment
linked_obs = self.observations.exclude(status='closed')
diff --git a/loko/interventions/views/forms.py b/loko/interventions/views/forms.py
index 8692c39..4e8b9c2 100644
--- a/loko/interventions/views/forms.py
+++ b/loko/interventions/views/forms.py
@@ -98,6 +98,14 @@ class InterventionForm(forms.ModelForm):
if 'type' in self.fields:
self.fields['type'].choices = get_type_choices(thematic_obj)
+ if 'sub_type' in self.fields:
+ from interventions.models import InterventionSubType
+ sub_type_qs = InterventionSubType.objects.filter(is_active=True)
+ if thematic_obj:
+ sub_type_qs = sub_type_qs.filter(thematic=thematic_obj)
+ self.fields['sub_type'].queryset = sub_type_qs
+ self.fields['sub_type'].required = False
+
type_value = self.initial.get('type') or self.data.get('type')
maintain_type_value = self.initial.get('maintain_type') or self.data.get('maintain_type')
if type_value == 'inspection':
@@ -173,7 +181,7 @@ class InterventionForm(forms.ModelForm):
fields = [
'code', 'title', 'status', 'priority', 'init_description',
- 'maintain_type', 'type', 'origin_type', 'begin_time', 'end_time',
+ 'maintain_type', 'type', 'sub_type', 'origin_type', 'begin_time', 'end_time',
'asset_category', 'symptom',
'source_category', 'source_detail', 'source_ref',
'lat', 'lon', 'geom', 'address',
@@ -193,6 +201,7 @@ class InterventionForm(forms.ModelForm):
'symptom': forms.Select(attrs={'class': 'form-select'}),
'priority': forms.Select(attrs={'class': 'form-select'}),
'type': forms.Select(attrs={'class': 'form-select'}),
+ 'sub_type': forms.Select(attrs={'class': 'form-select'}),
'origin_type': forms.Select(attrs={'class': 'form-select'}),
# 'project': forms.Select(attrs={'class': 'form-select'}),
diff --git a/loko/loko/settings.py b/loko/loko/settings.py
index 047dd02..1fe2d59 100644
--- a/loko/loko/settings.py
+++ b/loko/loko/settings.py
@@ -18,6 +18,14 @@ import platform
# Bypass WMI queries on Windows which can hang indefinitely due to OS/WMI service issues
if os.name == 'nt':
platform.win32_ver = lambda: ('10', '10.0.19045', '', 'Multiprocessor Free')
+ osgeo4w_bin = r'C:\OSGeo4W\bin'
+ if os.path.exists(osgeo4w_bin):
+ if hasattr(os, 'add_dll_directory'):
+ try:
+ os.add_dll_directory(osgeo4w_bin)
+ except Exception:
+ pass
+ os.environ['PATH'] = osgeo4w_bin + ';' + os.environ.get('PATH', '')
from datetime import timedelta
from django.utils.translation import gettext_lazy as _