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 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): class InspectionDocumentInline(admin.TabularInline):
@ -11,6 +20,7 @@ class InspectionDocumentInline(admin.TabularInline):
def make_cancelled(modeladmin, request, queryset): def make_cancelled(modeladmin, request, queryset):
for obj in queryset: for obj in queryset:
obj.result_status = 'cancelled' obj.result_status = 'cancelled'
obj.status = 'cancelled'
obj.save() obj.save()
@ -18,26 +28,28 @@ def make_cancelled(modeladmin, request, queryset):
def make_compliant(modeladmin, request, queryset): def make_compliant(modeladmin, request, queryset):
for obj in queryset: for obj in queryset:
obj.result_status = 'compliant' obj.result_status = 'compliant'
obj.status = 'completed'
obj.save() obj.save()
@admin.register(Inspection) @admin.register(Inspection)
class InspectionAdmin(admin.ModelAdmin): class InspectionAdmin(admin.ModelAdmin):
list_display = ( 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", "result_status", "mission_intervention", "checked_intervention",
"apply_changes_to_asset" "apply_changes_to_asset"
) )
list_filter = ( list_filter = (
"result_status", "nature", "inspector_role", "status", "result_status", "nature", "zone", "inspector_role",
"apply_changes_to_asset", "inspection_date" "apply_changes_to_asset", "inspection_date"
) )
search_fields = ( search_fields = (
"notes", "inspector__username", "inspector__first_name", "notes", "inspector__username", "inspector__first_name",
"inspector__last_name", "mission_intervention__name", "inspector__last_name", "mission_intervention__code",
"checked_intervention__name" "checked_intervention__code"
) )
raw_id_fields = ("inspector", "mission_intervention", "checked_intervention") raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "zone")
inlines = [InspectionDocumentInline] inlines = [InspectionDocumentInline]
actions = [make_cancelled, make_compliant] 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.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
@ -44,12 +44,26 @@ INSPECTION_RESULT_CONFIG = {
'icon': 'bi-slash-circle', 'icon': 'bi-slash-circle',
'color_hex': '#6c757d', '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( INSPECTION_RESULT_CHOICES = tuple(
(code, cfg['label']) for code, cfg in INSPECTION_RESULT_CONFIG.items() (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 = ( INSPECTION_NATURE_CHOICES = (
('visual', _('Inspection visuelle')), ('visual', _('Inspection visuelle')),
('technical', _('Inspection technique')), ('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): 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). 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") 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( verified_obsolescence = models.CharField(
max_length=50, max_length=50,
null=True, null=True,
@ -156,7 +246,104 @@ class Inspection(models.Model):
@property @property
def is_cancelled(self): 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): def can_edit(self, user):
from .permissions import can_edit_inspection from .permissions import can_edit_inspection
@ -165,7 +352,11 @@ class Inspection(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*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 target_asset = self.asset
modified = False 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'): if user_config.has_role('admin') or user_config.has_role('top_manager'):
return True 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: if inspection and inspection.inspector_id == user.id:
return True 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 return False
@ -316,8 +329,9 @@ def filter_viewable_inspections_for_user(user, qs=None):
conditions = [] 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(inspector=user))
conditions.append(Q(zone__inspectors=user))
# 2. Interventions liées visibles (missions d'intervention, contrôles post-travaux, ou cible intervention) # 2. Interventions liées visibles (missions d'intervention, contrôles post-travaux, ou cible intervention)
from interventions.permissions import filter_viewable_interventions_for_user 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_object_id').value = data.object_id;
document.getElementById('edit_insp_target_display').textContent = data.target_display || ''; 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); let datesText = "{% translate 'Créée le' %} " + (data.created_at || data.inspection_date);
if (data.updated_at) { if (data.updated_at) {
datesText += " | {% translate 'Dernière modification :' %} " + data.updated_at; datesText += " | {% translate 'Dernière modification :' %} " + data.updated_at;
} }
document.getElementById('edit_insp_dates_info').textContent = datesText; 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') { if (typeof window.toggleEditMissingResultChoice === 'function') {
window.toggleEditMissingResultChoice(Boolean(data.is_zone_rwiz)); window.toggleEditMissingResultChoice(Boolean(data.is_zone_rwiz));
} }
// Pre-check result_status radio // 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 (rad) {
if (data.is_zone_rwiz && data.result_status === 'missing') { if (data.is_zone_rwiz && targetRadioCode === 'missing') {
const compRad = document.getElementById('edit_res_compliant'); const compRad = document.getElementById('edit_res_compliant');
if (compRad) compRad.checked = true; if (compRad) compRad.checked = true;
} else { } else {

View file

@ -38,9 +38,22 @@
<!-- Navigation par Onglets --> <!-- Navigation par Onglets -->
<ul class="nav nav-tabs mb-3"> <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"> <li class="nav-item">
<a class="nav-link {% if filter_type == 'my' %}active fw-bold{% endif %}" href="?tab=my"> <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" %} <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> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -72,6 +85,17 @@
</select> </select>
</div> </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"> <div class="col-12 col-sm-6 col-md-2">
<select name="nature" class="form-select form-select-sm" onchange="this.form.submit()"> <select name="nature" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Natures --" %}</option> <option value="">{% translate "-- Natures --" %}</option>
@ -81,7 +105,7 @@
</select> </select>
</div> </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()"> <select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Tous les résultats --" %}</option> <option value="">{% translate "-- Tous les résultats --" %}</option>
{% for val, label in result_choices %} {% for val, label in result_choices %}
@ -90,7 +114,7 @@
</select> </select>
</div> </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> <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> <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> </div>
@ -128,14 +152,29 @@
<div class="card-body p-3 d-flex flex-column justify-content-between"> <div class="card-body p-3 d-flex flex-column justify-content-between">
<div> <div>
<!-- Entête card : Thématique/Type + Badge résultat --> <!-- Entête card : Thématique/Type + Badge résultat -->
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-1">
<span class="badge bg-light text-dark border d-inline-flex align-items-center me-2"> <div class="d-flex align-items-center gap-1">
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i> <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 }} {{ insp.asset_content_type.name }}
</span> </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 }}"> <span class="badge {{ insp.status_badge_class }}">
<i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }} <i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}
</span> </span>
{% endif %}
</div>
</div> </div>
<!-- Nom de la cible tronqué à 100 caractères --> <!-- Nom de la cible tronqué à 100 caractères -->
@ -143,6 +182,20 @@
{{ insp.target_display|truncatechars:100 }} {{ insp.target_display|truncatechars:100 }}
</h6> </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 --> <!-- Résumé des constats si présent -->
{% if insp.details_summary and insp.details_summary != "-" %} {% if insp.details_summary and insp.details_summary != "-" %}
<p class="text-muted small mb-2 text-truncate mt-1"> <p class="text-muted small mb-2 text-truncate mt-1">
@ -151,8 +204,19 @@
{% endif %} {% endif %}
</div> </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"> <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> <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"> <div class="d-flex align-items-center gap-2">
{% if insp.documents.all %} {% 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> <span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name|default:insp.inspector.username }}</span>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</div> </div>
</div> </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="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> <span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li> </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"> <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="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> <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="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> <span class="badge {{ insp.status_badge_class }}"><i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}</span>
</li> </li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0"> <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="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> <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"> <div class="modal-footer bg-light py-2">
{% can_edit_inspection insp request.user as user_can_edit %} {% can_edit_inspection insp request.user as user_can_edit %}
{% if 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 %} {% if insp.is_cancelled %}
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'restore')"> <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" %} <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" %} <i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button> </button>
{% endif %} {% 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"> <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" %} <i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button> </button>
{% endif %} {% endif %}
{% endif %}
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button> <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div> </div>

