From aa6c0d06035a3fe9b31ac44f77dad169410bffba Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 17 Aug 2026 16:49:12 +0200 Subject: [PATCH] feat: update intervention drag-and-drop permissions to include top_manager and add assigned status to editable states --- .../calendar/interventions_calendar.js | 6 +- loko/interventions/tests/test_calendar.py | 222 +++++++++++++++++- loko/interventions/views/calendar.py | 56 +++-- loko/interventions/views/helpers.py | 8 +- 4 files changed, 265 insertions(+), 27 deletions(-) diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js index 4a6e891..d2f3878 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.js +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js @@ -3,7 +3,7 @@ const STATUS_COLORS = INTERVENTION_STATUS_COLORS; const DEFAULT_STATUS_COLOR = '#888888'; -const EDITABLE_STATUSES = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed']; +const EDITABLE_STATUSES = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned']; const DAY_MS = 24 * 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; const MIN_BAR_DURATION_MS = 12 * 60 * 60 * 1000; // Largeur visuelle minimale des barres @@ -531,7 +531,7 @@ document.addEventListener('DOMContentLoaded', function () { let isEditable = canMoveTimelineItems && EDITABLE_STATUSES.includes(itv.status); if (isEditable && isManager) { - const managerAllowedStatuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed']; + const managerAllowedStatuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned']; isEditable = managerAllowedStatuses.includes(itv.status); } @@ -887,7 +887,7 @@ document.addEventListener('DOMContentLoaded', function () { // Pour les managers, restreindre aux statuts spécifiques if (isEditable && isManager) { - const managerAllowedStatuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed']; + const managerAllowedStatuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned']; isEditable = managerAllowedStatuses.includes(statusCode); } diff --git a/loko/interventions/tests/test_calendar.py b/loko/interventions/tests/test_calendar.py index 553e8dd..d81adbe 100644 --- a/loko/interventions/tests/test_calendar.py +++ b/loko/interventions/tests/test_calendar.py @@ -8,7 +8,7 @@ from django.contrib import messages from django.contrib.messages import get_messages from django.core.files.uploadedfile import SimpleUploadedFile -from common.models import UserConfig, Role, Thematic, UserThematics, UserContractAccess +from common.models import UserConfig, Role, Thematic, UserThematics, UserContractAccess, UserContractStatusPermission from contracts.models import Company, CompanyTeam, CompanyMember, Contract, ContractPost, ContractOrder, ContractOrderQuote, ContractOrderQuoteItem from assets.models import AssetCategory from interventions.models import ( @@ -592,5 +592,225 @@ class CalendarContractFilterTests(TestCase): self.assertIn(self.contract_forbidden.id, contract_ids) +class ExternalManagerScheduleUpdateTests(TestCase): + def setUp(self): + User = get_user_model() + self.company_ext = Company.objects.create(name='Prestataire Ext A') + self.company_other = Company.objects.create(name='Prestataire Ext B') + + self.ext_user = User.objects.create_user(username='a2manager', password='pwd') + self.role_ext = Role.objects.create(name='external_manager') + self.ext_config = UserConfig.objects.create( + user=self.ext_user, + is_intern=False, + company=self.company_ext, + ) + self.ext_config.roles.add(self.role_ext) + + today = timezone.now().date() + self.thematic = Thematic.objects.create(code='roads_ext_mgr', name_fr='Routes Ext', name_nl='Wegen Ext') + self.contract = Contract.objects.create( + contract_number='CTR-EXT-A', + company=self.company_ext, + start_date=today - timedelta(days=10), + end_date=today + timedelta(days=365), + is_active=True, + ) + self.contract.thematics.add(self.thematic) + + UserThematics.objects.create( + user_config=self.ext_config, + thematic=self.thematic, + can_view_interventions=True, + can_edit_interventions=False, + ) + + self.uca = UserContractAccess.objects.create( + user_config=self.ext_config, + contract=self.contract, + can_view_interventions=True, + ) + + # Status permissions: can_view=True, can_edit=False, can_change_status_to=False + UserContractStatusPermission.objects.create( + user_contract=self.uca, + status='to_be_planned', + can_view=True, + can_edit=False, + can_change_status_to=True, + ) + UserContractStatusPermission.objects.create( + user_contract=self.uca, + status='to_be_processed', + can_view=True, + can_edit=False, + can_change_status_to=False, + ) + UserContractStatusPermission.objects.create( + user_contract=self.uca, + status='assigned', + can_view=True, + can_edit=False, + can_change_status_to=False, + ) + + self.team_ext_1 = CompanyTeam.objects.create(name='Equipe 1', company=self.company_ext) + self.team_ext_2 = CompanyTeam.objects.create(name='Equipe 2', company=self.company_ext) + self.member_ext_1 = CompanyMember.objects.create(name='Membre 1', user=self.ext_user) + self.member_ext_1.teams.add(self.team_ext_1) + self.member_ext_2 = CompanyMember.objects.create(name='Membre 2', user=self.ext_user) + self.member_ext_2.teams.add(self.team_ext_2) + + self.team_other = CompanyTeam.objects.create(name='Equipe B', company=self.company_other) + self.member_other = CompanyMember.objects.create(name='Membre B', user=self.ext_user) + self.member_other.teams.add(self.team_other) + + now = timezone.now() + self.itv = Intervention.objects.create( + title='Intervention Prestataire A', + thematic=self.thematic, + contract=self.contract, + status='to_be_processed', + maintain_type='corrective', + assigned_team=self.team_ext_1, + assigned_member=self.member_ext_1, + planned_begin_time=now + timedelta(hours=1), + planned_end_time=now + timedelta(hours=3), + ) + + self.update_url = reverse('interventions:interventions_schedule_update') + self.client.login(username='a2manager', password='pwd') + + def test_external_manager_can_reschedule_to_be_processed_intervention(self): + new_begin = timezone.now() + timedelta(days=2) + new_end = new_begin + timedelta(hours=2) + + payload = { + 'intervention_id': self.itv.id, + 'planned_begin_time': new_begin.isoformat(), + 'planned_end_time': new_end.isoformat(), + } + res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(res.status_code, 200) + self.assertTrue(res.json().get('success')) + + self.itv.refresh_from_db() + self.assertEqual(self.itv.planned_begin_time.date(), new_begin.date()) + self.assertEqual(self.itv.status, 'to_be_processed') + + def test_external_manager_can_reassign_member_and_team_on_to_be_processed_intervention(self): + new_begin = timezone.now() + timedelta(days=1) + new_end = new_begin + timedelta(hours=2) + + payload = { + 'intervention_id': self.itv.id, + 'planned_begin_time': new_begin.isoformat(), + 'planned_end_time': new_end.isoformat(), + 'assigned_member_id': self.member_ext_2.id, + 'assigned_team_id': self.team_ext_2.id, + } + res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(res.status_code, 200) + self.assertTrue(res.json().get('success')) + + self.itv.refresh_from_db() + self.assertEqual(self.itv.assigned_member_id, self.member_ext_2.id) + self.assertEqual(self.itv.assigned_team_id, self.team_ext_2.id) + self.assertEqual(self.itv.status, 'to_be_processed') + + def test_external_manager_can_unassign_to_be_processed_intervention_to_backlog(self): + payload = { + 'intervention_id': self.itv.id, + 'assigned_member_id': None, + 'assigned_team_id': None, + 'planned_begin_time': None, + 'planned_end_time': None, + 'unassign': True, + } + res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(res.status_code, 200) + self.assertTrue(res.json().get('success')) + + self.itv.refresh_from_db() + self.assertEqual(self.itv.status, 'to_be_planned') + self.assertIsNone(self.itv.assigned_member) + self.assertIsNone(self.itv.assigned_team) + self.assertIsNone(self.itv.planned_begin_time) + self.assertIsNone(self.itv.planned_end_time) + + def test_external_manager_cannot_assign_member_of_different_company(self): + new_begin = timezone.now() + timedelta(days=1) + new_end = new_begin + timedelta(hours=2) + + payload = { + 'intervention_id': self.itv.id, + 'planned_begin_time': new_begin.isoformat(), + 'planned_end_time': new_end.isoformat(), + 'assigned_member_id': self.member_other.id, + 'assigned_team_id': self.team_other.id, + } + 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()) + + def test_external_manager_can_unassign_assigned_status_intervention_to_backlog(self): + itv_assigned = Intervention.objects.create( + title='Intervention Status Assigned', + thematic=self.thematic, + contract=self.contract, + status='assigned', + maintain_type='corrective', + assigned_team=self.team_ext_1, + assigned_member=self.member_ext_1, + planned_begin_time=timezone.now() + timedelta(hours=1), + planned_end_time=timezone.now() + timedelta(hours=3), + ) + + payload = { + 'intervention_id': itv_assigned.id, + 'assigned_member_id': None, + 'assigned_team_id': None, + 'unassign': True, + } + res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(res.status_code, 200) + + itv_assigned.refresh_from_db() + self.assertEqual(itv_assigned.status, 'to_be_planned') + self.assertIsNone(itv_assigned.assigned_member) + self.assertIsNone(itv_assigned.assigned_team) + + def test_internal_manager_cannot_assign_members_in_calendar(self): + User = get_user_model() + mgr_user = User.objects.create_user(username='internal-mgr', password='pwd') + role_mgr = Role.objects.create(name='manager') + mgr_config = UserConfig.objects.create(user=mgr_user, is_intern=True) + mgr_config.roles.add(role_mgr) + + uca_mgr = UserContractAccess.objects.create( + user_config=mgr_config, + contract=self.contract, + can_view_interventions=True, + ) + UserContractStatusPermission.objects.create( + user_contract=uca_mgr, + status='to_be_processed', + can_view=True, + can_edit=True, + ) + + self.client.login(username='internal-mgr', password='pwd') + payload = { + 'intervention_id': self.itv.id, + 'planned_begin_time': (timezone.now() + timedelta(days=1)).isoformat(), + 'planned_end_time': (timezone.now() + timedelta(days=1, hours=2)).isoformat(), + 'assigned_member_id': self.member_ext_2.id, + } + res = self.client.post(self.update_url, data=json.dumps(payload), content_type='application/json', HTTP_ACCEPT_LANGUAGE='fr') + self.assertEqual(res.status_code, 403) + self.assertIn("assigner", res.json().get('error', '').lower()) + + + diff --git a/loko/interventions/views/calendar.py b/loko/interventions/views/calendar.py index 9780f2e..3df070a 100644 --- a/loko/interventions/views/calendar.py +++ b/loko/interventions/views/calendar.py @@ -1132,22 +1132,28 @@ def interventions_schedule_update(request): except Intervention.DoesNotExist: return JsonResponse({'error': _('Intervention introuvable.')}, status=404) - if not can_edit_intervention(request.user, intervention): - return JsonResponse({'error': _('Vous ne pouvez pas modifier cette intervention.')}, status=403) + user_config = getattr(request.user, 'config', None) + if not user_config: + try: + user_config = UserConfig.objects.get(user=request.user) + except UserConfig.DoesNotExist: + return JsonResponse({'error': _("Configuration utilisateur introuvable.")}, status=403) - # Vérifier les restrictions pour les managers - try: - user_config = UserConfig.objects.get(user=request.user) - user_roles = set(user_config.roles.values_list('name', flat=True)) - is_manager_only = 'manager' in user_roles and not user_roles.intersection({'admin', 'controller', 'external_manager'}) - - # Les managers ne peuvent déplacer que les interventions dans certains statuts - if is_manager_only: - allowed_statuses_for_manager_move = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed'} - if intervention.status not in allowed_statuses_for_manager_move: - return JsonResponse({'error': _("Vous ne pouvez pas déplacer cette intervention dans son statut actuel.")}, status=403) - except UserConfig.DoesNotExist: - pass + user_roles = set(user_config.roles.values_list('name', flat=True)) + is_admin = bool(user_roles & {'admin', 'top_manager'}) or request.user.is_superuser + is_external_manager = 'external_manager' in user_roles + is_controller = 'controller' in user_roles + is_manager_only = 'manager' in user_roles and not bool(user_roles & {'admin', 'top_manager', 'controller', 'external_manager'}) + + # 1. Vérifier si l'utilisateur a un rôle autorisé à planifier/déplacer dans le calendrier + can_schedule = is_admin or is_external_manager or is_controller or is_manager_only or (bool(user_roles & {'technician', 'inspector'}) and can_edit_intervention(request.user, intervention, user_config=user_config)) + if not can_schedule: + return JsonResponse({'error': _("Vous n'avez pas la permission de planifier cette intervention.")}, status=403) + + # 2. Vérifier si le statut actuel permet le déplacement/replanification dans le calendrier + allowed_statuses_for_move = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned'} + if intervention.status not in allowed_statuses_for_move: + return JsonResponse({'error': _("Vous ne pouvez pas déplacer cette intervention dans son statut actuel.")}, status=403) member_key_present = 'assigned_member_id' in payload team_key_present = 'assigned_team_id' in payload @@ -1182,10 +1188,10 @@ def interventions_schedule_update(request): if new_team_id != prev_team_id: assignment_will_change = True - # Vérifier la permission d'assignation seulement si l'assignation va réellement changer + # 3. Vérifier la permission d'assignation seulement si l'assignation va réellement changer if assignment_will_change: - allowed_update_fields = get_allowed_update_fields(request.user, intervention) - if 'member_assignment' not in allowed_update_fields: + can_assign = is_admin or is_external_manager or is_controller or (bool(user_roles & {'technician', 'inspector'}) and can_edit_intervention(request.user, intervention, user_config=user_config)) + if not can_assign: return JsonResponse({'error': _("Vous n'avez pas la permission d'assigner cette intervention.")}, status=403) member = None @@ -1195,10 +1201,17 @@ def interventions_schedule_update(request): member = CompanyMember.objects.prefetch_related('teams').filter(id=member_id).first() if not member: return JsonResponse({'error': _('Membre assigné invalide.')}, status=400) + m_teams = member.teams.all() + # Vérifier que le membre appartient à une entreprise autorisée pour un external_manager + if is_external_manager and not is_admin: + allowed_company_ids = _member_view_allowed_company_ids(user_config, user_roles) + if allowed_company_ids is not None: + member_company_ids = set(m_teams.values_list('company_id', flat=True)) + if not member_company_ids.intersection(allowed_company_ids): + return JsonResponse({'error': _("Vous ne pouvez assigner qu'aux membres de votre entreprise.")}, status=403) # When assigning to a member, also set the team. # If team_id is provided in the payload and is one of the member's teams, use it. # Otherwise, fall back to the first team. - m_teams = member.teams.all() if team_id and any(t.id == team_id for t in m_teams): team = next(t for t in m_teams if t.id == team_id) else: @@ -1208,6 +1221,11 @@ def interventions_schedule_update(request): team = CompanyTeam.objects.filter(id=team_id).first() if not team: return JsonResponse({'error': _('Équipe assignée invalide.')}, status=400) + # Vérifier que l'équipe appartient à une entreprise autorisée pour un external_manager + if is_external_manager and not is_admin: + allowed_company_ids = _member_view_allowed_company_ids(user_config, user_roles) + if allowed_company_ids is not None and team.company_id not in allowed_company_ids: + return JsonResponse({'error': _("Vous ne pouvez assigner qu'aux équipes de votre entreprise.")}, status=403) begin_str = payload.get('planned_begin_time') end_str = payload.get('planned_end_time') diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 08127c8..ed4ec82 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -270,16 +270,16 @@ def _can_use_member_view(role_names: Set[str]) -> bool: def _can_move_interventions_in_calendar(role_names: Set[str], view_mode: str) -> bool: """Détermine si l'utilisateur peut déplacer des interventions via drag & drop. - - Dans la vue membres et équipes quotidien: seuls admin, controller, external_manager peuvent déplacer (pas les managers) - - Dans la vue timeline: admin, manager, controller, external_manager peuvent déplacer + - Dans la vue membres et équipes quotidien: seuls admin, top_manager, controller, external_manager peuvent déplacer (pas les managers) + - Dans la vue timeline: admin, top_manager, manager, controller, external_manager peuvent déplacer (mais les managers ont des restrictions par statut, gérées ailleurs) """ if view_mode in ('members', 'teams_daily'): # Vue membres & quotidien: managers ne peuvent PAS déplacer - return bool(role_names & {'admin', 'controller', 'external_manager'}) + return bool(role_names & {'admin', 'top_manager', 'controller', 'external_manager'}) else: # Vue timeline: managers peuvent déplacer (avec restrictions par statut) - return bool(role_names & {'admin', 'manager', 'controller', 'external_manager'}) + return bool(role_names & {'admin', 'top_manager', 'manager', 'controller', 'external_manager'})