From 6fe9908a1134e851c7743eeadc4ff2b7bef8008e Mon Sep 17 00:00:00 2001 From: Karl Determe Date: Tue, 25 Aug 2026 15:03:20 +0200 Subject: [PATCH] feat: introduce modular intervention workflow view components and logic for status management --- .../tests/test_timeline_workflow.py | 257 ++++++++++++++++++ loko/interventions/views/detail.py | 6 +- loko/interventions/views/helpers.py | 36 +++ loko/interventions/views/mobile.py | 4 +- loko/interventions/views/workflow.py | 5 +- 5 files changed, 300 insertions(+), 8 deletions(-) diff --git a/loko/interventions/tests/test_timeline_workflow.py b/loko/interventions/tests/test_timeline_workflow.py index 3768f36..10ebd01 100644 --- a/loko/interventions/tests/test_timeline_workflow.py +++ b/loko/interventions/tests/test_timeline_workflow.py @@ -873,6 +873,263 @@ class ProviderEmailNotificationTests(TestCase): ).exists()) +class PhotoRequirementWorkflowTests(TestCase): + def setUp(self): + User = get_user_model() + from datetime import date + from common.models import UserContractAccess, UserContractStatusPermission + from interventions.models import Symptom + + self.role_admin, _ = Role.objects.get_or_create(name='admin') + self.role_manager, _ = Role.objects.get_or_create(name='manager') + self.role_ext_manager, _ = Role.objects.get_or_create(name='external_manager') + self.role_technician, _ = Role.objects.get_or_create(name='technician') + + self.company = Company.objects.create(name='Société Tech') + self.team_photos_required = CompanyTeam.objects.create( + name='Equipe Photos Requises', + company=self.company, + photos_required=True, + ) + self.team_no_photos = CompanyTeam.objects.create( + name='Equipe Sans Photos', + company=self.company, + photos_required=False, + ) + + # Admin user + self.admin_user = User.objects.create_user(username='admin_ph_user', password='pwd') + self.admin_config = UserConfig.objects.create(user=self.admin_user, is_intern=True) + self.admin_config.roles.add(self.role_admin) + + # Manager user + self.manager_user = User.objects.create_user(username='manager_ph_user', password='pwd') + self.manager_config = UserConfig.objects.create(user=self.manager_user, is_intern=True) + self.manager_config.roles.add(self.role_manager) + + # External Manager user + self.ext_manager_user = User.objects.create_user(username='ext_manager_ph_user', password='pwd') + self.ext_manager_config = UserConfig.objects.create(user=self.ext_manager_user, is_intern=False, company=self.company) + self.ext_manager_config.roles.add(self.role_ext_manager) + self.ext_manager_member = CompanyMember.objects.create(name="Ext Mgr", user=self.ext_manager_user) + self.ext_manager_member.teams.add(self.team_photos_required) + + # Technician user + self.tech_user = User.objects.create_user(username='tech_ph_user', password='pwd') + self.tech_config = UserConfig.objects.create(user=self.tech_user, is_intern=False, company=self.company) + self.tech_config.roles.add(self.role_technician) + self.tech_member = CompanyMember.objects.create(name="Technicien 1", user=self.tech_user) + self.tech_member.teams.add(self.team_photos_required) + + # Thematic, Symptom, Contract + self.thematic = Thematic.objects.create(code='VOI_PH', name_fr='Voirie Photo', name_nl='Wegen Photo') + self.symptom = Symptom.objects.create(code='SYM-PH', name_fr='Symptome Photo', name_nl='Symptom Photo', thematic=self.thematic) + self.contract = Contract.objects.create( + contract_number='CTR-PHOTOS-TEST', + company=self.company, + start_date=date(2025, 1, 1), + end_date=date(2030, 1, 1), + ) + self.contract.thematics.add(self.thematic) + + for cfg in [self.admin_config, self.manager_config, self.ext_manager_config, self.tech_config]: + cfg.thematics.add(self.thematic) + cfg.contracts.add(self.contract) + uca = UserContractAccess.objects.get(user_config=cfg, contract=self.contract) + for st in ['in_preparation', 'to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished']: + UserContractStatusPermission.objects.create( + user_contract=uca, + status=st, + can_view=True, + can_edit=True, + can_change_status_to=True, + ) + + def test_is_photo_required_for_user_helper(self): + from interventions.views.helpers import _is_photo_required_for_user + + intervention = Intervention.objects.create( + title='Intervention Photo Helper Test', + maintain_type='corrective', + status='in_progress', + status_order=STATUS_ORDERS['in_progress'], + contract=self.contract, + assigned_provider=self.company, + assigned_team=self.team_photos_required, + assigned_member=self.tech_member, + thematic=self.thematic, + symptom=self.symptom, + created_by=self.admin_user, + ) + + # Technician with team photos_required=True -> True + self.assertTrue(_is_photo_required_for_user(self.tech_user, intervention)) + + # Manager -> False + self.assertFalse(_is_photo_required_for_user(self.manager_user, intervention)) + + # External Manager -> False + self.assertFalse(_is_photo_required_for_user(self.ext_manager_user, intervention)) + + # Admin -> False + self.assertFalse(_is_photo_required_for_user(self.admin_user, intervention)) + + # If team photos_required=False -> False for technician as well + intervention.assigned_team = self.team_no_photos + intervention.save(update_fields=['assigned_team']) + self.assertFalse(_is_photo_required_for_user(self.tech_user, intervention)) + + def test_technician_blocked_from_finishing_without_photos(self): + from interventions.views.workflow import attempt_status_update + + intervention = Intervention.objects.create( + title='Intervention Tech Blocked Test', + maintain_type='corrective', + status='in_progress', + status_order=STATUS_ORDERS['in_progress'], + contract=self.contract, + assigned_provider=self.company, + assigned_team=self.team_photos_required, + assigned_member=self.tech_member, + thematic=self.thematic, + symptom=self.symptom, + created_by=self.admin_user, + ) + + request = mock.Mock() + request.user = self.tech_user + request.POST = {} + request.content_type = 'application/x-www-form-urlencoded' + request.build_absolute_uri = lambda uri: f"http://localhost{uri}" + + # Technician tries to finish without photos + success, message, _ = attempt_status_update( + request, + intervention, + self.tech_user, + 'finished', + ) + + self.assertFalse(success) + self.assertIn("Au moins une photo", str(message)) + intervention.refresh_from_db() + self.assertEqual(intervention.status, 'in_progress') + + def test_technician_allowed_to_finish_with_photos(self): + from interventions.views.workflow import attempt_status_update + from common.models import Document + + intervention = Intervention.objects.create( + title='Intervention Tech Photo Allowed Test', + maintain_type='corrective', + status='in_progress', + status_order=STATUS_ORDERS['in_progress'], + contract=self.contract, + assigned_provider=self.company, + assigned_team=self.team_photos_required, + assigned_member=self.tech_member, + thematic=self.thematic, + symptom=self.symptom, + created_by=self.admin_user, + ) + + # Attach document/photo + doc_file = SimpleUploadedFile("chantier.jpg", b"fake image content", content_type="image/jpeg") + InterventionDocument.objects.create( + intervention=intervention, + file=doc_file, + uploaded_by=self.tech_user, + ) + + request = mock.Mock() + request.user = self.tech_user + request.POST = {} + request.content_type = 'application/x-www-form-urlencoded' + request.build_absolute_uri = lambda uri: f"http://localhost{uri}" + + success, message, _ = attempt_status_update( + request, + intervention, + self.tech_user, + 'finished', + ) + + self.assertTrue(success) + intervention.refresh_from_db() + self.assertEqual(intervention.status, 'finished') + + def test_manager_allowed_to_finish_without_photos(self): + from interventions.views.workflow import attempt_status_update + + intervention = Intervention.objects.create( + title='Intervention Manager No Photo Test', + maintain_type='corrective', + status='in_progress', + status_order=STATUS_ORDERS['in_progress'], + contract=self.contract, + assigned_provider=self.company, + assigned_team=self.team_photos_required, + assigned_member=self.tech_member, + thematic=self.thematic, + symptom=self.symptom, + created_by=self.admin_user, + ) + + request = mock.Mock() + request.user = self.manager_user + request.POST = {} + request.content_type = 'application/x-www-form-urlencoded' + request.build_absolute_uri = lambda uri: f"http://localhost{uri}" + + # Manager finishes intervention without any photo attached + success, message, _ = attempt_status_update( + request, + intervention, + self.manager_user, + 'finished', + ) + + self.assertTrue(success) + intervention.refresh_from_db() + self.assertEqual(intervention.status, 'finished') + + def test_external_manager_allowed_to_finish_without_photos(self): + from interventions.views.workflow import attempt_status_update + + intervention = Intervention.objects.create( + title='Intervention Ext Manager No Photo Test', + maintain_type='corrective', + status='in_progress', + status_order=STATUS_ORDERS['in_progress'], + contract=self.contract, + assigned_provider=self.company, + assigned_team=self.team_photos_required, + assigned_member=self.tech_member, + thematic=self.thematic, + symptom=self.symptom, + created_by=self.admin_user, + ) + + request = mock.Mock() + request.user = self.ext_manager_user + request.POST = {} + request.content_type = 'application/x-www-form-urlencoded' + request.build_absolute_uri = lambda uri: f"http://localhost{uri}" + + # External Manager finishes intervention without any photo attached + success, message, _ = attempt_status_update( + request, + intervention, + self.ext_manager_user, + 'finished', + ) + + self.assertTrue(success) + intervention.refresh_from_db() + self.assertEqual(intervention.status, 'finished') + + + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 956a664..ec86e26 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -724,11 +724,11 @@ def intervention_detail(request, intervention_id): 'desktop_assets_max_results': MAX_MOBILE_ASSET_RESULTS, # Droit de vérification des interventions (can_check_interventions + needs_checking) 'can_check_intervention': can_check_intervention, - # Photos obligatoires pour terminer l'intervention - 'photos_required': bool(intervention.assigned_team and intervention.assigned_team.photos_required), + # Photos obligatoires pour terminer l'intervention (techniciens uniquement) + 'photos_required': _is_photo_required_for_user(request.user, intervention, user_config=user_config), 'photos_required_since': (lambda s: s.isoformat() if s else '')( _get_photos_required_since(intervention) - ) if (intervention.assigned_team and intervention.assigned_team.photos_required) else '', + ) if _is_photo_required_for_user(request.user, intervention, user_config=user_config) else '', # Observations liées 'linked_observations': linked_observations, # Observation source diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 15e4c21..6b2788e 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -174,6 +174,7 @@ __all__ = [ '_get_available_occupation_members', '_get_equipment_templates_for_intervention', '_get_photos_required_since', + '_is_photo_required_for_user', '_get_role_names', '_get_templates_with_equipment_filters', '_get_user_short_name', @@ -2299,6 +2300,41 @@ def _get_photos_required_since(intervention): +def _is_photo_required_for_user(user, intervention, user_config=None): + """ + Vérifie si l'obligation d'ajouter une photo s'applique à cet utilisateur pour cette intervention. + L'obligation s'applique uniquement si : + 1. L'équipe assignée à l'intervention a photos_required=True. + 2. L'utilisateur est un technicien (possède le rôle 'technician' et aucun rôle privilégié de gestion/admin). + """ + if not intervention: + return False + assigned_team = getattr(intervention, 'assigned_team', None) + if not assigned_team or not getattr(assigned_team, 'photos_required', False): + return False + + if not user or not getattr(user, 'is_authenticated', False): + return False + + if getattr(user, 'is_superuser', False): + return False + + if user_config is None: + try: + user_config = UserConfig.objects.prefetch_related('roles').get(user=user) + except UserConfig.DoesNotExist: + return False + + roles = _get_role_names(user_config) + privileged_roles = {'admin', 'top_manager', 'manager', 'controller', 'operator', 'external_manager'} + + # L'obligation concerne uniquement les techniciens n'ayant pas de rôle privilégié (gestionnaire, admin...) + return 'technician' in roles and not bool(roles & privileged_roles) + + + + + def _get_user_short_name(user): if not user: return "" diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 4199611..3b72cb3 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -1121,10 +1121,10 @@ def intervention_detail_mobile(request, intervention_id): 'user_roles': user_roles, 'is_technician': is_technician, 'is_user_intern': user_config.is_intern if user_config else False, - 'photos_required': bool(intervention.assigned_team and intervention.assigned_team.photos_required), + 'photos_required': _is_photo_required_for_user(request.user, intervention, user_config=user_config), 'photos_required_since': (lambda s: s.isoformat() if s else '')( _get_photos_required_since(intervention) - ) if (intervention.assigned_team and intervention.assigned_team.photos_required) else '', + ) if _is_photo_required_for_user(request.user, intervention, user_config=user_config) else '', # Guided operation template 'guided_template': intervention.guided_operation_template, 'guided_operation_data': getattr(intervention, 'guided_operation_data', None), diff --git a/loko/interventions/views/workflow.py b/loko/interventions/views/workflow.py index 960f6b4..081ed13 100644 --- a/loko/interventions/views/workflow.py +++ b/loko/interventions/views/workflow.py @@ -403,10 +403,9 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason= if current_status == new_status: return True, _("Statut inchangé."), False - # Photos obligatoires : si l'équipe assignée l'exige, au moins une photo post-'to_be_processed' est nécessaire + # Photos obligatoires : si l'équipe assignée l'exige, au moins une photo post-'to_be_processed' est nécessaire (techniciens uniquement) if new_status == 'finished': - assigned_team = intervention.assigned_team - if assigned_team and assigned_team.photos_required: + if _is_photo_required_for_user(user, intervention): photos_since = _get_photos_required_since(intervention) if photos_since: has_photos = intervention.documents.filter(upload_date__gte=photos_since).exists()