feat(calendar): intégrer les notes et documents dans la modale d'intervention

This commit is contained in:
kdeterme 2026-10-07 10:26:08 +02:00
parent 775151c72d
commit 2ef730508f
7 changed files with 682 additions and 85 deletions

View file

@ -1146,4 +1146,44 @@
opacity: 0.4;
}
/* --- Modal Notes & Documents --- */
@media (min-width: 992px) {
.border-start-lg {
border-left: 1px solid #dee2e6 !important;
padding-left: 1.25rem !important;
}
}
.calendar-modal-notes-docs-container {
min-height: 250px;
max-height: 60vh;
overflow-y: auto;
scrollbar-width: thin;
padding-right: 4px;
}
.cal-note-card {
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.cal-note-card:hover {
background-color: #ffffff !important;
border-color: #b0c4de !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.cal-doc-card {
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.cal-doc-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
border-color: #b0c4de !important;
}
.cal-nd-filter-btn {
transition: all 0.15s ease;
}

View file

@ -4894,6 +4894,7 @@ document.addEventListener('DOMContentLoaded', function () {
}
clearPlanningFeedback();
loadModalNotesAndDocuments(currentModalInterventionId);
planningModal.show();
}
@ -5087,6 +5088,102 @@ document.addEventListener('DOMContentLoaded', function () {
planningFeedback.textContent = '';
}
function loadModalNotesAndDocuments(interventionId) {
const container = document.getElementById('modal-notes-docs-content');
const summaryText = document.getElementById('modal-notes-docs-summary');
if (!container) return;
if (summaryText) {
summaryText.textContent = '…';
}
container.innerHTML = `
<div class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status">
<span class="visually-hidden">${gettext('Chargement...')}</span>
</div>
<span class="small">${gettext('Chargement des notes et documents...')}</span>
</div>
`;
if (!interventionId) {
container.innerHTML = `<div class="text-muted small p-3 text-center">${gettext('Aucune intervention sélectionnée.')}</div>`;
return;
}
const url = (typeof interventionCalendarDetailsUrlPattern !== 'undefined')
? interventionCalendarDetailsUrlPattern.replace('/0/details/', `/${interventionId}/details/`)
: `/interventions/calendar/intervention/${interventionId}/details/`;
fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(response => {
if (!response.ok) {
throw new Error(response.statusText || 'Erreur de chargement');
}
return response.json();
})
.then(data => {
if (currentModalInterventionId !== interventionId) return;
if (data.success && data.html) {
container.innerHTML = data.html;
const notesCount = data.notes_count || 0;
const docsCount = data.documents_count || 0;
const notesText = notesCount > 1 ? `${notesCount} ${gettext('notes')}` : `${notesCount} ${gettext('note')}`;
const docsText = docsCount > 1 ? `${docsCount} ${gettext('docs')}` : `${docsCount} ${gettext('doc')}`;
if (summaryText) {
summaryText.textContent = `${notesText} • ${docsText}`;
}
// Setup filter buttons
const filterBtns = container.querySelectorAll('.cal-nd-filter-btn');
const notesSection = container.querySelector('#cal-modal-notes-section');
const docsSection = container.querySelector('#cal-modal-docs-section');
filterBtns.forEach(btn => {
btn.addEventListener('click', function () {
filterBtns.forEach(b => {
b.classList.remove('active');
b.classList.remove('btn-primary');
b.classList.add('btn-outline-primary');
});
this.classList.add('active');
this.classList.remove('btn-outline-primary');
this.classList.add('btn-primary');
const filter = this.getAttribute('data-filter');
if (notesSection) {
notesSection.style.display = (filter === 'all' || filter === 'notes') ? '' : 'none';
}
if (docsSection) {
docsSection.style.display = (filter === 'all' || filter === 'docs') ? '' : 'none';
}
});
});
} else {
container.innerHTML = `<div class="text-muted small p-3 text-center">${gettext('Impossible de charger les notes et documents.')}</div>`;
}
})
.catch(err => {
if (currentModalInterventionId !== interventionId) return;
console.error('Error loading notes/documents:', err);
container.innerHTML = `
<div class="alert alert-warning py-2 px-3 small m-2 d-flex align-items-center justify-content-between">
<span><i class="bi bi-exclamation-triangle me-2"></i>${gettext('Erreur lors du chargement des notes et documents.')}</span>
<button type="button" class="btn btn-sm btn-outline-warning text-dark py-0 px-2" id="retry-load-notes-docs-btn">${gettext('Réessayer')}</button>
</div>
`;
const retryBtn = container.querySelector('#retry-load-notes-docs-btn');
if (retryBtn) {
retryBtn.addEventListener('click', () => loadModalNotesAndDocuments(interventionId));
}
});
}
function loadCalendarData(customDate) {
let url = interventionseventsUrl;

View file

@ -554,36 +554,45 @@
<!-- Modal Bootstrap pour afficher les détails d'une intervention -->
<div class="modal fade" id="interventionModal" tabindex="-1" aria-labelledby="interventionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-dialog modal-dialog-centered modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interventionModalLabel">{% translate "Planification de l’intervention" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
<div class="modal-header py-2 px-3 bg-light border-bottom">
<h5 class="modal-title d-flex align-items-center gap-2 mb-0" id="interventionModalLabel">
<i class="bi bi-calendar2-event text-primary"></i>
<span>{% translate "Détail & Planification de l’intervention" %}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<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>
<div class="modal-body p-3">
<!-- En-tête résumé intervention -->
<div class="p-2 mb-3 bg-light rounded border d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="d-flex flex-wrap align-items-center gap-2">
<span id="modal-intervention-code" class="text-primary fw-bold fs-6"></span>
<span id="modal-intervention-priority" class="badge text-bg-light border"></span>
<span id="modal-intervention-status" class="badge rounded-pill"></span>
<span id="modal-preparation-status"></span>
</div>
<div>
<span id="modal-notes-docs-badge" class="badge text-bg-light border text-muted">
<i class="bi bi-journal-text me-1 text-primary"></i>
<span id="modal-notes-docs-summary">—</span>
</span>
</div>
<p class="mb-1">
<strong>{% translate "Titre" %} :</strong> <span id="modal-intervention-title"></span>
</p>
<p class="mb-1">
<strong>{% translate "Statut" %} :</strong> <span id="modal-intervention-status" class="badge rounded-pill"></span>
</p>
<p class="mb-0">
<strong>{% translate "Bon de préparation" %} :</strong> <span id="modal-preparation-status"></span>
</p>
</div>
<!-- Dates sections -->
<div class="mb-3">
<div class="table-responsive">
<strong>{% translate "Titre" %} :</strong> <span id="modal-intervention-title" class="fw-semibold"></span>
</div>
<div class="row g-3">
<!-- Colonne gauche: Planification & Dates -->
<div class="col-12 col-lg-5">
<div class="card h-100 border-0 bg-transparent">
<h6 class="text-muted fw-bold mb-2 d-flex align-items-center">
<i class="bi bi-calendar3 me-2 text-primary"></i>{% translate "Dates" %}
</h6>
<!-- Dates sections -->
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
@ -611,32 +620,32 @@
</tbody>
</table>
</div>
</div>
<form id="planning-form" class="row g-3 mt-2">
<form id="planning-form" class="row g-2">
<div class="col-12">
<h6 class="text-muted mb-2">{% translate "Modifier la planification" %}</h6>
<h6 class="text-muted fw-bold mb-2 d-flex align-items-center">
<i class="bi bi-pencil-square me-2 text-primary"></i>{% translate "Modifier la planification" %}
</h6>
</div>
<div class="col-12 col-md-6">
<label for="planning-begin-input" class="form-label">{% translate "Début planifié" %}</label>
<input type="datetime-local" class="form-control" id="planning-begin-input" required>
<div class="col-12 col-sm-6">
<label for="planning-begin-input" class="form-label small mb-1">{% translate "Début planifié" %}</label>
<input type="datetime-local" class="form-control form-control-sm" id="planning-begin-input" required>
</div>
<div class="col-12 col-md-6">
<label for="planning-end-input" class="form-label">{% translate "Fin planifiée" %}</label>
<input type="datetime-local" class="form-control" id="planning-end-input" required>
<div class="col-12 col-sm-6">
<label for="planning-end-input" class="form-label small mb-1">{% translate "Fin planifiée" %}</label>
<input type="datetime-local" class="form-control form-control-sm" id="planning-end-input" required>
</div>
<div class="col-12 col-md-6">
<label for="planning-duration-input" class="form-label">{% translate "Durée (heures)" %}</label>
<input type="number" class="form-control" id="planning-duration-input" min="0.25" step="0.25" inputmode="decimal">
<div class="col-12 col-sm-6">
<label for="planning-duration-input" class="form-label small mb-1">{% translate "Durée (heures)" %}</label>
<input type="number" class="form-control form-control-sm" id="planning-duration-input" min="0.25" step="0.25" inputmode="decimal">
</div>
<div class="row mt-3">
<div class="col-12 col-md-6">
<label for="planning-team-input" class="form-label">{% translate "Équipe assignée" %}</label>
<input type="text" class="form-control" id="planning-team-input" readonly disabled>
<div class="col-12 col-sm-6">
<label for="planning-team-input" class="form-label small mb-1">{% translate "Équipe assignée" %}</label>
<input type="text" class="form-control form-control-sm" id="planning-team-input" readonly disabled>
</div>
<div class="col-12 col-md-6">
<label for="planning-member-input" class="form-label">{% translate "Membre assigné" %}</label>
<div class="input-group">
<div class="col-12">
<label for="planning-member-input" class="form-label small mb-1">{% translate "Membre assigné" %}</label>
<div class="input-group input-group-sm">
<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' %}">
@ -645,10 +654,19 @@
{% endif %}
</div>
</div>
</div>
<div class="col-12 d-none" id="planning-feedback"></div>
</form>
</div>
</div>
<!-- Colonne droite: Notes & Documents -->
<div class="col-12 col-lg-7 border-start-lg">
<div id="modal-notes-docs-content" class="calendar-modal-notes-docs-container">
<!-- Injected via AJAX -->
</div>
</div>
</div>
</div>
<div class="modal-footer">
<a id="modal-intervention-link" href="#" class="btn btn-outline-primary" target="_blank">{% translate "Voir l’intervention" %}</a>
<button type="button" class="btn btn-primary" id="planning-save-btn">{% translate "Enregistrer" %}</button>
@ -892,6 +910,7 @@
const teamDailyMembersUrlPattern = "{% url 'interventions:team_daily_members_ajax' 0 %}";
const teamDailyMembersBaseUrl = teamDailyMembersUrlPattern.replace('/0/members/', '/');
const teamPlanningAssignUrl = "{% url 'contracts:team_planning_assign_member_ajax' %}";
const interventionCalendarDetailsUrlPattern = "{% url 'interventions:intervention_calendar_details_ajax' 0 %}";
</script>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% static 'interventions/status_colors.js' %}"></script>

View file

@ -0,0 +1,141 @@
{% load i18n tz short_name interventions_tags document_viewer_tags %}
<div class="calendar-notes-docs-wrapper">
<!-- Barre d'outils filtres / compteurs -->
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<div class="btn-group btn-group-sm" role="group" aria-label="{% translate 'Filtrer les notes et documents' %}">
<button type="button" class="btn btn-outline-primary active cal-nd-filter-btn" data-filter="all">
<i class="bi bi-grid me-1"></i>{% translate "Tout" %}
<span class="badge text-bg-primary rounded-pill ms-1" id="cal-count-all">{{ total_count }}</span>
</button>
<button type="button" class="btn btn-outline-primary cal-nd-filter-btn" data-filter="notes">
<i class="bi bi-sticky me-1"></i>{% translate "Notes" %}
<span class="badge text-bg-secondary rounded-pill ms-1" id="cal-count-notes">{{ notes_count }}</span>
</button>
<button type="button" class="btn btn-outline-primary cal-nd-filter-btn" data-filter="docs">
<i class="bi bi-paperclip me-1"></i>{% translate "Documents" %}
<span class="badge text-bg-secondary rounded-pill ms-1" id="cal-count-docs">{{ documents_count }}</span>
</button>
</div>
<div class="text-muted small">
{% if total_count > 0 %}
<span>{{ total_count }} {% if total_count > 1 %}{% translate "éléments" %}{% else %}{% translate "élément" %}{% endif %}</span>
{% endif %}
</div>
</div>
{% if total_count == 0 %}
<div class="text-center text-muted py-5">
<i class="bi bi-journal-x fs-1 d-block mb-2 text-secondary opacity-50"></i>
<p class="mb-1 fw-semibold text-secondary">{% translate "Aucune note ni document" %}</p>
<p class="small text-muted mb-0">{% translate "Cette intervention n'a pas encore de note ni de document associé." %}</p>
</div>
{% else %}
<!-- Section Notes -->
<div id="cal-modal-notes-section" class="cal-nd-section mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-0 fw-bold text-dark d-flex align-items-center" style="font-size: 0.85rem;">
<i class="bi bi-sticky-fill text-warning me-2"></i>{% translate "Notes" %}
<span class="badge bg-secondary-subtle text-secondary-emphasis rounded-pill ms-2" style="font-size: 0.72rem;">{{ notes_count }}</span>
</h6>
</div>
{% if notes %}
<div class="cal-notes-list d-flex flex-column gap-2">
{% for note in notes %}
<div class="card shadow-none border bg-light-subtle rounded-3 cal-note-card">
<div class="card-body p-2 px-3">
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
<div class="d-flex flex-wrap align-items-center gap-1">
<span class="badge {{ note.type_badge_class }} d-inline-flex align-items-center gap-1 py-1 px-2" style="font-size: 0.72rem;">
<i class="bi {{ note.type_icon_class }}"></i>
{{ note.get_note_type_display }}
</span>
</div>
<span class="text-muted small text-nowrap" style="font-size: 0.75rem;">
<i class="bi bi-clock me-1"></i>{{ note.note_time|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
</span>
</div>
<div class="text-dark small note-content-text py-1" style="white-space: pre-wrap; line-height: 1.45; word-break: break-word;">{{ note.content }}</div>
<div class="d-flex justify-content-between align-items-center mt-1 pt-1 border-top border-light-subtle" style="font-size: 0.72rem;">
<span class="text-muted">
<i class="bi bi-person me-1"></i>{{ note.note_author|short_name|default:"—" }}
</span>
{% if note.updated_at %}
<span class="text-muted fst-italic" title="{% translate 'Mis à jour le' %} {{ note.updated_at|timezone:'Europe/Paris'|date:'d/m/Y H:i' }}{% if note.updated_by %} {% translate 'par' %} {{ note.updated_by|short_name }}{% endif %}">
<i class="bi bi-pencil-fill me-1" style="font-size: 0.65rem;"></i>{% translate "Modifié" %}
</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small fst-italic py-2 px-1">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucune note pour cette intervention." %}
</div>
{% endif %}
</div>
<!-- Section Documents -->
<div id="cal-modal-docs-section" class="cal-nd-section {% if notes %}mt-4 pt-3 border-top{% endif %}">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-0 fw-bold text-dark d-flex align-items-center" style="font-size: 0.85rem;">
<i class="bi bi-paperclip text-primary me-2"></i>{% translate "Documents & Photos" %}
<span class="badge bg-secondary-subtle text-secondary-emphasis rounded-pill ms-2" style="font-size: 0.72rem;">{{ documents_count }}</span>
</h6>
</div>
{% if documents %}
<div class="row g-2">
{% for doc in documents %}
{% private_file_url doc 'file' as file_url %}
{% if doc.thumbnail %}
{% private_file_url doc 'thumbnail' as thumb_url %}
{% else %}
{% private_file_url doc 'file' as thumb_url %}
{% endif %}
<div class="col-6 col-sm-4">
<div class="card h-100 border shadow-none bg-white rounded-3 overflow-hidden position-relative cal-doc-card">
<a href="{{ file_url }}" target="_blank" rel="noopener noreferrer" class="d-block text-decoration-none bg-light d-flex align-items-center justify-content-center text-center overflow-hidden position-relative" style="height: 105px;" title="{% translate 'Ouvrir' %} {{ doc.filename }}">
{% if doc.is_image %}
<img src="{{ thumb_url }}" alt="{{ doc.filename }}" style="width: 100%; height: 100%; object-fit: cover;" loading="lazy">
<div class="position-absolute bottom-0 end-0 p-1">
<span class="badge bg-dark bg-opacity-75 rounded-pill" style="font-size: 0.65rem;" title="{% translate 'Agrandir' %}">
<i class="bi bi-zoom-in"></i>
</span>
</div>
{% else %}
<i class="{{ doc.ext|file_icon_class }}" style="font-size: 2.3rem;"></i>
{% endif %}
</a>
<div class="card-body p-2 d-flex flex-column justify-content-between" style="min-height: 52px;">
<div class="text-truncate fw-semibold text-dark small" style="font-size: 0.76rem;" title="{{ doc.filename }}">
{{ doc.filename }}
</div>
<div class="d-flex justify-content-between align-items-center mt-1 pt-1 border-top" style="font-size: 0.7rem;">
<span class="text-muted text-truncate me-1" title="{{ doc.upload_date|timezone:'Europe/Paris'|date:'d/m/Y H:i' }}{% if doc.uploaded_by %} · {{ doc.uploaded_by|short_name }}{% endif %}">
{{ doc.upload_date|timezone:"Europe/Paris"|date:"d/m/Y" }}
</span>
<a href="{{ file_url }}" download="{{ doc.filename }}" class="text-primary text-decoration-none p-0" title="{% translate 'Télécharger' %}">
<i class="bi bi-download"></i>
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small fst-italic py-2 px-1">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun document pour cette intervention." %}
</div>
{% endif %}
</div>
{% endif %}
</div>

View file

@ -0,0 +1,234 @@
import json
from unittest import mock
from datetime import datetime
from django.test import SimpleTestCase, RequestFactory
from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
from django.utils import timezone
from interventions.views.calendar import intervention_calendar_details_ajax
from interventions.models import InterventionNote
class DummyConfig:
regional_standard = 'FR'
linked_configs = mock.MagicMock(values_list=lambda *args, **kwargs: [])
class DummyUser:
def __init__(self, id=1, first_name="Jean", last_name="Dupont"):
self.id = id
self.pk = id
self.first_name = first_name
self.last_name = last_name
self.is_authenticated = True
self.config = DummyConfig()
def get_full_name(self):
return f"{self.first_name} {self.last_name}"
def __str__(self):
return self.get_full_name()
class DummyNote:
def __init__(self, id, note_type, type_display, badge_class, icon_class, content, note_time, note_author, updated_at=None, updated_by=None):
self.id = id
self.pk = id
self.note_type = note_type
self._type_display = type_display
self.type_badge_class = badge_class
self.type_icon_class = icon_class
self.content = content
self.note_time = note_time
self.note_author = note_author
self.updated_at = updated_at
self.updated_by = updated_by
def get_note_type_display(self):
return self._type_display
class DummyDoc:
def __init__(self, id, filename, thumbnail_path=None, upload_date=None, uploaded_by=None):
self.id = id
self.pk = id
self.filename = filename
self.upload_date = upload_date or timezone.now()
self.uploaded_by = uploaded_by
self.file = type('DummyFile', (), {'name': filename})()
if thumbnail_path:
self.thumbnail = type('DummyThumb', (), {'name': thumbnail_path})()
else:
self.thumbnail = None
self._meta = type('DummyMeta', (), {
'app_label': 'interventions',
'model_name': 'interventiondocument',
'object_name': 'InterventionDocument',
})()
class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase):
databases = set()
def setUp(self):
self.factory = RequestFactory()
self.url = reverse('interventions:intervention_calendar_details_ajax', args=[123])
def _create_request(self, user=None):
request = self.factory.get(self.url)
request.user = user if user is not None else AnonymousUser()
request.session = {
'account_switch_allowed_user_ids': [getattr(request.user, 'pk', 1)],
'account_switch_origin_user_id': getattr(request.user, 'pk', 1),
}
return request
def test_url_resolves(self):
self.assertEqual(self.url, '/interventions/calendar/intervention/123/details/')
def test_anonymous_user_redirects_to_login(self):
request = self._create_request(AnonymousUser())
response = intervention_calendar_details_ajax(request, intervention_id=123)
self.assertEqual(response.status_code, 302)
self.assertIn('/accounts/login/', response.url)
@mock.patch('interventions.views.calendar.can_view_intervention_summary', return_value=False)
@mock.patch('interventions.views.calendar.can_view_intervention', return_value=False)
@mock.patch('interventions.views.calendar.get_object_or_404')
def test_permission_denied_returns_403(self, mock_get_obj, mock_can_view, mock_can_view_summary):
mock_itv = mock.MagicMock(id=123)
mock_get_obj.return_value = mock_itv
user = DummyUser(id=99)
request = self._create_request(user)
response = intervention_calendar_details_ajax(request, intervention_id=123)
self.assertEqual(response.status_code, 403)
@mock.patch('interventions.views.calendar.can_view_intervention_summary', return_value=False)
@mock.patch('interventions.views.calendar.can_view_intervention', return_value=True)
@mock.patch('interventions.views.calendar.InterventionNote.objects')
@mock.patch('interventions.views.calendar.get_object_or_404')
def test_authorized_user_receives_notes_and_documents(
self, mock_get_obj, mock_notes_qs, mock_can_view, mock_can_view_summary
):
mock_itv = mock.MagicMock(id=123, title="Intervention Réparation Éclairage")
mock_get_obj.return_value = mock_itv
now = timezone.now()
author = DummyUser(id=1, first_name="Jean", last_name="Dupont")
# Notes
note1 = DummyNote(
id=1,
note_type=InterventionNote.NoteType.EQUIPMENT,
type_display="Matériel",
badge_class="text-bg-warning text-dark",
icon_class="bi-tools",
content="Nacelle 15m requise pour intervention sur mât",
note_time=now,
note_author=author,
)
note2 = DummyNote(
id=2,
note_type=InterventionNote.NoteType.IMPORTANT_INFO,
type_display="Information importante",
badge_class="text-bg-danger text-white",
icon_class="bi-exclamation-triangle-fill",
content="Attention présence de câbles haute tension à proximité",
note_time=now,
note_author=author,
updated_at=now,
updated_by=author,
)
mock_filtered_notes = mock.MagicMock()
mock_filtered_notes.select_related.return_value = mock_filtered_notes
mock_filtered_notes.annotate.return_value = mock_filtered_notes
mock_filtered_notes.order_by.return_value = [note1, note2]
mock_notes_qs.filter.return_value = mock_filtered_notes
# Documents
doc_img = DummyDoc(
id=10,
filename="photo_degats.jpg",
thumbnail_path="thumbnails/photo_degats.jpg",
upload_date=now,
uploaded_by=author,
)
doc_pdf = DummyDoc(
id=11,
filename="rapport_securite.pdf",
thumbnail_path=None,
upload_date=now,
uploaded_by=author,
)
mock_docs_qs = mock.MagicMock()
mock_docs_qs.select_related.return_value = mock_docs_qs
mock_docs_qs.order_by.return_value = [doc_img, doc_pdf]
mock_itv.documents = mock_docs_qs
user = DummyUser(id=99)
request = self._create_request(user)
response = intervention_calendar_details_ajax(request, intervention_id=123)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))
self.assertTrue(data['success'])
self.assertEqual(data['notes_count'], 2)
self.assertEqual(data['documents_count'], 2)
self.assertEqual(data['total_count'], 4)
# Content assertions
self.assertIn("Nacelle 15m requise", data['html'])
self.assertIn("Attention présence de câbles haute tension", data['html'])
self.assertIn("photo_degats.jpg", data['html'])
self.assertIn("rapport_securite.pdf", data['html'])
self.assertIn("Jean D.", data['html'])
# Document flags computed by view
self.assertTrue(doc_img.is_image)
self.assertFalse(doc_img.is_pdf)
self.assertEqual(doc_img.ext, '.jpg')
self.assertFalse(doc_pdf.is_image)
self.assertTrue(doc_pdf.is_pdf)
self.assertEqual(doc_pdf.ext, '.pdf')
@mock.patch('interventions.views.calendar.can_view_intervention_summary', return_value=False)
@mock.patch('interventions.views.calendar.can_view_intervention', return_value=True)
@mock.patch('interventions.views.calendar.InterventionNote.objects')
@mock.patch('interventions.views.calendar.get_object_or_404')
def test_empty_notes_and_documents(
self, mock_get_obj, mock_notes_qs, mock_can_view, mock_can_view_summary
):
mock_itv = mock.MagicMock(id=456, title="Intervention Sans Notes")
mock_get_obj.return_value = mock_itv
mock_filtered_notes = mock.MagicMock()
mock_filtered_notes.select_related.return_value = mock_filtered_notes
mock_filtered_notes.annotate.return_value = mock_filtered_notes
mock_filtered_notes.order_by.return_value = []
mock_notes_qs.filter.return_value = mock_filtered_notes
mock_docs_qs = mock.MagicMock()
mock_docs_qs.select_related.return_value = mock_docs_qs
mock_docs_qs.order_by.return_value = []
mock_itv.documents = mock_docs_qs
user = DummyUser(id=99)
request = self._create_request(user)
response = intervention_calendar_details_ajax(request, intervention_id=456)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode('utf-8'))
self.assertTrue(data['success'])
self.assertEqual(data['notes_count'], 0)
self.assertEqual(data['documents_count'], 0)
self.assertEqual(data['total_count'], 0)
self.assertIn("Aucune note ni document", data['html'])

