From 8fc2798c522aeae1c6982aeaa591d89672da747d Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 17 Sep 2026 14:35:53 +0200 Subject: [PATCH 01/45] feat: add approval exception for urgent contract interventions and update status transition permissions --- loko/contracts/admin.py | 2 +- .../0007_contract_approval_except_urgent.py | 18 ++ loko/contracts/models.py | 5 + loko/interventions/permissions.py | 90 +++++-- .../tests/test_contract_approval.py | 247 ++++++++++++++++++ loko/interventions/views/workflow.py | 9 + 6 files changed, 351 insertions(+), 20 deletions(-) create mode 100644 loko/contracts/migrations/0007_contract_approval_except_urgent.py create mode 100644 loko/interventions/tests/test_contract_approval.py diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index 144a361..5cd3b76 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -154,7 +154,7 @@ class ContractAdmin(admin.ModelAdmin): ), }), ('Options de workflow', { - 'fields': ('needs_approval', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit'), + 'fields': ('needs_approval', 'approval_except_urgent', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit'), }), ) diff --git a/loko/contracts/migrations/0007_contract_approval_except_urgent.py b/loko/contracts/migrations/0007_contract_approval_except_urgent.py new file mode 100644 index 0000000..80faf87 --- /dev/null +++ b/loko/contracts/migrations/0007_contract_approval_except_urgent.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-17 12:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0006_usercompanyteamaccess_can_tag'), + ] + + operations = [ + migrations.AddField( + model_name='contract', + name='approval_except_urgent', + field=models.BooleanField(default=True, help_text="Indique si la nécessité d'approbation est levée pour les interventions à priorité urgente.", verbose_name='Sauf si urgence'), + ), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index d007917..d763d89 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -350,6 +350,11 @@ class Contract(models.Model): verbose_name=_("Nécessite une approbation"), help_text=_("Indique si les interventions liées à ce contrat nécessitent une approbation avant d'être transmises pour traitement."), ) + approval_except_urgent = models.BooleanField( + default=True, + verbose_name=_("Sauf si urgence"), + help_text=_("Indique si la nécessité d'approbation est levée pour les interventions à priorité urgente."), + ) needs_checking = models.BooleanField( default=False, verbose_name=_("Nécessite une vérification"), diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index 38d4712..b5f7433 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -1322,6 +1322,71 @@ def can_approve_intervention(user, intervention): ).exists() +def intervention_requires_approval(intervention): + """ + Détermine si une intervention liée à un contrat nécessite une approbation avant traitement. + Si le contrat a needs_approval=True : + - Si le contrat a approval_except_urgent=True et que l'intervention est urgente (priority='1'), + l'approbation n'est pas requise. + - Sinon, l'approbation est requise (y compris en cas d'urgence si approval_except_urgent=False). + """ + if not intervention or not intervention.contract_id: + return False + + contract = getattr(intervention, 'contract', None) + if not contract or not getattr(contract, 'needs_approval', False): + return False + + # Si le contrat prévoit 'sauf si urgence' et que l'intervention est urgente + if getattr(contract, 'approval_except_urgent', False) and getattr(intervention, 'priority', None) == '1': + return False + + return True + + +def user_has_explicit_contract_status_permission(user, contract_id, target_status='to_be_processed'): + """ + Vérifie si l'utilisateur a explicitement le droit de passer à ce statut sur ce contrat + via ses permissions de statut de contrat (UserContractStatusPermission.can_change_status_to). + Les administrateurs et superutilisateurs ont tous les droits. + Rétrocompatibilité : can_approve=True sur UserContractAccess confère le droit vers 'to_be_processed'. + """ + if not contract_id or not user or not user.is_authenticated: + return False + + if getattr(user, 'is_superuser', False): + return True + + try: + user_config = UserConfig.objects.get(user=user) + except UserConfig.DoesNotExist: + return False + + if user_config.roles.filter(name='admin').exists(): + return True + + uca = UserContractAccess.objects.filter( + user_config=user_config, + contract_id=contract_id, + ).first() + if not uca: + return False + + # Permission explicite par statut sur le contrat + if UserContractStatusPermission.objects.filter( + user_contract=uca, + status=target_status, + can_change_status_to=True, + ).exists(): + return True + + # Rétrocompatibilité avec le flag can_approve + if target_status == 'to_be_processed' and uca.can_approve: + return True + + return False + + def get_allowed_transitions(request, intervention): current_status = intervention.status next_candidates = user_allowed_transitions(request.user, current_status, intervention=intervention) @@ -1337,25 +1402,12 @@ def get_allowed_transitions(request, intervention): if not can_approve_intervention(request.user, intervention): return [] - # Si l'intervention est en préparation, que le contrat exige une approbation, - # que la priorité n'est pas urgente, et que l'utilisateur est opérateur, - # alors seul le passage à 'to_be_approved' est autorisé. - if ( - current_status == 'in_preparation' - and intervention.contract_id - and getattr(intervention.contract, 'needs_approval', False) - and intervention.priority != '1' - ): - try: - user_config = UserConfig.objects.get(user=request.user) - is_operator = user_config.roles.filter(name='operator').exists() - is_only_operator = is_operator and not user_config.roles.filter( - name__in=['admin', 'manager', 'controller', 'external_manager'] - ).exists() - except UserConfig.DoesNotExist: - is_only_operator = False - - if is_only_operator: + # Si l'intervention est en préparation et nécessite une approbation : + # seuls les utilisateurs ayant explicitement le droit de passer au statut 'à traiter' + # sur ce contrat peuvent faire passer dans ce statut directement. + # Pour les autres, seul le passage à 'to_be_approved' (ou 'canceled') est autorisé. + if current_status == 'in_preparation' and intervention_requires_approval(intervention): + if not user_has_explicit_contract_status_permission(request.user, intervention.contract_id, 'to_be_processed'): next_candidates = [s for s in next_candidates if s in ('to_be_approved', 'canceled')] # Un external_manager (sans rôle privilégié interne) ne peut annuler que les interventions diff --git a/loko/interventions/tests/test_contract_approval.py b/loko/interventions/tests/test_contract_approval.py new file mode 100644 index 0000000..c873e0d --- /dev/null +++ b/loko/interventions/tests/test_contract_approval.py @@ -0,0 +1,247 @@ +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from django.utils import timezone + +from common.models import UserConfig, Role, Thematic, UserThematics, UserContractAccess, UserContractStatusPermission +from contracts.models import Company, Contract +from interventions.models import Intervention, Symptom +from interventions.permissions import ( + get_allowed_transitions, + intervention_requires_approval, + user_has_explicit_contract_status_permission, +) +from interventions.views.workflow import attempt_status_update + + +class ContractApprovalTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.User = get_user_model() + self.thematic, _ = Thematic.objects.get_or_create(code='trafficlights', defaults={'name': 'Feux de signalisation'}) + + # Roles + self.admin_role, _ = Role.objects.get_or_create(name='admin') + self.operator_role, _ = Role.objects.get_or_create(name='operator') + self.manager_role, _ = Role.objects.get_or_create(name='manager') + + # Company + self.company = Company.objects.create(name='Prestataire Test') + today = timezone.now().date() + + # Contract needing approval, with approval_except_urgent=True (default) + self.contract_approval_except_urgent = Contract.objects.create( + contract_number='CTR-APP-EXCEPT-URGENT', + company=self.company, + start_date=today, + end_date=today + timezone.timedelta(days=365), + is_active=True, + needs_approval=True, + approval_except_urgent=True, + ) + self.contract_approval_except_urgent.thematics.add(self.thematic) + + # Contract needing approval, with approval_except_urgent=False + self.contract_approval_strict = Contract.objects.create( + contract_number='CTR-APP-STRICT', + company=self.company, + start_date=today, + end_date=today + timezone.timedelta(days=365), + is_active=True, + needs_approval=True, + approval_except_urgent=False, + ) + self.contract_approval_strict.thematics.add(self.thematic) + + # Contract not needing approval + self.contract_no_approval = Contract.objects.create( + contract_number='CTR-NO-APPROVAL', + company=self.company, + start_date=today, + end_date=today + timezone.timedelta(days=365), + is_active=True, + needs_approval=False, + approval_except_urgent=True, + ) + self.contract_no_approval.thematics.add(self.thematic) + + # Users + # 1. Admin + self.admin_user = self.User.objects.create_user(username='admin_u', password='pwd') + self.admin_cfg = UserConfig.objects.create(user=self.admin_user, is_intern=True) + self.admin_cfg.roles.add(self.admin_role) + + # 2. Operator without explicit contract status permission + self.op_user = self.User.objects.create_user(username='operator_u', password='pwd') + self.op_cfg = UserConfig.objects.create(user=self.op_user, is_intern=True) + self.op_cfg.roles.add(self.operator_role) + self.op_ut = UserThematics.objects.create(user_config=self.op_cfg, thematic=self.thematic, can_view_interventions=True, can_edit_interventions=True) + + # 3. Manager without explicit contract status permission + self.mgr_user = self.User.objects.create_user(username='manager_u', password='pwd') + self.mgr_cfg = UserConfig.objects.create(user=self.mgr_user, is_intern=True) + self.mgr_cfg.roles.add(self.manager_role) + self.mgr_ut = UserThematics.objects.create(user_config=self.mgr_cfg, thematic=self.thematic, can_view_interventions=True, can_edit_interventions=True) + + # 4. User with explicit contract status permission (can_change_status_to for to_be_processed) + self.authorized_user = self.User.objects.create_user(username='authorized_u', password='pwd') + self.authorized_cfg = UserConfig.objects.create(user=self.authorized_user, is_intern=True) + self.authorized_cfg.roles.add(self.operator_role) + self.auth_ut = UserThematics.objects.create(user_config=self.authorized_cfg, thematic=self.thematic, can_view_interventions=True, can_edit_interventions=True) + + from common.models import UserThematicStatusPermission + for ut in [self.op_ut, self.mgr_ut, self.auth_ut]: + for st in ['in_preparation', 'to_be_approved', 'to_be_processed', 'canceled']: + UserThematicStatusPermission.objects.create( + user_thematic=ut, + status=st, + can_view=True, + can_edit=True, + can_change_status_to=True, + ) + + # Give authorized_user explicit permission on contract_approval_strict + self.uca_strict = UserContractAccess.objects.create( + user_config=self.authorized_cfg, + contract=self.contract_approval_strict, + can_view_interventions=True, + ) + UserContractStatusPermission.objects.create( + user_contract=self.uca_strict, + status='to_be_processed', + can_view=True, + can_edit=True, + can_change_status_to=True, + ) + + self.symptom = Symptom.objects.create(name_fr="Test Symptôme", name_nl="Test Symptoom", thematic=self.thematic) + + def test_intervention_requires_approval_helper(self): + # Sans contrat -> False + itv_no_contract = Intervention(status='in_preparation', priority='3') + self.assertFalse(intervention_requires_approval(itv_no_contract)) + + # Contrat sans needs_approval -> False + itv_no_app = Intervention(status='in_preparation', contract=self.contract_no_approval, priority='3') + self.assertFalse(intervention_requires_approval(itv_no_app)) + + # Contrat strict (approval_except_urgent=False) : + # - Priorité normale ('3') -> True + itv_strict_normal = Intervention(status='in_preparation', contract=self.contract_approval_strict, priority='3') + self.assertTrue(intervention_requires_approval(itv_strict_normal)) + # - Priorité urgente ('1') -> True (car approval_except_urgent=False) + itv_strict_urgent = Intervention(status='in_preparation', contract=self.contract_approval_strict, priority='1') + self.assertTrue(intervention_requires_approval(itv_strict_urgent)) + + # Contrat avec approval_except_urgent=True : + # - Priorité normale ('3') -> True + itv_except_normal = Intervention(status='in_preparation', contract=self.contract_approval_except_urgent, priority='3') + self.assertTrue(intervention_requires_approval(itv_except_normal)) + # - Priorité urgente ('1') -> False (urgence dispense d'approbation) + itv_except_urgent = Intervention(status='in_preparation', contract=self.contract_approval_except_urgent, priority='1') + self.assertFalse(intervention_requires_approval(itv_except_urgent)) + + def test_user_has_explicit_contract_status_permission_helper(self): + # Admin -> True + self.assertTrue(user_has_explicit_contract_status_permission(self.admin_user, self.contract_approval_strict.id, 'to_be_processed')) + + # Opérateur sans accès au contrat -> False + self.assertFalse(user_has_explicit_contract_status_permission(self.op_user, self.contract_approval_strict.id, 'to_be_processed')) + + # Manager sans accès au contrat -> False + self.assertFalse(user_has_explicit_contract_status_permission(self.mgr_user, self.contract_approval_strict.id, 'to_be_processed')) + + # Utilisateur avec permission explicite can_change_status_to=True -> True + self.assertTrue(user_has_explicit_contract_status_permission(self.authorized_user, self.contract_approval_strict.id, 'to_be_processed')) + + # Rétrocompatibilité : avec can_approve=True sur UserContractAccess + user_legacy = self.User.objects.create_user(username='legacy_u', password='pwd') + cfg_legacy = UserConfig.objects.create(user=user_legacy, is_intern=True) + UserContractAccess.objects.create(user_config=cfg_legacy, contract=self.contract_approval_strict, can_approve=True) + self.assertTrue(user_has_explicit_contract_status_permission(user_legacy, self.contract_approval_strict.id, 'to_be_processed')) + + def test_get_allowed_transitions_strict_approval(self): + itv_urgent = Intervention.objects.create( + title='Test Urgent Strict', + status='in_preparation', + contract=self.contract_approval_strict, + thematic=self.thematic, + symptom=self.symptom, + priority='1', # Urgent + ) + + # Pour un opérateur sans droit explicite : seul to_be_approved et canceled doivent être proposés + req_op = self.factory.get('/') + req_op.user = self.op_user + transitions_op = [t['to'] for t in get_allowed_transitions(req_op, itv_urgent)] + self.assertIn('to_be_approved', transitions_op) + self.assertNotIn('to_be_processed', transitions_op) + + # Même chose pour un MANAGER sans droit explicite sur ce contrat : + # Pas de condition de rôle -> le manager est restreint à to_be_approved aussi + req_mgr = self.factory.get('/') + req_mgr.user = self.mgr_user + transitions_mgr = [t['to'] for t in get_allowed_transitions(req_mgr, itv_urgent)] + self.assertIn('to_be_approved', transitions_mgr) + self.assertNotIn('to_be_processed', transitions_mgr) + + # Pour l'utilisateur autorisé ayant can_change_status_to sur ce contrat : to_be_processed est disponible + req_auth = self.factory.get('/') + req_auth.user = self.authorized_user + transitions_auth = [t['to'] for t in get_allowed_transitions(req_auth, itv_urgent)] + self.assertIn('to_be_processed', transitions_auth) + + # Pour l'administrateur : to_be_processed est disponible + req_admin = self.factory.get('/') + req_admin.user = self.admin_user + transitions_admin = [t['to'] for t in get_allowed_transitions(req_admin, itv_urgent)] + self.assertIn('to_be_processed', transitions_admin) + + def test_get_allowed_transitions_except_urgent(self): + # Sur un contrat avec approval_except_urgent=True : + itv_urgent = Intervention.objects.create( + title='Test Urgent Except Urgent', + status='in_preparation', + contract=self.contract_approval_except_urgent, + thematic=self.thematic, + symptom=self.symptom, + priority='1', # Urgent + ) + + req_op = self.factory.get('/') + req_op.user = self.op_user + transitions_op = [t['to'] for t in get_allowed_transitions(req_op, itv_urgent)] + # L'urgence dispense d'approbation : to_be_processed doit être proposé à l'opérateur + self.assertIn('to_be_processed', transitions_op) + + # Mais si l'intervention n'est pas urgente (priorité 3) : + itv_normal = Intervention.objects.create( + title='Test Normal Except Urgent', + status='in_preparation', + contract=self.contract_approval_except_urgent, + thematic=self.thematic, + symptom=self.symptom, + priority='3', # Normal + ) + transitions_op_normal = [t['to'] for t in get_allowed_transitions(req_op, itv_normal)] + self.assertIn('to_be_approved', transitions_op_normal) + self.assertNotIn('to_be_processed', transitions_op_normal) + + def test_attempt_status_update_enforces_contract_approval(self): + itv_strict = Intervention.objects.create( + title='Test Security Workflow', + status='in_preparation', + contract=self.contract_approval_strict, + thematic=self.thematic, + symptom=self.symptom, + priority='1', # Urgent mais approval_except_urgent=False + ) + + req = self.factory.post('/') + # L'opérateur non autorisé tente de passer directement à to_be_processed + success, msg, _ = attempt_status_update(req, itv_strict, self.op_user, 'to_be_processed') + self.assertFalse(success) + self.assertIn("nécessite une approbation", str(msg)) + + # L'utilisateur avec droit explicite sur ce contrat peut passer à to_be_processed + success_auth, _, _ = attempt_status_update(req, itv_strict, self.authorized_user, 'to_be_processed') + self.assertTrue(success_auth) diff --git a/loko/interventions/views/workflow.py b/loko/interventions/views/workflow.py index 29ebb1d..2b9a74c 100644 --- a/loko/interventions/views/workflow.py +++ b/loko/interventions/views/workflow.py @@ -433,6 +433,15 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason= from interventions.permissions import can_approve_intervention if not can_approve_intervention(user, intervention): return False, _("Vous n'avez pas le droit d'approuver les interventions de ce contrat."), False + + # Si l'intervention est en préparation et nécessite une approbation, + # seuls les utilisateurs ayant explicitement le droit de passer à 'to_be_processed' + # sur ce contrat peuvent effectuer cette transition directement. + if current_status == 'in_preparation' and new_status == 'to_be_processed': + from interventions.permissions import intervention_requires_approval, user_has_explicit_contract_status_permission + if intervention_requires_approval(intervention): + if not user_has_explicit_contract_status_permission(user, intervention.contract_id, 'to_be_processed'): + return False, _("Cette intervention nécessite une approbation avant d'être transmise pour traitement."), False # Vérifie la transition dans la matrice # allowed_transitions = ALLOWED_TRANSITIONS.get(current_status, []) From 7ca27197bff59b038b3092db0e7e95671990e6ad Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 17 Sep 2026 15:41:57 +0200 Subject: [PATCH 02/45] feat: add custom admin assets to dynamically toggle approval_except_urgent field visibility --- loko/contracts/admin.py | 6 ++++++ .../static/contracts/css/contract_admin.css | 16 ++++++++++++++++ .../static/contracts/js/contract_admin.js | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 loko/contracts/static/contracts/css/contract_admin.css create mode 100644 loko/contracts/static/contracts/js/contract_admin.js diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index 5cd3b76..9b04071 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -158,6 +158,12 @@ class ContractAdmin(admin.ModelAdmin): }), ) + class Media: + css = { + 'all': ('contracts/css/contract_admin.css',) + } + js = ('contracts/js/contract_admin.js',) + def thematics_list(self, obj): return ", ".join([thematic.name for thematic in obj.thematics.all()]) thematics_list.short_description = 'Thématiques' diff --git a/loko/contracts/static/contracts/css/contract_admin.css b/loko/contracts/static/contracts/css/contract_admin.css new file mode 100644 index 0000000..050de46 --- /dev/null +++ b/loko/contracts/static/contracts/css/contract_admin.css @@ -0,0 +1,16 @@ +/* Indentation et mise en valeur ergonomique de l'option "Sauf si urgence" */ +.form-row.field-approval_except_urgent { + margin-left: 32px !important; + padding-left: 16px !important; + border-left: 3px solid #79aec8 !important; + background-color: rgba(121, 174, 200, 0.04); + transition: opacity 0.2s ease-in-out; +} + +.form-row.field-approval_except_urgent .checkbox-row { + position: relative; +} + +.form-row.field-approval_except_urgent label { + font-weight: 500; +} diff --git a/loko/contracts/static/contracts/js/contract_admin.js b/loko/contracts/static/contracts/js/contract_admin.js new file mode 100644 index 0000000..d51d799 --- /dev/null +++ b/loko/contracts/static/contracts/js/contract_admin.js @@ -0,0 +1,19 @@ +document.addEventListener('DOMContentLoaded', function() { + const needsApprovalCheckbox = document.getElementById('id_needs_approval'); + const exceptUrgentRow = document.querySelector('.form-row.field-approval_except_urgent'); + + if (needsApprovalCheckbox && exceptUrgentRow) { + function syncExceptUrgentVisibility() { + if (needsApprovalCheckbox.checked) { + exceptUrgentRow.style.opacity = '1'; + exceptUrgentRow.style.pointerEvents = 'auto'; + } else { + exceptUrgentRow.style.opacity = '0.45'; + exceptUrgentRow.style.pointerEvents = 'none'; + } + } + + needsApprovalCheckbox.addEventListener('change', syncExceptUrgentVisibility); + syncExceptUrgentVisibility(); + } +}); From 396d26414cb91747cd5fb5d1ddf8dcd95b9b0012 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 17 Sep 2026 16:05:36 +0200 Subject: [PATCH 03/45] refactor: support multiple intervention types in subtypes and update non-compliance notifications for company members --- loko/inspections/models.py | 56 +++++++++---- loko/inspections/services.py | 6 +- loko/inspections/tests.py | 18 +++-- loko/interventions/admin.py | 28 ++++++- ...er_interventionsubtype_options_and_more.py | 54 +++++++++++++ loko/interventions/models.py | 13 ++- loko/interventions/tests/test_inspections.py | 81 +++++++++++++++++++ loko/interventions/views/forms.py | 10 ++- 8 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 loko/interventions/migrations/0011_alter_interventionsubtype_options_and_more.py diff --git a/loko/inspections/models.py b/loko/inspections/models.py index 6f21e66..1832b59 100644 --- a/loko/inspections/models.py +++ b/loko/inspections/models.py @@ -304,7 +304,19 @@ class Inspection(models.Model): if not teams: return - from notifications.services import notify_team + from contracts.models import CompanyMember + from notifications.models import Notification, NotificationRecipient + from notifications.webpush import send_webpush_to_users + + # Collecter tous les membres actifs avec un compte utilisateur + recipient_user_ids = set( + CompanyMember.objects.filter( + teams__in=teams, user__isnull=False, user__is_active=True + ).values_list('user_id', flat=True) + ) + if not recipient_user_ids: + return + status_label = self.get_result_status_display() title = _("Non-conformité durabilité (%(status)s) : %(code)s") % { 'status': status_label, @@ -325,22 +337,32 @@ class Inspection(models.Model): } 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}" - ) + try: + notification = Notification.objects.create( + title=str(title), + content=str(content), + notification_type='alert', + action_url=action_url, + action_label=str(_("Consulter l'intervention")), + source_app='inspections', + is_system=True, + ) + NotificationRecipient.objects.bulk_create([ + NotificationRecipient(notification=notification, user_id=uid) + for uid in recipient_user_ids + ], ignore_conflicts=True) + + send_webpush_to_users( + users=list(recipient_user_ids), + title=str(title), + body=str(content), + action_url=action_url, + ) + except Exception: + import logging + logging.getLogger(__name__).exception( + f"Error sending non-compliance notification for inspection {self.pk}" + ) self.non_compliance_notified = True Inspection.objects.filter(pk=self.pk).update(non_compliance_notified=True) diff --git a/loko/inspections/services.py b/loko/inspections/services.py index 808671b..523239d 100644 --- a/loko/inspections/services.py +++ b/loko/inspections/services.py @@ -53,10 +53,12 @@ def schedule_post_intervention_inspection(intervention): # 2. Résolution administrative par commune associée if not zone: + from common.models import Municipality 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() + muni_to_check = loc_obj if isinstance(loc_obj, Municipality) else getattr(loc_obj, 'municipality', None) + if muni_to_check: + zone = zones.filter(municipalities=muni_to_check).first() if zone: break diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py index 688c34e..17bfd7f 100644 --- a/loko/inspections/tests.py +++ b/loko/inspections/tests.py @@ -1507,9 +1507,11 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): 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 contracts.models import Company, CompanyTeam, CompanyMember + self.company = Company.objects.create(name='Test Company Asphalt') + self.team = CompanyTeam.objects.create(name='Équipe Contrôle Enrobés', company=self.company) + self.member = CompanyMember.objects.create(name='Test Inspector', user=self.user) + self.member.teams.add(self.team) from interventions.models import InterventionSubType self.hotbox_subtype = InterventionSubType.objects.create( @@ -1517,7 +1519,7 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): name_fr='Hot Box (asphalte à chaud)', name_nl='Hot Box (warm asfalt)', thematic=self.roads_thematic, - intervention_type='repair', + intervention_types=['repair'], requires_post_inspection=True, post_inspection_delay_min_days=30, post_inspection_delay_max_days=60, @@ -1529,7 +1531,7 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): code='test_roads_repair_standard', name_fr='Réparation standard', thematic=self.roads_thematic, - intervention_type='repair', + intervention_types=['repair'], requires_post_inspection=False ) @@ -1570,7 +1572,7 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): thematic=self.roads_thematic ) zone.inspectors.add(self.user) - muni = Municipality.objects.create(name_fr='Schaerbeek Test', code='21099') + muni = Municipality.objects.create(name_fr='Schaerbeek Test', name_nl='Schaarbeek Test', niscode='21099', fid='21099') zone.municipalities.add(muni) intervention = Intervention.objects.create( @@ -1596,7 +1598,7 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): self.assertTrue(insp.can_edit(self.user)) def test_non_compliance_triggers_team_notification(self): - from notifications.models import TeamNotificationTarget + from notifications.models import NotificationRecipient intervention = Intervention.objects.create( thematic=self.roads_thematic, type='repair', @@ -1615,7 +1617,7 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): insp.refresh_from_db() self.assertTrue(insp.non_compliance_notified) - self.assertTrue(TeamNotificationTarget.objects.filter(team=self.team).exists()) + self.assertTrue(NotificationRecipient.objects.filter(user=self.user, notification__source_app='inspections').exists()) def test_subtype_without_post_inspection_does_not_trigger(self): intervention = Intervention.objects.create( diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py index 5513f4d..3e72c62 100644 --- a/loko/interventions/admin.py +++ b/loko/interventions/admin.py @@ -42,20 +42,42 @@ from .services import generate_interventions_excel from common.models import Thematic from assets.models import AssetCategory from interventions.views.preventive_import import process_worksheet as _process_worksheet -from interventions.models import STATUS_CHOICES, DirectContractRouting +from interventions.models import STATUS_CHOICES, DirectContractRouting, TYPE_CHOICES + + +class InterventionSubTypeAdminForm(forms.ModelForm): + intervention_types = forms.MultipleChoiceField( + choices=TYPE_CHOICES, + required=False, + widget=FilteredSelectMultiple(_("Types d'intervention"), is_stacked=False), + label=_("Types d'intervention"), + help_text=_("Types d'intervention auxquels ce sous-type s'applique. Si aucun n'est sélectionné, s'applique à tous les types."), + ) + + class Meta: + model = InterventionSubType + fields = '__all__' @admin.register(InterventionSubType) class InterventionSubTypeAdmin(admin.ModelAdmin): + form = InterventionSubTypeAdminForm list_display = ( - "code", "name_fr", "thematic", "intervention_type", + "code", "name_fr", "thematic", "types_display", "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") + list_filter = ("thematic", "requires_post_inspection", "is_active") search_fields = ("code", "name_fr", "name_nl") filter_horizontal = ("notification_teams",) + def types_display(self, obj): + if not obj.intervention_types: + return _("Tous") + type_dict = dict(TYPE_CHOICES) + return ", ".join(str(type_dict.get(t, t)) for t in obj.intervention_types) + types_display.short_description = _("Types d'intervention") + @admin.register(Intervention) class InterventionAdmin(admin.ModelAdmin): diff --git a/loko/interventions/migrations/0011_alter_interventionsubtype_options_and_more.py b/loko/interventions/migrations/0011_alter_interventionsubtype_options_and_more.py new file mode 100644 index 0000000..71b9aa6 --- /dev/null +++ b/loko/interventions/migrations/0011_alter_interventionsubtype_options_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 6.0.7 on 2026-09-17 13:59 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +def migrate_single_type_to_multiple(apps, schema_editor): + InterventionSubType = apps.get_model('interventions', 'InterventionSubType') + for subtype in InterventionSubType.objects.all(): + old_val = getattr(subtype, 'intervention_type', None) + if old_val: + subtype.intervention_types = [old_val] + subtype.save(update_fields=['intervention_types']) + elif subtype.code == 'roads_repair_hotbox': + subtype.intervention_types = ['repair'] + subtype.save(update_fields=['intervention_types']) + + +def reverse_multiple_to_single(apps, schema_editor): + InterventionSubType = apps.get_model('interventions', 'InterventionSubType') + for subtype in InterventionSubType.objects.all(): + if subtype.intervention_types: + subtype.intervention_type = subtype.intervention_types[0] + subtype.save(update_fields=['intervention_type']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0007_contract_approval_except_urgent'), + ('interventions', '0010_seed_roads_hotbox_subtype'), + ] + + operations = [ + migrations.AlterModelOptions( + name='interventionsubtype', + options={'ordering': ['thematic', 'order_number', 'name_fr'], 'verbose_name': "Sous-type d'intervention", 'verbose_name_plural': "Sous-types d'interventions"}, + ), + migrations.AddField( + model_name='interventionsubtype', + name='intervention_types', + field=django.contrib.postgres.fields.ArrayField(base_field=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), blank=True, default=list, help_text="Types d'intervention auxquels ce sous-type s'applique. Laisser vide pour s'appliquer à tous les types.", verbose_name="Types d'intervention"), + ), + migrations.RunPython(migrate_single_type_to_multiple, reverse_code=reverse_multiple_to_single), + migrations.RemoveField( + model_name='interventionsubtype', + name='intervention_type', + ), + migrations.AlterField( + model_name='interventionsubtype', + name='notification_teams', + field=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='contracts.companyteam', verbose_name='Équipes à notifier'), + ), + ] diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 81fa062..495f321 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -5,6 +5,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import get_language, gettext_lazy as _ from django.utils import timezone +from django.contrib.postgres.fields import ArrayField from django.db.models import Sum, F, Q from django.db.models.functions import Coalesce @@ -201,7 +202,13 @@ class InterventionSubType(models.Model): 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")) + intervention_types = ArrayField( + models.CharField(max_length=50, choices=TYPE_CHOICES), + default=list, + blank=True, + verbose_name=_("Types d'intervention"), + help_text=_("Types d'intervention auxquels ce sous-type s'applique. Laisser vide pour s'appliquer à tous les types.") + ) # Paramètres de durabilité / inspection post-travaux requires_post_inspection = models.BooleanField( @@ -226,7 +233,7 @@ class InterventionSubType(models.Model): verbose_name=_("Nature de l'inspection") ) notification_teams = models.ManyToManyField( - 'notifications.Team', + CompanyTeam, blank=True, related_name='intervention_subtypes', verbose_name=_("Équipes à notifier"), @@ -238,7 +245,7 @@ class InterventionSubType(models.Model): class Meta: verbose_name = _("Sous-type d'intervention") verbose_name_plural = _("Sous-types d'interventions") - ordering = ['thematic', 'intervention_type', 'order_number', 'name_fr'] + ordering = ['thematic', 'order_number', 'name_fr'] def __str__(self): return f"{self.get_name()} ({self.thematic.name})" diff --git a/loko/interventions/tests/test_inspections.py b/loko/interventions/tests/test_inspections.py index d2f4c39..37e796d 100644 --- a/loko/interventions/tests/test_inspections.py +++ b/loko/interventions/tests/test_inspections.py @@ -566,5 +566,86 @@ class InspectionTests(TestCase): self.assertIn('member_assignment', allowed_fields) +class InterventionSubTypeTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username='subtype_test_user', password='pwd') + self.thematic = Thematic.objects.create(code='roads_test', name_fr='Voirie Test', name_nl='Wegen Test') + self.company = Company.objects.create(name='Test Contractor') + self.team1 = CompanyTeam.objects.create(name='Équipe Régie 1', company=self.company) + self.team2 = CompanyTeam.objects.create(name='Équipe Prestataire 2', company=self.company) + + from interventions.models import InterventionSubType + self.subtype_multi = InterventionSubType.objects.create( + code='sub_multi', + name_fr='Hot Box Multi', + name_nl='Hot Box NL', + thematic=self.thematic, + intervention_types=['repair', 'fixing'], + requires_post_inspection=True, + order_number=1, + is_active=True, + ) + self.subtype_multi.notification_teams.add(self.team1, self.team2) + + self.subtype_universal = InterventionSubType.objects.create( + code='sub_univ', + name_fr='Universel', + name_nl='Universeel', + thematic=self.thematic, + intervention_types=[], + requires_post_inspection=False, + order_number=2, + is_active=True, + ) + + self.subtype_build_only = InterventionSubType.objects.create( + code='sub_build', + name_fr='Construction Seule', + thematic=self.thematic, + intervention_types=['build'], + order_number=3, + is_active=True, + ) + + def test_subtype_multiple_types_and_teams(self): + from interventions.models import InterventionSubType + st = InterventionSubType.objects.get(code='sub_multi') + self.assertEqual(st.intervention_types, ['repair', 'fixing']) + self.assertEqual(st.notification_teams.count(), 2) + self.assertIn(self.team1, st.notification_teams.all()) + self.assertIn(self.team2, st.notification_teams.all()) + + def test_form_filters_subtypes_by_type(self): + from interventions.views.forms import InterventionForm + # Form for 'repair' should include sub_multi and sub_univ, but NOT sub_build + form_repair = InterventionForm(thematic=self.thematic, user=self.user, initial={'type': 'repair'}) + qs_repair = form_repair.fields['sub_type'].queryset + self.assertIn(self.subtype_multi, qs_repair) + self.assertIn(self.subtype_universal, qs_repair) + self.assertNotIn(self.subtype_build_only, qs_repair) + + # Form for 'build' should include sub_build and sub_univ, but NOT sub_multi + form_build = InterventionForm(thematic=self.thematic, user=self.user, initial={'type': 'build'}) + qs_build = form_build.fields['sub_type'].queryset + self.assertIn(self.subtype_build_only, qs_build) + self.assertIn(self.subtype_universal, qs_build) + self.assertNotIn(self.subtype_multi, qs_build) + + def test_admin_types_display(self): + from interventions.admin import InterventionSubTypeAdmin + from django.contrib.admin.sites import AdminSite + from django.utils import translation + admin_instance = InterventionSubTypeAdmin(self.subtype_multi.__class__, AdminSite()) + with translation.override('fr'): + display_text = admin_instance.types_display(self.subtype_multi) + self.assertIn("Réparation", display_text) + self.assertIn("Dépannage", display_text) + + display_univ = admin_instance.types_display(self.subtype_universal) + self.assertEqual(str(display_univ), "Tous") + + + diff --git a/loko/interventions/views/forms.py b/loko/interventions/views/forms.py index 4e8b9c2..f2c9f85 100644 --- a/loko/interventions/views/forms.py +++ b/loko/interventions/views/forms.py @@ -98,16 +98,22 @@ class InterventionForm(forms.ModelForm): if 'type' in self.fields: self.fields['type'].choices = get_type_choices(thematic_obj) + type_value = self.initial.get('type') or self.data.get('type') or (getattr(self.instance, 'type', None) if self.instance else None) + maintain_type_value = self.initial.get('maintain_type') or self.data.get('maintain_type') or (getattr(self.instance, 'maintain_type', None) if self.instance else None) + 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) + if type_value: + from django.db.models import Q + sub_type_qs = sub_type_qs.filter( + Q(intervention_types__contains=[type_value]) | Q(intervention_types=[]) + ) 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': if not self.initial.get('status') and not self.data.get('status'): self.fields['status'].initial = 'to_be_processed' From 368a167b9c58c8ab2af34d38aabb098f651548e4 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 17 Sep 2026 16:27:33 +0200 Subject: [PATCH 04/45] feat: add support for dynamic intervention subtype filtering and validation --- loko/interventions/permissions.py | 2 +- .../interventions/detail/cms_edit_mode.js | 91 ++++++++++ .../add/_intervention_add_form.html | 5 + .../add/_intervention_add_scripts.html | 45 +++++ .../interventions/detail/card_info.html | 25 +++ .../interventions/detail/scripts.html | 1 + loko/interventions/tests/test_inspections.py | 159 ++++++++++++++++++ loko/interventions/views/detail.py | 40 ++++- loko/interventions/views/helpers.py | 25 ++- 9 files changed, 387 insertions(+), 6 deletions(-) diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index b5f7433..2036e30 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -471,7 +471,7 @@ def get_allowed_update_fields(user, intervention): # Type d'intervention, Osiris, PMO, Assets if intervention.status_order < STATUS_ORDERS.get('validated') and any(role in user_roles for role in ['admin', 'manager', 'controller', 'external_manager', 'inspector']): - allowed_fields.update(['type', 'osiris', 'pmo_number']) + allowed_fields.update(['type', 'sub_type', 'osiris', 'pmo_number']) if intervention.status_order < STATUS_ORDERS.get('validated'): allowed_fields.add('category') diff --git a/loko/interventions/static/interventions/detail/cms_edit_mode.js b/loko/interventions/static/interventions/detail/cms_edit_mode.js index 5986e27..484f93c 100644 --- a/loko/interventions/static/interventions/detail/cms_edit_mode.js +++ b/loko/interventions/static/interventions/detail/cms_edit_mode.js @@ -10,6 +10,8 @@ let isEditMode = false; let originalValues = {}; let originalContractOptions = null; // Store original contract options for cancel + let originalSubtypeOptions = null; // Store original subtype options for cancel + let originalSubtypeVisible = null; // Store original subtype visibility for cancel let isSavingInProgress = false; let isAutoSavingAndNavigating = false; let isSubmittingAutoSavedForm = false; @@ -29,6 +31,67 @@ // Setup dynamic contract loading when provider changes setupProviderContractLink(); + // Setup dynamic subtype loading/visibility when type changes + setupTypeSubtypeLink(); + + /** + * Setup the dynamic link between type and subtype selects. + * When the type changes, filter the subtype options and show/hide the subtype field. + */ + function setupTypeSubtypeLink() { + const typeField = document.querySelector('.editable-field[data-field="type"]'); + const subtypeField = document.querySelector('.editable-field[data-field="sub_type"]'); + + if (!typeField || !subtypeField) return; + + const typeSelect = typeField.querySelector('.field-edit select'); + const subtypeSelect = subtypeField.querySelector('.field-edit select'); + + if (!typeSelect || !subtypeSelect) return; + + let subtypes = []; + try { + if (subtypeField.dataset.subtypes) { + subtypes = JSON.parse(subtypeField.dataset.subtypes); + } else if (typeof interventionSubtypesData !== 'undefined') { + subtypes = interventionSubtypesData; + } + } catch (e) { + console.error('Error parsing subtypes:', e); + } + + function updateSubtype(selectedType, keepValue) { + const available = subtypes.filter(st => !st.types || st.types.length === 0 || st.types.includes(selectedType)); + const hasSubtypes = available.length > 0; + + if (!hasSubtypes) { + subtypeField.classList.add('d-none'); + subtypeSelect.value = ''; + } else { + subtypeField.classList.remove('d-none'); + const currentVal = subtypeSelect.value; + subtypeSelect.innerHTML = ''; + let valueStillValid = false; + available.forEach(st => { + const opt = document.createElement('option'); + opt.value = String(st.id); + opt.textContent = st.name; + if (keepValue && String(st.id) === currentVal) { + opt.selected = true; + valueStillValid = true; + } + subtypeSelect.appendChild(opt); + }); + if (keepValue && !valueStillValid) { + subtypeSelect.value = ''; + } + } + } + + typeSelect.addEventListener('change', function() { + updateSubtype(this.value, false); + }); + } /** * Setup the dynamic link between provider and contract selects. @@ -130,6 +193,16 @@ } } + // Store original subtype options and visibility + const subtypeField = document.querySelector('.editable-field[data-field="sub_type"]'); + if (subtypeField) { + const subtypeSelect = subtypeField.querySelector('.field-edit select'); + if (subtypeSelect) { + originalSubtypeOptions = subtypeSelect.innerHTML; + } + originalSubtypeVisible = !subtypeField.classList.contains('d-none'); + } + // Store original values originalValues = {}; editableFields.forEach(field => { @@ -196,6 +269,22 @@ } } + // Restore original subtype options and visibility + if (originalSubtypeOptions !== null) { + const subtypeField = document.querySelector('.editable-field[data-field="sub_type"]'); + if (subtypeField) { + const subtypeSelect = subtypeField.querySelector('.field-edit select'); + if (subtypeSelect) { + subtypeSelect.innerHTML = originalSubtypeOptions; + } + if (originalSubtypeVisible) { + subtypeField.classList.remove('d-none'); + } else { + subtypeField.classList.add('d-none'); + } + } + } + // Restore original values editableFields.forEach(field => { const canEdit = field.dataset.canEdit === 'true'; @@ -234,6 +323,8 @@ originalValues = {}; originalContractOptions = null; + originalSubtypeOptions = null; + originalSubtypeVisible = null; } async function saveChanges(options = {}) { diff --git a/loko/interventions/templates/interventions/add/_intervention_add_form.html b/loko/interventions/templates/interventions/add/_intervention_add_form.html index 1f14bfd..4ad053b 100644 --- a/loko/interventions/templates/interventions/add/_intervention_add_form.html +++ b/loko/interventions/templates/interventions/add/_intervention_add_form.html @@ -335,6 +335,11 @@ {{ form.type }} {{ form.type.errors }} +
+ + {{ form.sub_type }} + {{ form.sub_type.errors }} +
{% endif %} diff --git a/loko/interventions/templates/interventions/add/_intervention_add_scripts.html b/loko/interventions/templates/interventions/add/_intervention_add_scripts.html index 7bd67f7..00d711a 100644 --- a/loko/interventions/templates/interventions/add/_intervention_add_scripts.html +++ b/loko/interventions/templates/interventions/add/_intervention_add_scripts.html @@ -14,6 +14,7 @@ const thematic = '{{ thematic.code }}'; const symptomsData = {{ symptoms_data|safe }}; const sourceCategoriesData = {{ source_categories_data|safe }}; + const subtypesData = {{ subtypes_data|default:"[]"|safe }}; const autocomplete_street_url = "{% url 'common:autocomplete_street' %}"; var autocomplete_locations_url = "{% url 'assets:locations_autocomplete' %}?thematic={{ thematic.code }}{% if inspection_config %}&prioritize={% if inspection_config.default_category %}{{ inspection_config.default_category.code|lower }}{% else %}giep{% endif %}{% endif %}"; var regional_roads_url = '{% url "common:get_regional_roads_geojson" %}'; @@ -54,6 +55,50 @@ {% endif %} + + + diff --git a/loko/interventions/templates/interventions/detail/card_info.html b/loko/interventions/templates/interventions/detail/card_info.html index bec750c..85a009e 100644 --- a/loko/interventions/templates/interventions/detail/card_info.html +++ b/loko/interventions/templates/interventions/detail/card_info.html @@ -498,6 +498,31 @@ + + +
+ + {% translate "Sous-type" %}: +
+ {{ intervention.sub_type.get_name|default:"-" }} +
+
+ +
+
diff --git a/loko/interventions/templates/interventions/detail/scripts.html b/loko/interventions/templates/interventions/detail/scripts.html index c397211..9a9422d 100644 --- a/loko/interventions/templates/interventions/detail/scripts.html +++ b/loko/interventions/templates/interventions/detail/scripts.html @@ -23,6 +23,7 @@ const recommendedNextStatus = '{{ recommended_next_status }}'; const interventionId = '{{ intervention.id }}'; const interventionThematicId = {% if intervention.thematic %}{{ intervention.thematic.id }}{% else %}null{% endif %}; + const interventionSubtypesData = {{ subtypes_data_json|default:"[]"|safe }}; const allowed_transitions = {{ allowed_transitions|safe }}; const canViewPrices = {{ can_view_prices|yesno:"true,false" }}; var contractId = {% if contract_id %} {{ contract_id }} {% else %} null {% endif %}; diff --git a/loko/interventions/tests/test_inspections.py b/loko/interventions/tests/test_inspections.py index 37e796d..ed39622 100644 --- a/loko/interventions/tests/test_inspections.py +++ b/loko/interventions/tests/test_inspections.py @@ -645,6 +645,165 @@ class InterventionSubTypeTests(TestCase): display_univ = admin_instance.types_display(self.subtype_universal) self.assertEqual(str(display_univ), "Tous") + def test_update_intervention_field_subtype(self): + from interventions.models import Intervention + from interventions.views.helpers import _update_intervention_field + + intervention = Intervention.objects.create( + title='Test SubType Update', + type='repair', + thematic=self.thematic, + status='in_preparation', + ) + + # 1. Setting compatible subtype succeeds + success, err = _update_intervention_field(intervention, 'sub_type', str(self.subtype_multi.id)) + self.assertTrue(success) + self.assertIsNone(err) + self.assertEqual(intervention.sub_type, self.subtype_multi) + + # 2. Setting universal subtype succeeds + success, err = _update_intervention_field(intervention, 'sub_type', str(self.subtype_universal.id)) + self.assertTrue(success) + self.assertEqual(intervention.sub_type, self.subtype_universal) + + # 3. Setting incompatible subtype fails + success, err = _update_intervention_field(intervention, 'sub_type', str(self.subtype_build_only.id)) + self.assertFalse(success) + self.assertIn("non compatible", err) + # Old value unchanged + self.assertEqual(intervention.sub_type, self.subtype_universal) + + # 4. Setting empty string clears subtype + success, err = _update_intervention_field(intervention, 'sub_type', "") + self.assertTrue(success) + self.assertIsNone(intervention.sub_type) + + def test_type_change_resets_incompatible_subtype(self): + from interventions.models import Intervention + from interventions.views.helpers import _update_intervention_field + + intervention = Intervention.objects.create( + title='Test SubType Incompatible Reset', + type='repair', + sub_type=self.subtype_multi, + thematic=self.thematic, + status='in_preparation', + ) + self.assertEqual(intervention.sub_type, self.subtype_multi) + + # Change type to 'build' (incompatible with subtype_multi ['repair', 'fixing']) + success, err = _update_intervention_field(intervention, 'type', 'build') + self.assertTrue(success) + self.assertEqual(intervention.type, 'build') + self.assertIsNone(intervention.sub_type) + + def test_permissions_includes_sub_type(self): + from interventions.models import Intervention + from interventions.permissions import get_allowed_update_fields + from common.models import UserConfig, Role + + admin_role, _ = Role.objects.get_or_create(name='admin') + user_config, _ = UserConfig.objects.get_or_create(user=self.user) + user_config.roles.add(admin_role) + user_config.is_intern = True + user_config.save() + + intervention = Intervention.objects.create( + title='Test Perms', + type='repair', + thematic=self.thematic, + status='in_preparation', + ) + allowed_fields = get_allowed_update_fields(self.user, intervention) + self.assertIn('type', allowed_fields) + self.assertIn('sub_type', allowed_fields) + + def test_detail_view_subtypes_context(self): + from interventions.models import Intervention + from common.models import UserConfig, Role + import json + + admin_role, _ = Role.objects.get_or_create(name='admin') + user_config, _ = UserConfig.objects.get_or_create(user=self.user) + user_config.roles.add(admin_role) + user_config.is_intern = True + user_config.save() + + # 1. Intervention with type 'repair' (has matching subtypes: sub_multi, sub_univ) + interv_repair = Intervention.objects.create( + title='Detail View Subtype Test', + type='repair', + thematic=self.thematic, + status='in_preparation', + created_by=self.user, + ) + + self.client.force_login(self.user) + response = self.client.get(reverse('interventions:intervention_detail', args=[interv_repair.id])) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context['has_subtypes_for_current_type']) + subtypes_in_json = json.loads(response.context['subtypes_data_json']) + self.assertTrue(any(s['id'] == self.subtype_multi.id for s in subtypes_in_json)) + + # 2. Deactivate universal subtype to test type without subtypes + self.subtype_universal.is_active = False + self.subtype_universal.save() + + interv_cleaning = Intervention.objects.create( + title='Detail View No Subtype Test', + type='cleaning', + thematic=self.thematic, + status='in_preparation', + created_by=self.user, + ) + response_cleaning = self.client.get(reverse('interventions:intervention_detail', args=[interv_cleaning.id])) + self.assertEqual(response_cleaning.status_code, 200) + self.assertFalse(response_cleaning.context['has_subtypes_for_current_type']) + + def test_update_intervention_endpoint_subtype(self): + from interventions.models import Intervention + from common.models import UserConfig, Role + import json + + admin_role, _ = Role.objects.get_or_create(name='admin') + user_config, _ = UserConfig.objects.get_or_create(user=self.user) + user_config.roles.add(admin_role) + user_config.is_intern = True + user_config.save() + + intervention = Intervention.objects.create( + title='API Update Subtype Test', + type='repair', + thematic=self.thematic, + status='in_preparation', + created_by=self.user, + ) + + self.client.force_login(self.user) + url = reverse('interventions:update_intervention', args=[intervention.id]) + + # Batch update sub_type + response = self.client.post( + url, + data=json.dumps({'sub_type': str(self.subtype_multi.id)}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + intervention.refresh_from_db() + self.assertEqual(intervention.sub_type, self.subtype_multi) + + # Batch update type and sub_type together: switch type to 'build' and sub_type to sub_build + response = self.client.post( + url, + data=json.dumps({'type': 'build', 'sub_type': str(self.subtype_build_only.id)}), + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + intervention.refresh_from_db() + self.assertEqual(intervention.type, 'build') + self.assertEqual(intervention.sub_type, self.subtype_build_only) + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 02928fe..f10c319 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -54,7 +54,7 @@ from common.private_files.helpers import build_private_url from common.utils import get_short_name from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, - InterventionLink, + InterventionLink, InterventionSubType, ContractPauseReasonAssignment, Symptom, SourceCategory, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, InterventionOccupation, @@ -632,6 +632,20 @@ def intervention_detail(request, intervention_id): sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes) + sub_types_qs = InterventionSubType.objects.filter(is_active=True, thematic=intervention.thematic).order_by('order_number', 'name_fr') + subtypes_data = [ + { + 'id': st.id, + 'name': st.get_name(), + 'types': st.intervention_types, + } + for st in sub_types_qs + ] + has_subtypes_for_current_type = any( + not st.intervention_types or intervention.type in st.intervention_types + for st in sub_types_qs + ) + context = { "intervention": intervention, "status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], @@ -663,6 +677,9 @@ def intervention_detail(request, intervention_id): 'contracts': contracts, 'maintain_choices': MAINTAIN_CHOICES, 'type_choices': get_type_choices(intervention.thematic), + 'sub_types': sub_types_qs, + 'subtypes_data_json': _json_mod.dumps(subtypes_data), + 'has_subtypes_for_current_type': has_subtypes_for_current_type, 'origin_type_choices': ORIGIN_TYPE_CHOICES, 'priority_choices': Intervention._meta.get_field('priority').choices, 'symptoms': Symptom.objects.filter(is_active=True, thematic=intervention.thematic).order_by('name_fr'), @@ -1694,6 +1711,16 @@ def interventions_add(request, thematic_code): for sc in SourceCategory.objects.filter(is_active=True) ] + subtypes_qs = InterventionSubType.objects.filter(thematic=thematic, is_active=True).order_by('order_number', 'name_fr') + subtypes_data = [ + { + "id": st.id, + "name": st.get_name(), + "types": st.intervention_types, + } + for st in subtypes_qs + ] + if request.method == 'POST': maintain_type = request.POST.get('maintain_type', 'corrective') form = InterventionForm(request.POST, request.FILES, thematic=thematic, user=request.user) @@ -2017,7 +2044,7 @@ def interventions_add(request, thematic_code): for key in [ 'title', 'lat', 'lon', 'address', 'asset_category', 'status', 'source_category', 'source_ref', 'source_detail', 'init_description', - 'type', 'origin_type', 'priority' + 'type', 'sub_type', 'origin_type', 'priority' ]: val = request.GET.get(key) if val not in (None, ""): @@ -2093,6 +2120,7 @@ def interventions_add(request, thematic_code): 'thematic': thematic, 'symptoms_data': json.dumps(symptoms_data), 'source_categories_data': json.dumps(source_categories_data), + 'subtypes_data': json.dumps(subtypes_data), 'maintain_type': maintain_type, 'observation': observation, 'observation_photos': observation_photos, @@ -2654,14 +2682,18 @@ def update_intervention(request, intervention_id): # Handle notes separately (special case) notes_data = data.pop('notes', None) - # Sort fields to process assigned_provider before contract + # Sort fields to process assigned_provider before contract, and type before sub_type def _field_priority(item): f_name = item[0] if f_name == 'assigned_provider': return 0 if f_name == 'contract': return 1 - return 2 + if f_name == 'type': + return 2 + if f_name == 'sub_type': + return 3 + return 4 for field, new_value in sorted(data.items(), key=_field_priority): if field not in allowed_update_fields: diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 7c91f27..b62dd00 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -54,7 +54,7 @@ from common.private_files.helpers import build_private_url from common.utils import get_short_name from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, - InterventionLink, + InterventionLink, InterventionSubType, ContractPauseReasonAssignment, Symptom, SourceCategory, InterventionPlanificationTimeLine, InterventionRealizationTimeLine, InterventionOccupation, @@ -2604,9 +2604,32 @@ def _update_intervention_field(intervention, field, new_value, user_config=None) allowed_types = dict(get_type_choices(intervention.thematic)).keys() if new_value in allowed_types: intervention.type = new_value + if intervention.sub_type and intervention.sub_type.intervention_types: + if new_value not in intervention.sub_type.intervention_types: + intervention.sub_type = None return True, None return False, _('Type not found') + elif field == "sub_type": + if new_value in ("", None, "null"): + intervention.sub_type = None + return True, None + try: + sub_type_id = int(new_value) + sub_type = InterventionSubType.objects.filter( + id=sub_type_id, + is_active=True, + thematic=intervention.thematic, + ).first() + if not sub_type: + return False, _('Sous-type introuvable') + if sub_type.intervention_types and intervention.type not in sub_type.intervention_types: + return False, _("Sous-type non compatible avec le type d'intervention") + intervention.sub_type = sub_type + return True, None + except (ValueError, TypeError): + return False, _('Sous-type invalide') + elif field == "origin_type": if new_value in dict(ORIGIN_TYPE_CHOICES).keys(): intervention.origin_type = new_value From 6c8002890652ee5f5aa8bd1206afd6f33408f150 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Thu, 17 Sep 2026 17:07:51 +0200 Subject: [PATCH 05/45] feat: add concerned categories and target asset model to intervention subtypes for dynamic inspection targeting --- loko/inspections/services.py | 148 ++++++++++++++++-- loko/inspections/tests.py | 142 +++++++++++++++++ loko/inspections/views.py | 14 +- loko/interventions/admin.py | 76 ++++++++- ...onsubtype_concerned_categories_and_more.py | 26 +++ loko/interventions/models.py | 17 ++ .../admin_intervention_subtype.js | 81 ++++++++++ 7 files changed, 479 insertions(+), 25 deletions(-) create mode 100644 loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py create mode 100644 loko/interventions/static/interventions/admin_intervention_subtype.js diff --git a/loko/inspections/services.py b/loko/inspections/services.py index 523239d..8a13218 100644 --- a/loko/inspections/services.py +++ b/loko/inspections/services.py @@ -6,6 +6,130 @@ from django.contrib.contenttypes.models import ContentType logger = logging.getLogger(__name__) +def _resolve_inspection_target(intervention, sub_type): + """ + Détermine l'équipement (Asset), la localisation ou l'intervention cible pour l'inspection. + 1. Si un équipement valide est déjà attaché à l'intervention (interventionasset_set), on le conserve + (en vérifiant s'il correspond au modèle/catégories configurés si applicables). + 2. Sinon, on identifie les modèles d'assets candidats : + - sub_type.target_asset_model si configuré + - allowed_models des sub_type.concerned_categories + - Fallback : Roadway si thématique roads ou sous-type de type Hot Box / voirie + 3. On sélectionne l'équipement du modèle candidat le plus proche spatialement + du centroïde de l'intervention (en priorité sur la rue associée). + 4. Fallback sur la première localisation ou l'intervention elle-même. + """ + from django.contrib.gis.db import models as gis_models + from django.contrib.gis.geos import Point + from django.contrib.gis.db.models.functions import Distance + + # 1. Vérifier si un équipement correspondant est déjà rattaché à l'intervention + if hasattr(intervention, 'interventionasset_set') and intervention.interventionasset_set.exists(): + if sub_type and sub_type.target_asset_model_id: + matching = intervention.interventionasset_set.filter(content_type=sub_type.target_asset_model).first() + if matching and matching.content_type_id and matching.object_id: + return matching.content_type, matching.object_id + + if sub_type and sub_type.concerned_categories.exists(): + allowed_cts = ContentType.objects.filter(asset_categories__in=sub_type.concerned_categories.all()).distinct() + if allowed_cts.exists(): + matching = intervention.interventionasset_set.filter(content_type__in=allowed_cts).first() + if matching and matching.content_type_id and matching.object_id: + return matching.content_type, matching.object_id + + first_asset = intervention.interventionasset_set.first() + if first_asset and first_asset.content_type_id and first_asset.object_id: + return first_asset.content_type, first_asset.object_id + + # 2. Identifier les modèles d'assets cibles candidats + candidate_cts = [] + if sub_type: + if sub_type.target_asset_model_id: + candidate_cts.append(sub_type.target_asset_model) + elif sub_type.concerned_categories.exists(): + allowed_cts = list(ContentType.objects.filter(asset_categories__in=sub_type.concerned_categories.all()).distinct()) + candidate_cts.extend(allowed_cts) + + # Fallback si aucun modèle configuré : pour les voiries ou Hot Box, cibler Roadway + if not candidate_cts: + thematic_code = getattr(intervention.thematic, 'code', None) if intervention.thematic else None + is_road_repair = (thematic_code == 'roads') or ( + sub_type and any(k in (sub_type.code or '').lower() or k in (sub_type.name_fr or '').lower() for k in ('hotbox', 'hot_box', 'hot box', 'chaussee', 'chaussée')) + ) + if is_road_repair: + roadway_ct = ContentType.objects.filter(app_label='assets', model='roadway').first() + if roadway_ct: + candidate_cts.append(roadway_ct) + + # 3. Résolution spatiale du plus proche asset + centroid = None + if intervention.geom: + try: + centroid = intervention.geom.centroid + except Exception as e: + logger.debug(f"Error computing centroid from intervention.geom: {e}") + + if not centroid and intervention.lon and intervention.lat: + try: + p = Point(float(intervention.lon), float(intervention.lat), srid=4326) + p.transform(3812) + centroid = p + except Exception as e: + logger.debug(f"Error transforming intervention coordinates to 3812: {e}") + + # Récupérer les rues associées à l'intervention + street_ids = [] + if hasattr(intervention, 'locations'): + for loc in intervention.locations.all(): + if loc.content_type and loc.content_type.model == 'roadstreet': + street_ids.append(loc.object_id) + + for ct in candidate_cts: + model_cls = ct.model_class() + if not model_cls: + continue + + has_geom = any( + isinstance(f, gis_models.GeometryField) + for f in model_cls._meta.fields + if hasattr(f, 'geom_type') + ) or hasattr(model_cls, 'geom') + + if has_geom and centroid: + # Recherche prioritaire sur la même rue + if street_ids and hasattr(model_cls, 'street'): + closest = model_cls.objects.filter( + street_id__in=street_ids, + geom__isnull=False + ).annotate(distance=Distance('geom', centroid)).order_by('distance').first() + if closest: + return ct, closest.pk + + # Recherche globale du plus proche + closest = model_cls.objects.filter( + geom__isnull=False + ).annotate(distance=Distance('geom', centroid)).order_by('distance').first() + if closest: + return ct, closest.pk + + # Sans géométrie ou centroïde : filtrer par rue si possible + if street_ids and hasattr(model_cls, 'street'): + obj = model_cls.objects.filter(street_id__in=street_ids).first() + if obj: + return ct, obj.pk + + first_obj = model_cls.objects.first() + if first_obj: + return ct, first_obj.pk + + # 4. Fallback sur la première localisation ou l'intervention + 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: + return first_loc.content_type, first_loc.object_id + + return ContentType.objects.get_for_model(intervention), intervention.pk + + def schedule_post_intervention_inspection(intervention): """ Planifie automatiquement une inspection de durabilité pour une intervention @@ -23,6 +147,14 @@ def schedule_post_intervention_inspection(intervention): nature=sub_type.post_inspection_nature ).exclude(status='cancelled').first() if existing: + # Si l'inspection existante pointe sur une localisation (ex: RoadStreet) et qu'un asset plus précis est trouvé + if existing.asset_content_type and existing.asset_content_type.model == 'roadstreet': + better_ct, better_id = _resolve_inspection_target(intervention, sub_type) + if better_ct and better_ct.model != 'roadstreet': + existing.asset_content_type = better_ct + existing.asset_object_id = better_id + existing.save(update_fields=['asset_content_type', 'asset_object_id']) + logger.info(f"Updated inspection {existing.pk} target from roadstreet to {better_ct.model} #{better_id}") return existing # Calcul des dates de la fenêtre d'inspection [fin + x, fin + x + y] @@ -63,21 +195,7 @@ def schedule_post_intervention_inspection(intervention): 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 + target_ct, target_id = _resolve_inspection_target(intervention, sub_type) # Pré-assignation si un seul inspecteur est assigné à la zone assigned_inspector = None diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py index 17bfd7f..64583a7 100644 --- a/loko/inspections/tests.py +++ b/loko/inspections/tests.py @@ -1632,4 +1632,146 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): self.assertEqual(Inspection.objects.filter(checked_intervention=intervention).count(), 0) + def test_inspection_datetime_localtime_formatting(self): + """Vérifie que les dates et heures sont correctement converties dans le fuseau horaire local.""" + from datetime import datetime, timezone as dt_tz + # 14:32 UTC correspond à 16:32 en été (UTC+2 Europe/Brussels) + utc_dt = datetime(2026, 9, 17, 14, 32, tzinfo=dt_tz.utc) + + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='in_progress', + created_by=self.user + ) + insp = Inspection.objects.create( + asset_content_type=ContentType.objects.get_for_model(intervention), + asset_object_id=intervention.id, + checked_intervention=intervention, + nature='post_intervention', + status='scheduled', + inspection_date=utc_dt + ) + + self.client.force_login(self.user) + response = self.client.get(reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': insp.id})) + self.assertEqual(response.status_code, 200) + data = response.json() + # 14:32 UTC -> 16:32 local + self.assertEqual(data['inspection_date'], '17/09/2026 16:32') + + def test_spatial_target_resolution_closest_roadway(self): + """Vérifie que pour un Hot Box, l'asset cible sélectionné est la Roadway la plus proche du centroïde de l'intervention.""" + from django.contrib.gis.geos import MultiPolygon, Polygon + from assets.models.roads import RoadStreet, Roadway + from interventions.models import InterventionLocation + + street = RoadStreet.objects.create(code='WAT_R22_TEST', name_fr='Chaussée de Test') + + # Deux chaussées sur la même rue : une proche (150000, 170000), une lointaine (160000, 180000) + p_near = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000))) + roadway_near = Roadway.objects.create( + street=street, + geom=MultiPolygon(p_near, srid=3812) + ) + + p_far = Polygon(((160000, 180000), (160010, 180000), (160010, 180010), (160000, 180010), (160000, 180000))) + roadway_far = Roadway.objects.create( + street=street, + geom=MultiPolygon(p_far, srid=3812) + ) + + # Configurer le sous-type Hot Box avec target_asset_model = Roadway + self.hotbox_subtype.target_asset_model = ContentType.objects.get_for_model(Roadway) + self.hotbox_subtype.save() + + # Créer une intervention à proximité de roadway_near (150002, 170002) avec la rue en localisation + p_intv = Polygon(((150002, 170002), (150005, 170002), (150005, 170005), (150002, 170005), (150002, 170002))) + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='in_progress', + end_time=timezone.now(), + geom=MultiPolygon(p_intv, srid=3812) + ) + InterventionLocation.objects.create( + intervention=intervention, + content_type=ContentType.objects.get_for_model(street), + object_id=street.pk + ) + + # Terminer l'intervention + intervention.status = 'finished' + intervention.save() + + insp = Inspection.objects.get(checked_intervention=intervention) + self.assertEqual(insp.asset_content_type, ContentType.objects.get_for_model(Roadway)) + self.assertEqual(insp.asset_object_id, roadway_near.pk) + + def test_existing_inspection_roadstreet_upgraded_to_roadway(self): + """Vérifie qu'une inspection existante ciblant une rue est mise à niveau vers la Roadway la plus proche.""" + from django.contrib.gis.geos import MultiPolygon, Polygon + from assets.models.roads import RoadStreet, Roadway + from interventions.models import InterventionLocation + from .services import schedule_post_intervention_inspection + + street = RoadStreet.objects.create(code='WAT_R22_UPGRADE', name_fr='Rue Upgrade') + p_near = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000))) + roadway = Roadway.objects.create( + street=street, + geom=MultiPolygon(p_near, srid=3812) + ) + + self.hotbox_subtype.target_asset_model = ContentType.objects.get_for_model(Roadway) + self.hotbox_subtype.save() + + p_intv = Polygon(((150002, 170002), (150005, 170002), (150005, 170005), (150002, 170005), (150002, 170002))) + intervention = Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=self.hotbox_subtype, + status='finished', + end_time=timezone.now(), + geom=MultiPolygon(p_intv, srid=3812) + ) + InterventionLocation.objects.create( + intervention=intervention, + content_type=ContentType.objects.get_for_model(street), + object_id=street.pk + ) + + # Créer manuellement une inspection ciblant la rue (comme avant le fix) + existing_insp = Inspection.objects.create( + asset_content_type=ContentType.objects.get_for_model(street), + asset_object_id=street.pk, + checked_intervention=intervention, + nature='post_intervention', + status='scheduled' + ) + + # Appel à schedule_post_intervention_inspection + res = schedule_post_intervention_inspection(intervention) + self.assertEqual(res.pk, existing_insp.pk) + + existing_insp.refresh_from_db() + self.assertEqual(existing_insp.asset_content_type, ContentType.objects.get_for_model(Roadway)) + self.assertEqual(existing_insp.asset_object_id, roadway.pk) + + def test_admin_form_category_and_target_model_filtering(self): + """Vérifie le filtrage du formulaire admin pour les catégories et le modèle d'asset cible.""" + from interventions.admin import InterventionSubTypeAdminForm + from assets.models import AssetCategory + + cat_roads = AssetCategory.objects.create(code='cat_roads', name_fr='Voirie Test', thematic=self.roads_thematic) + other_thematic = Thematic.objects.create(code='mobility', name_fr='Mobilité') + cat_mobility = AssetCategory.objects.create(code='cat_mob', name_fr='Vélo Test', thematic=other_thematic) + + form = InterventionSubTypeAdminForm(instance=self.hotbox_subtype) + self.assertIn(cat_roads, form.fields['concerned_categories'].queryset) + self.assertNotIn(cat_mobility, form.fields['concerned_categories'].queryset) + self.assertIn('data-cat-models', form.fields['target_asset_model'].widget.attrs) + + diff --git a/loko/inspections/views.py b/loko/inspections/views.py index 2f7e191..80d0a30 100644 --- a/loko/inspections/views.py +++ b/loko/inspections/views.py @@ -873,9 +873,9 @@ def get_inspection_detail_api(request, inspection_id): 'details_html': rendered_detail_html, 'apply_changes_to_asset': leaf.apply_changes_to_asset, 'is_cancelled': leaf.is_cancelled, - 'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '', - 'created_at': leaf.created_at.strftime('%d/%m/%Y %H:%M') if leaf.created_at else '', - 'updated_at': leaf.updated_at.strftime('%d/%m/%Y %H:%M') if leaf.updated_at else '', + 'inspection_date': timezone.localtime(leaf.inspection_date).strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '', + 'created_at': timezone.localtime(leaf.created_at).strftime('%d/%m/%Y %H:%M') if leaf.created_at else '', + 'updated_at': timezone.localtime(leaf.updated_at).strftime('%d/%m/%Y %H:%M') if leaf.updated_at else '', 'can_edit': leaf.can_edit(request.user), 'documents': docs_data, } @@ -1029,7 +1029,7 @@ def update_inspection_api(request, inspection_id): return JsonResponse({ 'success': True, 'inspection_id': inspection.id, - 'updated_at': inspection.updated_at.strftime('%d/%m/%Y %H:%M'), + 'updated_at': timezone.localtime(inspection.updated_at).strftime('%d/%m/%Y %H:%M'), 'message': str(_("Inspection mise à jour avec succès.")) }) except Exception as e: @@ -1262,7 +1262,7 @@ def inspections_geojson(request): inspections_data.append({ 'id': insp.id, - 'date': insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', + 'date': timezone.localtime(insp.inspection_date).strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', 'result_status': insp.result_status, 'result_status_display': insp.get_result_status_display(), 'status_badge_class': insp.status_badge_class, @@ -1395,7 +1395,7 @@ def inspections_geojson(request): "inspector": get_short_name(insp.inspector) if insp.inspector else '-', "inspector_username": insp.inspector.username if insp.inspector else '', "inspection_date": insp.inspection_date.isoformat() if insp.inspection_date else '', - "inspection_date_formatted": insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', + "inspection_date_formatted": timezone.localtime(insp.inspection_date).strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', "notes": insp.notes or '', "details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '', "verified_obsolescence": insp.verified_obsolescence or '', @@ -1495,7 +1495,7 @@ def inspections_export_excel(request): row_data = [ f"INSP#{insp.id:05d}", - insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', + timezone.localtime(insp.inspection_date).strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', th.name if th else '', insp.asset_content_type.name if insp.asset_content_type else '', target_display, diff --git a/loko/interventions/admin.py b/loko/interventions/admin.py index 3e72c62..234932b 100644 --- a/loko/interventions/admin.py +++ b/loko/interventions/admin.py @@ -42,6 +42,8 @@ from .services import generate_interventions_excel from common.models import Thematic from assets.models import AssetCategory from interventions.views.preventive_import import process_worksheet as _process_worksheet +import json +from django.contrib.contenttypes.models import ContentType from interventions.models import STATUS_CHOICES, DirectContractRouting, TYPE_CHOICES @@ -58,18 +60,79 @@ class InterventionSubTypeAdminForm(forms.ModelForm): model = InterventionSubType fields = '__all__' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # 1. Filtrer les catégories selon la thématique + thematic = None + if self.instance and self.instance.pk and self.instance.thematic_id: + thematic = self.instance.thematic + else: + thematic_id = self.data.get('thematic') or self.initial.get('thematic') + if thematic_id: + thematic = Thematic.objects.filter(pk=thematic_id).first() + + if 'concerned_categories' in self.fields: + if thematic: + self.fields['concerned_categories'].queryset = AssetCategory.objects.filter(thematic=thematic) + else: + self.fields['concerned_categories'].queryset = AssetCategory.objects.all() + + # 2. Configurer le champ target_asset_model + if 'target_asset_model' in self.fields: + self.fields['target_asset_model'].label_from_instance = ( + lambda obj: f"{obj.model_class()._meta.verbose_name.capitalize()} ({obj.model})" if obj.model_class() else obj.model + ) + + category_ids = [] + if 'concerned_categories' in self.data: + try: + category_ids = [int(x) for x in self.data.getlist('concerned_categories') if str(x).isdigit()] + except Exception: + pass + elif self.instance.pk: + category_ids = list(self.instance.concerned_categories.values_list('pk', flat=True)) + elif self.initial.get('concerned_categories'): + try: + category_ids = [int(x) for x in self.initial.get('concerned_categories') if str(x).isdigit()] + except Exception: + pass + + allowed_models_qs = ContentType.objects.none() + if category_ids: + allowed_models_qs = ContentType.objects.filter(asset_categories__id__in=category_ids).distinct() + + if allowed_models_qs.exists(): + qs = allowed_models_qs + if self.instance.pk and self.instance.target_asset_model_id: + qs = (qs | ContentType.objects.filter(id=self.instance.target_asset_model_id)).distinct() + self.fields['target_asset_model'].queryset = qs.order_by('model') + else: + self.fields['target_asset_model'].queryset = ContentType.objects.filter(app_label='assets').order_by('model') + + # Mapping JSON des modèles autorisés par catégorie pour le script admin_intervention_subtype.js + cat_model_map = {} + for cat in AssetCategory.objects.prefetch_related('allowed_models').all(): + model_ids = list(cat.allowed_models.values_list('id', flat=True)) + if model_ids: + cat_model_map[str(cat.id)] = model_ids + self.fields['target_asset_model'].widget.attrs['data-cat-models'] = json.dumps(cat_model_map) + @admin.register(InterventionSubType) class InterventionSubTypeAdmin(admin.ModelAdmin): form = InterventionSubTypeAdminForm list_display = ( "code", "name_fr", "thematic", "types_display", - "requires_post_inspection", "post_inspection_delay_min_days", - "post_inspection_delay_max_days", "is_active" + "target_asset_model_display", "requires_post_inspection", + "post_inspection_delay_min_days", "post_inspection_delay_max_days", "is_active" ) list_filter = ("thematic", "requires_post_inspection", "is_active") search_fields = ("code", "name_fr", "name_nl") - filter_horizontal = ("notification_teams",) + filter_horizontal = ("notification_teams", "concerned_categories") + + class Media: + js = ('interventions/admin_intervention_subtype.js',) def types_display(self, obj): if not obj.intervention_types: @@ -78,6 +141,13 @@ class InterventionSubTypeAdmin(admin.ModelAdmin): return ", ".join(str(type_dict.get(t, t)) for t in obj.intervention_types) types_display.short_description = _("Types d'intervention") + def target_asset_model_display(self, obj): + if not obj.target_asset_model: + return "—" + cls = obj.target_asset_model.model_class() + return cls._meta.verbose_name.capitalize() if cls else obj.target_asset_model.model + target_asset_model_display.short_description = _("Asset cible") + @admin.register(Intervention) class InterventionAdmin(admin.ModelAdmin): diff --git a/loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py b/loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py new file mode 100644 index 0000000..d81b9c8 --- /dev/null +++ b/loko/interventions/migrations/0012_interventionsubtype_concerned_categories_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.7 on 2026-09-17 14:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0026_add_naturetree_specific_fields'), + ('contenttypes', '0002_remove_content_type_name'), + ('interventions', '0011_alter_interventionsubtype_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='interventionsubtype', + name='concerned_categories', + field=models.ManyToManyField(blank=True, help_text="Catégories d'équipements concernées par ce sous-type pour cibler les inspections post-travaux.", related_name='intervention_subtypes', to='assets.assetcategory', verbose_name='Catégories concernées'), + ), + migrations.AddField( + model_name='interventionsubtype', + name='target_asset_model', + field=models.ForeignKey(blank=True, help_text="Modèle d'équipement ciblé automatiquement pour l'inspection de durabilité (ex: Roadway / Chaussée). L'objet le plus proche du centroïde de l'intervention sera sélectionné.", limit_choices_to=models.Q(('app_label', 'assets')), null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='intervention_subtypes_as_target', to='contenttypes.contenttype', verbose_name="Modèle d'asset cible"), + ), + ] diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 495f321..29cf51b 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -232,6 +232,23 @@ class InterventionSubType(models.Model): default='post_intervention', verbose_name=_("Nature de l'inspection") ) + concerned_categories = models.ManyToManyField( + AssetCategory, + blank=True, + related_name='intervention_subtypes', + verbose_name=_("Catégories concernées"), + help_text=_("Catégories d'équipements concernées par ce sous-type pour cibler les inspections post-travaux.") + ) + target_asset_model = models.ForeignKey( + ContentType, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='intervention_subtypes_as_target', + limit_choices_to=models.Q(app_label='assets'), + verbose_name=_("Modèle d'asset cible"), + help_text=_("Modèle d'équipement ciblé automatiquement pour l'inspection de durabilité (ex: Roadway / Chaussée). L'objet le plus proche du centroïde de l'intervention sera sélectionné.") + ) notification_teams = models.ManyToManyField( CompanyTeam, blank=True, diff --git a/loko/interventions/static/interventions/admin_intervention_subtype.js b/loko/interventions/static/interventions/admin_intervention_subtype.js new file mode 100644 index 0000000..2ae1aa0 --- /dev/null +++ b/loko/interventions/static/interventions/admin_intervention_subtype.js @@ -0,0 +1,81 @@ +(function() { + 'use strict'; + window.addEventListener('DOMContentLoaded', function() { + const targetModelSelect = document.getElementById('id_target_asset_model'); + if (!targetModelSelect) return; + + const rawMap = targetModelSelect.getAttribute('data-cat-models'); + if (!rawMap) return; + + let catModelMap = {}; + try { + catModelMap = JSON.parse(rawMap); + } catch (e) { + return; + } + + // Sauvegarder toutes les options initiales de target_asset_model + const originalOptions = Array.from(targetModelSelect.options).map(opt => ({ + value: opt.value, + text: opt.text, + selected: opt.selected + })); + + function updateTargetModelOptions() { + const catSelectTo = document.getElementById('id_concerned_categories_to') || document.getElementById('id_concerned_categories'); + if (!catSelectTo) return; + + const selectedCatIds = []; + for (let i = 0; i < catSelectTo.options.length; i++) { + const opt = catSelectTo.options[i]; + if (catSelectTo.id.endsWith('_to') || opt.selected) { + if (opt.value) selectedCatIds.push(opt.value); + } + } + + // Réunir tous les model_ids autorisés pour les catégories sélectionnées + let allowedModelIds = new Set(); + let hasCategoryRules = false; + selectedCatIds.forEach(catId => { + if (catModelMap[catId] && catModelMap[catId].length > 0) { + hasCategoryRules = true; + catModelMap[catId].forEach(mid => allowedModelIds.add(String(mid))); + } + }); + + const currentValue = targetModelSelect.value; + targetModelSelect.innerHTML = ''; + + originalOptions.forEach(opt => { + if (!opt.value || !hasCategoryRules || allowedModelIds.has(opt.value) || opt.value === currentValue) { + const newOpt = document.createElement('option'); + newOpt.value = opt.value; + newOpt.text = opt.text; + if (opt.value === currentValue) { + newOpt.selected = true; + } + targetModelSelect.appendChild(newOpt); + } + }); + } + + const catSelectTo = document.getElementById('id_concerned_categories_to') || document.getElementById('id_concerned_categories'); + const catSelectFrom = document.getElementById('id_concerned_categories_from'); + + if (catSelectTo) { + catSelectTo.addEventListener('change', updateTargetModelOptions); + } + if (catSelectFrom) { + catSelectFrom.addEventListener('change', updateTargetModelOptions); + } + + if (catSelectTo && window.MutationObserver) { + const observer = new MutationObserver(function() { + updateTargetModelOptions(); + }); + observer.observe(catSelectTo, { childList: true }); + } + + updateTargetModelOptions(); + }); +})(); From 250a2b4f2ff9fa83dfc8f5c84679045b3e510584 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 18 Sep 2026 10:44:58 +0200 Subject: [PATCH 06/45] feat: add related inspections to intervention detail views and templates --- .../assets/components/asset_inspections.html | 24 ++- loko/assets/tests_inspections_views.py | 68 ++++++- .../controls/controls_detail_content.html | 4 +- .../controls/controls_detail_mobile.html | 6 +- .../controls/remark_form_content.html | 2 +- loko/inspections/models.py | 32 +++ .../static/inspections/inspections_index.js | 36 ++++ .../inspections/inspections_index.html | 26 ++- .../inspections/inspections_list.html | 42 +++- .../inspections/inspections_list_mobile.html | 42 +++- loko/inspections/tests.py | 78 +++++++- loko/inspections/views.py | 9 +- loko/interventions/models.py | 31 +++ .../interventions/detail/card_controles.html | 189 +++++++++++++++++- .../intervention_detail_mobile.html | 35 +++- loko/interventions/views/detail.py | 2 + loko/interventions/views/mobile.py | 2 + 17 files changed, 586 insertions(+), 42 deletions(-) diff --git a/loko/assets/templates/assets/components/asset_inspections.html b/loko/assets/templates/assets/components/asset_inspections.html index 9ec3245..4dba7e2 100644 --- a/loko/assets/templates/assets/components/asset_inspections.html +++ b/loko/assets/templates/assets/components/asset_inspections.html @@ -54,7 +54,7 @@ {{ insp.get_nature_display }} - {{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }} + {{ insp.inspector|short_name|default:"-" }} @@ -100,11 +100,31 @@
{% endif %} + {% if insp.related_intervention %} + + {% endif %}
  • {% translate "Équipement cible" %} {{ insp.target_display }}
  • + {% if insp.related_intervention %} +
  • + {% translate "Intervention liée" %} + + + {{ insp.related_intervention.code }} + + {% if insp.related_intervention.sub_type %} + {{ insp.related_intervention.sub_type.get_name }} + {% endif %} + +
  • + {% endif %}
  • {% translate "Date & Heure" %} {{ insp.inspection_date|date:"d/m/Y H:i" }} @@ -119,7 +139,7 @@
  • {% translate "Inspecteur" %} - {{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }} + {{ insp.inspector|short_name|default:"-" }}
  • {% translate "Date de modification" %} diff --git a/loko/assets/tests_inspections_views.py b/loko/assets/tests_inspections_views.py index 7ff0704..9dea371 100644 --- a/loko/assets/tests_inspections_views.py +++ b/loko/assets/tests_inspections_views.py @@ -24,6 +24,8 @@ class InspectionViewsTestCase(TestCase): from common.models import UserConfig, UserThematics, Thematic user_config, _ = UserConfig.objects.get_or_create(user=self.user) + user_config.is_intern = True + user_config.save() t_parking, _ = Thematic.objects.get_or_create(code='parking', defaults={'name_fr': 'Stationnement'}) UserThematics.objects.update_or_create(user_config=user_config, thematic=t_parking, defaults={'can_inspect': True, 'can_view_assets': True}) self.client.force_login(self.user) @@ -37,7 +39,7 @@ class InspectionViewsTestCase(TestCase): 'standard_stand_count': 5 } ) - self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326) + self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812) self.spot.save() self.spot_ct = ContentType.objects.get_for_model(ParkingSpot) @@ -52,7 +54,10 @@ class InspectionViewsTestCase(TestCase): response = self.client.get(reverse('inspections:inspections_list')) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'PK-001') + + response_mobile = self.client.get(reverse('mobile:inspections_list_mobile')) + self.assertEqual(response_mobile.status_code, 200) + self.assertContains(response_mobile, 'PK-001') def test_quick_inspection_map_view(self): response = self.client.get(reverse('inspections:quick_inspection_map')) @@ -162,7 +167,7 @@ class InspectionViewsTestCase(TestCase): def test_inspections_geojson_view(self): from django.contrib.gis.geos import Polygon - self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326) + self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812) self.spot.save() AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete() @@ -198,7 +203,7 @@ class InspectionViewsTestCase(TestCase): nature='visual' ) - response = self.client.get(reverse('inspections:inspections_list') + '?tab=all&q=SEARCH-99') + response = self.client.get(reverse('mobile:inspections_list_mobile') + '?tab=all&q=SEARCH-99') self.assertEqual(response.status_code, 200) self.assertIn(insp, response.context['inspections']) @@ -209,11 +214,13 @@ class InspectionViewsTestCase(TestCase): user_custom = User.objects.create_user(username='user_custom_perm', password='pwd') user_config, _ = UserConfig.objects.get_or_create(user=user_custom) + user_config.is_intern = True + user_config.save() UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_view_assets=True, can_inspect=False) UserThematics.objects.create(user_config=user_config, thematic=t_lighting, can_view_assets=False, can_inspect=False) self.client.force_login(user_custom) - resp = self.client.get(reverse('inspections:inspections_list')) + resp = self.client.get(reverse('mobile:inspections_list_mobile')) self.assertEqual(resp.status_code, 200) self.assertIn(t_parking, resp.context['thematics']) self.assertNotIn(t_lighting, resp.context['thematics']) @@ -246,3 +253,54 @@ class InspectionViewsTestCase(TestCase): self.assertEqual(data['total_count'], 1) self.assertIn(insp, data['inspections']) self.assertEqual(data['asset_pk'], self.spot.pk) + + def test_asset_inspections_templatetag_renders_with_null_inspector(self): + """Vérifie que le composant asset_inspections se rend sans erreur quand inspector est None.""" + from assets.models import AssetInspection + from django.template import Template, Context + + AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete() + insp = AssetInspection.objects.create( + asset=self.spot, + inspector=None, + result_status='pending', + nature='post_intervention' + ) + + request = self.client.get(reverse('inspections:inspections_list')).wsgi_request + t = Template('{% load asset_tags %}{% asset_inspections asset %}') + rendered = t.render(Context({'request': request, 'asset': self.spot})) + self.assertIn('assetInspectionDetailModal', rendered) + self.assertIn('-', rendered) + + def test_roadway_detail_renders_with_inspection_without_inspector(self): + """Vérifie que la page de détail d'une Roadway avec une inspection planifiée (sans inspecteur) ne plante pas (500).""" + from assets.models.roads import RoadStreet, Roadway + from common.models import UserThematics, Thematic, UserConfig + from inspections.models import Inspection + from django.contrib.gis.geos import MultiPolygon, Polygon + + t_roads, _ = Thematic.objects.get_or_create(code='roads', defaults={'name_fr': 'Voiries'}) + user_config = UserConfig.objects.get(user=self.user) + UserThematics.objects.update_or_create(user_config=user_config, thematic=t_roads, defaults={'can_view_assets': True}) + + street = RoadStreet.objects.create(code='WAT_R22_TEST_VIEW', name_fr='Chaussée Test') + p = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000))) + roadway = Roadway.objects.create( + street=street, + geom=MultiPolygon(p, srid=3812) + ) + + # Créer une inspection ciblant la Roadway sans inspecteur (comme une inspection de durabilité automatique) + insp = Inspection.objects.create( + asset_content_type=ContentType.objects.get_for_model(Roadway), + asset_object_id=roadway.id, + inspector=None, + nature='post_intervention', + status='scheduled' + ) + + url = reverse('assets:roads_assets_detail', kwargs={'asset_model': 'roadway', 'asset_id': roadway.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + diff --git a/loko/controls/templates/controls/controls_detail_content.html b/loko/controls/templates/controls/controls_detail_content.html index f07c6bb..289d923 100644 --- a/loko/controls/templates/controls/controls_detail_content.html +++ b/loko/controls/templates/controls/controls_detail_content.html @@ -435,7 +435,7 @@ {{ entry.get_status_display }} - ({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }}) + ({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }}) {% if entry.description %}
    {{ entry.description }}
    @@ -486,7 +486,7 @@ {{ remark.get_status_display }} - ({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }}) + ({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }}) {% if remark.description %}
    {{ remark.description }}
    diff --git a/loko/controls/templates/controls/controls_detail_mobile.html b/loko/controls/templates/controls/controls_detail_mobile.html index 2745859..4c601cd 100644 --- a/loko/controls/templates/controls/controls_detail_mobile.html +++ b/loko/controls/templates/controls/controls_detail_mobile.html @@ -467,7 +467,7 @@

    {% load short_name %} - {% translate 'Mise à jour par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }} + {% translate 'Mise à jour par' %} {{ remark.updated_by|short_name|default:"-" }} {% translate 'le' %} {{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}

    @@ -483,7 +483,7 @@ {{ entry.get_status_display }} - ({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }}) + ({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }}) {% if entry.description %}
    {{ entry.description }}
    @@ -534,7 +534,7 @@ {{ remark.get_status_display }} - ({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }}) + ({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }}) {% if remark.description %}
    {{ remark.description }}
    diff --git a/loko/controls/templates/controls/remark_form_content.html b/loko/controls/templates/controls/remark_form_content.html index 5e76196..98446d8 100644 --- a/loko/controls/templates/controls/remark_form_content.html +++ b/loko/controls/templates/controls/remark_form_content.html @@ -36,7 +36,7 @@ {{ entry.get_status_display }} - ({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }}) + ({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }}) {% if entry.description %}
    {{ entry.description }}
    diff --git a/loko/inspections/models.py b/loko/inspections/models.py index 1832b59..efc0b7d 100644 --- a/loko/inspections/models.py +++ b/loko/inspections/models.py @@ -244,6 +244,38 @@ class Inspection(models.Model): def is_cross_check(self): return self.checked_intervention_id is not None + @property + def related_intervention(self): + """ + Renvoie l'intervention liée à cette inspection si elle existe : + - checked_intervention (intervention de travaux contrôlée, ex: durabilité) + - mission_intervention (mission d'inspection) + - asset si la cible de l'inspection est une intervention + """ + if self.checked_intervention_id: + return self.checked_intervention + if self.mission_intervention_id: + return self.mission_intervention + if self.asset_content_type and self.asset_content_type.model == 'intervention': + return self.asset + return None + + @property + def related_intervention_url(self): + interv = self.related_intervention + if interv: + from django.urls import reverse + return reverse('interventions:intervention_detail', kwargs={'intervention_id': interv.id}) + return '' + + @property + def mobile_related_intervention_url(self): + interv = self.related_intervention + if interv: + from django.urls import reverse + return reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': interv.id}) + return '' + @property def is_cancelled(self): return self.result_status == 'cancelled' or self.status == 'cancelled' diff --git a/loko/inspections/static/inspections/inspections_index.js b/loko/inspections/static/inspections/inspections_index.js index 65988bc..4732bb6 100644 --- a/loko/inspections/static/inspections/inspections_index.js +++ b/loko/inspections/static/inspections/inspections_index.js @@ -88,6 +88,11 @@ document.addEventListener('DOMContentLoaded', () => { inspectionsgeojsonloader.loadGeojson(initialUrl); _updateExportExcelUrl(query); + + const urlInspectionId = new URLSearchParams(window.location.search).get('inspection_id'); + if (urlInspectionId) { + openInspectionDetailModal(parseInt(urlInspectionId, 10)); + } }); inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => { @@ -923,6 +928,37 @@ window.openInspectionDetailModal = function(inspectionId) { } } + // Intervention link + const intervBtnContainer = document.getElementById('detail-intervention-btn-container'); + const intervLink = document.getElementById('detail-intervention-link'); + const intervBtnText = document.getElementById('detail-intervention-btn-text'); + const intervRow = document.getElementById('detail-intervention-row'); + const intervRowLink = document.getElementById('detail-intervention-row-link'); + const intervCode = document.getElementById('detail-intervention-code'); + const intervBadge = document.getElementById('detail-intervention-badge'); + + if (data.intervention_url && data.intervention_code) { + if (intervLink) intervLink.href = data.intervention_url; + if (intervBtnText) intervBtnText.textContent = `${gettext("Intervention")} ${data.intervention_code}`; + if (intervBtnContainer) intervBtnContainer.classList.remove('d-none'); + if (intervRow) { + intervRow.classList.remove('d-none'); + if (intervRowLink) intervRowLink.href = data.intervention_url; + if (intervCode) intervCode.textContent = data.intervention_code; + if (intervBadge) { + if (data.intervention_subtype) { + intervBadge.textContent = data.intervention_subtype; + intervBadge.classList.remove('d-none'); + } else { + intervBadge.classList.add('d-none'); + } + } + } + } else { + if (intervBtnContainer) intervBtnContainer.classList.add('d-none'); + if (intervRow) intervRow.classList.add('d-none'); + } + // Key fields document.getElementById('detail-target-display').textContent = data.target_display || '-'; document.getElementById('detail-asset-type').textContent = data.asset_type_name || '-'; diff --git a/loko/inspections/templates/inspections/inspections_index.html b/loko/inspections/templates/inspections/inspections_index.html index 35f58f0..0a8321d 100644 --- a/loko/inspections/templates/inspections/inspections_index.html +++ b/loko/inspections/templates/inspections/inspections_index.html @@ -186,11 +186,13 @@
  • + {% if can_inspect %} + {% endif %}
