refactor: update intervention calendar to support priority-based backlog sorting and extend pre-processing status visibility
This commit is contained in:
parent
718403c295
commit
093457ce8d
7 changed files with 500 additions and 34 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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']])
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue