feat: add reusable new discussion modal with @mention autocompletion support

This commit is contained in:
kdeterme 2026-08-12 11:46:29 +02:00
parent 1edfb85a0e
commit fb9664b65a
14 changed files with 669 additions and 73 deletions

View file

@ -16,6 +16,34 @@
<div class="background-layer"></div> <div class="background-layer"></div>
<div class="bg-image"> <div class="bg-image">
<div class="container content-box"> <div class="container content-box">
{% if unread_notifications_count %}
<div id="home-unread-notifications-alert" class="row justify-content-center mt-3 mb-2">
<div class="col-md-10 col-lg-8">
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center justify-content-between p-3 rounded-3" role="alert" style="background-color: rgba(255, 243, 205, 0.95);">
<div class="d-flex align-items-center text-start me-3">
<div class="rounded-circle bg-warning bg-opacity-25 p-2 me-3 d-flex align-items-center justify-content-center" style="width: 42px; height: 42px; flex-shrink: 0;">
<i class="bi bi-bell-fill fs-5 text-warning-emphasis"></i>
</div>
<div>
<div class="fw-bold text-dark">
{% translate "Notifications non lues" %}
</div>
<div class="small text-secondary">
{% blocktranslate count count=unread_notifications_count %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notification non lue.
{% plural %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notifications non lues.
{% endblocktranslate %}
</div>
</div>
</div>
<a href="{% url 'notifications:inbox' %}" class="btn btn-warning btn-sm px-3 py-2 fw-semibold text-nowrap rounded-2 text-dark">
<i class="bi bi-envelope-open me-1"></i>{% translate "Consulter" %}
</a>
</div>
</div>
</div>
{% endif %}
<!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> --> <!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> -->
<div class="row justify-content-center mt-2 mb-3"> <div class="row justify-content-center mt-2 mb-3">
<div class="col-md-12"> <div class="col-md-12">
@ -56,6 +84,9 @@
<i class="bi bi-currency-euro me-2"></i>{% translate "Nouvelle commande" %} <i class="bi bi-currency-euro me-2"></i>{% translate "Nouvelle commande" %}
</a> </a>
{% endif %} {% endif %}
<button class="btn btn-light main-button my-1 ms-1" type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
<i class="bi bi-chat-dots me-2"></i>{% translate "Nouvelle discussion" %}
</button>
</div> </div>
</div> </div>
@ -101,10 +132,18 @@
{% empty %} {% empty %}
{% include "home/widgets/_default.html" %} {% include "home/widgets/_default.html" %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<!-- Floating Discussion FAB -->
<button id="home-discussion-fab" class="btn btn-primary rounded-circle shadow-lg d-flex align-items-center justify-content-center"
type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal"
style="position: fixed; bottom: 28px; right: 28px; width: 58px; height: 58px; z-index: 1050; font-size: 1.5rem;"
title="{% translate 'Nouvelle discussion' %}" aria-label="{% translate 'Nouvelle discussion' %}">
<i class="bi bi-chat-dots-fill"></i>
</button>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content%} {% endblock content%}

View file

@ -267,3 +267,29 @@ class HomeIndexViewTests(TestCase):
self.assertContains(response, reverse('inspections:quick_inspection_map')) self.assertContains(response, reverse('inspections:quick_inspection_map'))
self.assertNotContains(response, 'data-bs-target="#newInspectionModal"') self.assertNotContains(response, 'data-bs-target="#newInspectionModal"')
def test_home_index_unread_notifications_banner(self):
from django.urls import reverse
from django.contrib.auth import get_user_model
from common.models import UserConfig
from notifications.models import Notification, NotificationRecipient
User = get_user_model()
user = User.objects.create_user('notif_user', password='pass')
UserConfig.objects.create(user=user)
self.client.force_login(user)
# 1. No unread notifications -> banner not shown
response = self.client.get(reverse('home:index'))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'id="home-unread-notifications-alert"')
# 2. Add unread notification -> banner shown with count
notif = Notification.objects.create(title="Test notification")
NotificationRecipient.objects.create(notification=notif, user=user)
response = self.client.get(reverse('home:index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="home-unread-notifications-alert"')
self.assertContains(response, 'Vous avez <strong class="home-unread-notifications-count">1</strong> notification non lue.')

View file

@ -812,9 +812,11 @@ document.addEventListener('DOMContentLoaded', function () {
let mentionStart = -1; let mentionStart = -1;
let mentionQuery = null; let mentionQuery = null;
let searchTimer = null; let searchTimer = null;
let currentResults = [];
let selectedIndex = -1;
function escHtml(str) { function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
function syncReal() { function syncReal() {
@ -842,10 +844,25 @@ document.addEventListener('DOMContentLoaded', function () {
mentions = mentions.filter(m => m.end <= start || m.start >= end); mentions = mentions.filter(m => m.end <= start || m.start >= end);
} }
function highlightIndex(idx) {
const items = dropdown.querySelectorAll('.list-group-item');
if (!items || !items.length) return;
items.forEach((btn, i) => {
if (i === idx) {
btn.classList.add('active');
btn.scrollIntoView({ block: 'nearest' });
} else {
btn.classList.remove('active');
}
});
}
function showDropdown(results) { function showDropdown(results) {
dropdown.innerHTML = ''; dropdown.innerHTML = '';
currentResults = results || [];
selectedIndex = -1;
if (!results || !results.length) { hideDropdown(); return; } if (!results || !results.length) { hideDropdown(); return; }
results.forEach(r => { results.forEach((r, idx) => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small'; btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
@ -858,8 +875,15 @@ document.addEventListener('DOMContentLoaded', function () {
e.preventDefault(); e.preventDefault();
insertMention(r); insertMention(r);
}); });
btn.addEventListener('mouseenter', function () {
selectedIndex = idx;
highlightIndex(selectedIndex);
});
dropdown.appendChild(btn); dropdown.appendChild(btn);
}); });
selectedIndex = 0;
highlightIndex(0);
dropdown.style.display = 'block'; dropdown.style.display = 'block';
} }
@ -868,6 +892,8 @@ document.addEventListener('DOMContentLoaded', function () {
dropdown.innerHTML = ''; dropdown.innerHTML = '';
mentionStart = -1; mentionStart = -1;
mentionQuery = null; mentionQuery = null;
currentResults = [];
selectedIndex = -1;
} }
function insertMention(r) { function insertMention(r) {
@ -890,6 +916,35 @@ document.addEventListener('DOMContentLoaded', function () {
hideDropdown(); hideDropdown();
} }
textarea.addEventListener('keydown', function (e) {
if (dropdown.style.display === 'block' && currentResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
e.preventDefault();
insertMention(currentResults[selectedIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideDropdown();
return;
}
}
});
function searchMentions(q) { function searchMentions(q) {
clearTimeout(searchTimer); clearTimeout(searchTimer);
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {

View file

@ -5,6 +5,33 @@
{% block content %} {% block content %}
<div class="row g-3"> <div class="row g-3">
{% if unread_notifications_count %}
<div id="home-unread-notifications-alert" class="col-12">
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center justify-content-between p-3 mb-0 rounded-4" role="alert" style="background-color: rgba(255, 243, 205, 0.95);">
<div class="d-flex align-items-center text-start me-2">
<div class="rounded-circle bg-warning bg-opacity-25 p-2 me-2.5 d-flex align-items-center justify-content-center" style="width: 38px; height: 38px; flex-shrink: 0;">
<i class="bi bi-bell-fill fs-5 text-warning-emphasis"></i>
</div>
<div>
<div class="fw-bold text-dark small">
{% translate "Notifications non lues" %}
</div>
<div class="small text-secondary" style="font-size: 0.825rem;">
{% blocktranslate count count=unread_notifications_count %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notification non lue.
{% plural %}
Vous avez <strong class="home-unread-notifications-count">{{ count }}</strong> notifications non lues.
{% endblocktranslate %}
</div>
</div>
</div>
<a href="{% url 'mobile:notifications_mobile' %}" class="btn btn-warning btn-sm px-3 py-2 fw-semibold text-nowrap rounded-3 text-dark">
{% translate "Consulter" %}
</a>
</div>
</div>
{% endif %}
<!-- New items / Quick Actions --> <!-- New items / Quick Actions -->
{% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" or is_inspector or can_add_structures_repair or can_add_intervention_mobile %} {% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" or is_inspector or can_add_structures_repair or can_add_intervention_mobile %}
<div class="col-12"> <div class="col-12">
@ -40,6 +67,11 @@
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
</a> </a>
{% endif %} {% endif %}
<button type="button" data-bs-toggle="modal" data-bs-target="#newDiscussionModal" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
<span><i class="bi bi-chat-dots me-2"></i>{% translate "Nouvelle discussion" %}</span>
<i class="bi bi-plus-lg"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -129,4 +161,5 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content %} {% endblock content %}

View file

@ -0,0 +1,266 @@
{% load i18n %}
<!-- Modal Nouvelle Discussion -->
<div class="modal fade" id="newDiscussionModal" tabindex="-1" aria-labelledby="newDiscussionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow rounded-4">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="newDiscussionModalLabel">
<i class="bi bi-chat-left-text-fill text-primary me-2"></i>{% translate "Nouvelle discussion" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form method="post" action="{% url 'notifications:discussion_create' %}" enctype="multipart/form-data" id="new-discussion-form">
{% csrf_token %}
<div class="modal-body pt-3">
<div class="mb-3">
<label for="new-discussion-title" class="form-label small fw-semibold text-secondary">
{% translate "Sujet / Titre (optionnel)" %}
</label>
<input type="text" class="form-control rounded-3" id="new-discussion-title" name="title"
placeholder="{% translate 'Ex: Question sur le dossier...' %}">
</div>
<div class="mb-3 position-relative">
<label for="new-discussion-content-display" class="form-label small fw-semibold text-secondary">
{% translate "Message *" %}
</label>
<div class="form-text small text-muted mb-1">
<i class="bi bi-info-circle me-1"></i>{% translate "Tapez @ pour mentionner/tagger des utilisateurs ou des équipes." %}
</div>
<input type="hidden" name="content" id="new-discussion-content-real">
<textarea class="form-control rounded-3 p-3" id="new-discussion-content-display" rows="4"
placeholder="{% translate 'Rédigez votre message... (@ pour tagger quelqu\'un)' %}" required style="resize:none;"></textarea>
<!-- Autocomplete dropdown pour @mentions -->
<div id="new-discussion-mention-dropdown" class="list-group position-absolute shadow-lg border-0 rounded-3"
style="display:none; z-index:1060; max-height:200px; overflow-y:auto; width:100%; top: 100%;"></div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold text-secondary d-block">
{% translate "Pièces jointes" %}
</label>
<label class="btn btn-outline-secondary btn-sm rounded-pill px-3 mb-1" for="new-discussion-file-input">
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre des fichiers" %}
</label>
<input type="file" name="attachments" multiple class="d-none" id="new-discussion-file-input">
<div id="new-discussion-file-preview" class="d-flex flex-wrap gap-2 mt-2" style="display:none;"></div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light rounded-pill px-3" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="submit" class="btn btn-primary rounded-pill px-4">
<i class="bi bi-send-fill me-1.5"></i>{% translate "Envoyer" %}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
document.addEventListener('DOMContentLoaded', function () {
const modalEl = document.getElementById('newDiscussionModal');
if (modalEl && modalEl.parentElement !== document.body) {
document.body.appendChild(modalEl);
}
const textarea = document.getElementById('new-discussion-content-display');
const realInput = document.getElementById('new-discussion-content-real');
const dropdown = document.getElementById('new-discussion-mention-dropdown');
const form = document.getElementById('new-discussion-form');
const fileInput = document.getElementById('new-discussion-file-input');
const filePreview = document.getElementById('new-discussion-file-preview');
if (!textarea || !dropdown || !realInput || !form) return;
let mentions = [];
let mentionStart = -1;
let mentionQuery = null;
let searchTimer = null;
let currentResults = [];
let selectedIndex = -1;
function escHtml(str) {
return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function syncReal() {
const dv = textarea.value;
if (!mentions.length) { realInput.value = dv; return; }
let real = '';
let pos = 0;
const sorted = [...mentions].sort((a, b) => a.start - b.start);
for (const m of sorted) {
if (m.start > pos) real += dv.slice(pos, m.start);
real += m.marker;
pos = m.end;
}
real += dv.slice(pos);
realInput.value = real;
}
function shiftMentions(fromPos, delta) {
for (const m of mentions) {
if (m.start >= fromPos) { m.start += delta; m.end += delta; }
}
}
function removeMentionsIn(start, end) {
mentions = mentions.filter(m => m.end <= start || m.start >= end);
}
function highlightIndex(idx) {
const items = dropdown.querySelectorAll('.list-group-item');
if (!items || !items.length) return;
items.forEach((btn, i) => {
if (i === idx) {
btn.classList.add('active');
btn.scrollIntoView({ block: 'nearest' });
} else {
btn.classList.remove('active');
}
});
}
function showDropdown(results) {
dropdown.innerHTML = '';
currentResults = results || [];
selectedIndex = -1;
if (!results || !results.length) { hideDropdown(); return; }
results.forEach((r, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
const icon = r.type === 'user' ? 'bi-person-fill' : 'bi-people-fill';
const badgeCls = r.type === 'user'
? 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle'
: 'badge bg-info-subtle text-info-emphasis border border-info-subtle';
btn.innerHTML = `<span class="${badgeCls}"><i class="bi ${icon}"></i></span><span>${escHtml(r.display)}</span>`;
btn.addEventListener('mousedown', function (e) {
e.preventDefault();
insertMention(r);
});
btn.addEventListener('mouseenter', function () {
selectedIndex = idx;
highlightIndex(selectedIndex);
});
dropdown.appendChild(btn);
});
selectedIndex = 0;
highlightIndex(0);
dropdown.style.display = 'block';
}
function hideDropdown() {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
mentionStart = -1;
mentionQuery = null;
currentResults = [];
selectedIndex = -1;
}
function insertMention(r) {
const shortTag = `@[${r.display}]`;
const marker = r.type === 'user'
? `@[${r.display}](u:${r.id})`
: `@[${r.display}](t:${r.id})`;
const dv = textarea.value;
const qLen = mentionQuery ? mentionQuery.length : 0;
const before = dv.slice(0, mentionStart);
const after = dv.slice(mentionStart + 1 + qLen);
textarea.value = before + shortTag + ' ' + after;
removeMentionsIn(mentionStart, mentionStart + 1 + qLen);
shiftMentions(mentionStart + 1 + qLen, shortTag.length + 1 - (1 + qLen));
mentions.push({ start: mentionStart, end: mentionStart + shortTag.length, marker });
syncReal();
const pos = mentionStart + shortTag.length + 1;
textarea.setSelectionRange(pos, pos);
textarea.focus();
hideDropdown();
}
textarea.addEventListener('keydown', function (e) {
if (dropdown.style.display === 'block' && currentResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
highlightIndex(selectedIndex);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
e.preventDefault();
insertMention(currentResults[selectedIndex]);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideDropdown();
return;
}
}
});
textarea.addEventListener('input', function () {
const val = textarea.value;
const cursorPos = textarea.selectionStart;
const lastAt = val.lastIndexOf('@', cursorPos - 1);
if (lastAt !== -1) {
const query = val.slice(lastAt + 1, cursorPos);
if (!/\s/.test(query) && query.length <= 30) {
mentionStart = lastAt;
mentionQuery = query;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
fetch("{% url 'notifications:api_mention_search' %}?q=" + encodeURIComponent(query))
.then(r => r.json())
.then(data => showDropdown(data.results))
.catch(() => hideDropdown());
}, 150);
syncReal();
return;
}
}
hideDropdown();
syncReal();
});
form.addEventListener('submit', function () {
syncReal();
});
// Aperçu pièces jointes
if (fileInput && filePreview) {
fileInput.addEventListener('change', function () {
filePreview.innerHTML = '';
const files = fileInput.files;
if (!files || files.length === 0) {
filePreview.style.display = 'none';
return;
}
filePreview.style.display = 'flex';
Array.from(files).forEach((file) => {
const chip = document.createElement('span');
chip.className = 'badge bg-light text-dark border px-2 py-1 small';
chip.innerHTML = `<i class="bi bi-paperclip me-1"></i>${escHtml(file.name)}`;
filePreview.appendChild(chip);
});
});
}
});
})();
</script>

View file

@ -1,35 +1,41 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load mention_tags %}
{% block head %} {% block head %}
<style> <style>
.thread-container { .thread-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1.25rem;
} }
.msg-bubble { .msg-bubble {
max-width: 80%; max-width: 85%;
display: inline-block; display: inline-block;
word-break: break-word; word-break: break-word;
line-height: 1.5;
font-size: 0.935rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
} }
.msg-mine .msg-bubble { .msg-mine .msg-bubble {
background: var(--brand-color, #2f3e8b); background: var(--brand-color, #2f3e8b);
color: #fff; color: #fff;
border-radius: 16px 16px 4px 16px; border-radius: 18px 18px 4px 18px;
} }
.msg-other .msg-bubble { .msg-other .msg-bubble {
background: #f1f3f5; background: #f1f3f5;
color: #212529; color: #212529;
border-radius: 16px 16px 16px 4px; border-radius: 18px 18px 18px 4px;
border: 1px solid #e9ecef;
} }
.msg-mine { align-items: flex-end; } .msg-mine { align-items: flex-end; }
.msg-other { align-items: flex-start; } .msg-other { align-items: flex-start; }
.avatar-xs { .avatar-xs {
width: 32px; width: 34px;
height: 32px; height: 34px;
font-size: 0.75rem; font-size: 0.8rem;
font-weight: 600;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -37,83 +43,111 @@
flex-shrink: 0; flex-shrink: 0;
background: #6c757d; background: #6c757d;
color: #fff; color: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
} }
#thread-scroll { #thread-scroll {
max-height: 60vh; max-height: 65vh;
overflow-y: auto; overflow-y: auto;
background-color: #fafbfc;
} }
.attachment-thumb { .attachment-thumb {
width: 60px; width: 70px;
height: 60px; height: 70px;
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 8px;
border: 1px solid #dee2e6;
transition: transform 0.15s;
}
.attachment-thumb:hover {
transform: scale(1.05);
}
.participant-pill {
font-size: 0.825rem;
font-weight: 500;
padding: 0.35rem 0.65rem;
background-color: #ffffff;
color: #334155;
border: 1px solid #cbd5e1;
border-radius: 9999px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
display: inline-flex;
align-items: center;
gap: 0.35rem;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container py-4" style="max-width:860px;"> <div class="container py-4" style="max-width:880px;">
<!-- Breadcrumb / Header --> <!-- Breadcrumb / Header -->
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-3 mb-3">
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-sm btn-outline-secondary"> <a href="{% url 'notifications:discussion_list' %}" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-left fs-6"></i>
</a> </a>
<div class="flex-grow-1"> <div class="flex-grow-1 min-w-0">
<h1 class="h4 mb-0">{{ discussion.title|default:discussion }}</h1> <div class="d-flex align-items-center gap-2 flex-wrap">
{% if discussion.object_label %} <h1 class="h4 mb-0 fw-bold text-dark">{{ discussion.title|default:discussion }}</h1>
<a href="{{ discussion.object_url }}" class="small text-muted"> {% if discussion.object_label %}
<i class="bi bi-link-45deg me-1"></i>{{ discussion.object_label }} <a href="{{ discussion.object_url }}" class="badge bg-primary bg-opacity-10 text-primary border border-primary-subtle text-decoration-none py-1.5 px-2.5 rounded-pill">
</a> <i class="bi bi-link-45deg me-1"></i>{{ discussion.object_label }}
{% endif %} </a>
{% endif %}
{% if discussion.is_closed %}
<span class="badge bg-secondary rounded-pill py-1.5 px-2.5"><i class="bi bi-lock me-1"></i>{% translate "Clôturée" %}</span>
{% endif %}
</div>
</div> </div>
{% if discussion.is_closed %}
<span class="badge bg-secondary"><i class="bi bi-lock me-1"></i>{% translate "Clôturée" %}</span>
{% endif %}
</div> </div>
<!-- Participants --> <!-- Participants -->
<div class="mb-3 d-flex flex-wrap gap-1"> <div class="mb-3 d-flex flex-wrap gap-1.5 align-items-center">
<span class="small text-muted me-1"><i class="bi bi-people me-1"></i>{% translate "Participants" %} :</span>
{% for p in discussion.participants.all %} {% for p in discussion.participants.all %}
<span class="badge bg-light text-dark border"> <span class="participant-pill">
<i class="bi bi-person me-1"></i>{{ p.get_full_name|default:p.username }} <i class="bi bi-person-fill text-primary opacity-75"></i>
{{ p.get_full_name|default:p.username }}
</span> </span>
{% endfor %} {% endfor %}
</div> </div>
<!-- Messages --> <!-- Messages -->
<div class="card shadow-sm mb-3"> <div class="card shadow-sm border-0 mb-3 rounded-3 overflow-hidden">
<div class="card-body p-3" id="thread-scroll"> <div class="card-body p-3 p-md-4" id="thread-scroll">
{% if not thread_messages %} {% if not thread_messages %}
<p class="text-muted text-center py-4 mb-0"> <div class="text-muted text-center py-5">
<i class="bi bi-chat-square d-block fs-2 mb-2"></i> <i class="bi bi-chat-square-dots d-block fs-1 mb-2 text-primary opacity-50"></i>
{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %} <p class="mb-0">{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}</p>
</p> </div>
{% else %} {% else %}
<div class="thread-container"> <div class="thread-container">
{% for msg in thread_messages %} {% for msg in thread_messages %}
<div class="d-flex flex-column {% if msg.author == request.user %}msg-mine{% else %}msg-other{% endif %}"> <div class="d-flex flex-column {% if msg.author == request.user %}msg-mine{% else %}msg-other{% endif %}">
{% if msg.author != request.user %} {% if msg.author != request.user %}
<div class="d-flex align-items-end gap-2"> <div class="d-flex align-items-start gap-2">
<div class="avatar-xs"> <div class="avatar-xs mt-1">
{{ msg.author.get_short_name|first|upper|default:msg.author.username|first|upper }} {{ msg.author.get_short_name|first|upper|default:msg.author.username|first|upper }}
</div> </div>
{% endif %} {% endif %}
<div> <div>
{% if msg.author != request.user %} {% if msg.author != request.user %}
<div class="small text-muted mb-1"> <div class="small text-muted mb-1 ms-1">
<strong>{{ msg.author.get_full_name|default:msg.author.username }}</strong> <strong>{{ msg.author.get_full_name|default:msg.author.username }}</strong>
· {{ msg.created_at|date:"d/m/Y H:i" }} · {{ msg.created_at|date:"d/m/Y H:i" }}
</div> </div>
{% else %} {% else %}
<div class="small text-muted mb-1 text-end"> <div class="small text-muted mb-1 text-end me-1">
{{ msg.created_at|date:"d/m/Y H:i" }} {{ msg.created_at|date:"d/m/Y H:i" }}
</div> </div>
{% endif %} {% endif %}
<div class="msg-bubble p-3"> <div class="msg-bubble p-3">
{{ msg.content|linebreaksbr }} {% if msg.author == request.user %}
{{ msg.content|render_mentions:True }}
{% else %}
{{ msg.content|render_mentions }}
{% endif %}
{% if msg.attachments.exists %} {% if msg.attachments.exists %}
<div class="mt-2 d-flex flex-wrap gap-2"> <div class="mt-2.5 pt-2 border-top border-opacity-25 d-flex flex-wrap gap-2">
{% for att in msg.attachments.all %} {% for att in msg.attachments.all %}
{% if att.is_image %} {% if att.is_image %}
<a href="{{ att.file.url }}" target="_blank"> <a href="{{ att.file.url }}" target="_blank">
@ -121,7 +155,7 @@
</a> </a>
{% else %} {% else %}
<a href="{{ att.file.url }}" target="_blank" <a href="{{ att.file.url }}" target="_blank"
class="btn btn-sm {% if msg.author == request.user %}btn-outline-light{% else %}btn-outline-secondary{% endif %}"> class="btn btn-sm {% if msg.author == request.user %}btn-light text-dark{% else %}btn-outline-secondary{% endif %}">
<i class="bi bi-paperclip me-1"></i>{{ att.name|truncatechars:24 }} <i class="bi bi-paperclip me-1"></i>{{ att.name|truncatechars:24 }}
</a> </a>
{% endif %} {% endif %}
@ -144,26 +178,28 @@
{% if not discussion.is_closed %} {% if not discussion.is_closed %}
<form method="post" enctype="multipart/form-data" action=""> <form method="post" enctype="multipart/form-data" action="">
{% csrf_token %} {% csrf_token %}
<div class="card shadow-sm"> <div class="card shadow-sm border-0 rounded-3">
<div class="card-body p-3"> <div class="card-body p-3">
<textarea name="content" class="form-control mb-2" rows="3" <textarea name="content" class="form-control mb-2 border-0 bg-light rounded-3 p-3" rows="3"
placeholder="{% translate 'Répondre...' %}" required id="reply-textarea"></textarea> placeholder="{% translate 'Écrire un message... (@ pour mentionner)' %}" required id="reply-textarea" style="resize:none;"></textarea>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center pt-2">
<label class="btn btn-outline-secondary btn-sm mb-0" id="attach-label"> <label class="btn btn-outline-secondary btn-sm mb-0 rounded-pill px-3" id="attach-label">
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre" %} <i class="bi bi-paperclip me-1"></i>{% translate "Joindre" %}
<input type="file" name="attachments" multiple class="d-none" id="attach-input" <input type="file" name="attachments" multiple class="d-none" id="attach-input"
onchange="document.getElementById('attach-count').textContent = this.files.length ? '(' + this.files.length + ')' : ''"> onchange="document.getElementById('attach-count').textContent = this.files.length ? '(' + this.files.length + ')' : ''">
</label> </label>
<span id="attach-count" class="small text-muted"></span> <div class="d-flex align-items-center gap-2">
<button type="submit" class="btn btn-primary"> <span id="attach-count" class="small text-muted"></span>
<i class="bi bi-send me-1"></i>{% translate "Envoyer" %} <button type="submit" class="btn btn-primary px-4 rounded-pill">
</button> <i class="bi bi-send-fill me-1.5"></i>{% translate "Envoyer" %}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
{% else %} {% else %}
<div class="alert alert-secondary"><i class="bi bi-lock me-2"></i>{% translate "Cette discussion est clôturée." %}</div> <div class="alert alert-secondary border-0 shadow-sm rounded-3"><i class="bi bi-lock me-2"></i>{% translate "Cette discussion est clôturée." %}</div>
{% endif %} {% endif %}
</div> </div>

View file

@ -36,9 +36,14 @@
<h1 class="h3 mb-0"> <h1 class="h3 mb-0">
<i class="bi bi-chat-dots me-2 text-primary"></i>{% translate "Discussions" %} <i class="bi bi-chat-dots me-2 text-primary"></i>{% translate "Discussions" %}
</h1> </h1>
<a href="{% url 'notifications:inbox' %}" class="btn btn-outline-secondary"> <div class="d-flex gap-2">
<i class="bi bi-bell me-1"></i>{% translate "Notifications" %} <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
</a> <i class="bi bi-chat-plus-fill me-1.5"></i>{% translate "Nouvelle discussion" %}
</button>
<a href="{% url 'notifications:inbox' %}" class="btn btn-outline-secondary">
<i class="bi bi-bell me-1"></i>{% translate "Notifications" %}
</a>
</div>
</div> </div>
{% if not items %} {% if not items %}
@ -108,4 +113,5 @@
{% endif %} {% endif %}
</div> </div>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content %} {% endblock content %}

View file

@ -177,7 +177,7 @@
<i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }} <i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }}
</span> </span>
{% endif %} {% endif %}
{% if notif.action_url %} {% if notif.action_url and notif.action_url != request.path and notif.action_url != '/notifications/' and notif.action_url != '/notifications' %}
<a href="{{ notif.action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2"> <a href="{{ notif.action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
{{ notif.action_label|default:notif.action_url }} {{ notif.action_label|default:notif.action_url }}
<i class="bi bi-arrow-right ms-1"></i> <i class="bi bi-arrow-right ms-1"></i>

View file

@ -98,7 +98,7 @@
{% if item.team_name %} {% if item.team_name %}
<span class="badge bg-secondary notif-type-pill">{{ item.team_name }}</span> <span class="badge bg-secondary notif-type-pill">{{ item.team_name }}</span>
{% endif %} {% endif %}
{% if item.mobile_action_url %} {% if item.mobile_action_url and item.mobile_action_url != request.path and item.mobile_action_url != '/notifications/' and item.mobile_action_url != '/notifications' and item.mobile_action_url != '/mobile/notifications/' and item.mobile_action_url != '/mobile/notifications' %}
<a href="{{ item.mobile_action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2"> <a href="{{ item.mobile_action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
{{ notif.action_label|default:item.mobile_action_url }} {{ notif.action_label|default:item.mobile_action_url }}
</a> </a>

View file

@ -11,17 +11,19 @@ MENTION_PATTERN = re.compile(r'@\[([^\]]+)\]\(([ut]):(\d+)\)')
@register.filter @register.filter
def render_mentions(value): def render_mentions(value, is_mine=False):
""" """
Renders @[Name](u:id) / @[Name](t:id) mention markers as styled badge spans, Renders @[Name](u:id) / @[Name](t:id) mention markers as styled badge spans,
and converts newlines to <br> (like linebreaksbr). and converts newlines to <br> (like linebreaksbr).
- u: user mention → primary badge - u: user mention → badge
- t: team mention → info badge - t: team mention → badge
""" """
if not value: if not value:
return '' return ''
is_mine_bool = bool(is_mine and str(is_mine).lower() not in ('false', '0', ''))
parts = [] parts = []
last_end = 0 last_end = 0
@ -32,16 +34,24 @@ def render_mentions(value):
name = m.group(1) name = m.group(1)
kind = m.group(2) kind = m.group(2)
if kind == 'u': if is_mine_bool:
css = 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle fw-semibold' if kind == 'u':
icon = 'bi-person-fill' css = 'badge bg-white text-primary shadow-sm fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-person-fill text-primary'
else:
css = 'badge bg-white text-info shadow-sm fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-people-fill text-info'
else: else:
css = 'badge bg-info-subtle text-info-emphasis border border-info-subtle fw-semibold' if kind == 'u':
icon = 'bi-people-fill' css = 'badge bg-primary bg-opacity-10 text-primary border border-primary-subtle fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-person-fill'
else:
css = 'badge bg-info bg-opacity-10 text-info border border-info-subtle fw-semibold d-inline-flex align-items-center align-middle mx-1 my-0.5 px-2 py-1 rounded-pill'
icon = 'bi-people-fill'
parts.append( parts.append(
f'<span class="{css}">' f'<span class="{css}">'
f'<i class="bi {icon} me-1" style="font-size:0.75em;"></i>' f'<i class="bi {icon} me-1" style="font-size:0.85em;"></i>'
f'@{escape(name)}' f'@{escape(name)}'
f'</span>' f'</span>'
) )

View file

@ -0,0 +1,62 @@
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from notifications.models import Discussion, Message
User = get_user_model()
class DiscussionListTests(TestCase):
def setUp(self):
self.user1 = User.objects.create_user(username='user1', password='pass')
self.user2 = User.objects.create_user(username='user2', password='pass')
self.user3 = User.objects.create_user(username='user3', password='pass')
def test_discussion_list_filtering(self):
# Discussion 1: created by user1
d1 = Discussion.objects.create(title="Discussion 1", created_by=self.user1)
d1.participants.add(self.user1, self.user2, self.user3)
# Discussion 2: created by user2, user1 wrote a message
d2 = Discussion.objects.create(title="Discussion 2", created_by=self.user2)
d2.participants.add(self.user1, self.user2, self.user3)
Message.objects.create(discussion=d2, author=self.user1, content="Hello from user1")
# Discussion 3: created by user2, user1 is tagged
d3 = Discussion.objects.create(title="Discussion 3", created_by=self.user2)
d3.participants.add(self.user1, self.user2, self.user3)
Message.objects.create(discussion=d3, author=self.user2, content=f"Hello @[User 1](u:{self.user1.pk})")
# Discussion 4: created by user2, user3 participant but user1 is NOT author and NOT tagged
d4 = Discussion.objects.create(title="Discussion 4", created_by=self.user2)
d4.participants.add(self.user1, self.user2, self.user3)
# Discussion 5: auto-created intervention discussion with created_by=user1, but user1 never posted a message and was never tagged
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(User)
d5 = Discussion.objects.create(title="Discussion 5", created_by=self.user1, content_type=ct, object_id=1)
Message.objects.create(discussion=d5, author=self.user2, content="Hello user3")
self.client.force_login(self.user1)
response = self.client.get(reverse('notifications:discussion_list'))
self.assertEqual(response.status_code, 200)
discussions_in_context = [item['discussion'] for item in response.context['items']]
self.assertIn(d1, discussions_in_context)
self.assertIn(d2, discussions_in_context)
self.assertIn(d3, discussions_in_context)
self.assertNotIn(d4, discussions_in_context)
self.assertNotIn(d5, discussions_in_context)
def test_discussion_create(self):
self.client.force_login(self.user1)
response = self.client.post(reverse('notifications:discussion_create'), {
'title': 'Sujet test',
'content': f'Bonjour @[User 2](u:{self.user2.pk})',
})
self.assertEqual(response.status_code, 302)
discussion = Discussion.objects.get(title='Sujet test')
self.assertEqual(discussion.created_by, self.user1)
self.assertIn(self.user1, discussion.participants.all())
self.assertIn(self.user2, discussion.participants.all())
self.assertEqual(discussion.messages.count(), 1)

View file

@ -19,6 +19,7 @@ urlpatterns = [
path('teams/<int:pk>/delete/', views.team_delete, name='team_delete'), path('teams/<int:pk>/delete/', views.team_delete, name='team_delete'),
# Discussions # Discussions
path('discussions/', views.discussion_list, name='discussion_list'), path('discussions/', views.discussion_list, name='discussion_list'),
path('discussions/new/', views.discussion_create, name='discussion_create'),
path('discussions/<int:pk>/', views.discussion_detail, name='discussion_detail'), path('discussions/<int:pk>/', views.discussion_detail, name='discussion_detail'),
# API # API
path('api/unread-count/', views.api_unread_count, name='api_unread_count'), path('api/unread-count/', views.api_unread_count, name='api_unread_count'),

View file

@ -476,9 +476,28 @@ def team_delete(request, pk):
@login_required @login_required
def discussion_list(request): def discussion_list(request):
"""Liste les discussions auxquelles l'utilisateur participe.""" """
Liste les discussions dont l'utilisateur est l'auteur (créateur de la discussion
ou auteur d'un message) ou pour lesquelles il (ou une de ses équipes) a été explicitement taggé.
"""
user = request.user
user_team_ids = list(user.notification_teams.values_list('id', flat=True))
mention_q = Q(messages__content__icontains=f'(u:{user.pk})')
for tid in user_team_ids:
mention_q |= Q(messages__content__icontains=f'(t:{tid})')
discussion_filter = (
Q(messages__author=user, messages__is_deleted=False)
| Q(notifications__recipients__user=user)
| Q(notifications__team_targets__team_id__in=user_team_ids)
| (mention_q & Q(messages__is_deleted=False))
| (Q(created_by=user) & Q(content_type__isnull=True))
)
discussions = ( discussions = (
request.user.discussions Discussion.objects.filter(discussion_filter)
.distinct()
.prefetch_related('participants', 'messages') .prefetch_related('participants', 'messages')
.select_related('created_by') .select_related('created_by')
.order_by('-updated_at') .order_by('-updated_at')
@ -536,6 +555,38 @@ def discussion_detail(request, pk):
}) })
@login_required
def discussion_create(request):
"""
Crée une nouvelle discussion indépendante (sans objet lié).
Poste le premier message et notifie les personnes/équipes mentionnées.
"""
if request.method == 'POST':
title = request.POST.get('title', '').strip()
content = request.POST.get('content', '').strip()
files = request.FILES.getlist('attachments')
if content:
discussion = Discussion.objects.create(
title=title or f"Discussion de {request.user.get_full_name() or request.user.username}",
created_by=request.user,
)
discussion.participants.add(request.user)
post_message(
discussion=discussion,
author=request.user,
content=content,
files=files or None,
)
messages.success(request, _("Discussion créée avec succès."))
return redirect('notifications:discussion_detail', pk=discussion.pk)
else:
messages.error(request, _("Le contenu du message ne peut pas être vide."))
return redirect('notifications:discussion_list')
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# Mention search API # Mention search API
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────

View file

@ -470,9 +470,20 @@
const badge = document.getElementById('navbar-notif-badge'); const badge = document.getElementById('navbar-notif-badge');
function updateBadge(count) { function updateBadge(count) {
if (!badge) return; if (badge) {
if (count > 0) { badge.textContent = count; badge.style.display = ''; } if (count > 0) { badge.textContent = count; badge.style.display = ''; }
else { badge.style.display = 'none'; } else { badge.style.display = 'none'; }
}
const homeAlert = document.getElementById('home-unread-notifications-alert');
if (homeAlert) {
if (count > 0) {
const countEls = homeAlert.querySelectorAll('.home-unread-notifications-count');
countEls.forEach(el => el.textContent = count);
homeAlert.style.display = '';
} else {
homeAlert.style.display = 'none';
}
}
} }
const TYPE_ICONS = { const TYPE_ICONS = {