@@ -270,10 +272,17 @@
-
- - {% translate "Consulter la fiche de l'asset" %} - +
    @@ -285,6 +294,15 @@ {% translate "Type d'asset" %} - +
  • + {% translate "Intervention liée" %} + + + + + + +
  • {% translate "Date & Heure" %} - diff --git a/loko/inspections/templates/inspections/inspections_list.html b/loko/inspections/templates/inspections/inspections_list.html index e4aad71..8208a06 100644 --- a/loko/inspections/templates/inspections/inspections_list.html +++ b/loko/inspections/templates/inspections/inspections_list.html @@ -163,7 +163,7 @@ {% endif %} {% if insp.inspector %} - {{ insp.inspector|short_name|default:insp.inspector.username }} + {{ insp.inspector|short_name }} {% endif %}
@@ -190,12 +190,23 @@ {% endif %} - - {% if insp.asset %} -
- - {% translate "Consulter la fiche de l'asset" %} - + + {% if insp.asset or insp.related_intervention %} +
+ {% if insp.asset %} + + {% endif %} + {% if insp.related_intervention %} + + {% endif %}
{% endif %} @@ -208,6 +219,19 @@ {% translate "Type d'asset" %} {{ insp.asset_content_type.name }} + {% if insp.related_intervention %} +
  • + {% translate "Intervention liée" %} + + + {{ insp.related_intervention.code }} + + {% if insp.related_intervention.sub_type %} + {{ insp.related_intervention.sub_type.get_name }} + {% endif %} + +
  • + {% endif %}
  • {% translate "Date & Heure" %} {{ insp.inspection_date|date:"d/m/Y H:i" }} @@ -222,7 +246,7 @@
  • {% translate "Inspecteur" %} - {{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }} + {{ insp.inspector|short_name|default:"-" }}
  • {% translate "Date de modification" %} @@ -477,7 +501,7 @@ document.addEventListener('DOMContentLoaded', function() { result: "{{ insp.get_result_status_display|escapejs }}", result_code: "{{ insp.result_status|escapejs }}", badge_class: "{{ insp.status_badge_class|escapejs }}", - inspector: "{{ insp.inspector|short_name|default:insp.inspector.username|escapejs }}", + inspector: "{{ insp.inspector|short_name|escapejs }}", nature: "{{ insp.get_nature_display|escapejs }}", summary: "{{ insp.details_summary|escapejs }}" }{% if not forloop.last %},{% endif %} diff --git a/loko/inspections/templates/inspections/inspections_list_mobile.html b/loko/inspections/templates/inspections/inspections_list_mobile.html index efdea91..2f9fe10 100644 --- a/loko/inspections/templates/inspections/inspections_list_mobile.html +++ b/loko/inspections/templates/inspections/inspections_list_mobile.html @@ -225,7 +225,7 @@ {% endif %} {% if insp.inspector %} - {{ insp.inspector|short_name|default:insp.inspector.username }} + {{ insp.inspector|short_name }} {% endif %}
  • {% endif %} @@ -253,12 +253,23 @@ {% endif %} - - {% if insp.asset %} -
    - - {% translate "Consulter la fiche de l'asset" %} - + + {% if insp.asset or insp.related_intervention %} +
    + {% if insp.asset %} + + {% endif %} + {% if insp.related_intervention %} + + {% endif %}
    {% endif %} @@ -271,6 +282,19 @@ {% translate "Type d'asset" %} {{ insp.asset_content_type.name }} + {% if insp.related_intervention %} +
  • + {% translate "Intervention liée" %} + + + {{ insp.related_intervention.code }} + + {% if insp.related_intervention.sub_type %} + {{ insp.related_intervention.sub_type.get_name }} + {% endif %} + +
  • + {% endif %} {% if insp.is_scheduled %}
  • {% translate "Statut" %} @@ -310,7 +334,7 @@
  • {% translate "Inspecteur" %} - {{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }} + {{ insp.inspector|short_name|default:"-" }}
  • {% translate "Date de modification" %} @@ -570,7 +594,7 @@ document.addEventListener('DOMContentLoaded', function() { result: "{{ insp.get_result_status_display|escapejs }}", result_code: "{{ insp.result_status|escapejs }}", badge_class: "{{ insp.status_badge_class|escapejs }}", - inspector: "{{ insp.inspector|short_name|default:insp.inspector.username|escapejs }}", + inspector: "{{ insp.inspector|short_name|escapejs }}", nature: "{{ insp.get_nature_display|escapejs }}", summary: "{{ insp.details_summary|escapejs }}" }{% if not forloop.last %},{% endif %} diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py index 64583a7..4d9866f 100644 --- a/loko/inspections/tests.py +++ b/loko/inspections/tests.py @@ -1492,7 +1492,7 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): ) from common.models import UserConfig, Role, UserThematics inspector_role, _ = Role.objects.get_or_create(name='inspector') - config = UserConfig.objects.create(user=self.user) + config = UserConfig.objects.create(user=self.user, is_intern=True) config.roles.add(inspector_role) self.roads_thematic, _ = Thematic.objects.get_or_create( @@ -1535,6 +1535,18 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): requires_post_inspection=False ) + def _create_finished_intervention(self, sub_type=None): + sub_type = sub_type or self.hotbox_subtype + return Intervention.objects.create( + thematic=self.roads_thematic, + type='repair', + sub_type=sub_type, + status='finished', + created_by=self.user, + assigned_team=self.team, + end_time=timezone.now() + ) + def test_hotbox_intervention_finished_triggers_inspection(self): from datetime import timedelta now = timezone.now() @@ -1773,5 +1785,69 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): self.assertNotIn(cat_mobility, form.fields['concerned_categories'].queryset) self.assertIn('data-cat-models', form.fields['target_asset_model'].widget.attrs) + def test_inspection_related_intervention_properties_and_api(self): + """Vérifie les propriétés related_intervention de l'inspection et les données renvoyées par get_inspection_detail_api.""" + from inspections.services import schedule_post_intervention_inspection + intervention = self._create_finished_intervention(sub_type=self.hotbox_subtype) + insp = schedule_post_intervention_inspection(intervention) + self.assertIsNotNone(insp) + + # Propriétés du modèle + self.assertEqual(insp.related_intervention, intervention) + expected_url = reverse('interventions:intervention_detail', kwargs={'intervention_id': intervention.id}) + expected_mobile_url = reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': intervention.id}) + self.assertEqual(insp.related_intervention_url, expected_url) + self.assertEqual(insp.mobile_related_intervention_url, expected_mobile_url) + + # API get_inspection_detail_api + self.client.force_login(self.user) + api_url = reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': insp.id}) + resp = self.client.get(api_url) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertTrue(data['success']) + self.assertEqual(data['intervention_id'], intervention.id) + self.assertEqual(data['intervention_code'], intervention.code) + self.assertEqual(data['intervention_url'], expected_url) + self.assertEqual(data['mobile_intervention_url'], expected_mobile_url) + + def test_intervention_related_inspections_and_detail_view(self): + """Vérifie que l'intervention expose ses inspections liées et que la vue détail les affiche dans le bloc Contrôles.""" + from inspections.services import schedule_post_intervention_inspection + intervention = self._create_finished_intervention(sub_type=self.hotbox_subtype) + insp = schedule_post_intervention_inspection(intervention) + self.assertIsNotNone(insp) + + # Méthode du modèle + related = intervention.get_related_inspections(user=self.user) + self.assertIn(insp, related) + + # Page détail de l'intervention + self.client.force_login(self.user) + detail_url = reverse('interventions:intervention_detail', kwargs={'intervention_id': intervention.id}) + resp = self.client.get(detail_url) + self.assertEqual(resp.status_code, 200) + self.assertIn('visible_inspections', resp.context) + self.assertIn(insp, resp.context['visible_inspections']) + # Vérifie la présence du lien vers l'inspection dans le HTML + self.assertContains(resp, f"inspection_id={insp.id}") + self.assertContains(resp, "Inspections de terrain / conformité") + + def test_intervention_detail_mobile_renders_linked_inspections(self): + """Vérifie que la vue mobile de l'intervention affiche aussi les inspections dans le bloc Contrôles.""" + from inspections.services import schedule_post_intervention_inspection + intervention = self._create_finished_intervention(sub_type=self.hotbox_subtype) + insp = schedule_post_intervention_inspection(intervention) + self.assertIsNotNone(insp) + + self.client.force_login(self.user) + mobile_url = reverse('interventions:intervention_detail_mobile', kwargs={'intervention_id': intervention.id}) + resp = self.client.get(mobile_url) + self.assertEqual(resp.status_code, 200) + self.assertIn('visible_inspections', resp.context) + self.assertIn(insp, resp.context['visible_inspections']) + self.assertContains(resp, f"inspection_id={insp.id}") + self.assertContains(resp, "Inspections de terrain") + diff --git a/loko/inspections/views.py b/loko/inspections/views.py index 80d0a30..2055bc1 100644 --- a/loko/inspections/views.py +++ b/loko/inspections/views.py @@ -864,6 +864,11 @@ def get_inspection_detail_api(request, inspection_id): '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 '', + 'intervention_id': leaf.related_intervention.id if leaf.related_intervention else None, + 'intervention_code': leaf.related_intervention.code if leaf.related_intervention else '', + 'intervention_url': leaf.related_intervention_url, + 'mobile_intervention_url': leaf.mobile_related_intervention_url, + 'intervention_subtype': leaf.related_intervention.sub_type.get_name() if leaf.related_intervention and getattr(leaf.related_intervention, 'sub_type', None) 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 '', @@ -1173,8 +1178,8 @@ def inspections_geojson(request): ct_ids.add(ContentType.objects.get_for_model(SignPole).id) elif thematic_code == 'parking': from assets.models import ParkingSpot, ParkingLocation - ct_ids.add(ContentType.objects.get_for_model(ParkingSpot)) - ct_ids.add(ContentType.objects.get_for_model(ParkingLocation)) + ct_ids.add(ContentType.objects.get_for_model(ParkingSpot).id) + ct_ids.add(ContentType.objects.get_for_model(ParkingLocation).id) except Thematic.DoesNotExist: pass diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 29cf51b..7e21850 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -849,6 +849,37 @@ class Intervention(models.Model): ), ] + def get_absolute_url(self): + from django.urls import reverse + return reverse('interventions:intervention_detail', kwargs={'intervention_id': self.pk}) + + def get_related_inspections(self, user=None): + """ + Retourne l'ensemble des inspections liées à cette intervention : + - quality_inspections (checked_intervention, ex: durabilité post-travaux) + - generated_inspections (mission_intervention, ex: inspection de contrôle) + - inspections ciblant directement cette intervention + """ + from inspections.models import Inspection + from django.contrib.contenttypes.models import ContentType + ct = ContentType.objects.get_for_model(self.__class__) + qs = Inspection.objects.filter( + models.Q(checked_intervention=self) | + models.Q(mission_intervention=self) | + (models.Q(asset_content_type=ct) & models.Q(asset_object_id=self.id)) + ).distinct().select_related( + 'asset_content_type', 'inspector', 'checked_intervention', 'mission_intervention' + ).prefetch_related('documents').order_by('-inspection_date', '-created_at') + + if user: + from inspections.permissions import can_view_inspection + return [insp for insp in qs if can_view_inspection(user, insp)] + return list(qs) + + @property + def related_inspections(self): + return self.get_related_inspections() + @property def intervention_sheet_filename(self): if self.intervention_sheet: diff --git a/loko/interventions/templates/interventions/detail/card_controles.html b/loko/interventions/templates/interventions/detail/card_controles.html index f8de10d..c8d5af0 100644 --- a/loko/interventions/templates/interventions/detail/card_controles.html +++ b/loko/interventions/templates/interventions/detail/card_controles.html @@ -1,5 +1,5 @@ -{% load i18n %} -
    +{% load i18n short_name %} +

    {% translate "Contrôles / Rapports de suivi" %}

    {% if 'follow_up_reports' in allowed_update_fields %} @@ -43,7 +43,11 @@
    {% endif %} - + {% if visible_controls %} +
    + {% translate "Rapports de contrôle" %} +
    +
    @@ -63,9 +67,188 @@ {% endfor %}
    {% translate "Date" %}
    + {% elif not visible_inspections %} +

    {% translate "Aucun contrôle ou rapport pour le moment." %}

    + {% endif %} + + {% if visible_inspections %} +
    +
    +
    + {% translate "Inspections de terrain / conformité" %} + {{ visible_inspections|length }} +
    +
    +
    + + + + + + + + + + + + + + {% for insp in visible_inspections %} + + + + + + + + + + {% endfor %} + +
    {% translate "Inspection" %}{% translate "Date / Échéance" %}{% translate "Nature" %}{% translate "Cible" %}{% translate "Inspecteur" %}{% translate "Statut / Résultat" %}{% translate "Action" %}
    + + #{{ insp.id }} + + + {{ insp.inspection_date|date:"d/m/Y H:i"|default:"-" }} + {% if insp.is_scheduled and insp.scheduled_start_date %} +
    + {% translate "Prévue du" %} {{ insp.scheduled_start_date|date:"d/m/Y" }} {% translate "au" %} {{ insp.scheduled_due_date|date:"d/m/Y" }} +
    + {% endif %} +
    + {{ insp.get_nature_display }} + + {% if insp.asset and insp.asset.get_absolute_url %} + + {{ insp.target_display }} + + {% else %} + {{ insp.target_display }} + {% endif %} + + {{ insp.inspector|short_name|default:"-" }} + + + {{ insp.get_result_status_display }} + + {% if insp.is_scheduled %} + {% translate "Planifiée" %} + {% endif %} + + + + + +
    +
    +
    + {% endif %}
    +{% for insp in visible_inspections %} + + +{% endfor %} + diff --git a/loko/interventions/templates/interventions/intervention_detail_mobile.html b/loko/interventions/templates/interventions/intervention_detail_mobile.html index 78d2940..23320bb 100644 --- a/loko/interventions/templates/interventions/intervention_detail_mobile.html +++ b/loko/interventions/templates/interventions/intervention_detail_mobile.html @@ -1398,9 +1398,42 @@ {% endfor %} - {% else %} + {% elif not visible_inspections %}

    {% translate "Aucun contrôle pour le moment." %}

    {% endif %} + + {% if visible_inspections %} +
    +
    + {% translate "Inspections de terrain" %} ({{ visible_inspections|length }}) +
    + {% for insp in visible_inspections %} +
    +
    + + {% translate "Inspection" %} #{{ insp.id }} + + + {{ insp.get_result_status_display }} + +
    +
    + {{ insp.get_nature_display }} + {% if insp.is_scheduled %} + {% translate "Planifiée" %} + {% endif %} +
    +
    + {{ insp.target_display }} +
    +
    + {{ insp.inspection_date|date:"d/m/Y H:i"|default:"-" }} + {{ insp.inspector|short_name|default:"-" }} +
    +
    + {% endfor %} +
    + {% endif %}
  • {% endif %} diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index f10c319..6af23cb 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -502,6 +502,7 @@ def intervention_detail(request, intervention_id): control for control in intervention.related_controls.all() if user_can_view_control(request.user, control) ] + visible_inspections = intervention.get_related_inspections(user=request.user) # Bons de préparation liés à l'intervention: # - admin @@ -741,6 +742,7 @@ def intervention_detail(request, intervention_id): 'can_take_over': bool(contract and user_contract_access), # Contrôles visibles par l'utilisateur 'visible_controls': visible_controls, + 'visible_inspections': visible_inspections, # Équipes filtrées par thématique 'available_teams': available_teams, # Permission de voir les messages de correction diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 96e5a19..e5b8a22 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -912,6 +912,7 @@ def intervention_detail_mobile(request, intervention_id): control for control in intervention.related_controls.all() if user_can_view_control(request.user, control) ] + visible_inspections = intervention.get_related_inspections(user=request.user) # Historique des interventions liées aux mêmes localisations (3 derniers mois) location_history_interventions = [] @@ -1151,6 +1152,7 @@ def intervention_detail_mobile(request, intervention_id): 'is_restricted_team': is_restricted_team, # Contrôles visibles par l'utilisateur 'visible_controls': visible_controls, + 'visible_inspections': visible_inspections, 'tl_intersection_ids': tl_intersection_ids, # --- Timeline mobile --- 'status_choices': [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], From 010466e26f7d1ae752b1947fec19614fec2fe038 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 18 Sep 2026 12:02:05 +0200 Subject: [PATCH 07/45] feat: add HotBoxInspection model, views, templates, and admin configuration --- loko/inspections/admin.py | 19 +- .../migrations/0010_hotboxinspection.py | 29 +++ loko/inspections/models.py | 122 ++++++++++- loko/inspections/registry.py | 6 +- loko/inspections/services.py | 7 +- .../components/asset_details/_hotbox.html | 113 ++++++++++ .../components/asset_fields/_hotbox.html | 182 ++++++++++++++++ .../components/edit_inspection_modal.html | 198 ++++++++++++++++++ loko/inspections/tests.py | 117 ++++++++++- loko/inspections/views.py | 58 ++++- 10 files changed, 845 insertions(+), 6 deletions(-) create mode 100644 loko/inspections/migrations/0010_hotboxinspection.py create mode 100644 loko/inspections/templates/inspections/components/asset_details/_hotbox.html create mode 100644 loko/inspections/templates/inspections/components/asset_fields/_hotbox.html diff --git a/loko/inspections/admin.py b/loko/inspections/admin.py index 68a764e..ecad04a 100644 --- a/loko/inspections/admin.py +++ b/loko/inspections/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.contrib.gis import admin as gis_admin -from .models import Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, InspectionDocument +from .models import Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, HotBoxInspection, InspectionDocument @admin.register(InspectionZone) @@ -83,6 +83,23 @@ class GiepInspectionAdmin(admin.ModelAdmin): actions = [make_cancelled, make_compliant] +@admin.register(HotBoxInspection) +class HotBoxInspectionAdmin(admin.ModelAdmin): + list_display = ( + "id", "asset", "inspector", "inspection_date", "result_status", + "joint_level", "joint_sealing", "cracking", "water_drainage", + "checked_intervention" + ) + list_filter = ( + "result_status", "joint_level", "joint_sealing", + "cracking", "water_drainage", "inspection_date" + ) + search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name", "checked_intervention__code") + raw_id_fields = ("inspector", "mission_intervention", "checked_intervention", "zone") + inlines = [InspectionDocumentInline] + actions = [make_cancelled, make_compliant] + + @admin.register(InspectionDocument) class InspectionDocumentAdmin(admin.ModelAdmin): list_display = ("id", "inspection", "field_key", "filename", "created_at") diff --git a/loko/inspections/migrations/0010_hotboxinspection.py b/loko/inspections/migrations/0010_hotboxinspection.py new file mode 100644 index 0000000..8bca0fb --- /dev/null +++ b/loko/inspections/migrations/0010_hotboxinspection.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.7 on 2026-09-18 09:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inspections', '0009_inspection_non_compliance_notified_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='HotBoxInspection', + fields=[ + ('inspection_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='inspections.inspection')), + ('joint_level', models.CharField(blank=True, choices=[('flush', 'Bien à niveau / Arasé'), ('slight_bump_hollow', 'Léger décalage / Creux ou bosse'), ('step', 'Ressaut important (> 1-2 cm)')], default='flush', max_length=50, null=True, verbose_name='Planéité / Arasement')), + ('joint_sealing', models.CharField(blank=True, choices=[('good', 'Joints bien fermés / scellés'), ('cracked', 'Joints fissurés ou abîmés'), ('open', 'Joints ouverts ou décollés')], default='good', max_length=50, null=True, verbose_name='Joints de raccordement')), + ('cracking', models.CharField(blank=True, choices=[('none', 'Aucune fissure'), ('minor', 'Petites fissures / Faïençage'), ('major', 'Grosses fissures / Fissures ouvertes')], default='none', max_length=50, null=True, verbose_name='Fissuration')), + ('water_drainage', models.CharField(blank=True, choices=[('good', 'Bon écoulement / Pas de flaque'), ('stagnation', "Eau stagnante / Flaque d'eau")], default='good', max_length=50, null=True, verbose_name='Évacuation des eaux pluviales')), + ], + options={ + 'verbose_name': 'Inspection de réparation Hot Box', + 'verbose_name_plural': 'Inspections de réparations Hot Box', + }, + bases=('inspections.inspection',), + ), + ] diff --git a/loko/inspections/models.py b/loko/inspections/models.py index efc0b7d..69c76df 100644 --- a/loko/inspections/models.py +++ b/loko/inspections/models.py @@ -567,15 +567,39 @@ class Inspection(models.Model): return th.icon return 'bi-geo-alt' + @property + def is_hotbox_related(self): + """Détermine si l'inspection concerne une réparation Hot Box (asphalte à chaud).""" + if hasattr(self, 'hotboxinspection'): + return True + interv = self.related_intervention + if interv and getattr(interv, 'sub_type', None): + st = interv.sub_type + code = (st.code or '').lower() + name = (st.name_fr or '').lower() + if any(k in code or k in name for k in ('hotbox', 'hot_box', 'hot box')): + return True + if self.nature == 'post_intervention' and 'hot box' in (self.notes or '').lower(): + return True + return False + @property def detailed_inspection(self): - """Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection, GiepInspection, SignPanelInspection).""" + """Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection, GiepInspection, SignPanelInspection, HotBoxInspection).""" if hasattr(self, 'parkingspotinspection'): return self.parkingspotinspection if hasattr(self, 'giepinspection'): return self.giepinspection if hasattr(self, 'signpanelinspection'): return self.signpanelinspection + if hasattr(self, 'hotboxinspection'): + return self.hotboxinspection + if self.is_hotbox_related and self.pk: + try: + hb, _ = HotBoxInspection.objects.get_or_create(inspection_ptr=self) + return hb + except Exception: + pass return self @property @@ -615,6 +639,17 @@ class Inspection(models.Model): if getattr(leaf, 'date_derniere_pluie', None) and leaf.date_derniere_pluie != 'null': parts.append(str(_("Pluie : %s") % leaf.get_date_derniere_pluie_display())) + # Hot Box summary + if hasattr(leaf, 'joint_level') and hasattr(leaf, 'joint_sealing'): + if leaf.joint_level: + parts.append(f"{_('Planéité')} : {leaf.get_joint_level_display()}") + if leaf.joint_sealing: + parts.append(f"{_('Joints')} : {leaf.get_joint_sealing_display()}") + if leaf.cracking: + parts.append(f"{_('Fissuration')} : {leaf.get_cracking_display()}") + if leaf.water_drainage: + parts.append(f"{_('Évacuation')} : {leaf.get_water_drainage_display()}") + if leaf.verified_obsolescence: parts.append(str(_("Obsolescence : %s") % leaf.verified_obsolescence)) @@ -1010,6 +1045,91 @@ class SignPanelInspection(Inspection): target_asset.save() +class HotBoxInspection(Inspection): + """ + Spécialisation de l'inspection pour les réparations d'enrobé à chaud (Hot Box). + Formulaire visuel simplifié adapté aux observateurs non experts : + - Planéité (Arasement) + - Joints (Raccordement) + - Fissuration + - Évacuation des eaux pluviales + """ + JOINT_LEVEL_CHOICES = ( + ('flush', _("Bien à niveau / Arasé")), + ('slight_bump_hollow', _("Léger décalage / Creux ou bosse")), + ('step', _("Ressaut important (> 1-2 cm)")), + ) + joint_level = models.CharField( + max_length=50, + choices=JOINT_LEVEL_CHOICES, + default='flush', + null=True, + blank=True, + verbose_name=_("Planéité / Arasement") + ) + + JOINT_SEALING_CHOICES = ( + ('good', _("Joints bien fermés / scellés")), + ('cracked', _("Joints fissurés ou abîmés")), + ('open', _("Joints ouverts ou décollés")), + ) + joint_sealing = models.CharField( + max_length=50, + choices=JOINT_SEALING_CHOICES, + default='good', + null=True, + blank=True, + verbose_name=_("Joints de raccordement") + ) + + CRACKING_CHOICES = ( + ('none', _("Aucune fissure")), + ('minor', _("Petites fissures / Faïençage")), + ('major', _("Grosses fissures / Fissures ouvertes")), + ) + cracking = models.CharField( + max_length=50, + choices=CRACKING_CHOICES, + default='none', + null=True, + blank=True, + verbose_name=_("Fissuration") + ) + + WATER_DRAINAGE_CHOICES = ( + ('good', _("Bon écoulement / Pas de flaque")), + ('stagnation', _("Eau stagnante / Flaque d'eau")), + ) + water_drainage = models.CharField( + max_length=50, + choices=WATER_DRAINAGE_CHOICES, + default='good', + null=True, + blank=True, + verbose_name=_("Évacuation des eaux pluviales") + ) + + class Meta: + verbose_name = _("Inspection de réparation Hot Box") + verbose_name_plural = _("Inspections de réparations Hot Box") + + def compute_status(self): + """ + Calcule automatiquement le statut de conformité ('compliant', 'non_compliant', 'damaged') + selon la sévérité des constats visuels. + """ + if self.joint_level == 'step' or self.joint_sealing == 'open' or self.cracking == 'major': + return 'damaged' + if ( + self.joint_level == 'slight_bump_hollow' or + self.joint_sealing == 'cracked' or + self.cracking == 'minor' or + self.water_drainage == 'stagnation' + ): + return 'non_compliant' + return 'compliant' + + def inspection_document_upload_path(instance, filename): now = timezone.now() year = now.strftime("%Y") diff --git a/loko/inspections/registry.py b/loko/inspections/registry.py index 66739ca..17ea211 100644 --- a/loko/inspections/registry.py +++ b/loko/inspections/registry.py @@ -38,7 +38,9 @@ def get_inspection_detail_partial_template(insp_or_model_or_name): if not insp_or_model_or_name: return None - if hasattr(insp_or_model_or_name, 'asset') and insp_or_model_or_name.asset: + if hasattr(insp_or_model_or_name, 'is_hotbox_related') and insp_or_model_or_name.is_hotbox_related: + model_name = 'hotbox' + elif hasattr(insp_or_model_or_name, 'asset') and insp_or_model_or_name.asset: model_name = insp_or_model_or_name.asset._meta.model_name.lower() elif hasattr(insp_or_model_or_name, '_meta'): model_name = insp_or_model_or_name._meta.model_name.lower() @@ -51,6 +53,8 @@ def get_inspection_detail_partial_template(insp_or_model_or_name): model_name = 'parkingspot' elif model_name in ('signpanel', 'signpanelinspection'): model_name = 'signpanel' + elif model_name in ('hotbox', 'hotboxinspection'): + model_name = 'hotbox' template_path = f"inspections/components/asset_details/_{model_name}.html" try: diff --git a/loko/inspections/services.py b/loko/inspections/services.py index 8a13218..4438525 100644 --- a/loko/inspections/services.py +++ b/loko/inspections/services.py @@ -204,9 +204,14 @@ def schedule_post_intervention_inspection(intervention): if len(zone_inspectors) == 1: assigned_inspector = zone_inspectors[0] + from .models import Inspection, InspectionZone, HotBoxInspection + + is_hotbox = any(k in (sub_type.code or '').lower() or k in (sub_type.name_fr or '').lower() for k in ('hotbox', 'hot_box', 'hot box')) + inspection_cls = HotBoxInspection if is_hotbox else Inspection + # 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( + inspection = inspection_cls.objects.create( asset_content_type=target_ct, asset_object_id=target_id, checked_intervention=intervention, diff --git a/loko/inspections/templates/inspections/components/asset_details/_hotbox.html b/loko/inspections/templates/inspections/components/asset_details/_hotbox.html new file mode 100644 index 0000000..6f5049f --- /dev/null +++ b/loko/inspections/templates/inspections/components/asset_details/_hotbox.html @@ -0,0 +1,113 @@ +{% load i18n %} + +{% with leaf=insp.detailed_inspection %} +{% if leaf %} +
    +
    + {% translate "Contrôles visuels Hot Box (Enrobé à chaud)" %} +
    + +
      + + {% if leaf.joint_level %} +
    • + + {% translate "Planéité / Arasement" %} + + + {% if leaf.joint_level == 'flush' %} + + {% translate "Bien à niveau / Arasé" %} + + {% elif leaf.joint_level == 'slight_bump_hollow' %} + + {% translate "Léger décalage / Creux ou bosse" %} + + {% elif leaf.joint_level == 'step' %} + + {% translate "Ressaut important (> 1-2 cm)" %} + + {% else %} + {{ leaf.get_joint_level_display|default:leaf.joint_level }} + {% endif %} + +
    • + {% endif %} + + + {% if leaf.joint_sealing %} +
    • + + {% translate "Joints de raccordement" %} + + + {% if leaf.joint_sealing == 'good' %} + + {% translate "Joints bien fermés / scellés" %} + + {% elif leaf.joint_sealing == 'cracked' %} + + {% translate "Joints fissurés ou abîmés" %} + + {% elif leaf.joint_sealing == 'open' %} + + {% translate "Joints ouverts ou décollés" %} + + {% else %} + {{ leaf.get_joint_sealing_display|default:leaf.joint_sealing }} + {% endif %} + +
    • + {% endif %} + + + {% if leaf.cracking %} +
    • + + {% translate "Fissuration" %} + + + {% if leaf.cracking == 'none' %} + + {% translate "Aucune fissure" %} + + {% elif leaf.cracking == 'minor' %} + + {% translate "Petites fissures / Faïençage" %} + + {% elif leaf.cracking == 'major' %} + + {% translate "Grosses fissures / Fissures ouvertes" %} + + {% else %} + {{ leaf.get_cracking_display|default:leaf.cracking }} + {% endif %} + +
    • + {% endif %} + + + {% if leaf.water_drainage %} +
    • + + {% translate "Évacuation des eaux pluviales" %} + + + {% if leaf.water_drainage == 'good' %} + + {% translate "Bon écoulement / Pas de flaque" %} + + {% elif leaf.water_drainage == 'stagnation' %} + + {% translate "Eau stagnante / Flaque d'eau" %} + + {% else %} + {{ leaf.get_water_drainage_display|default:leaf.water_drainage }} + {% endif %} + +
    • + {% endif %} +
    +
    +{% endif %} +{% endwith %} diff --git a/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html b/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html new file mode 100644 index 0000000..6715b47 --- /dev/null +++ b/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html @@ -0,0 +1,182 @@ +{% load i18n %} + + +
    +
    + + {% translate "Contrôle visuel de réparation Hot Box" %} + + + {% translate "Suggéré : Conforme" %} + +
    + +

    + + {% translate "Évaluez visuellement la zone réparée sur les 4 critères simples ci-dessous :" %} +

    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + diff --git a/loko/inspections/templates/inspections/components/edit_inspection_modal.html b/loko/inspections/templates/inspections/components/edit_inspection_modal.html index 7794144..91cffa2 100644 --- a/loko/inspections/templates/inspections/components/edit_inspection_modal.html +++ b/loko/inspections/templates/inspections/components/edit_inspection_modal.html @@ -143,6 +143,135 @@ {% include "inspections/components/asset_fields/_naturerwiz.html" %} + +
    +
    +
    + {% translate "Contrôles visuels Hot Box" %} +
    + + {% translate "Suggéré : Conforme" %} + +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    @@ -630,6 +759,34 @@ document.addEventListener('DOMContentLoaded', function() { if (giepFieldsContainer) giepFieldsContainer.classList.add('d-none'); } + // Affichage et préremplissage des champs spécifiques Hot Box (HotBoxInspection) + const hotboxFieldsContainer = document.getElementById('editHotBoxSpecificFields'); + if (data.is_hotbox) { + if (hotboxFieldsContainer) hotboxFieldsContainer.classList.remove('d-none'); + const jl = data.joint_level || 'flush'; + const js = data.joint_sealing || 'good'; + const cr = data.cracking || 'none'; + const wd = data.water_drainage || 'good'; + + const radLevel = document.getElementById('edit_hb_level_' + (jl === 'slight_bump_hollow' ? 'slight' : jl)); + if (radLevel) radLevel.checked = true; + + const radSealing = document.getElementById('edit_hb_sealing_' + js); + if (radSealing) radSealing.checked = true; + + const radCracking = document.getElementById('edit_hb_cracking_' + cr); + if (radCracking) radCracking.checked = true; + + const radDrainage = document.getElementById('edit_hb_drainage_' + wd); + if (radDrainage) radDrainage.checked = true; + + if (typeof updateEditHotBoxRecommendation === 'function') { + updateEditHotBoxRecommendation(false); + } + } else { + if (hotboxFieldsContainer) hotboxFieldsContainer.classList.add('d-none'); + } + // Rendu des photos / documents existants const existingDocsContainer = document.getElementById('editInspectionExistingDocsContainer'); @@ -678,6 +835,47 @@ document.addEventListener('DOMContentLoaded', function() { }); }; + function updateEditHotBoxRecommendation(syncRadio = true) { + const level = document.querySelector('input[name="joint_level"]:checked')?.value || 'flush'; + const sealing = document.querySelector('input[name="joint_sealing"]:checked')?.value || 'good'; + const cracking = document.querySelector('input[name="cracking"]:checked')?.value || 'none'; + const drainage = document.querySelector('input[name="water_drainage"]:checked')?.value || 'good'; + + let recommended = 'compliant'; + let badgeText = '{% translate "Suggéré : Conforme" %}'; + let badgeClass = 'bg-success-subtle text-success border border-success-subtle'; + let icon = 'bi-check-circle-fill'; + + if (level === 'step' || sealing === 'open' || cracking === 'major') { + recommended = 'damaged'; + badgeText = '{% translate "Suggéré : Dégradé" %}'; + badgeClass = 'bg-danger-subtle text-danger border border-danger-subtle'; + icon = 'bi-exclamation-triangle-fill'; + } else if (level === 'slight_bump_hollow' || sealing === 'cracked' || cracking === 'minor' || drainage === 'stagnation') { + recommended = 'non_compliant'; + badgeText = '{% translate "Suggéré : Non conforme" %}'; + badgeClass = 'bg-warning-subtle text-warning-emphasis border border-warning-subtle'; + icon = 'bi-exclamation-circle-fill'; + } + + const badge = document.getElementById('editHotboxRecommendedBadge'); + if (badge) { + badge.className = `badge ${badgeClass} px-2.5 py-1 rounded-pill small`; + badge.innerHTML = `${badgeText}`; + } + + if (syncRadio) { + const rad = document.getElementById('edit_res_' + recommended); + if (rad) { + rad.checked = true; + } + } + } + + document.querySelectorAll('.edit-hotbox-input').forEach(input => { + input.addEventListener('change', () => updateEditHotBoxRecommendation(true)); + }); + window.deleteInspectionDoc = function(docId) { if (!docId) return; if (!confirm("{% translate 'Êtes-vous sûr de vouloir supprimer ce document ?' %}")) return; diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py index 4d9866f..eb267a3 100644 --- a/loko/inspections/tests.py +++ b/loko/inspections/tests.py @@ -7,7 +7,7 @@ from django.utils import timezone from assets.models import NatureRWIZ, NatureRWIAsset, ParkingSpot from interventions.models import Intervention from common.models import Thematic -from .models import Inspection, ParkingSpotInspection, GiepInspection, InspectionDocument +from .models import Inspection, ParkingSpotInspection, GiepInspection, HotBoxInspection, InspectionDocument from .registry import get_inspection_partial_template User = get_user_model() @@ -1849,5 +1849,120 @@ class PostInterventionDurabilityInspectionTestCase(TestCase): self.assertContains(resp, f"inspection_id={insp.id}") self.assertContains(resp, "Inspections de terrain") + def test_hotbox_inspection_model_and_compute_status(self): + """Vérifie le modèle HotBoxInspection, le calcul de statut automatique et details_summary.""" + intervention = self._create_finished_intervention() + interv_ct = ContentType.objects.get_for_model(Intervention) + hb = HotBoxInspection.objects.create( + asset_content_type=interv_ct, + asset_object_id=intervention.pk, + inspector=self.user, + inspection_date=timezone.now(), + joint_level='flush', + joint_sealing='good', + cracking='none', + water_drainage='good', + ) + self.assertEqual(hb.compute_status(), 'compliant') + self.assertTrue(hb.is_hotbox_related) + self.assertEqual(hb.detailed_inspection, hb) + + # Vérification details_summary + summary = hb.details_summary + self.assertIn("Planéité", summary) + self.assertIn("Joints", summary) + self.assertIn("Fissuration", summary) + self.assertIn("Évacuation", summary) + + # Problème mineur -> non_compliant + hb.water_drainage = 'stagnation' + self.assertEqual(hb.compute_status(), 'non_compliant') + hb.water_drainage = 'good' + hb.cracking = 'minor' + self.assertEqual(hb.compute_status(), 'non_compliant') + + # Problème majeur -> damaged + hb.cracking = 'major' + self.assertEqual(hb.compute_status(), 'damaged') + hb.cracking = 'none' + hb.joint_level = 'step' + self.assertEqual(hb.compute_status(), 'damaged') + hb.joint_level = 'flush' + hb.joint_sealing = 'open' + self.assertEqual(hb.compute_status(), 'damaged') + + def test_hotbox_inspection_apis(self): + """Vérifie la création, la lecture des détails et la mise à jour via les endpoints API.""" + self.client.force_login(self.user) + intervention = self._create_finished_intervention() + interv_ct = ContentType.objects.get_for_model(Intervention) + + # 1. Création via create_inspection_api + create_url = reverse('inspections:create_inspection_api') + post_data = { + 'content_type_id': interv_ct.id, + 'object_id': intervention.pk, + 'result_status': 'compliant', + 'nature': 'post_intervention', + 'is_hotbox': 'true', + 'joint_level': 'flush', + 'joint_sealing': 'good', + 'cracking': 'minor', + 'water_drainage': 'good', + 'notes': 'Contrôle réparation enrobé', + } + resp = self.client.post(create_url, post_data) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertTrue(data['success']) + insp_id = data['inspection_id'] + + # Vérifier que l'instance créée est bien un HotBoxInspection + hb_obj = HotBoxInspection.objects.get(pk=insp_id) + self.assertEqual(hb_obj.joint_level, 'flush') + self.assertEqual(hb_obj.cracking, 'minor') + self.assertEqual(hb_obj.water_drainage, 'good') + + # 2. Détail via get_inspection_detail_api + detail_url = reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': insp_id}) + resp_detail = self.client.get(detail_url) + self.assertEqual(resp_detail.status_code, 200) + detail_data = resp_detail.json() + self.assertTrue(detail_data['success']) + self.assertTrue(detail_data.get('is_hotbox')) + self.assertEqual(detail_data['joint_level'], 'flush') + self.assertEqual(detail_data['cracking'], 'minor') + self.assertIn("Contrôles visuels Hot Box", detail_data.get('details_html', '')) + + # 3. Mise à jour via update_inspection_api + update_url = reverse('inspections:update_inspection_api', kwargs={'inspection_id': insp_id}) + update_data = { + 'result_status': 'non_compliant', + 'joint_level': 'slight_bump_hollow', + 'joint_sealing': 'cracked', + 'cracking': 'minor', + 'water_drainage': 'stagnation', + 'notes': 'Flaque résiduelle observée après pluie', + } + resp_update = self.client.post(update_url, update_data) + self.assertEqual(resp_update.status_code, 200) + self.assertTrue(resp_update.json()['success']) + + hb_obj.refresh_from_db() + self.assertEqual(hb_obj.result_status, 'non_compliant') + self.assertEqual(hb_obj.joint_level, 'slight_bump_hollow') + self.assertEqual(hb_obj.joint_sealing, 'cracked') + self.assertEqual(hb_obj.water_drainage, 'stagnation') + self.assertEqual(hb_obj.notes, 'Flaque résiduelle observée après pluie') + + def test_schedule_post_intervention_inspection_creates_hotbox_inspection(self): + """Vérifie que schedule_post_intervention_inspection pour un sous-type Hot Box instancie directement un HotBoxInspection.""" + from inspections.services import schedule_post_intervention_inspection + intervention = self._create_finished_intervention(sub_type=self.hotbox_subtype) + insp = schedule_post_intervention_inspection(intervention) + self.assertIsNotNone(insp) + self.assertIsInstance(insp.detailed_inspection, HotBoxInspection) + self.assertTrue(insp.is_hotbox_related) + diff --git a/loko/inspections/views.py b/loko/inspections/views.py index 2055bc1..8a02273 100644 --- a/loko/inspections/views.py +++ b/loko/inspections/views.py @@ -12,7 +12,7 @@ from common.models import Thematic from common.utils import get_short_name from assets.models import AssetCategory from .models import ( - Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument, + Inspection, InspectionZone, ParkingSpotInspection, GiepInspection, SignPanelInspection, HotBoxInspection, InspectionDocument, INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES ) @@ -579,6 +579,18 @@ def create_inspection_api(request): is_parking_spot = (content_type.model == 'parkingspot') is_giep = (content_type.model == 'naturerwiz') is_signpanel = (content_type.model == 'signpanel') + is_hotbox = ( + request.POST.get('is_hotbox') == 'true' or + request.POST.get('inspection_type') == 'hotbox' or + content_type.model in ('hotbox', 'hotboxinspection') + ) + if not is_hotbox and checked_intervention_id: + from interventions.models import Intervention + interv = Intervention.objects.filter(pk=checked_intervention_id).first() + if interv and getattr(interv, 'sub_type', None): + st = interv.sub_type + if any(k in (st.code or '').lower() or k in (st.name_fr or '').lower() for k in ('hotbox', 'hot_box', 'hot box')): + is_hotbox = True if is_giep and result_status == 'missing': return JsonResponse({ @@ -715,6 +727,30 @@ def create_inspection_api(request): ai_confidence=ai_conf, apply_sign_updates_to_asset=apply_changes, ) + elif is_hotbox: + joint_level = request.POST.get('joint_level') or 'flush' + joint_sealing = request.POST.get('joint_sealing') or 'good' + cracking = request.POST.get('cracking') or 'none' + water_drainage = request.POST.get('water_drainage') or 'good' + + inspection = HotBoxInspection( + asset_content_type=content_type, + asset_object_id=target_obj.pk, + inspector=request.user, + inspection_date=timezone.now(), + result_status=result_status, + nature=nature, + inspector_role=inspector_role, + mission_intervention_id=mission_intervention_id, + checked_intervention_id=checked_intervention_id, + verified_obsolescence=verified_obsolescence, + notes=notes, + apply_changes_to_asset=apply_changes, + joint_level=joint_level, + joint_sealing=joint_sealing, + cracking=cracking, + water_drainage=water_drainage, + ) inspection.save() else: inspection = Inspection.objects.create( @@ -916,6 +952,17 @@ def get_inspection_detail_api(request, inspection_id): data['ai_confidence'] = leaf.ai_confidence data['apply_sign_updates_to_asset'] = leaf.apply_sign_updates_to_asset + elif isinstance(leaf, HotBoxInspection) or getattr(leaf, 'is_hotbox_related', False): + data['is_hotbox'] = True + data['joint_level'] = getattr(leaf, 'joint_level', 'flush') + data['joint_level_display'] = str(leaf.get_joint_level_display()) if hasattr(leaf, 'get_joint_level_display') else '' + data['joint_sealing'] = getattr(leaf, 'joint_sealing', 'good') + data['joint_sealing_display'] = str(leaf.get_joint_sealing_display()) if hasattr(leaf, 'get_joint_sealing_display') else '' + data['cracking'] = getattr(leaf, 'cracking', 'none') + data['cracking_display'] = str(leaf.get_cracking_display()) if hasattr(leaf, 'get_cracking_display') else '' + data['water_drainage'] = getattr(leaf, 'water_drainage', 'good') + data['water_drainage_display'] = str(leaf.get_water_drainage_display()) if hasattr(leaf, 'get_water_drainage_display') else '' + return JsonResponse(data) @@ -1015,6 +1062,15 @@ def update_inspection_api(request, inspection_id): inspection.verified_cleanliness = request.POST.get('verified_cleanliness') if 'apply_sign_updates_to_asset' in request.POST: inspection.apply_sign_updates_to_asset = (request.POST.get('apply_sign_updates_to_asset') == 'true') + elif isinstance(inspection, HotBoxInspection): + if 'joint_level' in request.POST: + inspection.joint_level = request.POST['joint_level'] + if 'joint_sealing' in request.POST: + inspection.joint_sealing = request.POST['joint_sealing'] + if 'cracking' in request.POST: + inspection.cracking = request.POST['cracking'] + if 'water_drainage' in request.POST: + inspection.water_drainage = request.POST['water_drainage'] # Traitement des nouvelles pièces jointes / photos uploaded_files = request.FILES.getlist('photos') + request.FILES.getlist('documents') + request.FILES.getlist('files') From ecb3bbddd1ccf92c928c6b95ea810b2b8cea7a9a Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 18 Sep 2026 13:46:53 +0200 Subject: [PATCH 08/45] feat: require all four hotbox inspection questions to be answered and update validation logic --- ...lter_hotboxinspection_cracking_and_more.py | 33 +++++ loko/inspections/models.py | 21 ++- .../components/asset_fields/_hotbox.html | 42 +++--- .../components/edit_inspection_modal.html | 121 +++++++++++++----- loko/inspections/tests.py | 42 ++++-- loko/inspections/views.py | 51 +++++--- 6 files changed, 232 insertions(+), 78 deletions(-) create mode 100644 loko/inspections/migrations/0011_alter_hotboxinspection_cracking_and_more.py diff --git a/loko/inspections/migrations/0011_alter_hotboxinspection_cracking_and_more.py b/loko/inspections/migrations/0011_alter_hotboxinspection_cracking_and_more.py new file mode 100644 index 0000000..4ab8543 --- /dev/null +++ b/loko/inspections/migrations/0011_alter_hotboxinspection_cracking_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.7 on 2026-09-18 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inspections', '0010_hotboxinspection'), + ] + + operations = [ + migrations.AlterField( + model_name='hotboxinspection', + name='cracking', + field=models.CharField(blank=True, choices=[('none', 'Aucune fissure'), ('minor', 'Petites fissures / Faïençage'), ('major', 'Grosses fissures / Fissures ouvertes')], default=None, max_length=50, null=True, verbose_name='Fissuration'), + ), + migrations.AlterField( + model_name='hotboxinspection', + name='joint_level', + field=models.CharField(blank=True, choices=[('flush', 'Bien à niveau / Arasé'), ('slight_bump_hollow', 'Léger décalage / Creux ou bosse'), ('step', 'Ressaut important (> 1-2 cm)')], default=None, max_length=50, null=True, verbose_name='Planéité / Arasement'), + ), + migrations.AlterField( + model_name='hotboxinspection', + name='joint_sealing', + field=models.CharField(blank=True, choices=[('good', 'Joints bien fermés / scellés'), ('cracked', 'Joints fissurés ou abîmés'), ('open', 'Joints ouverts ou décollés')], default=None, max_length=50, null=True, verbose_name='Joints de raccordement'), + ), + migrations.AlterField( + model_name='hotboxinspection', + name='water_drainage', + field=models.CharField(blank=True, choices=[('good', 'Bon écoulement / Pas de flaque'), ('stagnation', "Eau stagnante / Flaque d'eau")], default=None, max_length=50, null=True, verbose_name='Évacuation des eaux pluviales'), + ), + ] diff --git a/loko/inspections/models.py b/loko/inspections/models.py index 69c76df..baf7d45 100644 --- a/loko/inspections/models.py +++ b/loko/inspections/models.py @@ -586,6 +586,8 @@ class Inspection(models.Model): @property def detailed_inspection(self): """Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection, GiepInspection, SignPanelInspection, HotBoxInspection).""" + if self.__class__ is not Inspection: + return self if hasattr(self, 'parkingspotinspection'): return self.parkingspotinspection if hasattr(self, 'giepinspection'): @@ -1062,7 +1064,7 @@ class HotBoxInspection(Inspection): joint_level = models.CharField( max_length=50, choices=JOINT_LEVEL_CHOICES, - default='flush', + default=None, null=True, blank=True, verbose_name=_("Planéité / Arasement") @@ -1076,7 +1078,7 @@ class HotBoxInspection(Inspection): joint_sealing = models.CharField( max_length=50, choices=JOINT_SEALING_CHOICES, - default='good', + default=None, null=True, blank=True, verbose_name=_("Joints de raccordement") @@ -1090,7 +1092,7 @@ class HotBoxInspection(Inspection): cracking = models.CharField( max_length=50, choices=CRACKING_CHOICES, - default='none', + default=None, null=True, blank=True, verbose_name=_("Fissuration") @@ -1103,7 +1105,7 @@ class HotBoxInspection(Inspection): water_drainage = models.CharField( max_length=50, choices=WATER_DRAINAGE_CHOICES, - default='good', + default=None, null=True, blank=True, verbose_name=_("Évacuation des eaux pluviales") @@ -1116,7 +1118,7 @@ class HotBoxInspection(Inspection): def compute_status(self): """ Calcule automatiquement le statut de conformité ('compliant', 'non_compliant', 'damaged') - selon la sévérité des constats visuels. + selon la sévérité des constats visuels, ou None si l'évaluation n'est pas complète. """ if self.joint_level == 'step' or self.joint_sealing == 'open' or self.cracking == 'major': return 'damaged' @@ -1127,7 +1129,14 @@ class HotBoxInspection(Inspection): self.water_drainage == 'stagnation' ): return 'non_compliant' - return 'compliant' + if self.joint_level and self.joint_sealing and self.cracking and self.water_drainage: + return 'compliant' + return None + + @property + def is_fully_evaluated(self): + """Indique si les 4 questions de l'inspection ont été renseignées.""" + return bool(self.joint_level and self.joint_sealing and self.cracking and self.water_drainage) def inspection_document_upload_path(instance, filename): diff --git a/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html b/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html index 6715b47..281541a 100644 --- a/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html +++ b/loko/inspections/templates/inspections/components/asset_fields/_hotbox.html @@ -6,25 +6,25 @@ {% translate "Contrôle visuel de réparation Hot Box" %} - - {% translate "Suggéré : Conforme" %} + + {% translate "4 questions à évaluer" %}

    - {% translate "Évaluez visuellement la zone réparée sur les 4 critères simples ci-dessous :" %} + {% translate "Évaluez visuellement la zone réparée sur les 4 critères ci-dessous (sans valeur par défaut) :" %}

    - +