feat: add @mention autocomplete UI in discussion replies and implement server-side team mention handling
This commit is contained in:
parent
d5f4d8a765
commit
19810d101c
4 changed files with 338 additions and 13 deletions
|
|
@ -376,8 +376,48 @@ def _notify_mentions(discussion, author, content):
|
||||||
|
|
||||||
# Mentions d'équipes
|
# Mentions d'équipes
|
||||||
if mentioned_team_ids:
|
if mentioned_team_ids:
|
||||||
teams = Team.objects.filter(pk__in=mentioned_team_ids).prefetch_related('members')
|
from contracts.models import CompanyTeam
|
||||||
for team in teams:
|
company_teams = list(CompanyTeam.objects.filter(pk__in=mentioned_team_ids))
|
||||||
|
for team in company_teams:
|
||||||
|
team_users = list(
|
||||||
|
User.objects.filter(
|
||||||
|
companymember__teams=team,
|
||||||
|
is_active=True
|
||||||
|
).exclude(pk=exclude_pk).distinct()
|
||||||
|
)
|
||||||
|
if not team_users:
|
||||||
|
continue
|
||||||
|
|
||||||
|
team_title = f"{author_name} a mentionné l'équipe {team.name} — {discussion.title or str(discussion)}"
|
||||||
|
notif = Notification.objects.create(
|
||||||
|
title=team_title,
|
||||||
|
content=msg_preview,
|
||||||
|
notification_type=Notification.TYPE_MESSAGE,
|
||||||
|
sender=author,
|
||||||
|
action_url=action_url,
|
||||||
|
action_label=action_label,
|
||||||
|
source_app='discussions',
|
||||||
|
source_discussion=discussion,
|
||||||
|
)
|
||||||
|
NotificationRecipient.objects.bulk_create([
|
||||||
|
NotificationRecipient(notification=notif, user=u)
|
||||||
|
for u in team_users
|
||||||
|
], ignore_conflicts=True)
|
||||||
|
|
||||||
|
# Ajouter les membres de l'équipe comme participants
|
||||||
|
discussion.participants.add(*team_users)
|
||||||
|
|
||||||
|
from .webpush import send_webpush_to_users
|
||||||
|
send_webpush_to_users(
|
||||||
|
users=team_users,
|
||||||
|
title=team_title,
|
||||||
|
body=msg_preview,
|
||||||
|
action_url=action_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Support hérité notifications.Team (si existant)
|
||||||
|
legacy_teams = Team.objects.filter(pk__in=mentioned_team_ids).prefetch_related('members')
|
||||||
|
for team in legacy_teams:
|
||||||
notif = Notification.objects.create(
|
notif = Notification.objects.create(
|
||||||
title=title,
|
title=title,
|
||||||
content=msg_preview,
|
content=msg_preview,
|
||||||
|
|
@ -390,10 +430,11 @@ def _notify_mentions(discussion, author, content):
|
||||||
)
|
)
|
||||||
TeamNotificationTarget.objects.create(notification=notif, team=team)
|
TeamNotificationTarget.objects.create(notification=notif, team=team)
|
||||||
# Ajouter les membres de l'équipe comme participants
|
# Ajouter les membres de l'équipe comme participants
|
||||||
discussion.participants.add(*team.members.all())
|
legacy_members = list(team.members.exclude(pk=exclude_pk))
|
||||||
|
discussion.participants.add(*legacy_members)
|
||||||
from .webpush import send_webpush_to_users
|
from .webpush import send_webpush_to_users
|
||||||
send_webpush_to_users(
|
send_webpush_to_users(
|
||||||
users=team.members.all(),
|
users=legacy_members,
|
||||||
title=title,
|
title=title,
|
||||||
body=msg_preview,
|
body=msg_preview,
|
||||||
action_url=action_url,
|
action_url=action_url,
|
||||||
|
|
|
||||||
|
|
@ -176,12 +176,18 @@
|
||||||
|
|
||||||
<!-- Reply form -->
|
<!-- Reply form -->
|
||||||
{% if not discussion.is_closed %}
|
{% if not discussion.is_closed %}
|
||||||
<form method="post" enctype="multipart/form-data" action="">
|
<form method="post" enctype="multipart/form-data" action="" id="reply-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="card shadow-sm border-0 rounded-3">
|
<div class="card shadow-sm border-0 rounded-3">
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<textarea name="content" class="form-control mb-2 border-0 bg-light rounded-3 p-3" rows="3"
|
<div class="position-relative">
|
||||||
|
<input type="hidden" name="content" id="reply-content-real">
|
||||||
|
<textarea class="form-control mb-2 border-0 bg-light rounded-3 p-3" rows="3"
|
||||||
placeholder="{% translate 'Écrire un message... (@ pour mentionner)' %}" required id="reply-textarea" style="resize:none;"></textarea>
|
placeholder="{% translate 'Écrire un message... (@ pour mentionner)' %}" required id="reply-textarea" style="resize:none;"></textarea>
|
||||||
|
<!-- Autocomplete dropdown pour @mentions -->
|
||||||
|
<div id="reply-mention-dropdown" class="list-group position-absolute shadow-lg border-0 rounded-3"
|
||||||
|
style="display:none; z-index:1060; max-height:200px; overflow-y:auto; width:100%; bottom: 100%; margin-bottom: 6px;"></div>
|
||||||
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center pt-2">
|
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||||
<label class="btn btn-outline-secondary btn-sm mb-0 rounded-pill px-3" id="attach-label">
|
<label class="btn btn-outline-secondary btn-sm mb-0 rounded-pill px-3" id="attach-label">
|
||||||
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre" %}
|
<i class="bi bi-paperclip me-1"></i>{% translate "Joindre" %}
|
||||||
|
|
@ -212,5 +218,187 @@ const threadScroll = document.getElementById('thread-scroll');
|
||||||
if (threadScroll) {
|
if (threadScroll) {
|
||||||
threadScroll.scrollTop = threadScroll.scrollHeight;
|
threadScroll.scrollTop = threadScroll.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @mention autocomplete dans la réponse
|
||||||
|
(function () {
|
||||||
|
const textarea = document.getElementById('reply-textarea');
|
||||||
|
const realInput = document.getElementById('reply-content-real');
|
||||||
|
const dropdown = document.getElementById('reply-mention-dropdown');
|
||||||
|
const form = document.getElementById('reply-form');
|
||||||
|
if (!textarea || !dropdown || !realInput || !form) return;
|
||||||
|
|
||||||
|
let mentions = [];
|
||||||
|
let mentionStart = -1;
|
||||||
|
let mentionQuery = null;
|
||||||
|
let searchTimer = null;
|
||||||
|
let currentResults = [];
|
||||||
|
let selectedIndex = -1;
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReal() {
|
||||||
|
const dv = textarea.value;
|
||||||
|
if (!mentions.length) { realInput.value = dv; return; }
|
||||||
|
let real = '';
|
||||||
|
let pos = 0;
|
||||||
|
const sorted = [...mentions].sort((a, b) => a.start - b.start);
|
||||||
|
for (const m of sorted) {
|
||||||
|
if (m.start > pos) real += dv.slice(pos, m.start);
|
||||||
|
real += m.marker;
|
||||||
|
pos = m.end;
|
||||||
|
}
|
||||||
|
real += dv.slice(pos);
|
||||||
|
realInput.value = real;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftMentions(fromPos, delta) {
|
||||||
|
for (const m of mentions) {
|
||||||
|
if (m.start >= fromPos) { m.start += delta; m.end += delta; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMentionsIn(start, end) {
|
||||||
|
mentions = mentions.filter(m => m.end <= start || m.start >= end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightIndex(idx) {
|
||||||
|
const items = dropdown.querySelectorAll('.list-group-item');
|
||||||
|
if (!items || !items.length) return;
|
||||||
|
items.forEach((btn, i) => {
|
||||||
|
if (i === idx) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDropdown(results) {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
currentResults = results || [];
|
||||||
|
selectedIndex = -1;
|
||||||
|
if (!results || !results.length) { hideDropdown(); return; }
|
||||||
|
results.forEach((r, idx) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
|
||||||
|
const icon = r.type === 'user' ? 'bi-person-fill' : 'bi-people-fill';
|
||||||
|
const badgeCls = r.type === 'user'
|
||||||
|
? 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle'
|
||||||
|
: 'badge bg-info-subtle text-info-emphasis border border-info-subtle';
|
||||||
|
btn.innerHTML = `<span class="${badgeCls}"><i class="bi ${icon}"></i></span><span>${escHtml(r.display)}</span>`;
|
||||||
|
btn.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(r);
|
||||||
|
});
|
||||||
|
btn.addEventListener('mouseenter', function () {
|
||||||
|
selectedIndex = idx;
|
||||||
|
highlightIndex(selectedIndex);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedIndex = 0;
|
||||||
|
highlightIndex(0);
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDropdown() {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
mentionStart = -1;
|
||||||
|
mentionQuery = null;
|
||||||
|
currentResults = [];
|
||||||
|
selectedIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMention(r) {
|
||||||
|
const shortTag = `@[${r.display}]`;
|
||||||
|
const marker = r.type === 'user'
|
||||||
|
? `@[${r.display}](u:${r.id})`
|
||||||
|
: `@[${r.display}](t:${r.id})`;
|
||||||
|
const dv = textarea.value;
|
||||||
|
const qLen = mentionQuery ? mentionQuery.length : 0;
|
||||||
|
const before = dv.slice(0, mentionStart);
|
||||||
|
const after = dv.slice(mentionStart + 1 + qLen);
|
||||||
|
textarea.value = before + shortTag + ' ' + after;
|
||||||
|
removeMentionsIn(mentionStart, mentionStart + 1 + qLen);
|
||||||
|
shiftMentions(mentionStart + 1 + qLen, shortTag.length + 1 - (1 + qLen));
|
||||||
|
mentions.push({ start: mentionStart, end: mentionStart + shortTag.length, marker });
|
||||||
|
syncReal();
|
||||||
|
const pos = mentionStart + shortTag.length + 1;
|
||||||
|
textarea.setSelectionRange(pos, pos);
|
||||||
|
textarea.focus();
|
||||||
|
hideDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.addEventListener('keydown', function (e) {
|
||||||
|
if (dropdown.style.display === 'block' && currentResults.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = (selectedIndex + 1) % currentResults.length;
|
||||||
|
highlightIndex(selectedIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
|
||||||
|
highlightIndex(selectedIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(currentResults[selectedIndex]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
hideDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
textarea.addEventListener('input', function () {
|
||||||
|
syncReal();
|
||||||
|
const cur = this.selectionStart;
|
||||||
|
const before = this.value.slice(0, cur);
|
||||||
|
const match = before.match(/@([^\s@]*)$/);
|
||||||
|
if (!match) { hideDropdown(); return; }
|
||||||
|
mentionStart = match.index;
|
||||||
|
mentionQuery = match[1];
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
fetch("{% url 'notifications:api_mention_search' %}?q=" + encodeURIComponent(mentionQuery), {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => showDropdown(data.results))
|
||||||
|
.catch(() => hideDropdown());
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
syncReal();
|
||||||
|
if (!realInput.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
textarea.setCustomValidity('{% translate "Le message ne peut pas être vide." %}');
|
||||||
|
textarea.reportValidity();
|
||||||
|
} else {
|
||||||
|
textarea.setCustomValidity('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!dropdown.contains(e.target) && e.target !== textarea) {
|
||||||
|
hideDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -389,3 +389,84 @@ class WebPushTests(TestCase):
|
||||||
action_url="/interventions/99/",
|
action_url="/interventions/99/",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMentionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from contracts.models import Company, CompanyTeam, CompanyMember
|
||||||
|
self.company = Company.objects.create(name="Bruxelles Mobilité")
|
||||||
|
self.team = CompanyTeam.objects.create(name="BM - Régie - Team 1", company=self.company)
|
||||||
|
|
||||||
|
self.author = User.objects.create_user(username='author_user', password='pass')
|
||||||
|
self.team_member_user = User.objects.create_user(username='team_user', password='pass')
|
||||||
|
self.other_user = User.objects.create_user(username='other_user', password='pass')
|
||||||
|
|
||||||
|
self.company_member = CompanyMember.objects.create(
|
||||||
|
name="Agent Régie",
|
||||||
|
user=self.team_member_user
|
||||||
|
)
|
||||||
|
self.company_member.teams.add(self.team)
|
||||||
|
|
||||||
|
def test_api_mention_search_finds_company_team_by_name(self):
|
||||||
|
self.client.force_login(self.author)
|
||||||
|
url = reverse('notifications:api_mention_search') + '?q=Régie'
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
results = data.get('results', [])
|
||||||
|
team_results = [r for r in results if r['type'] == 'team']
|
||||||
|
self.assertTrue(any(r['id'] == self.team.pk and r['display'] == "BM - Régie - Team 1" for r in team_results))
|
||||||
|
|
||||||
|
def test_api_mention_search_finds_company_team_by_company_name(self):
|
||||||
|
self.client.force_login(self.author)
|
||||||
|
url = reverse('notifications:api_mention_search') + '?q=Bruxelles'
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
results = data.get('results', [])
|
||||||
|
team_results = [r for r in results if r['type'] == 'team']
|
||||||
|
self.assertTrue(any(r['id'] == self.team.pk for r in team_results))
|
||||||
|
|
||||||
|
def test_team_mention_notifies_company_team_members_and_adds_participants(self):
|
||||||
|
from notifications.models import Discussion, NotificationRecipient
|
||||||
|
from notifications.services import post_message
|
||||||
|
from notifications.views import _get_user_discussions_data
|
||||||
|
|
||||||
|
discussion = Discussion.objects.create(title="Discussion Chantier", created_by=self.author)
|
||||||
|
discussion.participants.add(self.author)
|
||||||
|
|
||||||
|
content = f"Bonjour @[BM - Régie - Team 1](t:{self.team.pk}), intervention requise."
|
||||||
|
post_message(discussion=discussion, author=self.author, content=content)
|
||||||
|
|
||||||
|
# Vérifier que le membre de l'équipe a été ajouté aux participants
|
||||||
|
self.assertIn(self.team_member_user, discussion.participants.all())
|
||||||
|
self.assertNotIn(self.other_user, discussion.participants.all())
|
||||||
|
|
||||||
|
# Vérifier que le membre a reçu une NotificationRecipient
|
||||||
|
recipients = NotificationRecipient.objects.filter(user=self.team_member_user)
|
||||||
|
self.assertTrue(recipients.exists())
|
||||||
|
notif = recipients.first().notification
|
||||||
|
self.assertIn("BM - Régie - Team 1", notif.title)
|
||||||
|
|
||||||
|
# Vérifier que la discussion apparaît dans _get_user_discussions_data pour ce membre
|
||||||
|
items, unread_count = _get_user_discussions_data(self.team_member_user)
|
||||||
|
discussion_ids = [it['discussion'].pk for it in items]
|
||||||
|
self.assertIn(discussion.pk, discussion_ids)
|
||||||
|
|
||||||
|
def test_reply_in_discussion_detail_with_team_mention(self):
|
||||||
|
from notifications.models import Discussion, NotificationRecipient
|
||||||
|
|
||||||
|
discussion = Discussion.objects.create(title="Discussion Test Reply", created_by=self.author)
|
||||||
|
discussion.participants.add(self.author)
|
||||||
|
|
||||||
|
self.client.force_login(self.author)
|
||||||
|
url = reverse('notifications:discussion_detail', args=[discussion.pk])
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'content': f"Message via form @[BM - Régie - Team 1](t:{self.team.pk})"
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(discussion.messages.count(), 1)
|
||||||
|
self.assertIn(self.team_member_user, discussion.participants.all())
|
||||||
|
self.assertTrue(NotificationRecipient.objects.filter(user=self.team_member_user).exists())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,16 +190,21 @@ def _translate_notification_data(notification):
|
||||||
|
|
||||||
def _get_user_discussions_data(user):
|
def _get_user_discussions_data(user):
|
||||||
"""Récupère les discussions de l'utilisateur avec leur dernier message et compteur non-lus."""
|
"""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))
|
from contracts.models import CompanyTeam
|
||||||
|
company_team_ids = list(
|
||||||
|
CompanyTeam.objects.filter(members__user=user).values_list('id', flat=True).distinct()
|
||||||
|
)
|
||||||
|
legacy_team_ids = list(user.notification_teams.values_list('id', flat=True))
|
||||||
|
all_team_ids = set(company_team_ids) | set(legacy_team_ids)
|
||||||
|
|
||||||
mention_q = Q(messages__content__icontains=f'(u:{user.pk})')
|
mention_q = Q(messages__content__icontains=f'(u:{user.pk})')
|
||||||
for tid in user_team_ids:
|
for tid in all_team_ids:
|
||||||
mention_q |= Q(messages__content__icontains=f'(t:{tid})')
|
mention_q |= Q(messages__content__icontains=f'(t:{tid})')
|
||||||
|
|
||||||
discussion_filter = (
|
discussion_filter = (
|
||||||
Q(messages__author=user, messages__is_deleted=False)
|
Q(messages__author=user, messages__is_deleted=False)
|
||||||
| Q(notifications__recipients__user=user)
|
| Q(notifications__recipients__user=user)
|
||||||
| Q(notifications__team_targets__team_id__in=user_team_ids)
|
| Q(notifications__team_targets__team_id__in=legacy_team_ids)
|
||||||
| (mention_q & Q(messages__is_deleted=False))
|
| (mention_q & Q(messages__is_deleted=False))
|
||||||
| (Q(created_by=user) & Q(content_type__isnull=True))
|
| (Q(created_by=user) & Q(content_type__isnull=True))
|
||||||
)
|
)
|
||||||
|
|
@ -699,9 +704,19 @@ def api_mention_search(request):
|
||||||
display = f"{display} ({company_name})"
|
display = f"{display} ({company_name})"
|
||||||
results.append({'type': 'user', 'id': u.pk, 'display': display})
|
results.append({'type': 'user', 'id': u.pk, 'display': display})
|
||||||
|
|
||||||
# Teams
|
# Teams (CompanyTeam)
|
||||||
team_qs = Team.objects.filter(name__icontains=q).order_by('name')[:5]
|
from contracts.models import CompanyTeam
|
||||||
for t in team_qs:
|
company_team_qs = CompanyTeam.objects.filter(
|
||||||
|
Q(name__icontains=q) | Q(company__name__icontains=q)
|
||||||
|
).select_related('company').order_by('name')[:15]
|
||||||
|
for t in company_team_qs:
|
||||||
|
results.append({'type': 'team', 'id': t.pk, 'display': t.name})
|
||||||
|
|
||||||
|
# Legacy notifications.models.Team (si existant)
|
||||||
|
existing_team_ids = {r['id'] for r in results if r['type'] == 'team'}
|
||||||
|
notif_team_qs = Team.objects.filter(name__icontains=q).order_by('name')[:5]
|
||||||
|
for t in notif_team_qs:
|
||||||
|
if t.pk not in existing_team_ids:
|
||||||
results.append({'type': 'team', 'id': t.pk, 'display': t.name})
|
results.append({'type': 'team', 'id': t.pk, 'display': t.name})
|
||||||
|
|
||||||
return JsonResponse({'results': results})
|
return JsonResponse({'results': results})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue