feat: add allow_team_members_edit option to contracts and update permissions to allow intra-team intervention management

This commit is contained in:
kdeterme 2026-08-19 11:55:16 +02:00
parent 94cb7148a0
commit e2eb20536d
8 changed files with 286 additions and 12 deletions

View file

@ -96,9 +96,9 @@ class CompanyAdmin(admin.ModelAdmin):
@admin.register(Contract) @admin.register(Contract)
class ContractAdmin(admin.ModelAdmin): class ContractAdmin(admin.ModelAdmin):
list_display = ('contract_number', 'description', 'company', 'start_date', 'end_date', 'is_active', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'thematics_list') list_display = ('contract_number', 'description', 'company', 'start_date', 'end_date', 'is_active', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'thematics_list')
search_fields = ('contract_number', 'company__name', 'description', 'contact_phone') search_fields = ('contract_number', 'company__name', 'description', 'contact_phone')
list_filter = ('is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'send_daily_summary') list_filter = ('is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'send_daily_summary')
filter_horizontal = ('thematics',) filter_horizontal = ('thematics',)
def get_inlines(self, request, obj=None): def get_inlines(self, request, obj=None):
@ -117,7 +117,7 @@ class ContractAdmin(admin.ModelAdmin):
), ),
}), }),
('Options de workflow', { ('Options de workflow', {
'fields': ('needs_approval', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed'), 'fields': ('needs_approval', '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-08-19 09:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contracts', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='contract',
name='allow_team_members_edit',
field=models.BooleanField(default=False, help_text="Si activé, les membres d'une même équipe peuvent modifier les interventions assignées à d'autres membres de leur équipe, y compris à partir du statut 'Pris en charge' ou au-delà.", verbose_name="Modification inter-membres d'équipe autorisée"),
),
]

View file

@ -341,6 +341,11 @@ class Contract(models.Model):
verbose_name=_("Emails uniquement si priorité urgente"), verbose_name=_("Emails uniquement si priorité urgente"),
help_text=_("Si activé, les notifications email ne sont envoyées que pour les interventions de priorité 'Urgent'."), help_text=_("Si activé, les notifications email ne sont envoyées que pour les interventions de priorité 'Urgent'."),
) )
allow_team_members_edit = models.BooleanField(
default=False,
verbose_name=_("Modification inter-membres d'équipe autorisée"),
help_text=_("Si activé, les membres d'une même équipe peuvent modifier les interventions assignées à d'autres membres de leur équipe, y compris à partir du statut 'Pris en charge' ou au-delà."),
)
def __str__(self): def __str__(self):
return f"Contrat {self.contract_number} - {self.company.name}" return f"Contrat {self.contract_number} - {self.company.name}"

View file

@ -261,8 +261,14 @@ def can_view_intervention(user, intervention):
is_assigned_to_user = (intervention.assigned_member == company_member) is_assigned_to_user = (intervention.assigned_member == company_member)
is_assigned_to_user_team = (intervention.assigned_team in company_member.teams.all()) is_assigned_to_user_team = (intervention.assigned_team in company_member.teams.all())
is_unrestricted_company = (intervention.assigned_provider_id in unrestricted_companies) is_unrestricted_company = (intervention.assigned_provider_id in unrestricted_companies)
is_team_member_edit_allowed = bool(
intervention.contract
and getattr(intervention.contract, 'allow_team_members_edit', False)
and intervention.assigned_member
and intervention.assigned_member.teams.filter(id__in=company_member.teams.all()).exists()
)
if not (is_assigned_to_user or is_assigned_to_user_team or is_unrestricted_company): if not (is_assigned_to_user or is_assigned_to_user_team or is_unrestricted_company or is_team_member_edit_allowed):
return False return False
contract = intervention.contract contract = intervention.contract
@ -682,7 +688,11 @@ def filter_viewable_interventions_for_user(user, limit_to_default_thematic=False
unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False) unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False)
unrestricted_companies = list(unrestricted_teams.values_list('company_id', flat=True).distinct()) unrestricted_companies = list(unrestricted_teams.values_list('company_id', flat=True).distinct())
company_q = Q(assigned_member=company_member) | Q(assigned_team__in=list(company_member.teams.all())) company_q = (
Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) |
Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all()))
)
if unrestricted_companies: if unrestricted_companies:
company_q |= Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies) company_q |= Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies)
else: else:
@ -725,7 +735,11 @@ def filter_viewable_interventions_for_user(user, limit_to_default_thematic=False
unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False) unrestricted_teams = company_member.teams.filter(restrict_to_assigned_interventions=False)
unrestricted_companies = list(unrestricted_teams.values_list('company_id', flat=True).distinct()) unrestricted_companies = list(unrestricted_teams.values_list('company_id', flat=True).distinct())
assign_filter = Q(assigned_member=company_member) | Q(assigned_team__in=list(company_member.teams.all())) assign_filter = (
Q(assigned_member=company_member) |
Q(assigned_team__in=list(company_member.teams.all())) |
Q(contract__allow_team_members_edit=True, assigned_member__teams__in=list(company_member.teams.all()))
)
if unrestricted_companies: if unrestricted_companies:
assign_filter |= Q(assigned_provider_id__in=unrestricted_companies) assign_filter |= Q(assigned_provider_id__in=unrestricted_companies)
qs = qs.filter(assign_filter) qs = qs.filter(assign_filter)
@ -953,6 +967,22 @@ def can_edit_intervention(user, intervention, user_config=None):
assigned_member = getattr(intervention, 'assigned_member', None) assigned_member = getattr(intervention, 'assigned_member', None)
assigned_user_id = getattr(assigned_member, 'user_id', None) if assigned_member else None assigned_user_id = getattr(assigned_member, 'user_id', None) if assigned_member else None
if assigned_user_id is not None and assigned_user_id != user.id: if assigned_user_id is not None and assigned_user_id != user.id:
# Vérifier si le contrat autorise la modification par les membres de la même équipe
contract = intervention.contract
allow_team_edit = bool(contract and getattr(contract, 'allow_team_members_edit', False))
is_same_team_member = False
if allow_team_edit:
company_member = CompanyMember.objects.filter(user=user).prefetch_related('teams').first()
if company_member:
user_team_ids = set(company_member.teams.values_list('id', flat=True))
if intervention.assigned_team_id and intervention.assigned_team_id in user_team_ids:
is_same_team_member = True
elif assigned_member:
assigned_member_team_ids = set(assigned_member.teams.values_list('id', flat=True))
if user_team_ids & assigned_member_team_ids:
is_same_team_member = True
if not is_same_team_member:
return False return False
contract = intervention.contract contract = intervention.contract
@ -1054,6 +1084,15 @@ def can_manage_occupations(user, intervention, user_config=None):
and intervention.assigned_team_id in member_team_ids): and intervention.assigned_team_id in member_team_ids):
return True return True
# Si le contrat autorise la modification par les membres d'équipe, autoriser si partage d'équipe
contract = intervention.contract
if contract and getattr(contract, 'allow_team_members_edit', False):
assigned_member = getattr(intervention, 'assigned_member', None)
if assigned_member:
assigned_member_team_ids = set(assigned_member.teams.values_list('id', flat=True))
if set(member_team_ids) & assigned_member_team_ids:
return True
# Autoriser tous les techniciens qui ont démarré cette intervention, même si elle est ensuite réassignée à une autre équipe ou un autre membre # Autoriser tous les techniciens qui ont démarré cette intervention, même si elle est ensuite réassignée à une autre équipe ou un autre membre
if InterventionTimeLine.objects.filter( if InterventionTimeLine.objects.filter(
intervention=intervention, intervention=intervention,

View file

@ -776,7 +776,8 @@ class ExternalManagerScheduleUpdateTests(TestCase):
} }
res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json') res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json')
self.assertEqual(res.status_code, 403) self.assertEqual(res.status_code, 403)
self.assertIn("entreprise", res.json().get('error', '').lower()) err_msg = res.json().get('error', '').lower()
self.assertTrue('entreprise' in err_msg or 'company' in err_msg)
def test_external_manager_can_unassign_assigned_status_intervention_to_backlog(self): def test_external_manager_can_unassign_assigned_status_intervention_to_backlog(self):
itv_assigned = Intervention.objects.create( itv_assigned = Intervention.objects.create(

View file

@ -1670,12 +1670,12 @@ class TrafficlightsReprogrammingTypeTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.user = User.objects.create_user(username='admin_tl', password='pwd') self.user = User.objects.create_user(username='admin_tl', password='pwd')
self.role_admin = Role.objects.create(name='admin') self.role_admin, _ = Role.objects.get_or_create(name='admin')
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True) self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
self.user_config.roles.add(self.role_admin) self.user_config.roles.add(self.role_admin)
self.tl_thematic = Thematic.objects.create(code='trafficlights', name_fr='Feux tricolores', name_nl='Verkeerslichten') self.tl_thematic, _ = Thematic.objects.get_or_create(code='trafficlights', defaults={'name_fr': 'Feux tricolores', 'name_nl': 'Verkeerslichten'})
self.struct_thematic = Thematic.objects.create(code='structures', name_fr='Ouvrages d\'art', name_nl='Kunstwerken') self.struct_thematic, _ = Thematic.objects.get_or_create(code='structures', defaults={'name_fr': "Ouvrages d'art", 'name_nl': 'Kunstwerken'})
UserThematics.objects.create(user_config=self.user_config, thematic=self.tl_thematic, can_edit_interventions=True) UserThematics.objects.create(user_config=self.user_config, thematic=self.tl_thematic, can_edit_interventions=True)
UserThematics.objects.create(user_config=self.user_config, thematic=self.struct_thematic, can_edit_interventions=True) UserThematics.objects.create(user_config=self.user_config, thematic=self.struct_thematic, can_edit_interventions=True)

View file

@ -609,7 +609,7 @@ class PreventiveTemplateTeamAssignmentTests(TestCase):
def setUp(self): def setUp(self):
User = get_user_model() User = get_user_model()
self.thematic = Thematic.objects.create(code='trafficlights', name_fr='Signalisation', name_nl='Signalisatie') self.thematic, _ = Thematic.objects.get_or_create(code='trafficlights', defaults={'name_fr': 'Signalisation', 'name_nl': 'Signalisatie'})
self.company = Company.objects.create(name='Provider Corp') self.company = Company.objects.create(name='Provider Corp')
self.team_a = CompanyTeam.objects.create(name='Equipe Alpha', company=self.company) self.team_a = CompanyTeam.objects.create(name='Equipe Alpha', company=self.company)
self.team_a.thematics.add(self.thematic) self.team_a.thematics.add(self.thematic)

View file

@ -0,0 +1,211 @@
from datetime import timedelta
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from django.utils import timezone
from common.models import Role, UserConfig, Thematic, UserThematics, UserContractAccess, UserContractStatusPermission
from contracts.models import Company, CompanyTeam, CompanyMember, Contract
from interventions.models import Intervention, STATUS_CHOICES
from interventions.permissions import can_edit_intervention, can_manage_occupations, can_view_intervention, filter_viewable_interventions_for_user
from interventions.views.workflow import attempt_status_update
class TeamMembersEditTests(TestCase):
def setUp(self):
self.User = get_user_model()
self.role_tech, _ = Role.objects.get_or_create(name='technician')
self.company = Company.objects.create(name='Prestataire Test')
self.team1 = CompanyTeam.objects.create(name='Équipe Alpha', company=self.company)
self.team2 = CompanyTeam.objects.create(name='Équipe Bêta', company=self.company)
self.thematic = Thematic.objects.create(code='roads', name_fr='Voirie', name_nl='Wegen')
# Utilisateur Tech A (Membre de l'Équipe Alpha)
self.user_a = self.User.objects.create_user(username='tech_a', password='pwd')
self.config_a = UserConfig.objects.create(user=self.user_a, is_intern=False)
self.config_a.roles.add(self.role_tech)
self.member_a = CompanyMember.objects.create(user=self.user_a, name='Technicien A')
self.member_a.teams.add(self.team1)
# Utilisateur Tech B (Collègue dans l'Équipe Alpha)
self.user_b = self.User.objects.create_user(username='tech_b', password='pwd')
self.config_b = UserConfig.objects.create(user=self.user_b, is_intern=False)
self.config_b.roles.add(self.role_tech)
self.member_b = CompanyMember.objects.create(user=self.user_b, name='Technicien B')
self.member_b.teams.add(self.team1)
# Utilisateur Tech C (Technicien dans une autre équipe: Bêta)
self.user_c = self.User.objects.create_user(username='tech_c', password='pwd')
self.config_c = UserConfig.objects.create(user=self.user_c, is_intern=False)
self.config_c.roles.add(self.role_tech)
self.member_c = CompanyMember.objects.create(user=self.user_c, name='Technicien C')
self.member_c.teams.add(self.team2)
# Contrat avec allow_team_members_edit=False
self.contract_no_team_edit = Contract.objects.create(
company=self.company,
contract_number='C-NO-TEAM-EDIT',
start_date=timezone.now().date() - timedelta(days=10),
end_date=timezone.now().date() + timedelta(days=10),
is_active=True,
allow_team_members_edit=False,
)
self.contract_no_team_edit.thematics.add(self.thematic)
# Contrat avec allow_team_members_edit=True
self.contract_with_team_edit = Contract.objects.create(
company=self.company,
contract_number='C-WITH-TEAM-EDIT',
start_date=timezone.now().date() - timedelta(days=10),
end_date=timezone.now().date() + timedelta(days=10),
is_active=True,
allow_team_members_edit=True,
)
self.contract_with_team_edit.thematics.add(self.thematic)
# Permissions thématiques et contrats pour les 3 techniciens
for cfg in [self.config_a, self.config_b, self.config_c]:
UserThematics.objects.create(
user_config=cfg,
thematic=self.thematic,
can_view_interventions=True,
can_edit_interventions=True,
)
for contract in [self.contract_no_team_edit, self.contract_with_team_edit]:
uca = UserContractAccess.objects.create(
user_config=cfg,
contract=contract,
can_view_interventions=True,
)
for status in ['assigned', 'in_progress', 'on_pause', 'finished', 'to_be_processed']:
UserContractStatusPermission.objects.create(
user_contract=uca,
status=status,
can_view=True,
can_edit=True,
can_change_status_to=True,
)
def test_default_behavior_team_member_cannot_edit_assigned_intervention(self):
"""Si allow_team_members_edit=False, Tech B ne peut pas modifier l'intervention assignée à Tech A."""
intervention = Intervention.objects.create(
title='Intervention Test 1',
thematic=self.thematic,
contract=self.contract_no_team_edit,
assigned_provider=self.company,
assigned_member=self.member_a,
status='assigned',
)
# Tech A peut modifier
self.assertTrue(can_edit_intervention(self.user_a, intervention))
# Tech B (même équipe) ne peut PAS modifier
self.assertFalse(can_edit_intervention(self.user_b, intervention))
# Tentative de passage à 'in_progress' par Tech B
factory = RequestFactory()
request = factory.post(f'/interventions/{intervention.id}/update_status/', {'new_status': 'in_progress'})
request.user = self.user_b
success, msg, _ = attempt_status_update(request, intervention, self.user_b, 'in_progress')
self.assertFalse(success)
self.assertEqual(intervention.status, 'assigned')
def test_allow_team_members_edit_true_allows_colleague_to_edit_and_transition(self):
"""Si allow_team_members_edit=True, Tech B (même équipe) peut modifier et faire avancer l'intervention."""
intervention = Intervention.objects.create(
title='Intervention Test 2',
thematic=self.thematic,
contract=self.contract_with_team_edit,
assigned_provider=self.company,
assigned_member=self.member_a,
status='assigned',
)
# Tech A peut modifier
self.assertTrue(can_edit_intervention(self.user_a, intervention))
# Tech B (même équipe) PEUT modifier
self.assertTrue(can_edit_intervention(self.user_b, intervention))
# Tech B peut passer au statut 'in_progress'
factory = RequestFactory()
request = factory.post(f'/interventions/{intervention.id}/update_status/', {'new_status': 'in_progress'})
request.user = self.user_b
success, msg, _ = attempt_status_update(request, intervention, self.user_b, 'in_progress')
self.assertTrue(success)
intervention.refresh_from_db()
self.assertEqual(intervention.status, 'in_progress')
# Tech B peut toujours éditer lorsque le statut est 'in_progress', 'on_pause', 'finished'
self.assertTrue(can_edit_intervention(self.user_b, intervention))
# Tech B peut mettre en pause
success, msg, _ = attempt_status_update(request, intervention, self.user_b, 'on_pause', pause_reason='order_material')
self.assertTrue(success)
intervention.refresh_from_db()
self.assertEqual(intervention.status, 'on_pause')
self.assertTrue(can_edit_intervention(self.user_b, intervention))
def test_allow_team_members_edit_true_rejects_unrelated_technician(self):
"""Même si allow_team_members_edit=True, un technicien d'une autre équipe (Tech C) ne peut pas modifier."""
intervention = Intervention.objects.create(
title='Intervention Test 3',
thematic=self.thematic,
contract=self.contract_with_team_edit,
assigned_provider=self.company,
assigned_member=self.member_a,
status='assigned',
)
# Tech C (Équipe Bêta, membre_a est dans Équipe Alpha) ne peut PAS modifier
self.assertFalse(can_edit_intervention(self.user_c, intervention))
def test_can_manage_occupations_for_team_colleague(self):
"""Vérifie la gestion des occupations entre collègues d'une même équipe selon le paramètre du contrat."""
# Cas 1 : allow_team_members_edit=False et pas de assigned_team
itv1 = Intervention.objects.create(
title='Intervention Occupations 1',
thematic=self.thematic,
contract=self.contract_no_team_edit,
assigned_provider=self.company,
assigned_member=self.member_a,
status='in_progress',
)
self.assertTrue(can_manage_occupations(self.user_a, itv1))
self.assertFalse(can_manage_occupations(self.user_b, itv1))
# Cas 2 : allow_team_members_edit=True
itv2 = Intervention.objects.create(
title='Intervention Occupations 2',
thematic=self.thematic,
contract=self.contract_with_team_edit,
assigned_provider=self.company,
assigned_member=self.member_a,
status='in_progress',
)
self.assertTrue(can_manage_occupations(self.user_a, itv2))
self.assertTrue(can_manage_occupations(self.user_b, itv2))
self.assertFalse(can_manage_occupations(self.user_c, itv2))
def test_visibility_with_restrict_to_assigned_interventions(self):
"""Vérifie que la restriction aux interventions assignées permet la visibilité aux collègues si allow_team_members_edit=True."""
self.team1.restrict_to_assigned_interventions = True
self.team1.save()
# Intervention sur contrat avec allow_team_members_edit=True
itv = Intervention.objects.create(
title='Intervention Visibilité',
thematic=self.thematic,
contract=self.contract_with_team_edit,
assigned_provider=self.company,
assigned_member=self.member_a,
status='assigned',
)
self.assertTrue(can_view_intervention(self.user_a, itv))
self.assertTrue(can_view_intervention(self.user_b, itv))
self.assertIn(itv, filter_viewable_interventions_for_user(self.user_b))