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 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);
}

View file

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

View file

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

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:
"""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'})