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