- {{ msg.content|linebreaksbr }}
+ {% if msg.author == request.user %}
+ {{ msg.content|render_mentions:True }}
+ {% else %}
+ {{ msg.content|render_mentions }}
+ {% endif %}
{% if msg.attachments.exists %}
-
+
+{% 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 = {