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 = []
for r in valid_recipients:
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")
link = f"{domain}{n.action_url}" if n.action_url else f"{domain}/notifications/"

View file

@ -82,7 +82,7 @@
<!-- Breadcrumb / Header -->
<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>
</a>
<div class="flex-grow-1 min-w-0">

View file

@ -20,221 +20,446 @@
font-size: 0.7rem;
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>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
<i class="bi bi-bell me-2"></i>{% translate "Notifications" %}
{% if unread_count > 0 %}
<span class="badge rounded-pill ms-2" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
{{ unread_count }}
</span>
{% endif %}
</h2>
<div class="d-flex gap-2">
{% if unread_count > 0 %}
<form method="post" action="{% url 'notifications:mark_all_read' %}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %}
<!-- Header with Tabs and Contextual Actions -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3 border-bottom pb-2">
<!-- 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" %}
{% if unread_count > 0 %}
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
{{ unread_count }}
</span>
{% endif %}
</button>
</form>
{% endif %}
{% if user.config|has_any_role:"admin" %}
<a href="{% url 'notifications:create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouvelle notification" %}
</a>
{% endif %}
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-left-text me-1"></i>{% translate "Discussions" %}
</a>
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary btn-sm">
</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 %}
<form method="post" action="{% url 'notifications:mark_all_read' %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %}
</button>
</form>
{% endif %}
{% if user.config|has_any_role:"admin" %}
<a href="{% url 'notifications:create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouvelle notification" %}
</a>
{% endif %}
</div>
<!-- Discussions Tab Actions -->
<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" %}
</a>
</div>
</div>
<!-- Filters -->
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-center">
<div class="col-auto">
<select name="type" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "Tous les types" %}</option>
{% for val, label in notification_types %}
<option value="{{ val }}" {% if filter_type == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="showArchived"
name="archived" value="1"
{% if show_archived %}checked{% endif %}
onchange="this.form.submit()">
<label class="form-check-label" for="showArchived">{% translate "Archivées" %}</label>
</div>
</div>
{% if filter_type or show_archived %}
<div class="col-auto">
<a href="{% url 'notifications:inbox' %}" class="btn btn-link btn-sm text-muted">
<i class="bi bi-x-circle me-1"></i>{% translate "Réinitialiser" %}
</a>
</div>
{% endif %}
</form>
</div>
</div>
<!-- Tabs Content -->
<div class="tab-content" id="inboxTabsContent">
<!-- Notifications list -->
{% if notifications %}
<div class="card border-0 shadow-sm">
<div class="list-group list-group-flush">
{% for item in notifications %}
{% with notif=item.notification %}
<div class="list-group-item list-group-item-action notif-item
{% if not item.is_read and not item.is_archived %}unread{% endif %}"
id="notif-row-{{ item.recipient_id }}-{{ item.recipient_type }}">
<div class="d-flex align-items-start gap-3 py-1">
<!-- ================= 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">
<!-- Icon -->
<div class="flex-shrink-0 mt-1">
<span class="text-{{ notif.type_color }} fs-4">
<i class="bi {{ notif.type_icon }}"></i>
</span>
</div>
<!-- Body -->
<div class="flex-grow-1 min-w-0">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<span class="fw-semibold {% if not item.is_read and not item.is_archived %}text-dark{% else %}text-muted{% endif %}">
{{ notif.title }}
</span>
{% if not item.is_read and not item.is_archived %}
<span class="badge bg-primary notif-badge-type ms-1">{% translate "Nouveau" %}</span>
{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0 align-items-center">
<!-- Mark read checkbox -->
{% if not item.is_read %}
<div class="form-check mb-0">
<input class="form-check-input mark-read-btn" type="checkbox"
id="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}"
data-pk="{{ item.recipient_id }}"
data-type="{{ item.recipient_type }}"
title="{% if notif.is_single_claim %}{% translate 'Prendre en charge' %}{% else %}{% translate 'Marquer comme lu' %}{% endif %}">
<label class="form-check-label small text-muted"
for="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}">
{% if notif.is_single_claim %}
{% translate "Prendre en charge" %}
{% else %}
{% translate "Marquer comme lu" %}
{% endif %}
</label>
</div>
{% endif %}
<!-- Archive (personal only) -->
{% if item.recipient_type == 'personal' and not item.is_archived %}
<button class="btn btn-link btn-sm text-muted p-0 archive-btn"
data-pk="{{ item.recipient_id }}"
title="{% translate 'Archiver' %}">
<i class="bi bi-archive"></i>
</button>
{% endif %}
<!-- Filters -->
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body py-2">
<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">
<select name="type" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "Tous les types" %}</option>
{% for val, label in notification_types %}
<option value="{{ val }}" {% if filter_type == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="showArchived"
name="archived" value="1"
{% if show_archived %}checked{% endif %}
onchange="this.form.submit()">
<label class="form-check-label" for="showArchived">{% translate "Archivées" %}</label>
</div>
</div>
{% if notif.content %}
<p class="mb-1 text-muted small mt-1">{{ notif.content|linebreaksbr }}</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 align-items-center mt-1">
<!-- Metadata -->
<span class="badge bg-{{ notif.type_color }} bg-opacity-10 text-{{ notif.type_color }} notif-badge-type">
<i class="bi {{ notif.type_icon }} me-1"></i>{{ notif.get_notification_type_display }}
</span>
{% if item.team_name %}
<span class="badge bg-secondary notif-badge-type">
<i class="bi bi-people me-1"></i>{{ item.team_name }}
</span>
{% endif %}
{% if notif.source_app %}
<span class="badge bg-light text-muted notif-badge-type">{{ notif.source_app }}</span>
{% endif %}
<span class="text-muted small">
<i class="bi bi-clock me-1"></i>{{ notif.created_at|date:"d/m/Y H:i" }}
</span>
{% if notif.sender %}
<span class="text-muted small">
<i class="bi bi-person me-1"></i>{{ notif.sender.get_full_name|default:notif.sender.username }}
</span>
{% endif %}
{% if notif.expires_at %}
<span class="text-muted small {% if notif.is_expired %}text-danger{% endif %}">
<i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }}
</span>
{% endif %}
{% 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">
{{ notif.action_label|default:notif.action_url }}
<i class="bi bi-arrow-right ms-1"></i>
{% if filter_type or show_archived %}
<div class="col-auto">
<a href="{% url 'notifications:inbox' %}" class="btn btn-link btn-sm text-muted">
<i class="bi bi-x-circle me-1"></i>{% translate "Réinitialiser" %}
</a>
{% endif %}
</div>
<!-- Attachments -->
{% with attachments=notif.attachments.all %}
{% if attachments %}
<div class="mt-2 d-flex flex-wrap gap-2">
{% for att in attachments %}
{% if att.is_image %}
<a href="{{ att.file.url }}" target="_blank" title="{{ att.name }}">
<img src="{{ att.file.url }}" alt="{{ att.name }}"
style="height:60px; width:60px; object-fit:cover; border-radius:4px; border:1px solid #dee2e6;">
</a>
{% else %}
<a href="{{ att.file.url }}" target="_blank"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-paperclip me-1"></i>{{ att.name }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</form>
</div>
</div>
<!-- Notifications list -->
{% if notifications %}
<div class="card border-0 shadow-sm">
<div class="list-group list-group-flush">
{% for item in notifications %}
{% with notif=item.notification %}
<div class="list-group-item list-group-item-action notif-item
{% if not item.is_read and not item.is_archived %}unread{% endif %}"
id="notif-row-{{ item.recipient_id }}-{{ item.recipient_type }}">
<div class="d-flex align-items-start gap-3 py-1">
<!-- Icon -->
<div class="flex-shrink-0 mt-1">
<span class="text-{{ notif.type_color }} fs-4">
<i class="bi {{ notif.type_icon }}"></i>
</span>
</div>
<!-- Body -->
<div class="flex-grow-1 min-w-0">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<span class="fw-semibold {% if not item.is_read and not item.is_archived %}text-dark{% else %}text-muted{% endif %}">
{{ notif.title }}
</span>
{% if not item.is_read and not item.is_archived %}
<span class="badge bg-primary notif-badge-type ms-1">{% translate "Nouveau" %}</span>
{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0 align-items-center">
<!-- Mark read checkbox -->
{% if not item.is_read %}
<div class="form-check mb-0">
<input class="form-check-input mark-read-btn" type="checkbox"
id="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}"
data-pk="{{ item.recipient_id }}"
data-type="{{ item.recipient_type }}"
title="{% if notif.is_single_claim %}{% translate 'Prendre en charge' %}{% else %}{% translate 'Marquer comme lu' %}{% endif %}">
<label class="form-check-label small text-muted"
for="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}">
{% if notif.is_single_claim %}
{% translate "Prendre en charge" %}
{% else %}
{% translate "Marquer comme lu" %}
{% endif %}
</label>
</div>
{% endif %}
<!-- Archive (personal only) -->
{% if item.recipient_type == 'personal' and not item.is_archived %}
<button class="btn btn-link btn-sm text-muted p-0 archive-btn"
data-pk="{{ item.recipient_id }}"
title="{% translate 'Archiver' %}">
<i class="bi bi-archive"></i>
</button>
{% endif %}
</div>
</div>
{% if notif.content %}
<p class="mb-1 text-muted small mt-1">{{ notif.content|linebreaksbr }}</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 align-items-center mt-1">
<!-- Metadata -->
<span class="badge bg-{{ notif.type_color }} bg-opacity-10 text-{{ notif.type_color }} notif-badge-type">
<i class="bi {{ notif.type_icon }} me-1"></i>{{ notif.get_notification_type_display }}
</span>
{% if item.team_name %}
<span class="badge bg-secondary notif-badge-type">
<i class="bi bi-people me-1"></i>{{ item.team_name }}
</span>
{% endif %}
{% if notif.source_app %}
<span class="badge bg-light text-muted notif-badge-type">{{ notif.source_app }}</span>
{% endif %}
<span class="text-muted small">
<i class="bi bi-clock me-1"></i>{{ notif.created_at|date:"d/m/Y H:i" }}
</span>
{% if notif.sender %}
<span class="text-muted small">
<i class="bi bi-person me-1"></i>{{ notif.sender.get_full_name|default:notif.sender.username }}
</span>
{% endif %}
{% if notif.expires_at %}
<span class="text-muted small {% if notif.is_expired %}text-danger{% endif %}">
<i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }}
</span>
{% endif %}
{% 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">
{{ notif.action_label|default:notif.action_url }}
<i class="bi bi-arrow-right ms-1"></i>
</a>
{% endif %}
</div>
<!-- Attachments -->
{% with attachments=notif.attachments.all %}
{% if attachments %}
<div class="mt-2 d-flex flex-wrap gap-2">
{% for att in attachments %}
{% if att.is_image %}
<a href="{{ att.file.url }}" target="_blank" title="{{ att.name }}">
<img src="{{ att.file.url }}" alt="{{ att.name }}"
style="height:60px; width:60px; object-fit:cover; border-radius:4px; border:1px solid #dee2e6;">
</a>
{% else %}
<a href="{{ att.file.url }}" target="_blank"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-paperclip me-1"></i>{{ att.name }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
<p class="mt-3">
{% if show_archived %}
{% translate "Aucune notification archivée." %}
{% else %}
{% translate "Vous n'avez aucune notification." %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
<p class="mt-3">
{% if show_archived %}
{% translate "Aucune notification archivée." %}
{% else %}
{% translate "Vous n'avez aucune notification." %}
{% endif %}
</p>
</div>
{% endif %}
</p>
</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>
{% endif %}
</div>
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content %}
{% block script %}
<script>
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 ----
document.querySelectorAll('.mark-read-btn').forEach(checkbox => {
checkbox.addEventListener('change', async () => {

View file

@ -12,7 +12,7 @@ class DiscussionListTests(TestCase):
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):
def test_discussion_list_filtering_on_inbox(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)
@ -38,16 +38,23 @@ class DiscussionListTests(TestCase):
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'))
response = self.client.get(reverse('notifications:inbox') + '?tab=discussions')
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(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_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):
self.client.force_login(self.user1)
response = self.client.post(reverse('notifications:discussion_create'), {
@ -62,6 +69,46 @@ class DiscussionListTests(TestCase):
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):
def setUp(self):
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_delay_hours, 12)
from django.test import override_settings
@override_settings(EMAIL_OVERRIDE=None)
def test_send_unread_notification_reminders_command(self):
from datetime import timedelta
from django.utils import timezone

View file

@ -1,4 +1,5 @@
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.models import User
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):
"""Construit le contexte de la boîte de réception (desktop/mobile)."""
show_archived = request.GET.get('archived') == '1'
notification_type = request.GET.get('type', '')
active_tab = request.GET.get('tab', 'notifications')
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'])
discussion_items, unread_discussions_count = _get_user_discussions_data(request.user)
return {
'notifications': all_notifications,
'unread_count': unread_count,
'show_archived': show_archived,
'filter_type': notification_type,
'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
def discussion_list(request):
"""
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é.
Redirige vers la boîte de réception sur l'onglet discussions
(permet de préserver la compatibilité avec les anciens liens et routes).
"""
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 = (
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})
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
@login_required
@ -548,7 +559,7 @@ def discussion_detail(request, pk):
if not has_access:
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':
content = request.POST.get('content', '').strip()
@ -603,7 +614,7 @@ def discussion_create(request):
else:
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

View file

@ -207,10 +207,8 @@
{% endwith %}
<!-- Notifications -->
<li class="nav-item dropdown">
<a href="#" class="nav-link position-relative" id="notifDropdown"
data-bs-toggle="dropdown" aria-expanded="false"
data-bs-auto-close="outside">
<li class="nav-item">
<a href="{% url 'notifications:inbox' %}" class="nav-link position-relative" id="navbar-notif-link" title="{% translate 'Notifications' %}">
<i class="bi bi-bell" style="font-size: 1.5rem;"></i>
<span id="navbar-notif-badge"
class="position-absolute translate-middle badge rounded-pill"
@ -220,42 +218,6 @@
<span class="visually-hidden">{% translate "notifications non lues" %}</span>
</span>
</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>
<!-- Help Button -->
@ -457,16 +419,8 @@
{% endblock script %}
<script>
/* ── Notification dropdown ───────────────────────────────────────────── */
/* ── Notification badge update ───────────────────────────────────────── */
(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');
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
setInterval(async () => {
try {