feat: add approval exception for urgent contract interventions and update status transition permissions

This commit is contained in:
kdeterme 2026-09-17 14:35:53 +02:00
parent 676e781943
commit 8fc2798c52
6 changed files with 351 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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