View file

@ -78,6 +78,7 @@ urlpatterns = [
path('calendar/', views.interventions_calendar, name='interventions_calendar'),
path('calendar/member/<int:member_id>/history/', views.member_history_ajax, name='member_history_ajax'),
path('calendar/team/<int:team_id>/members/', views.team_daily_members_ajax, name='team_daily_members_ajax'),
path('calendar/intervention/<int:intervention_id>/details/', views.intervention_calendar_details_ajax, name='intervention_calendar_details_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

@ -1754,3 +1754,68 @@ def team_daily_members_ajax(request, team_id):
'available_members': available_members,
})
@login_required
@require_GET
def intervention_calendar_details_ajax(request, intervention_id):
"""
Retourne les notes et documents d'une intervention pour la modale du calendrier.
"""
intervention = get_object_or_404(Intervention, pk=intervention_id)
if not (can_view_intervention(request.user, intervention) or can_view_intervention_summary(request.user, intervention)):
return HttpResponseForbidden(_("Vous n'avez pas la permission de voir cette intervention."))
note_priority_case = Case(
When(note_type=InterventionNote.NoteType.EQUIPMENT, then=Value(0)),
When(note_type=InterventionNote.NoteType.IMPORTANT_INFO, then=Value(1)),
When(note_type=InterventionNote.NoteType.ORIGIN, then=Value(2)),
default=Value(3),
output_field=IntegerField(),
)
notes = list(
InterventionNote.objects
.filter(intervention=intervention)
.select_related('note_author', 'updated_by')
.annotate(type_priority_order=note_priority_case)
.order_by('type_priority_order', '-note_time', '-id')
)
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'}
documents = list(
intervention.documents
.select_related('uploaded_by')
.order_by('-upload_date', '-id')
)
for doc in documents:
fn = getattr(doc, 'filename', '') or ''
ext = os.path.splitext(fn)[1].lower() if fn else ''
doc.ext = ext
doc.is_image = bool(doc.thumbnail or ext in IMAGE_EXTENSIONS)
doc.is_pdf = ext == '.pdf'
context = {
'intervention': intervention,
'notes': notes,
'documents': documents,
'notes_count': len(notes),
'documents_count': len(documents),
'total_count': len(notes) + len(documents),
}
html = render_to_string(
'interventions/partials/calendar_intervention_notes_docs.html',
context,
request=request,
)
return JsonResponse({
'success': True,
'notes_count': len(notes),
'documents_count': len(documents),
'total_count': len(notes) + len(documents),
'html': html,
})