feat: update intervention drag-and-drop permissions to include top_manager and add assigned status to editable states

This commit is contained in:
kdeterme 2026-08-17 16:49:12 +02:00
parent 122086e2ab
commit aa6c0d0603
4 changed files with 265 additions and 27 deletions

View file

@ -3,7 +3,7 @@
const STATUS_COLORS = INTERVENTION_STATUS_COLORS; const STATUS_COLORS = INTERVENTION_STATUS_COLORS;
const DEFAULT_STATUS_COLOR = '#888888'; 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 DAY_MS = 24 * 60 * 60 * 1000;
const HOUR_MS = 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000;
const MIN_BAR_DURATION_MS = 12 * 60 * 60 * 1000; // Largeur visuelle minimale des barres 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); let isEditable = canMoveTimelineItems && EDITABLE_STATUSES.includes(itv.status);
if (isEditable && isManager) { 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); isEditable = managerAllowedStatuses.includes(itv.status);
} }
@ -887,7 +887,7 @@ document.addEventListener('DOMContentLoaded', function () {
// Pour les managers, restreindre aux statuts spécifiques // Pour les managers, restreindre aux statuts spécifiques
if (isEditable && isManager) { 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); isEditable = managerAllowedStatuses.includes(statusCode);
} }

View file

@ -8,7 +8,7 @@ from django.contrib import messages
from django.contrib.messages import get_messages from django.contrib.messages import get_messages
from django.core.files.uploadedfile import SimpleUploadedFile 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 contracts.models import Company, CompanyTeam, CompanyMember, Contract, ContractPost, ContractOrder, ContractOrderQuote, ContractOrderQuoteItem
from assets.models import AssetCategory from assets.models import AssetCategory
from interventions.models import ( from interventions.models import (
@ -592,5 +592,225 @@ class CalendarContractFilterTests(TestCase):
self.assertIn(self.contract_forbidden.id, contract_ids) 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())

View file

@ -1132,22 +1132,28 @@ def interventions_schedule_update(request):
except Intervention.DoesNotExist: except Intervention.DoesNotExist:
return JsonResponse({'error': _('Intervention introuvable.')}, status=404) return JsonResponse({'error': _('Intervention introuvable.')}, status=404)
if not can_edit_intervention(request.user, intervention): user_config = getattr(request.user, 'config', None)
return JsonResponse({'error': _('Vous ne pouvez pas modifier cette intervention.')}, status=403) if not user_config:
# Vérifier les restrictions pour les managers
try: try:
user_config = UserConfig.objects.get(user=request.user) 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: except UserConfig.DoesNotExist:
pass return JsonResponse({'error': _("Configuration utilisateur introuvable.")}, status=403)
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 member_key_present = 'assigned_member_id' in payload
team_key_present = 'assigned_team_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: if new_team_id != prev_team_id:
assignment_will_change = True 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: if assignment_will_change:
allowed_update_fields = get_allowed_update_fields(request.user, intervention) 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 'member_assignment' not in allowed_update_fields: if not can_assign:
return JsonResponse({'error': _("Vous n'avez pas la permission d'assigner cette intervention.")}, status=403) return JsonResponse({'error': _("Vous n'avez pas la permission d'assigner cette intervention.")}, status=403)
member = None member = None
@ -1195,10 +1201,17 @@ def interventions_schedule_update(request):
member = CompanyMember.objects.prefetch_related('teams').filter(id=member_id).first() member = CompanyMember.objects.prefetch_related('teams').filter(id=member_id).first()
if not member: if not member:
return JsonResponse({'error': _('Membre assigné invalide.')}, status=400) 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. # 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. # 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. # 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): 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) team = next(t for t in m_teams if t.id == team_id)
else: else:
@ -1208,6 +1221,11 @@ def interventions_schedule_update(request):
team = CompanyTeam.objects.filter(id=team_id).first() team = CompanyTeam.objects.filter(id=team_id).first()
if not team: if not team:
return JsonResponse({'error': _('Équipe assignée invalide.')}, status=400) 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') begin_str = payload.get('planned_begin_time')
end_str = payload.get('planned_end_time') end_str = payload.get('planned_end_time')

View file

@ -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: 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. """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 membres et équipes quotidien: seuls admin, top_manager, controller, external_manager peuvent déplacer (pas les managers)
- Dans la vue timeline: admin, manager, controller, external_manager peuvent déplacer - 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) (mais les managers ont des restrictions par statut, gérées ailleurs)
""" """
if view_mode in ('members', 'teams_daily'): if view_mode in ('members', 'teams_daily'):
# Vue membres & quotidien: managers ne peuvent PAS déplacer # 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: else:
# Vue timeline: managers peuvent déplacer (avec restrictions par statut) # 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'})