feat: implement notification inbox and discussion management views with corresponding templates and reminder command

This commit is contained in:
Karl Determe 2026-08-14 20:50:23 +02:00
parent 07375e3be0
commit d3484e626d
6 changed files with 513 additions and 371 deletions

View file

@ -102,7 +102,7 @@ class Command(BaseCommand):
html_items = [] html_items = []
for r in valid_recipients: for r in valid_recipients:
n = r.notification n = r.notification
created_str = n.created_at.strftime('%d/%m/%Y à %H:%i') created_str = n.created_at.strftime('%d/%m/%Y à %H:%M')
sender_str = n.sender.get_full_name() if n.sender else _("Système") sender_str = n.sender.get_full_name() if n.sender else _("Système")
link = f"{domain}{n.action_url}" if n.action_url else f"{domain}/notifications/" link = f"{domain}{n.action_url}" if n.action_url else f"{domain}/notifications/"

View file

@ -82,7 +82,7 @@
<!-- Breadcrumb / Header --> <!-- Breadcrumb / Header -->
<div class="d-flex align-items-center gap-3 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 rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;"> <a href="{% url 'notifications:inbox' %}?tab=discussions" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;" title="{% translate 'Retour aux discussions' %}">
<i class="bi bi-arrow-left fs-6"></i> <i class="bi bi-arrow-left fs-6"></i>
</a> </a>
<div class="flex-grow-1 min-w-0"> <div class="flex-grow-1 min-w-0">

View file

