diff --git a/streetup/interventions/models.py b/streetup/interventions/models.py index d3678c7..10a3aca 100644 --- a/streetup/interventions/models.py +++ b/streetup/interventions/models.py @@ -591,10 +591,12 @@ class Intervention(models.Model): 'status': self.status, } ) - - # Nettoyage des assignations futures obsolètes uniquement - qs_to_delete = InterventionAssignment.objects.filter(intervention=self).exclude(date__in=active_dates) - qs_to_delete.filter(date__gte=today).delete() + # Nettoyage des assignations futures obsolètes uniquement + qs_to_delete = InterventionAssignment.objects.filter(intervention=self).exclude(date__in=active_dates) + qs_to_delete.filter(date__gte=today).delete() + else: + # Si l'intervention n'a plus d'équipe assignée, supprimer toutes les assignations pour aujourd'hui et le futur + InterventionAssignment.objects.filter(intervention=self, date__gte=today).delete() def __str__(self): return f"{self.code} - {self.title}" diff --git a/streetup/interventions/static/interventions/calendar/interventions_calendar.js b/streetup/interventions/static/interventions/calendar/interventions_calendar.js index 29745c0..4a6e891 100644 --- a/streetup/interventions/static/interventions/calendar/interventions_calendar.js +++ b/streetup/interventions/static/interventions/calendar/interventions_calendar.js @@ -502,10 +502,19 @@ document.addEventListener('DOMContentLoaded', function () { let membersHTML = ''; const isFutureDay = day.date > todayStr; + const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; if (!isFutureDay) { if (dayData.members && dayData.members.length > 0) { + const formattedMembers = dayData.members.map(m => { + const name = typeof m === 'object' && m ? m.name : m; + const id = typeof m === 'object' && m ? m.id : null; + if (isMemberHistoryAllowed && id) { + return `${escapeHtml(name)}`; + } + return escapeHtml(name); + }).join(', '); membersHTML = `
- ${dayData.members.map(m => escapeHtml(m)).join(', ')} + ${formattedMembers}
`; } else if (team.id !== null) { membersHTML = `
@@ -541,7 +550,7 @@ document.addEventListener('DOMContentLoaded', function () {
${itv.member_name ? `
- ${escapeHtml(itv.member_name)} + ${(isMemberHistoryAllowed && itv.member_id) ? `${escapeHtml(itv.member_name)}` : escapeHtml(itv.member_name)}
` : ''} @@ -574,11 +583,13 @@ document.addEventListener('DOMContentLoaded', function () { card.addEventListener('dragstart', (e) => { const itvId = card.getAttribute('data-intervention-id'); let itvInfo = null; + let cardDayKey = null; for (const team of data.teams) { for (const dayKey in team.days) { const found = team.days[dayKey].interventions?.find(it => String(it.id) === String(itvId)); if (found) { itvInfo = found; + cardDayKey = dayKey; break; } } @@ -599,7 +610,8 @@ document.addEventListener('DOMContentLoaded', function () { title: itvInfo.title, status_code: itvInfo.status, status: itvInfo.status_display, - duration_ms: itvInfo.duration_ms || 4 * 60 * 60 * 1000 + duration_ms: itvInfo.duration_ms || 4 * 60 * 60 * 1000, + start: itvInfo.start || (cardDayKey ? `${cardDayKey}T08:00:00` : null) }; card.classList.add('is-dragging'); @@ -3432,6 +3444,18 @@ document.addEventListener('DOMContentLoaded', function () { function handleMembersClick(event) { const coords = getMembersCanvasCoordinates(event); if (!coords) return; + + if (coords.x < MEMBERS_LABEL_WIDTH) { + const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; + if (isMemberHistoryAllowed) { + const row = getMemberRowAtY(coords.y); + if (row && row.type === 'member' && row.memberId) { + openMemberHistoryModal(row.memberId); + } + } + return; + } + const hit = membersState.hitRegions.find(region => coords.x >= region.x && coords.x <= region.x + region.width && @@ -3662,6 +3686,14 @@ document.addEventListener('DOMContentLoaded', function () { return; } if (coords.x < MEMBERS_LABEL_WIDTH) { + const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; + if (isMemberHistoryAllowed) { + const row = getMemberRowAtY(coords.y); + if (row && row.type === 'member' && row.memberId) { + dom.bodyEl.classList.add('is-hovering-item'); + return; + } + } dom.bodyEl.classList.remove('is-hovering-item'); return; } @@ -3837,7 +3869,12 @@ document.addEventListener('DOMContentLoaded', function () { }; groupsOrder.forEach(key => { - const items = backlogGroups[key] || []; + const items = (backlogGroups[key] || []).slice().sort((a, b) => { + const pA = String(a.priority_code || '9'); + const pB = String(b.priority_code || '9'); + if (pA !== pB) return pA.localeCompare(pB); + return (b.id || 0) - (a.id || 0); + }); container.appendChild(createBacklogCard(labels[key], items, key)); }); @@ -4266,6 +4303,20 @@ document.addEventListener('DOMContentLoaded', function () { modalInterventionCode.textContent = record.code || record.extendedProps?.code || `#${currentModalInterventionId}`; modalInterventionTitle.textContent = record.title || ''; + const priority = record.priority || record.extendedProps?.priority || ''; + const priorityCode = record.priority_code || record.extendedProps?.priority_code || ''; + const modalInterventionPriority = document.getElementById('modal-intervention-priority'); + if (modalInterventionPriority) { + modalInterventionPriority.textContent = priority || gettext('Normal'); + const priorityClasses = { + '1': 'badge text-bg-danger', + '2': 'badge text-bg-warning text-dark', + '3': 'badge text-bg-info text-dark', + '4': 'badge text-bg-secondary' + }; + modalInterventionPriority.className = priorityClasses[priorityCode] || 'badge text-bg-light border text-dark'; + } + const status = record.status || record.extendedProps?.status || ''; const statusCode = record.status_code || record.extendedProps?.status_code || ''; const color = STATUS_COLORS[statusCode] || DEFAULT_STATUS_COLOR; @@ -4298,6 +4349,18 @@ document.addEventListener('DOMContentLoaded', function () { modalInterventionLink.href = link; modalInterventionLink.classList.toggle('disabled', !link); + const modalMemberBtn = document.getElementById('btn-modal-member-history'); + if (modalMemberBtn) { + const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; + if (isMemberHistoryAllowed && currentModalMemberId) { + modalMemberBtn.classList.remove('d-none'); + modalMemberBtn.setAttribute('data-member-id', currentModalMemberId); + } else { + modalMemberBtn.classList.add('d-none'); + modalMemberBtn.removeAttribute('data-member-id'); + } + } + clearPlanningFeedback(); planningModal.show(); } @@ -4672,3 +4735,92 @@ function snapToHour(timestampMs) { } return Math.round(timestampMs / HOUR_MS) * HOUR_MS; } + +function openMemberHistoryModal(memberId) { + const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; + if (!memberId || !isMemberHistoryAllowed) return; + const modalEl = document.getElementById('memberHistoryModal'); + if (!modalEl || typeof bootstrap === 'undefined') return; + + const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl); + const spinner = document.getElementById('member-history-spinner'); + const content = document.getElementById('member-history-content'); + const titleName = document.getElementById('member-history-title-name'); + const cardName = document.getElementById('member-history-name-card'); + const cardCompany = document.getElementById('member-history-company-card'); + const teamsTbody = document.getElementById('member-history-teams-tbody'); + const itvsTbody = document.getElementById('member-history-interventions-tbody'); + + if (spinner) spinner.classList.remove('d-none'); + if (content) content.classList.add('d-none'); + + modal.show(); + + fetch(`/interventions/calendar/member/${memberId}/history/`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(res => { + if (!res.ok) throw new Error('Network error'); + return res.json(); + }) + .then(data => { + if (titleName) titleName.textContent = `${gettext('Historique de')} ${data.member.name}`; + if (cardName) cardName.textContent = data.member.name; + if (cardCompany) cardCompany.textContent = data.member.company || '—'; + + if (teamsTbody) { + teamsTbody.innerHTML = (data.team_history && data.team_history.length > 0) + ? data.team_history.map(h => ` + + ${escapeHtml(h.team_name)} + ${escapeHtml(h.join_date || '—')} + ${h.is_active ? `${gettext('En poste')}` : escapeHtml(h.leave_date || '—')} + + `).join('') + : `${gettext('Aucun historique d\'équipe')}`; + } + + if (itvsTbody) { + if (data.interventions && data.interventions.length > 0) { + let lastDate = null; + itvsTbody.innerHTML = data.interventions.map((itv, idx) => { + const isNewDate = itv.date !== lastDate; + lastDate = itv.date; + const borderStyle = (isNewDate && idx > 0) ? 'border-top: 2px solid #cbd5e1 !important;' : ''; + return ` + + ${escapeHtml(itv.date)} + ${escapeHtml(itv.code)} + ${escapeHtml(itv.title)} + ${escapeHtml(itv.team_name)} + ${escapeHtml(itv.status_display)} + + `; + }).join(''); + } else { + itvsTbody.innerHTML = `${gettext('Aucune intervention enregistrée')}`; + } + } + + if (spinner) spinner.classList.add('d-none'); + if (content) content.classList.remove('d-none'); + }) + .catch(err => { + console.error(err); + if (teamsTbody) teamsTbody.innerHTML = `${gettext('Erreur de chargement')}`; + if (spinner) spinner.classList.add('d-none'); + if (content) content.classList.remove('d-none'); + }); +} + +document.addEventListener('click', function(e) { + const btn = e.target.closest('.member-history-link, #btn-modal-member-history'); + if (btn) { + const memberId = btn.getAttribute('data-member-id'); + if (memberId) { + e.preventDefault(); + e.stopPropagation(); + openMemberHistoryModal(memberId); + } + } +}); diff --git a/streetup/interventions/templates/interventions/interventions_calendar.html b/streetup/interventions/templates/interventions/interventions_calendar.html index 3d2f9ec..d1bd6d1 100644 --- a/streetup/interventions/templates/interventions/interventions_calendar.html +++ b/streetup/interventions/templates/interventions/interventions_calendar.html @@ -554,13 +554,18 @@
@@ -638,6 +650,101 @@ + + + {% endblock content%} {% block script %} @@ -647,6 +754,7 @@ const interventionScheduleUpdateUrl = "{% url 'interventions:interventions_schedule_update' %}"; const calendarGeojsonBaseUrl = "{% url 'interventions:interventions_geojson' %}"; const saveCalendarPreferencesUrl = "{% url 'common:save_calendar_preferences' %}"; + const canViewMemberHistory = {{ can_view_member_history|yesno:"true,false" }}; diff --git a/streetup/interventions/tests/test_calendar.py b/streetup/interventions/tests/test_calendar.py index e22a990..228ddc6 100644 --- a/streetup/interventions/tests/test_calendar.py +++ b/streetup/interventions/tests/test_calendar.py @@ -174,6 +174,122 @@ class CalendarEventsTests(TestCase): backlog_ids = [item['id'] for item in backlog_corrective] self.assertIn(past_unfinished_itv.id, backlog_ids) + def test_unassign_intervention_moves_it_to_backlog(self): + from interventions.models import InterventionAssignment + now = timezone.now() + itv = Intervention.objects.create( + title='Intervention Today Team A', + status='to_be_processed', + maintain_type='corrective', + assigned_team=self.team_a, + planned_begin_time=now, + planned_end_time=now + timedelta(hours=2), + ) + self.assertEqual(InterventionAssignment.objects.filter(intervention=itv, date=now.date()).count(), 1) + + update_url = reverse('interventions:interventions_schedule_update') + payload = { + 'intervention_id': itv.id, + 'planned_begin_time': now.isoformat(), + 'planned_end_time': (now + timedelta(hours=2)).isoformat(), + 'assigned_member_id': None, + 'assigned_team_id': None, + } + res = self.client.post(update_url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(res.status_code, 200) + + itv.refresh_from_db() + self.assertIsNone(itv.assigned_team) + self.assertIsNone(itv.assigned_member) + self.assertEqual(itv.status, 'to_be_planned') + self.assertEqual(InterventionAssignment.objects.filter(intervention=itv, date=now.date()).count(), 0) + + url = reverse('interventions:interventions_events') + '?view_mode=teams_daily' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + + backlog_corrective = data.get('backlog_groups', {}).get('corrective', []) + self.assertIn(itv.id, [item['id'] for item in backlog_corrective]) + + for team in data.get('teams', []): + for day_key, day_data in team.get('days', {}).items(): + itv_ids_in_day = [item['id'] for item in day_data.get('interventions', [])] + self.assertNotIn(itv.id, itv_ids_in_day) + + def test_in_preparation_interventions_only_in_backlog(self): + now = timezone.now() + itv = Intervention.objects.create( + title='In Preparation With Schedule', + status='in_preparation', + maintain_type='corrective', + planned_begin_time=now, + planned_end_time=now + timedelta(hours=2), + ) + + url = reverse('interventions:interventions_events') + '?view_mode=teams_daily' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + + backlog_corrective = data.get('backlog_groups', {}).get('corrective', []) + self.assertIn(itv.id, [item['id'] for item in backlog_corrective]) + + for team in data.get('teams', []): + for day_key, day_data in team.get('days', {}).items(): + itv_ids_in_day = [item['id'] for item in day_data.get('interventions', [])] + self.assertNotIn(itv.id, itv_ids_in_day) + + def test_backlog_sorting_order(self): + itv_low_old = Intervention.objects.create( + title='Low Priority Old', + status='to_be_planned', + maintain_type='corrective', + priority='4', + ) + itv_urgent_old = Intervention.objects.create( + title='Urgent Priority Old', + status='to_be_planned', + maintain_type='corrective', + priority='1', + ) + itv_urgent_new = Intervention.objects.create( + title='Urgent Priority New', + status='to_be_planned', + maintain_type='corrective', + priority='1', + ) + + url = reverse('interventions:interventions_events') + '?view_mode=teams_daily' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + + backlog_corrective = data.get('backlog_groups', {}).get('corrective', []) + ids = [item['id'] for item in backlog_corrective if item['id'] in {itv_low_old.id, itv_urgent_old.id, itv_urgent_new.id}] + + self.assertEqual(ids, [itv_urgent_new.id, itv_urgent_old.id, itv_low_old.id]) + + def test_member_history_ajax_permissions_and_data(self): + from contracts.models import CompanyMember, CompanyMemberHistory + member = CompanyMember.objects.create(name="Jean Dupont", user=self.user) + member.teams.add(self.team_a) + CompanyMemberHistory.objects.create( + member=member, + team=self.team_a, + join_date=timezone.now().date() - timedelta(days=10) + ) + url = reverse('interventions:member_history_ajax', args=[member.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertIn('member', data) + self.assertEqual(data['member']['name'], "Jean Dupont") + self.assertIn('team_history', data) + self.assertTrue(len(data['team_history']) >= 1) + self.assertEqual(data['team_history'][0]['team_name'], self.team_a.name) + diff --git a/streetup/interventions/tests/test_general.py b/streetup/interventions/tests/test_general.py index db81a5f..c7f10d2 100644 --- a/streetup/interventions/tests/test_general.py +++ b/streetup/interventions/tests/test_general.py @@ -936,13 +936,15 @@ class InterventionAssignmentTests(TestCase): # On 2026-06-09, team_a should have John Doe in members, and itv in interventions day_06_09 = team_a_entry['days']['2026-06-09'] - self.assertIn('John Doe', day_06_09['members']) + day_06_09_names = [m['name'] if isinstance(m, dict) else m for m in day_06_09['members']] + self.assertIn('John Doe', day_06_09_names) self.assertEqual(len(day_06_09['interventions']), 1) self.assertEqual(day_06_09['interventions'][0]['id'], itv.id) # On 2026-06-11, team_a should NOT have John Doe (he left on 2026-06-10) day_06_11 = team_a_entry['days']['2026-06-11'] - self.assertNotIn('John Doe', day_06_11['members']) + day_06_11_names = [m['name'] if isinstance(m, dict) else m for m in day_06_11['members']] + self.assertNotIn('John Doe', day_06_11_names) # Check "Sans équipe" row unassigned_entry = next((t for t in teams if t['id'] is None), None) @@ -989,7 +991,8 @@ class InterventionAssignmentTests(TestCase): # On 2026-06-09, members list should have ONLY ONE entry for 'John Doe' day_06_09 = team_a_entry['days']['2026-06-09'] - self.assertEqual(day_06_09['members'], ['John Doe']) + day_06_09_names = [m['name'] if isinstance(m, dict) else m for m in day_06_09['members']] + self.assertEqual(day_06_09_names, ['John Doe']) @mock.patch('django.utils.timezone.now') def test_teams_daily_ajax_view_member_transfer_resolution(self, mock_now): @@ -1031,14 +1034,14 @@ class InterventionAssignmentTests(TestCase): # On 2026-06-11, John Doe should still be in team_a and NOT in team_b day_06_11_a = team_a_entry['days']['2026-06-11'] day_06_11_b = team_b_entry['days']['2026-06-11'] - self.assertIn('John Doe', day_06_11_a['members']) - self.assertNotIn('John Doe', day_06_11_b['members']) + self.assertIn('John Doe', [m['name'] if isinstance(m, dict) else m for m in day_06_11_a['members']]) + self.assertNotIn('John Doe', [m['name'] if isinstance(m, dict) else m for m in day_06_11_b['members']]) # On 2026-06-12 (transfer day), John Doe should NOT be in team_a and should be in team_b day_06_12_a = team_a_entry['days']['2026-06-12'] day_06_12_b = team_b_entry['days']['2026-06-12'] - self.assertNotIn('John Doe', day_06_12_a['members']) - self.assertIn('John Doe', day_06_12_b['members']) + self.assertNotIn('John Doe', [m['name'] if isinstance(m, dict) else m for m in day_06_12_a['members']]) + self.assertIn('John Doe', [m['name'] if isinstance(m, dict) else m for m in day_06_12_b['members']]) @mock.patch('django.utils.timezone.now') def test_teams_daily_ajax_view_member_concurrent_resolution(self, mock_now): @@ -1080,8 +1083,8 @@ class InterventionAssignmentTests(TestCase): # On 2026-06-11, John Doe should be in both team_a and team_b day_06_11_a = team_a_entry['days']['2026-06-11'] day_06_11_b = team_b_entry['days']['2026-06-11'] - self.assertIn('John Doe', day_06_11_a['members']) - self.assertIn('John Doe', day_06_11_b['members']) + self.assertIn('John Doe', [m['name'] if isinstance(m, dict) else m for m in day_06_11_a['members']]) + self.assertIn('John Doe', [m['name'] if isinstance(m, dict) else m for m in day_06_11_b['members']]) diff --git a/streetup/interventions/urls.py b/streetup/interventions/urls.py index eba91de..4d180b9 100644 --- a/streetup/interventions/urls.py +++ b/streetup/interventions/urls.py @@ -73,6 +73,7 @@ urlpatterns = [ path('events/', views.interventions_events, name='interventions_events'), path('schedule/update/', views.interventions_schedule_update, name='interventions_schedule_update'), path('calendar/', views.interventions_calendar, name='interventions_calendar'), + path('calendar/member//history/', views.member_history_ajax, name='member_history_ajax'), path('add/choose-thematic/', views.choose_thematic_before_add, name='intervention_add_choose_thematic'), path('/add/', views.interventions_add, name='intervention_add'), path('/add-mobile/', views.interventions_add, name='intervention_add_mobile_form'), diff --git a/streetup/interventions/views/calendar.py b/streetup/interventions/views/calendar.py index 616cd38..9946380 100644 --- a/streetup/interventions/views/calendar.py +++ b/streetup/interventions/views/calendar.py @@ -268,6 +268,7 @@ def interventions_calendar(request): 'selected_view_mode': selected_view_mode, 'selected_search': selected_search, 'can_use_member_view': can_use_member_view, + 'can_view_member_history': bool(role_names & {'admin', 'manager', 'external_manager'}), 'can_move_interventions': can_move_interventions, 'is_manager': is_manager, 'asset_category_choices': asset_category_choices, @@ -487,6 +488,7 @@ def interventions_events(request): 'status_code': itv.status, # utile pour la couleur dans le Gantt 'prep_state': itv.prep_state, 'priority': itv.get_priority_display(), + 'priority_code': itv.priority, 'type': itv.get_type_display(), 'planned_begin': itv.planned_begin_time.isoformat() if itv.planned_begin_time else None, 'planned_end': itv.planned_end_time.isoformat() if itv.planned_end_time else None, @@ -550,7 +552,8 @@ def _interventions_events_by_members( planned_begin = getattr(itv, 'planned_begin_time', None) return bool(planned_begin and planned_begin > timezone.now()) - backlog_allowed_statuses = {'to_be_planned', 'to_be_processed'} + backlog_allowed_statuses = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed'} + PRE_PROCESSING_STATUSES = {'in_preparation', 'to_be_approved', 'to_be_planned'} backlog_allowed_types = {'corrective', 'preventive', 'ameliorative'} maintain_type_labels = dict(MAINTAIN_CHOICES) @@ -588,6 +591,7 @@ def _interventions_events_by_members( 'status_code': itv.status, 'prep_state': prep_state, 'priority': itv.get_priority_display(), + 'priority_code': itv.priority or '3', 'type': itv.get_type_display(), 'maintain_type': itv.maintain_type, 'maintain_type_label': maintain_type_labels.get(itv.maintain_type, itv.maintain_type), @@ -654,9 +658,9 @@ def _interventions_events_by_members( member = itv.assigned_member team = itv.assigned_team - # Interventions with status "to_be_planned" always go to the backlog (right column) + # Interventions with status lower than "to_be_processed" always go to the backlog (right column) # regardless of assignment or scheduling - if itv.status == 'to_be_planned': + if itv.status in PRE_PROCESSING_STATUSES: unassigned.append(itv) if not scheduled: unscheduled_ids.add(itv.id) @@ -757,7 +761,7 @@ def _interventions_events_by_members( and ( ( (itv.status in backlog_allowed_statuses) - and (not assigned or not scheduled or itv.status == 'to_be_planned') + and (not assigned or not scheduled or itv.status in PRE_PROCESSING_STATUSES) ) or is_past_unfinished ) @@ -766,6 +770,9 @@ def _interventions_events_by_members( if include_in_backlog: backlog_groups[itv.maintain_type].append(serialize_intervention(itv)) + for group_key in backlog_groups: + backlog_groups[group_key].sort(key=lambda x: (x.get('priority_code') or '9', -x.get('id', 0))) + return JsonResponse({ 'view_mode': 'members', 'teams': teams, @@ -879,12 +886,12 @@ def _interventions_events_by_teams_daily( # Add to team_compositions_sets for the remaining team(s) for h in active: - team_compositions_sets[(h['team_id'], d)].add(member_name) + team_compositions_sets[(h['team_id'], d)].add((m_id, member_name)) - # Convert sets to sorted lists for consistency and default to empty list on missing access + # Convert sets to sorted lists of dicts for consistency and default to empty list on missing access team_compositions = defaultdict(list) - for key, names_set in team_compositions_sets.items(): - team_compositions[key] = sorted(list(names_set)) + for key, members_set in team_compositions_sets.items(): + team_compositions[key] = [{'id': mid, 'name': mname} for mid, mname in sorted(list(members_set), key=lambda x: x[1])] # 6. Retrieve assignments for the 7 days # Filter assignments by the interventions list (which already has user view permissions and search filters applied!) @@ -922,7 +929,8 @@ def _interventions_events_by_teams_daily( # Unassigned interventions are those scheduled in this period but having no assignment in InterventionAssignment # OR assigned to a team that is not allowed (t_id is None in our loop above) # Let's find interventions scheduled in this week that are in our viewable interventions list: - scheduled_itvs = interventions.filter( + PRE_PROCESSING_STATUSES = {'in_preparation', 'to_be_approved', 'to_be_planned'} + scheduled_itvs = interventions.exclude(status__in=PRE_PROCESSING_STATUSES).filter( Q(effective_begin__lte=end_date) & (Q(effective_end__gte=start_date) | Q(effective_end__isnull=True)) ) @@ -994,7 +1002,7 @@ def _interventions_events_by_teams_daily( }) # Compute backlog groups for the assignment sidebar - backlog_allowed_statuses = {'to_be_planned', 'to_be_processed'} + backlog_allowed_statuses = {'in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed'} backlog_allowed_types = {'corrective', 'preventive', 'ameliorative'} maintain_type_labels = dict(MAINTAIN_CHOICES) contract_filter = None if allowed_contract_ids is None else set(allowed_contract_ids) @@ -1033,6 +1041,7 @@ def _interventions_events_by_teams_daily( 'status_code': itv.status, 'prep_state': prep_state, 'priority': itv.get_priority_display(), + 'priority_code': itv.priority or '3', 'type': itv.get_type_display(), 'maintain_type': itv.maintain_type, 'maintain_type_label': maintain_type_labels.get(itv.maintain_type, itv.maintain_type), @@ -1069,7 +1078,7 @@ def _interventions_events_by_teams_daily( and ( ( (itv.status in backlog_allowed_statuses) - and (not assigned or not scheduled or itv.status == 'to_be_planned') + and (not assigned or not scheduled or itv.status in PRE_PROCESSING_STATUSES) ) or is_past_unfinished ) @@ -1078,6 +1087,9 @@ def _interventions_events_by_teams_daily( if include_in_backlog: backlog_groups[itv.maintain_type].append(serialize_intervention(itv)) + for group_key in backlog_groups: + backlog_groups[group_key].sort(key=lambda x: (x.get('priority_code') or '9', -x.get('id', 0))) + return JsonResponse({ 'view_mode': 'teams_daily', 'start_date': start_date.isoformat(), @@ -1250,7 +1262,7 @@ def interventions_schedule_update(request): if prev_status == 'to_be_planned' and is_now_assigned: new_status = 'to_be_processed' # If intervention is being unassigned (back to backlog) -> change to "to_be_planned" - elif was_assigned and not is_now_assigned and prev_status == 'to_be_processed': + elif was_assigned and not is_now_assigned and prev_status in ('to_be_processed', 'assigned'): new_status = 'to_be_planned' if new_status != prev_status: @@ -1302,3 +1314,75 @@ def intervention_planning_data_ajax(request, intervention_id): 'team_name': intervention.assigned_team.name if intervention.assigned_team else None, }) + +def member_history_ajax(request, member_id): + if not request.user.is_authenticated: + return JsonResponse({'error': _("Authentification requise.")}, status=403) + + user_config = ( + UserConfig.objects + .select_related('user') + .prefetch_related('roles') + .filter(user=request.user) + .first() + ) + if not user_config: + return JsonResponse({'error': _("Accès non autorisé.")}, status=403) + + role_names = _get_role_names(user_config) + can_view = bool(role_names & {'admin', 'manager', 'external_manager'}) + if not can_view: + return JsonResponse({'error': _("Permission refusée.")}, status=403) + + member = get_object_or_404(CompanyMember.objects.prefetch_related('teams__company'), id=member_id) + company_names = set(t.company.name for t in member.teams.all() if getattr(t, 'company', None)) + company_str = ", ".join(sorted(company_names)) if company_names else '' + + team_history_qs = ( + CompanyMemberHistory.objects + .filter(member=member) + .select_related('team') + .order_by('-join_date') + ) + team_history = [ + { + 'team_name': h.team.name if h.team else '—', + 'join_date': h.join_date.strftime('%d/%m/%Y') if h.join_date else None, + 'leave_date': h.leave_date.strftime('%d/%m/%Y') if h.leave_date else None, + 'is_active': h.leave_date is None, + } + for h in team_history_qs + ] + + assignments_qs = ( + InterventionAssignment.objects + .filter(member=member) + .select_related('intervention', 'team') + .order_by('-date', '-intervention__code', '-intervention__id')[:100] + ) + interventions_list = [ + { + 'id': a.intervention.id, + 'code': a.intervention.code, + 'title': a.intervention.title, + 'date': a.date.strftime('%d/%m/%Y') if a.date else '', + 'raw_date': a.date.isoformat() if a.date else '', + 'team_name': a.team.name if a.team else '—', + 'status_code': a.status or a.intervention.status, + 'status_display': dict(STATUS_CHOICES).get(a.status or a.intervention.status, a.status or a.intervention.status), + 'detail_url': reverse('interventions:intervention_detail', args=[a.intervention.id]), + } + for a in assignments_qs + ] + interventions_list.sort(key=lambda x: (x['raw_date'], x['code'] or '', x['id']), reverse=True) + + return JsonResponse({ + 'member': { + 'id': member.id, + 'name': member.name, + 'company': company_str, + }, + 'team_history': team_history, + 'interventions': interventions_list, + }) +