refactor: update intervention calendar to support priority-based backlog sorting and extend pre-processing status visibility

This commit is contained in:
kdeterme 2026-08-12 08:12:09 +02:00
parent 718403c295
commit 093457ce8d
7 changed files with 500 additions and 34 deletions

View file

@ -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}"

View file

@ -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 `<a href="#" class="member-history-link text-decoration-none text-muted fw-semibold" data-member-id="${id}">${escapeHtml(name)}</a>`;
}
return escapeHtml(name);
}).join(', ');
membersHTML = `<div class="team-members-list text-muted small fst-italic mb-2" title="${gettext('Composition de l’équipe')}">
<i class="bi bi-people-fill me-1"></i>${dayData.members.map(m => escapeHtml(m)).join(', ')}
<i class="bi bi-people-fill me-1"></i>${formattedMembers}
</div>`;
} else if (team.id !== null) {
membersHTML = `<div class="team-members-list text-muted small fst-italic mb-2 text-opacity-50">
@ -541,7 +550,7 @@ document.addEventListener('DOMContentLoaded', function () {
</div>
${itv.member_name ? `
<div class="teams-daily-itv-member text-muted small mt-1">
<i class="bi bi-person me-1"></i>${escapeHtml(itv.member_name)}
<i class="bi bi-person me-1"></i>${(isMemberHistoryAllowed && itv.member_id) ? `<a href="#" class="member-history-link text-decoration-none text-muted fw-medium" data-member-id="${itv.member_id}">${escapeHtml(itv.member_name)}</a>` : escapeHtml(itv.member_name)}
</div>
` : ''}
</div>
@ -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 => `
<tr>
<td><span class="fw-semibold">${escapeHtml(h.team_name)}</span></td>
<td>${escapeHtml(h.join_date || '—')}</td>
<td>${h.is_active ? `<span class="badge text-bg-success">${gettext('En poste')}</span>` : escapeHtml(h.leave_date || '—')}</td>
</tr>
`).join('')
: `<tr><td colspan="3" class="text-center text-muted py-3">${gettext('Aucun historique d\'équipe')}</td></tr>`;
}
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 `
<tr style="${borderStyle}">
<td class="text-nowrap fw-semibold">${escapeHtml(itv.date)}</td>
<td><a href="${itv.detail_url}" target="_blank" class="fw-bold text-primary text-decoration-none">${escapeHtml(itv.code)}</a></td>
<td class="text-truncate" style="max-width: 180px;" title="${escapeHtml(itv.title)}">${escapeHtml(itv.title)}</td>
<td>${escapeHtml(itv.team_name)}</td>
<td><span class="badge rounded-pill" style="background-color: ${STATUS_COLORS[itv.status_code] || DEFAULT_STATUS_COLOR}; color: ${idealTextColor(STATUS_COLORS[itv.status_code] || DEFAULT_STATUS_COLOR)};">${escapeHtml(itv.status_display)}</span></td>
</tr>
`;
}).join('');
} else {
itvsTbody.innerHTML = `<tr><td colspan="5" class="text-center text-muted py-3">${gettext('Aucune intervention enregistrée')}</td></tr>`;
}
}
if (spinner) spinner.classList.add('d-none');
if (content) content.classList.remove('d-none');
})
.catch(err => {
console.error(err);
if (teamsTbody) teamsTbody.innerHTML = `<tr><td colspan="3" class="text-danger py-3 text-center">${gettext('Erreur de chargement')}</td></tr>`;
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);
}
}
});

View file

@ -554,13 +554,18 @@
</div>
<div class="modal-body">
<div class="mb-3">
<p class="mb-1">
<strong>{% translate "Code" %} :</strong> <span id="modal-intervention-code" class="text-primary"></span>
</p>
<div class="d-flex justify-content-between align-items-center mb-1">
<div>
<strong>{% translate "Code" %} :</strong> <span id="modal-intervention-code" class="text-primary fw-bold"></span>
</div>
<div>
<span id="modal-intervention-priority" class="badge text-bg-light border"></span>
</div>
</div>
<p class="mb-1">
<strong>{% translate "Titre" %} :</strong> <span id="modal-intervention-title"></span>
</p>
<p class="mb-0">
<p class="mb-1">
<strong>{% translate "Statut" %} :</strong> <span id="modal-intervention-status" class="badge rounded-pill"></span>
</p>
<p class="mb-0">
@ -623,7 +628,14 @@
</div>
<div class="col-12 col-md-6">
<label for="planning-member-input" class="form-label">{% translate "Membre assigné" %}</label>
<input type="text" class="form-control" id="planning-member-input" readonly disabled>
<div class="input-group">
<input type="text" class="form-control" id="planning-member-input" readonly disabled>
{% if can_view_member_history %}
<button type="button" class="btn btn-outline-secondary text-muted d-none" id="btn-modal-member-history" title="{% translate 'Voir l’historique du membre' %}">
<i class="bi bi-clock-history"></i>
</button>
{% endif %}
</div>
</div>
</div>
<div class="col-12 d-none" id="planning-feedback"></div>
@ -638,6 +650,101 @@
</div>
</div>
<!-- Modal Bootstrap pour l'historique d'un membre -->
<div class="modal fade" id="memberHistoryModal" tabindex="-1" aria-labelledby="memberHistoryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center gap-2" id="memberHistoryModalLabel">
<i class="bi bi-person-badge text-primary fs-4"></i>
<span id="member-history-title-name">{% translate "Historique du membre" %}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
<div id="member-history-spinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
</div>
<div id="member-history-content" class="d-none">
<!-- Header info card -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="p-3 bg-light rounded border">
<div class="small text-muted mb-1">{% translate "Technicien / Membre" %}</div>
<h5 class="mb-0 fw-bold text-primary" id="member-history-name-card"></h5>
</div>
</div>
<div class="col-md-6">
<div class="p-3 bg-light rounded border">
<div class="small text-muted mb-1">{% translate "Entreprise" %}</div>
<h5 class="mb-0 fw-bold" id="member-history-company-card"></h5>
</div>
</div>
</div>
<!-- Tables for Teams History and Interventions History -->
<div class="row g-4">
<!-- Team membership history -->
<div class="col-md-5">
<div class="card h-100 shadow-sm border">
<div class="card-header bg-white fw-bold d-flex align-items-center gap-2">
<i class="bi bi-people-fill text-primary"></i>
{% translate "Historique des équipes" %}
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Équipe" %}</th>
<th>{% translate "Entrée" %}</th>
<th>{% translate "Sortie" %}</th>
</tr>
</thead>
<tbody id="member-history-teams-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Interventions assignments history -->
<div class="col-md-7">
<div class="card h-100 shadow-sm border">
<div class="card-header bg-white fw-bold d-flex align-items-center gap-2">
<i class="bi bi-tools text-primary"></i>
{% translate "Dernières interventions réalisées / assignées" %}
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-hover align-middle mb-0">
<thead class="table-light sticky-top">
<tr>
<th>{% translate "Date" %}</th>
<th>{% translate "Code" %}</th>
<th>{% translate "Titre" %}</th>
<th>{% translate "Équipe" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody id="member-history-interventions-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% 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" }};
</script>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% static 'interventions/status_colors.js' %}"></script>

View file

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

View file

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

View file

@ -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/<int:member_id>/history/', views.member_history_ajax, name='member_history_ajax'),
path('add/choose-thematic/', views.choose_thematic_before_add, name='intervention_add_choose_thematic'),
path('<str:thematic_code>/add/', views.interventions_add, name='intervention_add'),
path('<str:thematic_code>/add-mobile/', views.interventions_add, name='intervention_add_mobile_form'),

View file

@ -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,
})