@ -20,25 +20,99 @@
font-size: 0.7rem; font-size: 0.7rem;
padding: 2px 6px; padding: 2px 6px;
} }
.discussion-item {
transition: background 0.15s;
border-left: 4px solid transparent;
}
.discussion-item.has-unread {
background-color: #f0f4ff;
border-left-color: var(--brand-color, #2f3e8b);
}
.discussion-item:hover {
background-color: #e8edf8;
text-decoration: none;
}
.avatar-sm {
width: 38px;
height: 38px;
font-size: 0.9rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.inbox-nav-tabs .nav-link {
font-size: 1.05rem;
padding: 0.6rem 1.25rem;
border: none;
border-bottom: 3px solid transparent;
color: #6c757d;
font-weight: 500;
background: transparent;
}
.inbox-nav-tabs .nav-link:hover {
color: var(--brand-color, #2f3e8b);
border-bottom-color: #dee2e6;
}
.inbox-nav-tabs .nav-link.active {
color: var(--brand-color, #2f3e8b);
font-weight: 700;
border-bottom-color: var(--brand-color, #2f3e8b);
background: transparent;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<!-- Header --> <!-- Header with Tabs and Contextual Actions -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3 border-bottom pb-2">
<h2 class="mb-0"> <!-- Tabs -->
<ul class="nav inbox-nav-tabs mb-0 flex-nowrap" id="inboxTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {% if active_tab != 'discussions' %}active{% endif %}"
id="tab-notifications-btn"
data-bs-toggle="tab"
data-bs-target="#pane-notifications"
type="button"
role="tab"
aria-controls="pane-notifications"
aria-selected="{% if active_tab != 'discussions' %}true{% else %}false{% endif %}">
<i class="bi bi-bell me-2"></i>{% translate "Notifications" %} <i class="bi bi-bell me-2"></i>{% translate "Notifications" %}
{% if unread_count > 0 %} {% if unread_count > 0 %}
<span class="badge rounded-pill ms-2" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;"> <span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
{{ unread_count }} {{ unread_count }}
</span> </span>
{% endif %} {% endif %}
</h2> </button>
<div class="d-flex gap-2"> </li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if active_tab == 'discussions' %}active{% endif %}"
id="tab-discussions-btn"
data-bs-toggle="tab"
data-bs-target="#pane-discussions"
type="button"
role="tab"
aria-controls="pane-discussions"
aria-selected="{% if active_tab == 'discussions' %}true{% else %}false{% endif %}">
<i class="bi bi-chat-dots me-2"></i>{% translate "Discussions" %}
{% if unread_discussions_count > 0 %}
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
{{ unread_discussions_count }}
</span>
{% endif %}
</button>
</li>
</ul>
<!-- Action buttons (contextual according to active tab) -->
<div class="d-flex gap-2 align-items-center">
<!-- Notifications Tab Actions -->
<div id="actions-notifications" class="d-flex gap-2 align-items-center {% if active_tab == 'discussions' %}d-none{% endif %}">
{% if unread_count > 0 %} {% if unread_count > 0 %}
<form method="post" action="{% url 'notifications:mark_all_read' %}"> <form method="post" action="{% url 'notifications:mark_all_read' %}" class="d-inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-outline-secondary btn-sm"> <button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %} <i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %}
@ -50,19 +124,36 @@
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouvelle notification" %} <i class="bi bi-plus-circle me-1"></i>{% translate "Nouvelle notification" %}
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-outline-secondary btn-sm"> </div>
<i class="bi bi-chat-left-text me-1"></i>{% translate "Discussions" %}
</a> <!-- Discussions Tab Actions -->
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary btn-sm"> <div id="actions-discussions" class="d-flex gap-2 align-items-center {% if active_tab != 'discussions' %}d-none{% endif %}">
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
<i class="bi bi-chat-plus-fill me-1"></i>{% translate "Nouvelle discussion" %}
</button>
</div>
<!-- Settings Button -->
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary btn-sm" title="{% translate 'Paramètres' %}">
<i class="bi bi-gear me-1"></i>{% translate "Paramètres" %} <i class="bi bi-gear me-1"></i>{% translate "Paramètres" %}
</a> </a>
</div> </div>
</div> </div>
<!-- Tabs Content -->
<div class="tab-content" id="inboxTabsContent">
<!-- ================= Pane 1: Notifications ================= -->
<div class="tab-pane fade {% if active_tab != 'discussions' %}show active{% endif %}"
id="pane-notifications"
role="tabpanel"
aria-labelledby="tab-notifications-btn">
<!-- Filters --> <!-- Filters -->
<div class="card mb-3 border-0 shadow-sm"> <div class="card mb-3 border-0 shadow-sm">
<div class="card-body py-2"> <div class="card-body py-2">
<form method="get" class="row g-2 align-items-center"> <form method="get" class="row g-2 align-items-center" id="notifications-filter-form">
<input type="hidden" name="tab" value="notifications">
<div class="col-auto"> <div class="col-auto">
<select name="type" class="form-select form-select-sm" onchange="this.form.submit()"> <select name="type" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "Tous les types" %}</option> <option value="">{% translate "Tous les types" %}</option>
@ -227,14 +318,148 @@
</p> </p>
</div> </div>
{% endif %} {% endif %}
</div>
<!-- ================= Pane 2: Discussions ================= -->
<div class="tab-pane fade {% if active_tab == 'discussions' %}show active{% endif %}"
id="pane-discussions"
role="tabpanel"
aria-labelledby="tab-discussions-btn">
{% if not discussion_items %}
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-chat-square fs-1 d-block mb-3"></i>
<p class="mb-0">{% translate "Vous n'avez encore aucune discussion." %}</p>
</div>
</div>
{% else %}
<div class="card border-0 shadow-sm">
<div class="list-group list-group-flush">
{% for item in discussion_items %}
<a href="{% url 'notifications:discussion_detail' item.discussion.pk %}"
class="list-group-item list-group-item-action discussion-item px-4 py-3 {% if item.unread_count %}has-unread{% endif %}">
<div class="d-flex align-items-start gap-3">
<!-- Icône -->
<div class="avatar-sm bg-primary text-white">
<i class="bi bi-chat-dots"></i>
</div>
<!-- Contenu -->
<div class="flex-grow-1 min-width-0">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold text-dark">{{ item.discussion.title|default:item.discussion }}</span>
{% if item.discussion.object_label %}
<span class="badge bg-light text-secondary ms-2 small border">
<i class="bi bi-link-45deg me-1"></i>{{ item.discussion.object_label }}
</span>
{% endif %}
</div>
<span class="text-muted small ms-3 text-nowrap">
{{ item.discussion.updated_at|date:"d/m/Y H:i" }}
</span>
</div>
{% if item.last_message %}
<p class="mb-1 small text-muted text-truncate mt-1" style="max-width:650px;">
<strong>{{ item.last_message.author.get_full_name|default:item.last_message.author.username }}</strong> :
{{ item.last_message.content|truncatechars:120 }}
</p>
{% else %}
<p class="mb-1 small text-muted mt-1">{% translate "Aucun message encore." %}</p>
{% endif %}
<!-- Participants -->
<div class="d-flex align-items-center gap-1 mt-1">
{% for p in item.discussion.participants.all|slice:":6" %}
<span class="badge bg-secondary" title="{{ p.get_full_name|default:p.username }}">
{{ p.get_full_name|default:p.username|truncatechars:12 }}
</span>
{% endfor %}
{% if item.discussion.participants.count > 6 %}
<span class="badge bg-light text-muted border">+{{ item.discussion.participants.count|add:"-6" }}</span>
{% endif %}
</div>
</div>
<!-- Badge non-lus -->
{% if item.unread_count %}
<span class="badge rounded-pill align-self-center" style="background-color: var(--brand-color, #2f3e8b);">{{ item.unread_count }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div> </div>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content %} {% endblock content %}
{% block script %} {% block script %}
<script> <script>
const CSRF_TOKEN = "{{ csrf_token }}"; const CSRF_TOKEN = "{{ csrf_token }}";
// ---- Tab Switching & State Persistence ----
document.addEventListener('DOMContentLoaded', () => {
const notifTabBtn = document.getElementById('tab-notifications-btn');
const discTabBtn = document.getElementById('tab-discussions-btn');
const notifActions = document.getElementById('actions-notifications');
const discActions = document.getElementById('actions-discussions');
function syncActionsToolbar(targetId) {
if (targetId === '#pane-discussions' || targetId === 'discussions') {
if (notifActions) notifActions.classList.add('d-none');
if (discActions) discActions.classList.remove('d-none');
} else {
if (discActions) discActions.classList.add('d-none');
if (notifActions) notifActions.classList.remove('d-none');
}
}
if (notifTabBtn && discTabBtn) {
notifTabBtn.addEventListener('shown.bs.tab', (e) => {
syncActionsToolbar(e.target.getAttribute('data-bs-target'));
if (history.replaceState) {
const url = new URL(window.location);
url.searchParams.delete('tab');
url.hash = 'notifications';
history.replaceState(null, '', url.pathname + url.search + url.hash);
}
});
discTabBtn.addEventListener('shown.bs.tab', (e) => {
syncActionsToolbar(e.target.getAttribute('data-bs-target'));
if (history.replaceState) {
const url = new URL(window.location);
url.searchParams.set('tab', 'discussions');
url.hash = 'discussions';
history.replaceState(null, '', url.pathname + url.search + url.hash);
}
});
// Check URL hash or query params on load
const urlParams = new URLSearchParams(window.location.search);
const tabParam = urlParams.get('tab');
const hash = window.location.hash;
if (tabParam === 'discussions' || hash === '#discussions') {
const tab = bootstrap.Tab.getOrCreateInstance(discTabBtn);
tab.show();
syncActionsToolbar('discussions');
} else if (tabParam === 'notifications' || hash === '#notifications') {
const tab = bootstrap.Tab.getOrCreateInstance(notifTabBtn);
tab.show();
syncActionsToolbar('notifications');
}
}
});
// ---- Mark Read ---- // ---- Mark Read ----
document.querySelectorAll('.mark-read-btn').forEach(checkbox => { document.querySelectorAll('.mark-read-btn').forEach(checkbox => {
checkbox.addEventListener('change', async () => { checkbox.addEventListener('change', async () => {

View file

@ -12,7 +12,7 @@ class DiscussionListTests(TestCase):
self.user2 = User.objects.create_user(username='user2', password='pass') self.user2 = User.objects.create_user(username='user2', password='pass')
self.user3 = User.objects.create_user(username='user3', password='pass') self.user3 = User.objects.create_user(username='user3', password='pass')
def test_discussion_list_filtering(self): def test_discussion_list_filtering_on_inbox(self):
# Discussion 1: created by user1 # Discussion 1: created by user1
d1 = Discussion.objects.create(title="Discussion 1", created_by=self.user1) d1 = Discussion.objects.create(title="Discussion 1", created_by=self.user1)
d1.participants.add(self.user1, self.user2, self.user3) d1.participants.add(self.user1, self.user2, self.user3)
@ -38,16 +38,23 @@ class DiscussionListTests(TestCase):
Message.objects.create(discussion=d5, author=self.user2, content="Hello user3") Message.objects.create(discussion=d5, author=self.user2, content="Hello user3")
self.client.force_login(self.user1) self.client.force_login(self.user1)
response = self.client.get(reverse('notifications:discussion_list')) response = self.client.get(reverse('notifications:inbox') + '?tab=discussions')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['active_tab'], 'discussions')
discussions_in_context = [item['discussion'] for item in response.context['items']] discussions_in_context = [item['discussion'] for item in response.context['discussion_items']]
self.assertIn(d1, discussions_in_context) self.assertIn(d1, discussions_in_context)
self.assertIn(d2, discussions_in_context) self.assertIn(d2, discussions_in_context)
self.assertIn(d3, discussions_in_context) self.assertIn(d3, discussions_in_context)
self.assertNotIn(d4, discussions_in_context) self.assertNotIn(d4, discussions_in_context)
self.assertNotIn(d5, discussions_in_context) self.assertNotIn(d5, discussions_in_context)
def test_discussion_list_url_redirect(self):
self.client.force_login(self.user1)
response = self.client.get(reverse('notifications:discussion_list'))
self.assertEqual(response.status_code, 302)
self.assertIn('tab=discussions', response.url)
def test_discussion_create(self): def test_discussion_create(self):
self.client.force_login(self.user1) self.client.force_login(self.user1)
response = self.client.post(reverse('notifications:discussion_create'), { response = self.client.post(reverse('notifications:discussion_create'), {
@ -62,6 +69,46 @@ class DiscussionListTests(TestCase):
self.assertEqual(discussion.messages.count(), 1) self.assertEqual(discussion.messages.count(), 1)
class InboxTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='test_inbox_user', password='pass')
from notifications.models import Notification, NotificationRecipient, Discussion
# Create a notification for user
notif = Notification.objects.create(
title="Test notification",
content="Notification content",
notification_type=Notification.TYPE_INFO,
)
NotificationRecipient.objects.create(
notification=notif,
user=self.user,
)
# Create a discussion for user
d = Discussion.objects.create(title="Test discussion", created_by=self.user)
d.participants.add(self.user)
def test_inbox_renders_both_notifications_and_discussions(self):
self.client.force_login(self.user)
response = self.client.get(reverse('notifications:inbox'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['active_tab'], 'notifications')
self.assertEqual(response.context['unread_count'], 1)
self.assertEqual(len(response.context['notifications']), 1)
self.assertEqual(len(response.context['discussion_items']), 1)
self.assertContains(response, 'Test notification')
self.assertContains(response, 'Test discussion')
self.assertContains(response, 'id="pane-notifications"')
self.assertContains(response, 'id="pane-discussions"')
self.assertContains(response, 'id="tab-notifications-btn"')
self.assertContains(response, 'id="tab-discussions-btn"')
def test_inbox_tab_discussions_parameter(self):
self.client.force_login(self.user)
response = self.client.get(reverse('notifications:inbox') + '?tab=discussions')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['active_tab'], 'discussions')
class NotificationReminderTests(TestCase): class NotificationReminderTests(TestCase):
def setUp(self): def setUp(self):
from common.models import UserConfig from common.models import UserConfig
@ -87,6 +134,9 @@ class NotificationReminderTests(TestCase):
self.assertEqual(self.config.notification_reminder_emails, 'custom@example.com, other@example.com') self.assertEqual(self.config.notification_reminder_emails, 'custom@example.com, other@example.com')
self.assertEqual(self.config.notification_reminder_delay_hours, 12) self.assertEqual(self.config.notification_reminder_delay_hours, 12)
from django.test import override_settings
@override_settings(EMAIL_OVERRIDE=None)
def test_send_unread_notification_reminders_command(self): def test_send_unread_notification_reminders_command(self):
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone

View file

@ -1,4 +1,5 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
@ -183,10 +184,46 @@ def _translate_notification_data(notification):
} }
def _get_user_discussions_data(user):
"""Récupère les discussions de l'utilisateur avec leur dernier message et compteur non-lus."""
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 = (
Discussion.objects.filter(discussion_filter)
.distinct()
.prefetch_related('participants', 'messages')
.select_related('created_by')
.order_by('-updated_at')
)
items = []
for d in discussions:
last_msg = d.messages.filter(is_deleted=False).order_by('-created_at').first()
items.append({
'discussion': d,
'last_message': last_msg,
'unread_count': d.unread_count_for(user),
})
unread_discussions_count = sum(1 for item in items if item['unread_count'] > 0)
return items, unread_discussions_count
def _get_inbox_context(request): def _get_inbox_context(request):
"""Construit le contexte de la boîte de réception (desktop/mobile).""" """Construit le contexte de la boîte de réception (desktop/mobile)."""
show_archived = request.GET.get('archived') == '1' show_archived = request.GET.get('archived') == '1'
notification_type = request.GET.get('type', '') notification_type = request.GET.get('type', '')
active_tab = request.GET.get('tab', 'notifications')
all_notifications = get_user_notifications(request.user, include_archived=show_archived) all_notifications = get_user_notifications(request.user, include_archived=show_archived)
@ -204,12 +241,17 @@ def _get_inbox_context(request):
unread_count = sum(1 for n in all_notifications if not n['is_read'] and not n['is_archived']) unread_count = sum(1 for n in all_notifications if not n['is_read'] and not n['is_archived'])
discussion_items, unread_discussions_count = _get_user_discussions_data(request.user)
return { return {
'notifications': all_notifications, 'notifications': all_notifications,
'unread_count': unread_count, 'unread_count': unread_count,
'show_archived': show_archived, 'show_archived': show_archived,
'filter_type': notification_type, 'filter_type': notification_type,
'notification_types': Notification.TYPES, 'notification_types': Notification.TYPES,
'discussion_items': discussion_items,
'unread_discussions_count': unread_discussions_count,
'active_tab': active_tab,
} }
@ -477,41 +519,10 @@ def team_delete(request, pk):
@login_required @login_required
def discussion_list(request): def discussion_list(request):
""" """
Liste les discussions dont l'utilisateur est l'auteur (créateur de la discussion Redirige vers la boîte de réception sur l'onglet discussions
ou auteur d'un message) ou pour lesquelles il (ou une de ses équipes) a été explicitement taggé. (permet de préserver la compatibilité avec les anciens liens et routes).
""" """
user = request.user return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
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 = (
Discussion.objects.filter(discussion_filter)
.distinct()
.prefetch_related('participants', 'messages')
.select_related('created_by')
.order_by('-updated_at')
)
# Enrichir avec le compteur de non-lus et le dernier message
items = []
for d in discussions:
last_msg = d.messages.filter(is_deleted=False).order_by('-created_at').first()
items.append({
'discussion': d,
'last_message': last_msg,
'unread_count': d.unread_count_for(request.user),
})
return render(request, 'notifications/discussion_list.html', {'items': items})
@login_required @login_required
@ -548,7 +559,7 @@ def discussion_detail(request, pk):
if not has_access: if not has_access:
messages.error(request, _("Vous n'êtes pas participant de cette discussion.")) messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
return redirect('notifications:discussion_list') return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
if request.method == 'POST': if request.method == 'POST':
content = request.POST.get('content', '').strip() content = request.POST.get('content', '').strip()
@ -603,7 +614,7 @@ def discussion_create(request):
else: else:
messages.error(request, _("Le contenu du message ne peut pas être vide.")) messages.error(request, _("Le contenu du message ne peut pas être vide."))
return redirect('notifications:discussion_list') return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
@login_required @login_required

View file

@ -207,10 +207,8 @@
{% endwith %} {% endwith %}
<!-- Notifications --> <!-- Notifications -->
<li class="nav-item dropdown"> <li class="nav-item">
<a href="#" class="nav-link position-relative" id="notifDropdown" <a href="{% url 'notifications:inbox' %}" class="nav-link position-relative" id="navbar-notif-link" title="{% translate 'Notifications' %}">
data-bs-toggle="dropdown" aria-expanded="false"
data-bs-auto-close="outside">
<i class="bi bi-bell" style="font-size: 1.5rem;"></i> <i class="bi bi-bell" style="font-size: 1.5rem;"></i>
<span id="navbar-notif-badge" <span id="navbar-notif-badge"
class="position-absolute translate-middle badge rounded-pill" class="position-absolute translate-middle badge rounded-pill"
@ -220,42 +218,6 @@
<span class="visually-hidden">{% translate "notifications non lues" %}</span> <span class="visually-hidden">{% translate "notifications non lues" %}</span>
</span> </span>
</a> </a>
<div class="dropdown-menu dropdown-menu-end p-0 shadow"
aria-labelledby="notifDropdown"
style="min-width: 360px; max-width: 420px;">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom bg-light rounded-top">
<span class="fw-semibold small">{% translate "Notifications" %}</span>
<div class="d-flex gap-2 align-items-center">
<button id="markAllReadBtn"
class="btn btn-link btn-sm p-0 text-muted small"
title="{% translate 'Tout marquer comme lu' %}">
<i class="bi bi-check2-all"></i>
</button>
<a href="{% url 'notifications:inbox' %}" class="btn btn-link btn-sm p-0 text-muted small">
{% translate "Voir tout" %}
</a>
</div>
</div>
<!-- Notification list (populated by JS) -->
<div id="notif-dropdown-list"
style="max-height: 380px; overflow-y: auto; overflow-x: hidden;">
<div class="text-center text-muted py-4 small" id="notif-dropdown-empty">
<i class="bi bi-bell-slash d-block mb-1" style="font-size:1.5rem;"></i>
{% translate "Aucune notification non lue" %}
</div>
</div>
<!-- Footer -->
<div class="border-top bg-light rounded-bottom py-2 px-3 d-flex justify-content-between align-items-center">
<a href="{% url 'notifications:discussion_list' %}" class="small text-secondary text-decoration-none">
<i class="bi bi-chat-dots me-1"></i>{% translate "Discussions" %}
</a>
<a href="{% url 'notifications:inbox' %}" class="small text-primary text-decoration-none">
{% translate "Boîte de réception" %}
<i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
</div>
</li> </li>
<!-- Help Button --> <!-- Help Button -->
@ -457,16 +419,8 @@
{% endblock script %} {% endblock script %}
<script> <script>
/* ── Notification dropdown ───────────────────────────────────────────── */ /* ── Notification badge update ───────────────────────────────────────── */
(function () { (function () {
const CSRF = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
const API_RECENT = "{% url 'notifications:api_recent' %}";
const MARK_READ_BASE = "{% url 'notifications:mark_read' 0 %}";
const MARK_ALL = "{% url 'notifications:mark_all_read' %}";
const dropdown = document.getElementById('notifDropdown');
const list = document.getElementById('notif-dropdown-list');
const empty = document.getElementById('notif-dropdown-empty');
const badge = document.getElementById('navbar-notif-badge'); const badge = document.getElementById('navbar-notif-badge');
function updateBadge(count) { function updateBadge(count) {
@ -486,104 +440,6 @@
} }
} }
const TYPE_ICONS = {
message: 'bi-chat-dots', reminder: 'bi-alarm',
task: 'bi-check2-square', alert: 'bi-exclamation-triangle', info: 'bi-info-circle',
};
const TYPE_COLORS = {
message: 'primary', reminder: 'warning', task: 'success', alert: 'danger', info: 'info',
};
async function loadRecent() {
try {
const resp = await fetch(API_RECENT, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const data = await resp.json();
updateBadge(data.unread_count);
renderNotifications(data.notifications);
} catch (e) { /* silent */ }
}
function renderNotifications(items) {
// Remove old dynamic items
list.querySelectorAll('.notif-dd-item').forEach(el => el.remove());
if (!items || items.length === 0) {
empty.style.display = '';
return;
}
empty.style.display = 'none';
items.forEach(n => {
const icon = TYPE_ICONS[n.notification_type] || 'bi-bell';
const color = TYPE_COLORS[n.notification_type] || 'secondary';
const dt = new Date(n.created_at);
const dateStr = dt.toLocaleDateString('fr-BE') + ' ' + dt.toLocaleTimeString('fr-BE', { hour: '2-digit', minute: '2-digit' });
const div = document.createElement('div');
div.className = 'notif-dd-item d-flex gap-2 px-3 py-2 border-bottom notif-dd-entry';
div.setAttribute('data-pk', n.id);
div.setAttribute('data-type', n.type);
div.style.cssText = 'background:#f0f4ff; cursor:default;';
div.innerHTML = `
<span class="text-${color} flex-shrink-0 mt-1"><i class="bi ${icon}"></i></span>
<div class="flex-grow-1 min-w-0">
<div class="d-flex justify-content-between align-items-start gap-1">
<span class="fw-semibold small" style="white-space: normal; word-break: break-word;">${n.title}</span>
<button class="btn btn-link btn-sm p-0 text-muted flex-shrink-0 notif-dd-mark-read"
data-pk="${n.id}" data-type="${n.type}" title="Marquer comme lu" style="font-size:.8rem;">
<i class="bi bi-check2"></i>
</button>
</div>
${n.content ? `<p class="small text-muted mb-1" style="white-space: normal; word-break: break-word;">${n.content}</p>` : ''}
<div class="d-flex gap-2 align-items-center flex-wrap">
<span class="text-muted" style="font-size:.7rem;">${dateStr}</span>
${n.team_name ? `<span class="badge bg-secondary" style="font-size:.65rem;">${n.team_name}</span>` : ''}
${n.action_url ? `<a href="${n.action_url}" class="badge bg-primary text-white text-decoration-none" style="font-size:.65rem;">${n.action_label || '→'}</a>` : ''}
</div>
</div>`;
list.insertBefore(div, empty);
});
// Bind mark-read buttons inside dropdown
list.querySelectorAll('.notif-dd-mark-read').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const pk = btn.dataset.pk;
const type = btn.dataset.type;
const url = MARK_READ_BASE.replace('0', pk);
const resp = await fetch(url, {
method: 'POST',
headers: { 'X-CSRFToken': CSRF, 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: `type=${type}`,
});
if (resp.ok) {
const d = await resp.json();
updateBadge(d.unread_count);
loadRecent();
}
});
});
}
// Load when dropdown opens
if (dropdown) {
dropdown.addEventListener('show.bs.dropdown', loadRecent);
}
// Mark all read button
const markAllBtn = document.getElementById('markAllReadBtn');
if (markAllBtn) {
markAllBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const resp = await fetch(MARK_ALL, {
method: 'POST',
headers: { 'X-CSRFToken': CSRF, 'X-Requested-With': 'XMLHttpRequest' },
});
if (resp.ok) {
const d = await resp.json();
updateBadge(d.unread_count);
renderNotifications([]);
}
});
}
// Refresh badge every 60s // Refresh badge every 60s
setInterval(async () => { setInterval(async () => {
try { try {