View file

@ -1481,3 +1481,153 @@ class GiepInspectionStatusCalculationTestCase(TestCase):
self.assertEqual(GiepInspection.compute_status_from_data(data_mix2), 'damaged') 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 common.utils import get_short_name
from assets.models import AssetCategory from assets.models import AssetCategory
from .models import ( from .models import (
Inspection, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument, Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument,
INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES
) )
@ -139,18 +139,52 @@ def _filter_inspections_queryset(request):
qs = qs.filter(combined) qs = qs.filter(combined)
statuses = request.GET.getlist('status') or request.GET.getlist('result_status') lifecycle_statuses = request.GET.getlist('lifecycle_status')
if statuses: result_statuses = request.GET.getlist('result_status')
qs = qs.filter(result_status__in=statuses) 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') natures = request.GET.getlist('nature')
if natures: if natures:
qs = qs.filter(nature__in=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' show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
if show_mine_only: if show_mine_only:
qs = qs.filter(inspector=request.user) 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_from = request.GET.get('date_from', '').strip()
date_to = request.GET.get('date_to', '').strip() date_to = request.GET.get('date_to', '').strip()
if date_from: if date_from:
@ -166,7 +200,9 @@ def _filter_inspections_queryset(request):
if checked_interv_id: if checked_interv_id:
qs = qs.filter(checked_intervention_id=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 @login_required
@ -183,24 +219,42 @@ def inspections_list(request):
can_inspect = bool(insp_thematics and insp_thematics.exists()) can_inspect = bool(insp_thematics and insp_thematics.exists())
if is_mobile: 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) filter_type = request.GET.get('tab', default_tab)
thematic_code = request.GET.get('thematic', '') thematic_code = request.GET.get('thematic', '')
status_filter = request.GET.get('status', '') status_filter = request.GET.get('status', '')
nature_filter = request.GET.get('nature', '') nature_filter = request.GET.get('nature', '')
zone_filter = request.GET.get('zone', '')
mission_id = request.GET.get('mission_id', '') mission_id = request.GET.get('mission_id', '')
checked_interv_id = request.GET.get('checked_interv_id', '') checked_interv_id = request.GET.get('checked_interv_id', '')
search_q = request.GET.get('q', '').strip() search_q = request.GET.get('q', '').strip()
from .permissions import filter_viewable_inspections_for_user qs = base_viewable.select_related(
qs = filter_viewable_inspections_for_user(request.user)
qs = qs.select_related(
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention', 'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention',
'parkingspotinspection', 'giepinspection', 'signpanelinspection' 'zone', 'parkingspotinspection', 'giepinspection', 'signpanelinspection'
).prefetch_related('documents').order_by('-inspection_date') ).prefetch_related('documents')
if filter_type == 'my': if filter_type == 'scheduled':
qs = qs.filter(inspector=request.user) 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: if thematic_code:
try: try:
@ -224,6 +278,9 @@ def inspections_list(request):
pass pass
if status_filter: 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) qs = qs.filter(result_status=status_filter)
if nature_filter: if nature_filter:
@ -270,15 +327,23 @@ def inspections_list(request):
qs = qs.filter(search_filter) 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 = { context = {
'base_template': "mobile/mobile_base.html", 'base_template': "mobile/mobile_base.html",
'is_mobile': True, 'is_mobile': True,
'inspections': qs[:100], 'inspections': qs[:100],
'total_count': qs.count(), 'total_count': qs.count(),
'filter_type': filter_type, 'filter_type': filter_type,
'scheduled_count': scheduled_count,
'my_count': my_count,
'selected_thematic': thematic_code, 'selected_thematic': thematic_code,
'selected_status': status_filter, 'selected_status': status_filter,
'selected_nature': nature_filter, 'selected_nature': nature_filter,
'selected_zone': zone_filter,
'zones': zones,
'selected_mission_id': mission_id, 'selected_mission_id': mission_id,
'selected_checked_interv_id': checked_interv_id, 'selected_checked_interv_id': checked_interv_id,
'search_q': search_q, 'search_q': search_q,
@ -314,6 +379,7 @@ def inspections_list(request):
selected_categories = request.GET.getlist('category') selected_categories = request.GET.getlist('category')
selected_statuses = request.GET.getlist('status') or request.GET.getlist('result_status') selected_statuses = request.GET.getlist('status') or request.GET.getlist('result_status')
selected_natures = request.GET.getlist('nature') selected_natures = request.GET.getlist('nature')
selected_zone = request.GET.get('zone', '')
search_query = request.GET.get('q', '') search_query = request.GET.get('q', '')
show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my' show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
date_from = request.GET.get('date_from', '') date_from = request.GET.get('date_from', '')
@ -337,6 +403,10 @@ def inspections_list(request):
for cfg in result_choices_config 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 = { context = {
'thematic_choices': [(t.code, t.name, t.icon or 'bi-geo-alt') for t in thematics], 'thematic_choices': [(t.code, t.name, t.icon or 'bi-geo-alt') for t in thematics],
'selected_thematics': selected_thematics, 'selected_thematics': selected_thematics,
@ -348,6 +418,8 @@ def inspections_list(request):
'selected_result_statuses': selected_statuses, 'selected_result_statuses': selected_statuses,
'nature_choices': INSPECTION_NATURE_CHOICES, 'nature_choices': INSPECTION_NATURE_CHOICES,
'selected_natures': selected_natures, 'selected_natures': selected_natures,
'zones': zones,
'selected_zone': selected_zone,
'search_query': search_query, 'search_query': search_query,
'show_mine_only': show_mine_only, 'show_mine_only': show_mine_only,
'date_from': date_from, 'date_from': date_from,
@ -779,6 +851,19 @@ def get_inspection_detail_api(request, inspection_id):
'status_icon': leaf.status_icon, 'status_icon': leaf.status_icon,
'nature': leaf.nature, 'nature': leaf.nature,
'nature_display': str(leaf.get_nature_display()), '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_name': get_short_name(leaf.inspector) if leaf.inspector else '-',
'inspector_role': leaf.inspector_role, 'inspector_role': leaf.inspector_role,
'inspector_role_display': str(leaf.get_inspector_role_display()) if leaf.inspector_role else '', '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) }, status=403)
try: 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: if 'result_status' in request.POST:
new_status = request.POST['result_status'] new_status = request.POST['result_status']
is_zone_rwiz = ( is_zone_rwiz = (

View file

@ -21,7 +21,10 @@ try:
except ImportError: except ImportError:
openpyxl = None 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 InterventionPlanificationTimeLine, InterventionRealizationTimeLine
from .models import Symptom, SymptomRecommendation, ReflexSheet, SourceCategory, SymptomContractAutoClose from .models import Symptom, SymptomRecommendation, ReflexSheet, SourceCategory, SymptomContractAutoClose
from .models import ( from .models import (
@ -42,10 +45,23 @@ from interventions.views.preventive_import import process_worksheet as _process_
from interventions.models import STATUS_CHOICES, DirectContractRouting 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) @admin.register(Intervention)
class InterventionAdmin(admin.ModelAdmin): class InterventionAdmin(admin.ModelAdmin):
search_fields = ("code", "title") 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" 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): 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).")) 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) name_fr = models.CharField(max_length=255)
@ -381,6 +446,15 @@ class Intervention(models.Model):
description = models.TextField(null=True,blank=True) description = models.TextField(null=True,blank=True)
maintain_type = models.CharField(choices=MAINTAIN_CHOICES, default='corrective') maintain_type = models.CharField(choices=MAINTAIN_CHOICES, default='corrective')
type = models.CharField(choices=TYPE_CHOICES,null=True,blank=True) 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') origin_type = models.CharField(choices=ORIGIN_TYPE_CHOICES, default='damage')
begin_time = models.DateTimeField(null=True,blank=True) begin_time = models.DateTimeField(null=True,blank=True)
end_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 is_new = self._state.adding # True uniquement lors de la création
status_changed_to_closed = False status_changed_to_closed = False
status_changed_to_validated = False status_changed_to_validated = False
status_changed_to_finished = False
old_status = None old_status = None
if not is_new: if not is_new:
old_status = Intervention.objects.filter(pk=self.pk).values_list('status', flat=True).first() 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 status_changed_to_closed = True
if old_status and old_status != 'validated' and self.status == 'validated': if old_status and old_status != 'validated' and self.status == 'validated':
status_changed_to_validated = True status_changed_to_validated = True
if old_status and old_status != 'finished' and self.status == 'finished':
status_changed_to_finished = True
else: else:
if self.status == 'validated': if self.status == 'validated':
status_changed_to_validated = True status_changed_to_validated = True
if self.status == 'finished':
status_changed_to_finished = True
if not self.code: # Generate code only if not defined if not self.code: # Generate code only if not defined
next_number = InterventionSequence.get_next_number() next_number = InterventionSequence.get_next_number()
@ -546,6 +625,17 @@ class Intervention(models.Model):
f"Error automatically generating preventive report for intervention {self.code}" 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: if status_changed_to_closed:
from observations.models import Observation, ObservationComment from observations.models import Observation, ObservationComment
linked_obs = self.observations.exclude(status='closed') linked_obs = self.observations.exclude(status='closed')

View file

@ -98,6 +98,14 @@ class InterventionForm(forms.ModelForm):
if 'type' in self.fields: if 'type' in self.fields:
self.fields['type'].choices = get_type_choices(thematic_obj) 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') 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') maintain_type_value = self.initial.get('maintain_type') or self.data.get('maintain_type')
if type_value == 'inspection': if type_value == 'inspection':
@ -173,7 +181,7 @@ class InterventionForm(forms.ModelForm):
fields = [ fields = [
'code', 'title', 'status', 'priority', 'init_description', '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', 'asset_category', 'symptom',
'source_category', 'source_detail', 'source_ref', 'source_category', 'source_detail', 'source_ref',
'lat', 'lon', 'geom', 'address', 'lat', 'lon', 'geom', 'address',
@ -193,6 +201,7 @@ class InterventionForm(forms.ModelForm):
'symptom': forms.Select(attrs={'class': 'form-select'}), 'symptom': forms.Select(attrs={'class': 'form-select'}),
'priority': forms.Select(attrs={'class': 'form-select'}), 'priority': forms.Select(attrs={'class': 'form-select'}),
'type': 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'}), 'origin_type': forms.Select(attrs={'class': 'form-select'}),
# 'project': 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 # Bypass WMI queries on Windows which can hang indefinitely due to OS/WMI service issues
if os.name == 'nt': if os.name == 'nt':
platform.win32_ver = lambda: ('10', '10.0.19045', '', 'Multiprocessor Free') 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 datetime import timedelta
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _