feat: add inspections application and update interventions module with new models, views, and migrations

This commit is contained in:
Karl Determe 2026-09-17 08:55:47 +02:00
parent 836b5c8edb
commit 676e781943
15 changed files with 1008 additions and 46 deletions

View file

@ -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]

View file

@ -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"),
),
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 || '';
if (data.is_scheduled) {
const titleModal = document.getElementById('editInspectionModalLabel');
if (titleModal) {
titleModal.innerHTML = `<i class="bi bi-clipboard-check text-success me-2"></i>{% 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 = `<i class="bi bi-pencil-square text-primary me-2"></i>{% 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 || '';
document.getElementById('edit_insp_current_status').textContent = data.result_status_display || data.result_status || '';
document.getElementById('edit_insp_current_status').className = 'badge bg-secondary';
}
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 {

View file

@ -38,9 +38,22 @@
<!-- Navigation par Onglets -->
<ul class="nav nav-tabs mb-3">
{% if can_inspect %}
<li class="nav-item">
<a class="nav-link {% if filter_type == 'scheduled' %}active fw-bold text-primary{% endif %} position-relative" href="?tab=scheduled">
<i class="bi bi-calendar2-check me-1"></i>{% translate "À réaliser" %}
{% if scheduled_count > 0 %}
<span class="badge rounded-pill bg-danger ms-1">{{ scheduled_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {% if filter_type == 'my' %}active fw-bold{% endif %}" href="?tab=my">
<i class="bi bi-person-check me-1"></i>{% translate "Mes inspections" %}
{% if my_count > 0 and filter_type != 'my' %}
<span class="badge rounded-pill bg-secondary ms-1">{{ my_count }}</span>
{% endif %}
</a>
</li>
<li class="nav-item">
@ -72,6 +85,17 @@
</select>
</div>
{% if zones %}
<div class="col-12 col-sm-6 col-md-2">
<select name="zone" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Districts / Zones --" %}</option>
{% for z in zones %}
<option value="{{ z.id }}" {% if selected_zone == z.id|stringformat:"s" %}selected{% endif %}>{{ z.name_fr }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-12 col-sm-6 col-md-2">
<select name="nature" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Natures --" %}</option>
@ -81,7 +105,7 @@
</select>
</div>
<div class="col-12 col-sm-6 col-md-3">
<div class="col-12 col-sm-6 col-md-2">
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Tous les résultats --" %}</option>
{% for val, label in result_choices %}
@ -90,7 +114,7 @@
</select>
</div>
<div class="col-12 col-sm-6 col-md-2 d-flex gap-2">
<div class="col-12 col-sm-6 col-md-1 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-secondary flex-grow-1">{% translate "Filtrer" %}</button>
<a href="?tab={{ filter_type }}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Réinitialiser' %}"><i class="bi bi-x-circle"></i></a>
</div>
@ -128,14 +152,29 @@
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Entête card : Thématique/Type + Badge résultat -->
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="badge bg-light text-dark border d-inline-flex align-items-center me-2">
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-1">
<div class="d-flex align-items-center gap-1">
<span class="badge bg-light text-dark border d-inline-flex align-items-center">
<i class="bi {{ insp.thematic_icon }} me-1 text-primary"></i>
{{ insp.asset_content_type.name }}
</span>
{% if insp.zone %}
<span class="badge bg-light text-secondary border">
<i class="bi bi-geo me-1"></i>{{ insp.zone.name_fr }}
</span>
{% endif %}
</div>
<div>
{% if insp.is_scheduled %}
<span class="badge {{ insp.timeliness_badge_class }}">
<i class="bi bi-hourglass-split me-1"></i>{{ insp.timeliness_label }}
</span>
{% else %}
<span class="badge {{ insp.status_badge_class }}">
<i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}
</span>
{% endif %}
</div>
</div>
<!-- Nom de la cible tronqué à 100 caractères -->
@ -143,6 +182,20 @@
{{ insp.target_display|truncatechars:100 }}
</h6>
{% if insp.is_scheduled %}
<div class="alert alert-light border py-1 px-2 my-2 small text-muted d-flex align-items-center justify-content-between flex-wrap gap-1">
<span>
<i class="bi bi-calendar-range text-primary me-1"></i>
{% translate "Fenêtre" %} : {{ insp.scheduled_start_date|date:"d/m/Y" }} &rarr; <strong>{{ insp.scheduled_due_date|date:"d/m/Y" }}</strong>
</span>
{% if insp.checked_intervention %}
<span class="text-primary fw-semibold">
<i class="bi bi-tools me-1"></i>{{ insp.checked_intervention.code }}{% if insp.checked_intervention.sub_type %} ({{ insp.checked_intervention.sub_type.get_name }}){% endif %}
</span>
{% endif %}
</div>
{% endif %}
<!-- Résumé des constats si présent -->
{% if insp.details_summary and insp.details_summary != "-" %}
<p class="text-muted small mb-2 text-truncate mt-1">
@ -151,8 +204,19 @@
{% endif %}
</div>
<!-- Ligne de pied card : Date & Inspecteur -->
<!-- Ligne de pied card : Date & Inspecteur / Action -->
<div class="d-flex justify-content-between align-items-center pt-2 border-top mt-2 text-muted extra-small">
{% if insp.is_scheduled %}
<span><i class="bi bi-clock-history me-1"></i>{% translate "Planifiée" %}</span>
<div class="d-flex align-items-center gap-2">
{% can_edit_inspection insp request.user as can_perform %}
{% if can_perform %}
<button type="button" class="btn btn-sm btn-success fw-bold py-1 px-2.5 shadow-sm" onclick="event.stopPropagation(); openEditInspectionModal({{ insp.id }});">
<i class="bi bi-clipboard-check me-1"></i>{% translate "Réaliser" %}
</button>
{% endif %}
</div>
{% else %}
<span><i class="bi bi-clock me-1"></i>{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
<div class="d-flex align-items-center gap-2">
{% if insp.documents.all %}
@ -164,6 +228,7 @@
<span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name|default:insp.inspector.username }}</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
@ -206,6 +271,30 @@
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li>
{% if insp.is_scheduled %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-hourglass-split me-1"></i>{% translate "Statut" %}</span>
<span class="badge {{ insp.timeliness_badge_class }}">{{ insp.get_status_display }} ({{ insp.timeliness_label }})</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-range me-1"></i>{% translate "Fenêtre d'inspection" %}</span>
<span class="fw-semibold">{{ insp.scheduled_start_date|date:"d/m/Y" }} &rarr; {{ insp.scheduled_due_date|date:"d/m/Y" }}</span>
</li>
{% if insp.zone %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo me-1"></i>{% translate "District / Zone" %}</span>
<span class="fw-semibold">{{ insp.zone.name_fr }}</span>
</li>
{% endif %}
{% if insp.checked_intervention %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
<a href="/interventions/{{ insp.checked_intervention.id }}/" class="fw-bold text-decoration-none">
{{ insp.checked_intervention.code }} {% if insp.checked_intervention.sub_type %}({{ insp.checked_intervention.sub_type.get_name }}){% endif %}
</a>
</li>
{% endif %}
{% else %}
<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>
@ -214,6 +303,7 @@
<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 }}"><i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}</span>
</li>
{% endif %}
<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>
@ -307,6 +397,11 @@
<div class="modal-footer bg-light py-2">
{% can_edit_inspection insp request.user as user_can_edit %}
{% if user_can_edit %}
{% if insp.is_scheduled %}
<button type="button" class="btn btn-success btn-sm fw-bold px-3" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
<i class="bi bi-clipboard-check me-1"></i>{% translate "Réaliser le contrôle" %}
</button>
{% endif %}
{% if insp.is_cancelled %}
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'restore')">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
@ -316,10 +411,12 @@
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button>
{% endif %}
{% if not insp.is_scheduled %}
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button>
{% endif %}
{% endif %}
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>

View file

@ -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)

View file

@ -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,6 +278,9 @@ def inspections_list(request):
pass
if 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:
@ -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 = (

View file

@ -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"

View file

@ -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"),
),
]

View file

@ -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),
]

View file

@ -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')

View file

@ -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'}),

View file

@ -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 _