feat: integrate global document viewer modal and update note UI for interventions

This commit is contained in:
kdeterme 2026-08-31 14:29:45 +02:00
parent 2d5166e3f3
commit 2a6066381e
8 changed files with 142 additions and 26 deletions

View file

@ -18,6 +18,7 @@
{% if docs %} {% if docs %}
{% if not modal_only %}
{# ═══════════════════════════ Thumbnail Grid ═══════════════════════════ #} {# ═══════════════════════════ Thumbnail Grid ═══════════════════════════ #}
<div class="row g-2" id="{{ modal_id }}-grid"> <div class="row g-2" id="{{ modal_id }}-grid">
{% for doc in docs %} {% for doc in docs %}
@ -163,8 +164,10 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
{% if not grid_only %}
{# ═══════════════════════════ Viewer Modal ════════════════════════════ #} {# ═══════════════════════════ Viewer Modal ════════════════════════════ #}
<div class="modal fade" <div class="modal fade"
id="{{ modal_id }}" id="{{ modal_id }}"
@ -264,8 +267,13 @@
{% endif %} {% endif %}
</div> </div>
{# Caption bar #} {# Caption bar #}
<div class="px-3 py-2 text-center text-white" <div class="px-3 py-2 text-center text-white d-flex align-items-center justify-content-center flex-wrap gap-2"
style="background: rgba(0,0,0,.55);"> style="background: rgba(0,0,0,.65);">
{% if doc.phase_title %}
<span class="badge {{ doc.phase_badge_class }}" style="font-size: .8rem;">
<i class="bi {{ doc.phase_icon }} me-1"></i>{{ doc.phase_title }}
</span>
{% endif %}
<strong>{{ doc.filename }}</strong> <strong>{{ doc.filename }}</strong>
{% if show_tags and doc.tags_list %} {% if show_tags and doc.tags_list %}
{% for t in doc.tags_list %} {% for t in doc.tags_list %}
@ -277,7 +285,7 @@
{% endif %} {% endif %}
{% if doc.attached_at %} {% if doc.attached_at %}
<span class="ms-2 opacity-75" style="font-size: .85em;"> <span class="ms-2 opacity-75" style="font-size: .85em;">
{{ doc.attached_at|date:"d/m/Y" }} {{ doc.attached_at|date:"d/m/Y H:i" }}
</span> </span>
{% endif %} {% endif %}
{% if doc.attached_by %} {% if doc.attached_by %}
@ -322,6 +330,7 @@
data-bs-target="#{{ carousel_id }}" data-bs-target="#{{ carousel_id }}"
data-bs-slide-to="{{ doc.index }}" data-bs-slide-to="{{ doc.index }}"
aria-label="{{ doc.title }}" aria-label="{{ doc.title }}"
title="{% if doc.phase_title %}{{ doc.phase_title }} - {% endif %}{{ doc.filename }}"
style="border: 3px solid {% if forloop.first %}#0d6efd{% else %}transparent{% endif %}; style="border: 3px solid {% if forloop.first %}#0d6efd{% else %}transparent{% endif %};
padding: 0; background: none; cursor: pointer; padding: 0; background: none; cursor: pointer;
border-radius: 4px; transition: border-color .2s;"> border-radius: 4px; transition: border-color .2s;">
@ -355,6 +364,7 @@
</div>{# /modal-content #} </div>{# /modal-content #}
</div>{# /modal-dialog #} </div>{# /modal-dialog #}
</div>{# /modal #} </div>{# /modal #}
{% endif %}
{# ═══════════════════════════ Inline JS ══════════════════════════════ #} {# ═══════════════════════════ Inline JS ══════════════════════════════ #}
<script> <script>

View file

@ -124,6 +124,8 @@ def render_document_viewer(
show_tags=False, show_tags=False,
show_versions=False, show_versions=False,
location_id=None, location_id=None,
modal_only=False,
grid_only=False,
): ):
"""Render a thumbnail grid + carousel viewer modal. """Render a thumbnail grid + carousel viewer modal.
@ -142,6 +144,10 @@ def render_document_viewer(
doc_tags = [tag] if tag else [] doc_tags = [tag] if tag else []
attached_at = getattr(doc, "updated_at", None) attached_at = getattr(doc, "updated_at", None)
attached_by = getattr(version, "uploaded_by", None) if version else None attached_by = getattr(version, "uploaded_by", None) if version else None
phase_title = item.get("phase_title") or getattr(doc, "phase_title", "")
phase_badge_class = item.get("phase_badge_class") or getattr(doc, "phase_badge_class", "")
phase_icon = item.get("phase_icon") or getattr(doc, "phase_icon", "")
global_index = item.get("global_index") if item.get("global_index") is not None else getattr(doc, "global_index", None)
elif hasattr(item, "file") and not hasattr(item, "document"): elif hasattr(item, "file") and not hasattr(item, "document"):
# ── Direct legacy Document subclass (InterventionDocument, ITSLocationDocument, …) ── # ── Direct legacy Document subclass (InterventionDocument, ITSLocationDocument, …) ──
doc = item # the item IS the file-bearing object doc = item # the item IS the file-bearing object
@ -150,6 +156,10 @@ def render_document_viewer(
tag = None tag = None
attached_at = getattr(item, "upload_date", None) attached_at = getattr(item, "upload_date", None)
attached_by = getattr(item, "uploaded_by", None) attached_by = getattr(item, "uploaded_by", None)
phase_title = getattr(item, "phase_title", "")
phase_badge_class = getattr(item, "phase_badge_class", "")
phase_icon = getattr(item, "phase_icon", "")
global_index = getattr(item, "global_index", None)
else: else:
# ── Legacy DocumentAttachment format ── # ── Legacy DocumentAttachment format ──
doc = getattr(item, "document", None) doc = getattr(item, "document", None)
@ -158,6 +168,10 @@ def render_document_viewer(
tag = doc_tags[0] if doc_tags else None tag = doc_tags[0] if doc_tags else None
attached_at = getattr(item, "attached_at", None) attached_at = getattr(item, "attached_at", None)
attached_by = getattr(item, "attached_by", None) attached_by = getattr(item, "attached_by", None)
phase_title = getattr(item, "phase_title", "") or getattr(doc, "phase_title", "")
phase_badge_class = getattr(item, "phase_badge_class", "") or getattr(doc, "phase_badge_class", "")
phase_icon = getattr(item, "phase_icon", "") or getattr(doc, "phase_icon", "")
global_index = getattr(item, "global_index", None) or getattr(doc, "global_index", None)
if not doc: if not doc:
continue continue
@ -238,6 +252,7 @@ def render_document_viewer(
docs.append({ docs.append({
"index": i, "index": i,
"global_index": global_index,
"doc_id": doc_id, "doc_id": doc_id,
"title": getattr(doc, "title", None) or filename, "title": getattr(doc, "title", None) or filename,
"description": getattr(doc, "description", None) or "", "description": getattr(doc, "description", None) or "",
@ -254,6 +269,10 @@ def render_document_viewer(
"attached_at": attached_at, "attached_at": attached_at,
"attached_by": attached_by, "attached_by": attached_by,
"legacy_delete_url": legacy_delete_url, "legacy_delete_url": legacy_delete_url,
# ── Phase info ──
"phase_title": phase_title,
"phase_badge_class": phase_badge_class,
"phase_icon": phase_icon,
# ── Tag info ── # ── Tag info ──
"tag_name": tag.name if tag else "", "tag_name": tag.name if tag else "",
"tag_slug": tag.slug if tag else "", "tag_slug": tag.slug if tag else "",
@ -267,7 +286,10 @@ def render_document_viewer(
"unique_params": unique_params, "unique_params": unique_params,
}) })
# ── Sort docs by tag display order, then tag name, then document title ── # ── Sort docs: if global_index is specified, preserve that order; otherwise sort by tag/title ──
if any(d["global_index"] is not None for d in docs):
docs.sort(key=lambda d: d["global_index"] if d["global_index"] is not None else 9999)
else:
docs.sort(key=lambda d: (d["tag_order"], d["tag_name"].lower(), d["title"].lower())) docs.sort(key=lambda d: (d["tag_order"], d["tag_name"].lower(), d["title"].lower()))
# Re-assign sequential indices after sorting so carousel slides stay in sync # Re-assign sequential indices after sorting so carousel slides stay in sync
@ -287,4 +309,6 @@ def render_document_viewer(
"show_tags": show_tags, "show_tags": show_tags,
"show_versions": show_versions, "show_versions": show_versions,
"location_id": location_id, "location_id": location_id,
"modal_only": modal_only,
"grid_only": grid_only,
} }

View file

@ -216,7 +216,7 @@ def get_sequenced_intervention_documents(
'icon': 'bi-clipboard-check', 'icon': 'bi-clipboard-check',
'badge_class': 'bg-danger-subtle text-danger-emphasis border border-danger-subtle', 'badge_class': 'bg-danger-subtle text-danger-emphasis border border-danger-subtle',
'period_display': period_before, 'period_display': period_before,
'modal_id': 'interventionDocViewerModal_before', 'modal_id': 'interventionDocViewerModal',
'documents': before_docs, 'documents': before_docs,
'notes': before_notes, 'notes': before_notes,
'documents_count': len(before_docs), 'documents_count': len(before_docs),
@ -231,7 +231,7 @@ def get_sequenced_intervention_documents(
'icon': 'bi-tools', 'icon': 'bi-tools',
'badge_class': 'bg-primary-subtle text-primary-emphasis border border-primary-subtle', 'badge_class': 'bg-primary-subtle text-primary-emphasis border border-primary-subtle',
'period_display': period_during, 'period_display': period_during,
'modal_id': 'interventionDocViewerModal_during', 'modal_id': 'interventionDocViewerModal',
'documents': during_docs, 'documents': during_docs,
'notes': during_notes, 'notes': during_notes,
'documents_count': len(during_docs), 'documents_count': len(during_docs),
@ -246,7 +246,7 @@ def get_sequenced_intervention_documents(
'icon': 'bi-shield-check', 'icon': 'bi-shield-check',
'badge_class': 'bg-success-subtle text-success-emphasis border border-success-subtle', 'badge_class': 'bg-success-subtle text-success-emphasis border border-success-subtle',
'period_display': period_after, 'period_display': period_after,
'modal_id': 'interventionDocViewerModal_after', 'modal_id': 'interventionDocViewerModal',
'documents': after_docs, 'documents': after_docs,
'notes': after_notes, 'notes': after_notes,
'documents_count': len(after_docs), 'documents_count': len(after_docs),
@ -256,12 +256,27 @@ def get_sequenced_intervention_documents(
}, },
] ]
# Attribution de l'index global et des métadonnées de phase à chaque document
all_documents = []
global_idx = 0
for block in blocks:
for doc in block['documents']:
doc.global_index = global_idx
doc.phase_key = block['key']
doc.phase_title = block['title']
doc.phase_badge_class = block['badge_class']
doc.phase_icon = block['icon']
doc.phase_period = block['period_display']
all_documents.append(doc)
global_idx += 1
total_docs = len(docs) total_docs = len(docs)
total_notes = len(notes_list) total_notes = len(notes_list)
total_items = total_docs + total_notes total_items = total_docs + total_notes
return { return {
'blocks': blocks, 'blocks': blocks,
'all_documents': all_documents,
'total_documents_count': total_docs, 'total_documents_count': total_docs,
'total_notes_count': total_notes, 'total_notes_count': total_notes,
'total_count': total_docs, # compatibilité ascendante pour les vérifications de documents 'total_count': total_docs, # compatibilité ascendante pour les vérifications de documents

View file

@ -81,7 +81,7 @@
<!-- Documents du bloc --> <!-- Documents du bloc -->
{% if block.documents %} {% if block.documents %}
<div class="mb-3"> <div class="mb-3">
{% render_document_viewer block.documents modal_id=block.modal_id allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %} {% render_document_viewer block.documents modal_id="interventionDocViewerModal" grid_only=True allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %}
</div> </div>
{% endif %} {% endif %}
@ -119,12 +119,15 @@
{% endif %} {% endif %}
</span> </span>
</div> </div>
<p class="mb-0 note-text">{{ note.content|safe|linebreaksbr }}</p> <p class="mb-0">{{ note.content|linebreaksbr }}</p>
</div> </div>
{% if note.id in editable_note_ids and can_edit_notes %} {% if note.id in editable_note_ids and can_edit_notes %}
<div class="note-actions ms-auto"> <div class="note-actions cms-note-actions" style="display: none;">
<button type="button" class="btn btn-link text-danger p-0 delete-note-btn" data-note-id="{{ note.id }}" aria-label="{% translate 'Supprimer la note' %}" style="display: none;"> <button type="button" class="btn btn-sm btn-outline-primary btn-edit-note me-1" title="{% translate 'Modifier la note' %}">
<i class="bi bi-trash fs-6"></i> <i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-note" title="{% translate 'Supprimer la note' %}">
<i class="bi bi-trash"></i>
</button> </button>
</div> </div>
{% endif %} {% endif %}
@ -158,6 +161,10 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if all_sequenced_documents %}
{% render_document_viewer all_sequenced_documents modal_id="interventionDocViewerModal" modal_only=True allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -5,6 +5,7 @@
{% load short_name %} {% load short_name %}
{% load interventions_tags %} {% load interventions_tags %}
{% load mention_tags %} {% load mention_tags %}
{% load document_viewer_tags %}
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
@ -615,7 +616,13 @@
</button> </button>
{% endif %} {% endif %}
{% private_file_url document 'file' as document_file_url %} {% private_file_url document 'file' as document_file_url %}
<a href="{{ document_file_url }}" target="_blank"> <a href="#"
class="d-block text-decoration-none doc-viewer-thumb"
data-bs-toggle="modal"
data-bs-target="#interventionDocViewerModal"
data-viewer-modal="interventionDocViewerModal"
data-doc-index="{{ document.global_index }}"
title="{{ document.filename|default:'' }}">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;"> <div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %} {% if document.thumbnail %}
{% private_file_url document 'thumbnail' as document_thumbnail_url %} {% private_file_url document 'thumbnail' as document_thumbnail_url %}
@ -685,6 +692,10 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if all_sequenced_documents %}
{% render_document_viewer all_sequenced_documents modal_id="interventionDocViewerModal" modal_only=True allow_delete=False %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -348,3 +348,46 @@ class DocumentSequencingViewsTests(TestCase):
self.assertIn('Execution of the intervention', titles_en) self.assertIn('Execution of the intervention', titles_en)
self.assertIn('After realization', titles_en) self.assertIn('After realization', titles_en)
def test_all_documents_with_phase_metadata(self):
result = get_sequenced_intervention_documents(self.intervention)
self.assertIn('all_documents', result)
all_docs = result['all_documents']
self.assertEqual(len(all_docs), 2)
# Vérifier index global et métadonnées de phase
self.assertEqual(all_docs[0].global_index, 0)
self.assertEqual(all_docs[0].phase_key, 'before_work')
self.assertTrue(all_docs[0].phase_title)
self.assertTrue(all_docs[0].phase_badge_class)
self.assertTrue(all_docs[0].phase_icon)
self.assertEqual(all_docs[1].global_index, 1)
self.assertEqual(all_docs[1].phase_key, 'during_work')
self.assertTrue(all_docs[1].phase_title)
self.assertTrue(all_docs[1].phase_badge_class)
self.assertTrue(all_docs[1].phase_icon)
def test_desktop_detail_view_renders_global_carousel_modal(self):
url = reverse('interventions:intervention_detail', args=[self.intervention.id])
response = self.client.get(url + '?force_desktop=1')
self.assertEqual(response.status_code, 200)
self.assertIn('all_sequenced_documents', response.context)
content = response.content.decode('utf-8')
# Vérifie la présence de la modale carousel globale
self.assertIn('id="interventionDocViewerModal"', content)
self.assertIn('id="interventionDocViewerModalCarousel"', content)
self.assertIn('data-viewer-modal="interventionDocViewerModal"', content)
def test_mobile_detail_view_renders_global_carousel_modal(self):
url = reverse('interventions:intervention_detail_mobile', args=[self.intervention.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn('all_sequenced_documents', response.context)
content = response.content.decode('utf-8')
# Vérifie la présence de la modale carousel globale et les data-attributes des vignettes mobiles
self.assertIn('id="interventionDocViewerModal"', content)
self.assertIn('id="interventionDocViewerModalCarousel"', content)
self.assertIn('data-viewer-modal="interventionDocViewerModal"', content)

View file

@ -624,6 +624,8 @@ def intervention_detail(request, intervention_id):
import json as _json_mod import json as _json_mod
copy_targets_json = _json_mod.dumps(_copy_targets) copy_targets_json = _json_mod.dumps(_copy_targets)
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
context = { context = {
"intervention": intervention, "intervention": intervention,
"status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], "status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()],
@ -743,11 +745,12 @@ def intervention_detail(request, intervention_id):
'discussion_messages': discussion_messages, 'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message, 'can_post_discussion_message': can_post_discussion_message,
# Sequenced notes & documents # Sequenced notes & documents
'sequenced_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'], 'sequenced_blocks': sequenced_data['blocks'],
'document_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'], 'document_blocks': sequenced_data['blocks'],
'total_documents_count': intervention.documents.count(), 'all_sequenced_documents': sequenced_data['all_documents'],
'total_notes_count': notes.count() if hasattr(notes, 'count') else len(notes), 'total_documents_count': sequenced_data['total_documents_count'],
'total_items_count': intervention.documents.count() + (notes.count() if hasattr(notes, 'count') else len(notes)), 'total_notes_count': sequenced_data['total_notes_count'],
'total_items_count': sequenced_data['total_items_count'],
'can_delete_documents': 'documents' in allowed_update_fields, 'can_delete_documents': 'documents' in allowed_update_fields,
'can_copy_documents': 'documents' in allowed_update_fields and copy_targets_json != '[]', 'can_copy_documents': 'documents' in allowed_update_fields and copy_targets_json != '[]',
# Guided operation template # Guided operation template

View file

@ -1073,13 +1073,16 @@ def intervention_detail_mobile(request, intervention_id):
except Exception: except Exception:
pass pass
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
context = { context = {
'intervention': intervention, 'intervention': intervention,
'sequenced_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'], 'sequenced_blocks': sequenced_data['blocks'],
'document_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'], 'document_blocks': sequenced_data['blocks'],
'total_documents_count': intervention.documents.count(), 'all_sequenced_documents': sequenced_data['all_documents'],
'total_notes_count': notes.count() if hasattr(notes, 'count') else len(notes), 'total_documents_count': sequenced_data['total_documents_count'],
'total_items_count': intervention.documents.count() + (notes.count() if hasattr(notes, 'count') else len(notes)), 'total_notes_count': sequenced_data['total_notes_count'],
'total_items_count': sequenced_data['total_items_count'],
'assets': assets, 'assets': assets,
'notes': notes, 'notes': notes,
'can_edit_intervention': can_edit_mobile, 'can_edit_intervention': can_edit_mobile,