From fb9664b65a3a76eaf79a681e8c5db9aa8876c373 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 12 Aug 2026 11:46:29 +0200 Subject: [PATCH] feat: add reusable new discussion modal with @mention autocompletion support --- streetup/home/templates/home/home_index.html | 41 ++- streetup/home/tests.py | 26 ++ .../interventions/detail/scripts.html | 59 +++- .../mobile/templates/mobile/mobile_index.html | 33 +++ .../notifications/_new_discussion_modal.html | 266 ++++++++++++++++++ .../notifications/discussion_detail.html | 138 +++++---- .../notifications/discussion_list.html | 12 +- .../templates/notifications/inbox.html | 2 +- .../templates/notifications/inbox_mobile.html | 2 +- .../templatetags/mention_tags.py | 28 +- streetup/notifications/tests.py | 62 ++++ streetup/notifications/urls.py | 1 + streetup/notifications/views.py | 55 +++- streetup/templates/base.html | 17 +- 14 files changed, 669 insertions(+), 73 deletions(-) create mode 100644 streetup/notifications/templates/notifications/_new_discussion_modal.html create mode 100644 streetup/notifications/tests.py diff --git a/streetup/home/templates/home/home_index.html b/streetup/home/templates/home/home_index.html index 1fbf5f0..005698e 100644 --- a/streetup/home/templates/home/home_index.html +++ b/streetup/home/templates/home/home_index.html @@ -16,6 +16,34 @@
+ {% if unread_notifications_count %} +
+
+ +
+
+ {% endif %}
@@ -56,6 +84,9 @@ {% translate "Nouvelle commande" %} {% endif %} +
@@ -101,10 +132,18 @@ {% empty %} {% include "home/widgets/_default.html" %} {% endfor %} -
+ + + + {% include "notifications/_new_discussion_modal.html" %} {% endblock content%} diff --git a/streetup/home/tests.py b/streetup/home/tests.py index 8633fbd..adffa02 100644 --- a/streetup/home/tests.py +++ b/streetup/home/tests.py @@ -267,3 +267,29 @@ class HomeIndexViewTests(TestCase): self.assertContains(response, reverse('inspections:quick_inspection_map')) 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 1 notification non lue.') + + diff --git a/streetup/interventions/templates/interventions/detail/scripts.html b/streetup/interventions/templates/interventions/detail/scripts.html index ac66f77..481d35d 100644 --- a/streetup/interventions/templates/interventions/detail/scripts.html +++ b/streetup/interventions/templates/interventions/detail/scripts.html @@ -812,9 +812,11 @@ document.addEventListener('DOMContentLoaded', function () { let mentionStart = -1; let mentionQuery = null; let searchTimer = null; + let currentResults = []; + let selectedIndex = -1; function escHtml(str) { - return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function syncReal() { @@ -842,10 +844,25 @@ document.addEventListener('DOMContentLoaded', function () { 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 => { + 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'; @@ -858,8 +875,15 @@ document.addEventListener('DOMContentLoaded', function () { e.preventDefault(); insertMention(r); }); + btn.addEventListener('mouseenter', function () { + selectedIndex = idx; + highlightIndex(selectedIndex); + }); dropdown.appendChild(btn); }); + + selectedIndex = 0; + highlightIndex(0); dropdown.style.display = 'block'; } @@ -868,6 +892,8 @@ document.addEventListener('DOMContentLoaded', function () { dropdown.innerHTML = ''; mentionStart = -1; mentionQuery = null; + currentResults = []; + selectedIndex = -1; } function insertMention(r) { @@ -890,6 +916,35 @@ document.addEventListener('DOMContentLoaded', function () { 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) { clearTimeout(searchTimer); searchTimer = setTimeout(() => { diff --git a/streetup/mobile/templates/mobile/mobile_index.html b/streetup/mobile/templates/mobile/mobile_index.html index 68af866..62b7e02 100644 --- a/streetup/mobile/templates/mobile/mobile_index.html +++ b/streetup/mobile/templates/mobile/mobile_index.html @@ -5,6 +5,33 @@ {% block content %}
+ {% if unread_notifications_count %} +
+ +
+ {% endif %} + {% 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 %}
@@ -40,6 +67,11 @@ {% endif %} + +
@@ -129,4 +161,5 @@ {% endif %} + {% include "notifications/_new_discussion_modal.html" %} {% endblock content %} diff --git a/streetup/notifications/templates/notifications/_new_discussion_modal.html b/streetup/notifications/templates/notifications/_new_discussion_modal.html new file mode 100644 index 0000000..34d0dea --- /dev/null +++ b/streetup/notifications/templates/notifications/_new_discussion_modal.html @@ -0,0 +1,266 @@ +{% load i18n %} + + + + diff --git a/streetup/notifications/templates/notifications/discussion_detail.html b/streetup/notifications/templates/notifications/discussion_detail.html index 3b5e018..209541f 100644 --- a/streetup/notifications/templates/notifications/discussion_detail.html +++ b/streetup/notifications/templates/notifications/discussion_detail.html @@ -1,35 +1,41 @@ {% extends "base.html" %} {% load i18n %} {% load static %} +{% load mention_tags %} {% block head %} {% endblock %} {% block content %} -
+
-
- - +
+ + -
-

{{ discussion.title|default:discussion }}

- {% if discussion.object_label %} - - {{ discussion.object_label }} - - {% endif %} +
+
+

{{ discussion.title|default:discussion }}

+ {% if discussion.object_label %} + + {{ discussion.object_label }} + + {% endif %} + {% if discussion.is_closed %} + {% translate "Clôturée" %} + {% endif %} +
- {% if discussion.is_closed %} - {% translate "Clôturée" %} - {% endif %}
-
+
+ {% translate "Participants" %} : {% for p in discussion.participants.all %} - - {{ p.get_full_name|default:p.username }} + + + {{ p.get_full_name|default:p.username }} {% endfor %}
-
-
+
+
{% if not thread_messages %} -

- - {% translate "Aucun message pour l'instant. Soyez le premier à écrire." %} -

+
+ +

{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}

+
{% else %}
{% for msg in thread_messages %}
{% if msg.author != request.user %} -
-
+
+
{{ msg.author.get_short_name|first|upper|default:msg.author.username|first|upper }}
{% endif %}
{% if msg.author != request.user %} -
+
{{ msg.author.get_full_name|default:msg.author.username }} · {{ msg.created_at|date:"d/m/Y H:i" }}
{% else %} -
+
{{ msg.created_at|date:"d/m/Y H:i" }}
{% endif %}
- {{ msg.content|linebreaksbr }} + {% if msg.author == request.user %} + {{ msg.content|render_mentions:True }} + {% else %} + {{ msg.content|render_mentions }} + {% endif %} {% if msg.attachments.exists %} -
+
{% for att in msg.attachments.all %} {% if att.is_image %} @@ -121,7 +155,7 @@ {% else %} + class="btn btn-sm {% if msg.author == request.user %}btn-light text-dark{% else %}btn-outline-secondary{% endif %}"> {{ att.name|truncatechars:24 }} {% endif %} @@ -144,26 +178,28 @@ {% if not discussion.is_closed %}
{% csrf_token %} -
+
- -
-
{% else %} -
{% translate "Cette discussion est clôturée." %}
+
{% translate "Cette discussion est clôturée." %}
{% endif %}
diff --git a/streetup/notifications/templates/notifications/discussion_list.html b/streetup/notifications/templates/notifications/discussion_list.html index ca0e150..925a8e8 100644 --- a/streetup/notifications/templates/notifications/discussion_list.html +++ b/streetup/notifications/templates/notifications/discussion_list.html @@ -36,9 +36,14 @@

{% translate "Discussions" %}

- - {% translate "Notifications" %} - +
+ + + {% translate "Notifications" %} + +
{% if not items %} @@ -108,4 +113,5 @@ {% endif %}
+{% include "notifications/_new_discussion_modal.html" %} {% endblock content %} diff --git a/streetup/notifications/templates/notifications/inbox.html b/streetup/notifications/templates/notifications/inbox.html index 85d5be1..f19578b 100644 --- a/streetup/notifications/templates/notifications/inbox.html +++ b/streetup/notifications/templates/notifications/inbox.html @@ -177,7 +177,7 @@ {% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }} {% 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' %} {{ notif.action_label|default:notif.action_url }} diff --git a/streetup/notifications/templates/notifications/inbox_mobile.html b/streetup/notifications/templates/notifications/inbox_mobile.html index dbaa8c6..6568d31 100644 --- a/streetup/notifications/templates/notifications/inbox_mobile.html +++ b/streetup/notifications/templates/notifications/inbox_mobile.html @@ -98,7 +98,7 @@ {% if item.team_name %} {{ item.team_name }} {% 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' %} {{ notif.action_label|default:item.mobile_action_url }} diff --git a/streetup/notifications/templatetags/mention_tags.py b/streetup/notifications/templatetags/mention_tags.py index 6192e52..5d448fb 100644 --- a/streetup/notifications/templatetags/mention_tags.py +++ b/streetup/notifications/templatetags/mention_tags.py @@ -11,17 +11,19 @@ MENTION_PATTERN = re.compile(r'@\[([^\]]+)\]\(([ut]):(\d+)\)') @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, and converts newlines to
(like linebreaksbr). - - u: user mention → primary badge - - t: team mention → info badge + - u: user mention → badge + - t: team mention → badge """ if not value: return '' + is_mine_bool = bool(is_mine and str(is_mine).lower() not in ('false', '0', '')) + parts = [] last_end = 0 @@ -32,16 +34,24 @@ def render_mentions(value): name = m.group(1) kind = m.group(2) - if kind == 'u': - css = 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle fw-semibold' - icon = 'bi-person-fill' + if is_mine_bool: + if kind == 'u': + 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: - css = 'badge bg-info-subtle text-info-emphasis border border-info-subtle fw-semibold' - icon = 'bi-people-fill' + if kind == 'u': + 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( f'' - f'' + f'' f'@{escape(name)}' f'' ) diff --git a/streetup/notifications/tests.py b/streetup/notifications/tests.py new file mode 100644 index 0000000..747683c --- /dev/null +++ b/streetup/notifications/tests.py @@ -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) diff --git a/streetup/notifications/urls.py b/streetup/notifications/urls.py index 8f1cde6..38e15be 100644 --- a/streetup/notifications/urls.py +++ b/streetup/notifications/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ path('teams//delete/', views.team_delete, name='team_delete'), # Discussions path('discussions/', views.discussion_list, name='discussion_list'), + path('discussions/new/', views.discussion_create, name='discussion_create'), path('discussions//', views.discussion_detail, name='discussion_detail'), # API path('api/unread-count/', views.api_unread_count, name='api_unread_count'), diff --git a/streetup/notifications/views.py b/streetup/notifications/views.py index 9b4af0f..ef7d3da 100644 --- a/streetup/notifications/views.py +++ b/streetup/notifications/views.py @@ -476,9 +476,28 @@ def team_delete(request, pk): @login_required 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 = ( - request.user.discussions + Discussion.objects.filter(discussion_filter) + .distinct() .prefetch_related('participants', 'messages') .select_related('created_by') .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 # ────────────────────────────────────────────────────────────────────────────── diff --git a/streetup/templates/base.html b/streetup/templates/base.html index 87c4be6..8ad313e 100644 --- a/streetup/templates/base.html +++ b/streetup/templates/base.html @@ -470,9 +470,20 @@ const badge = document.getElementById('navbar-notif-badge'); function updateBadge(count) { - if (!badge) return; - if (count > 0) { badge.textContent = count; badge.style.display = ''; } - else { badge.style.display = 'none'; } + if (badge) { + if (count > 0) { badge.textContent = count; badge.style.display = ''; } + 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 = {