From e2eb20536d7b2e37663793d4a88ee1e42e156fe9 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 19 Aug 2026 11:55:16 +0200 Subject: [PATCH] feat: add allow_team_members_edit option to contracts and update permissions to allow intra-team intervention management --- loko/contracts/admin.py | 6 +- .../0003_contract_allow_team_members_edit.py | 18 ++ loko/contracts/models.py | 5 + loko/interventions/permissions.py | 47 +++- loko/interventions/tests/test_calendar.py | 3 +- loko/interventions/tests/test_general.py | 6 +- .../tests/test_preventive_templates.py | 2 +- .../tests/test_team_members_edit.py | 211 ++++++++++++++++++ 8 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 loko/contracts/migrations/0003_contract_allow_team_members_edit.py create mode 100644 loko/interventions/tests/test_team_members_edit.py diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index 0a2d644..e52a60f 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -96,9 +96,9 @@ class CompanyAdmin(admin.ModelAdmin): @admin.register(Contract) 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') - 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',) def get_inlines(self, request, obj=None): @@ -117,7 +117,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'), + 'fields': ('needs_approval', '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/0003_contract_allow_team_members_edit.py b/loko/contracts/migrations/0003_contract_allow_team_members_edit.py new file mode 100644 index 0000000..3b76764 --- /dev/null +++ b/loko/contracts/migrations/0003_contract_allow_team_members_edit.py @@ -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"), + ), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index 98b90bd..db56dbe 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -341,6 +341,11 @@ class Contract(models.Model): 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'."), ) + 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): return f"Contrat {self.contract_number} - {self.company.name}" diff --git a/loko/interventions/permissions.py b/loko/interventions/permissions.py index 834593c..b390206 100644 --- a/loko/interventions/permissions.py +++ b/loko/interventions/permissions.py @@ -261,8 +261,14 @@ def can_view_intervention(user, intervention): is_assigned_to_user = (intervention.assigned_member == company_member) is_assigned_to_user_team = (intervention.assigned_team in company_member.teams.all()) 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 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_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: company_q |= Q(assigned_member__isnull=True, assigned_team__isnull=True, assigned_provider_id__in=unrestricted_companies) 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_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: assign_filter |= Q(assigned_provider_id__in=unrestricted_companies) qs = qs.filter(assign_filter) @@ -953,7 +967,23 @@ def can_edit_intervention(user, intervention, user_config=None): assigned_member = getattr(intervention, 'assigned_member', 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: - return False + # 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 contract = intervention.contract thematic = intervention.thematic @@ -1054,6 +1084,15 @@ def can_manage_occupations(user, intervention, user_config=None): and intervention.assigned_team_id in member_team_ids): 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 if InterventionTimeLine.objects.filter( intervention=intervention, diff --git a/loko/interventions/tests/test_calendar.py b/loko/interventions/tests/test_calendar.py index b6515bd..2fcdaeb 100644 --- a/loko/interventions/tests/test_calendar.py +++ b/loko/interventions/tests/test_calendar.py @@ -776,7 +776,8 @@ class ExternalManagerScheduleUpdateTests(TestCase): } res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json') 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): itv_assigned = Intervention.objects.create( diff --git a/loko/interventions/tests/test_general.py b/loko/interventions/tests/test_general.py index ee32de2..c292c33 100644 --- a/loko/interventions/tests/test_general.py +++ b/loko/interventions/tests/test_general.py @@ -1670,12 +1670,12 @@ class TrafficlightsReprogrammingTypeTests(TestCase): def setUp(self): User = get_user_model() 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.roles.add(self.role_admin) - self.tl_thematic = Thematic.objects.create(code='trafficlights', name_fr='Feux tricolores', name_nl='Verkeerslichten') - self.struct_thematic = Thematic.objects.create(code='structures', name_fr='Ouvrages d\'art', name_nl='Kunstwerken') + self.tl_thematic, _ = Thematic.objects.get_or_create(code='trafficlights', defaults={'name_fr': 'Feux tricolores', 'name_nl': 'Verkeerslichten'}) + 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.struct_thematic, can_edit_interventions=True) diff --git a/loko/interventions/tests/test_preventive_templates.py b/loko/interventions/tests/test_preventive_templates.py index 0336a19..01f51e4 100644 --- a/loko/interventions/tests/test_preventive_templates.py +++ b/loko/interventions/tests/test_preventive_templates.py @@ -609,7 +609,7 @@ class PreventiveTemplateTeamAssignmentTests(TestCase): def setUp(self): 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.team_a = CompanyTeam.objects.create(name='Equipe Alpha', company=self.company) self.team_a.thematics.add(self.thematic) diff --git a/loko/interventions/tests/test_team_members_edit.py b/loko/interventions/tests/test_team_members_edit.py new file mode 100644 index 0000000..5e6c45a --- /dev/null +++ b/loko/interventions/tests/test_team_members_edit.py @@ -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))