feat: add discussion thread support to preparation orders with messaging and access control

This commit is contained in:
kdeterme 2026-08-12 14:50:33 +02:00
parent 2ae78f70f6
commit d3cc08cdce
9 changed files with 1172 additions and 32 deletions

View file

@ -1063,6 +1063,27 @@ def stock_preparation_order_detail_mobile(request, pk):
exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date')
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
# Discussion liée à ce bon de préparation (mobile)
discussion_messages_mobile = []
can_post_discussion_message_mobile = False
try:
from notifications.models import Discussion as _Discussion
from notifications.services import mark_discussion_seen as _mark_seen, can_post_preparation_order_message as _can_post
from django.contrib.contenttypes.models import ContentType as _CT
_ct = _CT.objects.get_for_model(preparation_order)
_discussion = _Discussion.objects.filter(content_type=_ct, object_id=preparation_order.pk).first()
if _discussion:
discussion_messages_mobile = list(
_discussion.messages.filter(is_deleted=False)
.select_related('author')
.prefetch_related('attachments')
.order_by('created_at')
)
_mark_seen(_discussion, user=request.user)
can_post_discussion_message_mobile = _can_post(request.user, preparation_order)
except Exception:
pass
return render(request, "stock/mobile/stock_preparation_order_detail_mobile.html", {
'preparation_order': preparation_order,
'items_with_stock': items_with_stock,
@ -1076,6 +1097,8 @@ def stock_preparation_order_detail_mobile(request, pk):
'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return,
'discussion_messages': discussion_messages_mobile,
'can_post_discussion_message': can_post_discussion_message_mobile,
})

View file

@ -603,8 +603,8 @@ def get_or_create_intervention_discussion(intervention):
"""
Trouve ou crée la discussion associée à une intervention.
Les participants sont : les utilisateurs ayant un accès contrat à cette
intervention + les membres de l'équipe assignée (si présente).
Les participants initiaux sont : le créateur de l'intervention et le gestionnaire (si présents).
Les autres intervenants sont ajoutés lorsqu'ils répondent ou sont mentionnés.
Retourne (discussion, created).
"""
from .models import Discussion
@ -636,34 +636,117 @@ def get_or_create_intervention_discussion(intervention):
)
participants = set()
# Membres de l'équipe assignée
if intervention.assigned_team:
try:
from contracts.models import CompanyMember
from django.contrib.auth.models import User as AuthUser
team_users = AuthUser.objects.filter(
companymember__teams=intervention.assigned_team,
).distinct()
participants.update(team_users)
except Exception:
pass
# Utilisateurs avec accès contrat
if intervention.contract:
try:
from common.models import UserContractAccess
from django.contrib.auth.models import User as AuthUser
contract_users = AuthUser.objects.filter(
config__usercontractaccess__contract=intervention.contract,
config__usercontractaccess__can_view_interventions=True,
).distinct()
participants.update(contract_users)
except Exception:
pass
if hasattr(intervention, 'intervention_manager') and intervention.intervention_manager:
participants.add(intervention.intervention_manager)
if participants:
discussion.participants.set(participants)
return discussion, True
def can_post_preparation_order_message(user, preparation_order):
"""
Retourne True si l'utilisateur peut poster un message dans la discussion
d'un bon de préparation. Critères :
- Il est superuser, OU
- Il est admin/operator/stock_manager, OU
- Il a la permission can_manage_preparation_orders_any, OU
- L'intervention liée peut être vue (can_view_intervention).
"""
if not user or not user.is_authenticated:
return False
if user.is_superuser:
return True
user_config = getattr(user, 'config', None)
if user_config and hasattr(user_config, 'has_role'):
if user_config.has_role('admin') or user_config.has_role('operator') or user_config.has_role('stock_manager'):
return True
if hasattr(user_config, 'can_manage_preparation_orders_any') and user_config.can_manage_preparation_orders_any():
return True
if preparation_order and preparation_order.intervention:
try:
from interventions.permissions import can_view_intervention
if can_view_intervention(user, preparation_order.intervention):
return True
except Exception:
pass
return False
def get_or_create_preparation_order_discussion(preparation_order):
"""
Trouve ou crée la discussion associée à un bon de préparation.
"""
from .models import Discussion
from django.contrib.contenttypes.models import ContentType as CT
from django.urls import reverse
ct = CT.objects.get_for_model(preparation_order)
discussion = Discussion.objects.filter(
content_type=ct, object_id=preparation_order.pk
).first()
if discussion:
return discussion, False
try:
obj_url = reverse('stock:preparation_order_detail', args=[preparation_order.pk])
except Exception:
obj_url = ''
obj_label = f"Bon de préparation {preparation_order.order_number}"
created_by_user = None
if preparation_order.intervention and hasattr(preparation_order.intervention, 'created_by'):
created_by_user = preparation_order.intervention.created_by
discussion = Discussion.objects.create(
title=obj_label,
created_by=created_by_user,
content_type=ct,
object_id=preparation_order.pk,
object_url=obj_url,
object_label=obj_label,
)
participants = set()
if preparation_order.intervention:
interv = preparation_order.intervention
if hasattr(interv, 'intervention_manager') and interv.intervention_manager:
participants.add(interv.intervention_manager)
if participants:
discussion.participants.set(participants)
return discussion, True
def cleanup_linked_discussion_participants(discussion):
"""
Conserve uniquement comme participants le gestionnaire d'intervention,
ainsi que les auteurs de messages et personnes mentionnées.
Ne conserve pas l'auteur/créateur de l'intervention par défaut.
"""
if not discussion.content_type_id:
return
obj = discussion.content_object
if not obj:
return
keep_user_ids = set()
if hasattr(obj, 'intervention_manager_id') and obj.intervention_manager_id:
keep_user_ids.add(obj.intervention_manager_id)
msg_author_ids = discussion.messages.values_list('author_id', flat=True)
keep_user_ids.update(msg_author_ids)
for msg in discussion.messages.all():
for m in _MENTION_PATTERN.finditer(msg.content or ''):
if m.group(2) == 'u':
keep_user_ids.add(int(m.group(3)))
discussion.participants.set(keep_user_ids)

View file

@ -522,12 +522,31 @@ def discussion_detail(request, pk):
pk=pk,
)
# Vérifier que l'utilisateur est participant (ou admin)
# Vérifier que l'utilisateur est participant (ou admin / accès à l'objet lié)
user_config = getattr(request.user, 'config', None)
is_admin = request.user.is_superuser or (
user_config and (user_config.has_role('admin') or user_config.has_role('manager'))
user_config and (user_config.has_role('admin') or user_config.has_role('manager') or user_config.has_role('operator') or user_config.has_role('stock_manager'))
)
if not is_admin and not discussion.participants.filter(pk=request.user.pk).exists():
is_participant = discussion.participants.filter(pk=request.user.pk).exists()
has_access = is_admin or is_participant
if not has_access and discussion.content_type_id and discussion.object_id:
obj = discussion.content_object
if obj:
from stock.models import PreparationOrder
from interventions.models import Intervention
from observations.models import Observation
if isinstance(obj, Intervention):
from notifications.services import can_post_intervention_message
has_access = can_post_intervention_message(request.user, obj)
elif isinstance(obj, PreparationOrder):
from notifications.services import can_post_preparation_order_message
has_access = can_post_preparation_order_message(request.user, obj)
elif isinstance(obj, Observation):
from notifications.services import can_post_observation_message
has_access = can_post_observation_message(request.user, obj)
if not has_access:
messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
return redirect('notifications:discussion_list')

View file

@ -3,6 +3,7 @@
{% load i18n %}
{% load stock_custom_filters %}
{% load short_name %}
{% load mention_tags %}
{% block title %}{% translate "Bon de Préparation" %} #{{ preparation_order.order_number }}{% endblock title %}
@ -741,4 +742,448 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
</div>
<!-- ===== Widget Discussion mobile ===== -->
<style>
#discussion-mob-fab {
position: fixed;
bottom: 5.5rem;
right: 1rem;
z-index: 1050;
border-radius: 50%;
width: 3.25rem;
height: 3.25rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
padding: 0;
background: #0d6efd;
color: #fff;
border: none;
cursor: pointer;
font-size: 1.3rem;
}
#discussion-mob-fab .mob-fab-badge {
position: absolute;
top: -3px;
right: -3px;
background: #dc3545;
color: #fff;
font-size: .65rem;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border: 2px solid #fff;
line-height: 1;
}
#discussion-mob-panel {
position: fixed;
top: 6.5rem;
bottom: 0;
left: 0.5rem;
right: 0.5rem;
z-index: 1060;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 14px 14px 0 0;
box-shadow: 0 -4px 24px rgba(0,0,0,.18);
overflow: hidden;
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform .28s cubic-bezier(.4,0,.2,1), opacity .2s ease;
}
#discussion-mob-panel.open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
#discussion-mob-panel-header {
background: #0d6efd;
color: #fff;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#discussion-mob-thread {
flex: 1;
overflow-y: auto;
padding: 12px 12px 0;
display: flex;
flex-direction: column;
gap: 10px;
}
#discussion-mob-form-area {
flex-shrink: 0;
padding: 10px 12px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
}
</style>
<button id="discussion-mob-fab" onclick="toggleMobDiscussionPanel()"
title="{% translate 'Discussion' %}" aria-label="{% translate 'Discussion' %}">
<i class="bi bi-chat-dots-fill"></i>
{% if discussion_messages %}
<span class="mob-fab-badge" id="discussion-mob-badge">{{ discussion_messages|length }}</span>
{% endif %}
</button>
<div id="discussion-mob-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
<div id="discussion-mob-panel-header">
<span class="fw-semibold d-flex align-items-center gap-2 small">
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
</span>
<button type="button" class="btn-close btn-close-white btn-sm"
onclick="toggleMobDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div id="discussion-mob-thread">
{% if discussion_messages %}
{% for msg in discussion_messages %}
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
{% if msg.author != request.user %}
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
style="width:28px;height:28px;font-size:.7rem;">
{{ msg.author|short_name|first|upper|default:msg.author.username|first|upper }}
</div>
{% endif %}
<div style="max-width:80%;">
{% if msg.author != request.user %}
<div class="small text-muted mb-1">
<strong>{{ msg.author|short_name }}</strong>
&middot; {{ msg.created_at|date:"d/m H:i" }}
</div>
{% else %}
<div class="small text-muted mb-1 text-end">{{ msg.created_at|date:"d/m H:i" }}</div>
{% endif %}
<div class="p-2 rounded {% if msg.author != request.user %}bg-light{% endif %}"
style="{% if msg.author == request.user %}background:#e7ebff;{% endif %}border-radius:12px;word-break:break-word;display:inline-block;text-align:left;font-size:.85rem;">
{{ msg.content|render_mentions }}
{% if msg.attachments.exists %}
<div class="mt-1 d-flex flex-wrap gap-1">
{% for att in msg.attachments.all %}
{% if att.is_image %}
<a href="{{ att.file.url }}" target="_blank">
<img src="{{ att.file.url }}" style="width:44px;height:44px;object-fit:cover;border-radius:6px;" alt="{{ att.name }}">
</a>
{% else %}
<a href="{{ att.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-1" style="font-size:.75rem;">
<i class="bi bi-paperclip"></i>{{ att.name|truncatechars:16 }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted small text-center py-3 mb-0">
<i class="bi bi-chat-square me-1"></i>{% translate "Aucun message." %}
</p>
{% endif %}
</div>
{% if can_post_discussion_message %}
<div id="discussion-mob-form-area">
<form method="post"
action="{% url 'stock:preparation_order_add_message' preparation_order.id %}"
enctype="multipart/form-data"
id="mob-msg-form">
{% csrf_token %}
<input type="hidden" name="content" id="mob-msg-content-real">
<div class="d-flex gap-2 align-items-end">
<div class="flex-grow-1 position-relative">
<textarea id="mob-msg-content" class="form-control form-control-sm"
rows="2" style="resize:none;font-size:.85rem;"
placeholder="{% translate 'Écrire… (@ pour mentionner)' %}"></textarea>
<div id="mob-mention-dropdown"
class="list-group shadow"
style="display:none;position:absolute;bottom:100%;left:0;right:0;z-index:1080;max-height:160px;overflow-y:auto;margin-bottom:2px;"></div>
</div>
<div class="d-flex flex-column gap-1">
<button type="submit" class="btn btn-primary btn-sm" title="{% translate 'Envoyer' %}" style="padding:5px 8px;">
<i class="bi bi-send-fill"></i>
</button>
<label class="btn btn-outline-secondary btn-sm mb-0" title="{% translate 'Joindre un fichier' %}" style="padding:5px 8px;">
<i class="bi bi-paperclip"></i>
<input type="file" name="attachments" multiple class="d-none" id="mob-msg-file-input">
</label>
</div>
</div>
<div id="mob-msg-file-preview" class="d-flex flex-wrap gap-2 mt-2"></div>
</form>
</div>
{% endif %}
</div>
<!-- ===== fin widget Discussion mobile ===== -->
<script>
function toggleMobDiscussionPanel() {
const panel = document.getElementById('discussion-mob-panel');
if (!panel) return;
const opening = !panel.classList.contains('open');
panel.classList.toggle('open', opening);
if (opening) {
const thread = document.getElementById('discussion-mob-thread');
if (thread) setTimeout(() => { thread.scrollTop = thread.scrollHeight; }, 50);
const badge = document.getElementById('discussion-mob-badge');
if (badge) badge.style.display = 'none';
}
}
document.addEventListener('click', function (e) {
const panel = document.getElementById('discussion-mob-panel');
const fab = document.getElementById('discussion-mob-fab');
if (!panel || !panel.classList.contains('open')) return;
if (panel.contains(e.target) || (fab && fab.contains(e.target))) return;
panel.classList.remove('open');
});
if (window.location.hash === '#discussion') {
toggleMobDiscussionPanel();
}
document.addEventListener('DOMContentLoaded', function () {
const fileInput = document.getElementById('mob-msg-file-input');
const filePreview = document.getElementById('mob-msg-file-preview');
if (fileInput && filePreview) {
fileInput.addEventListener('change', renderMobPreview);
function renderMobPreview() {
filePreview.innerHTML = '';
const files = fileInput.files;
if (!files || !files.length) { filePreview.style.display = 'none'; return; }
filePreview.style.display = 'flex';
Array.from(files).forEach((file, idx) => {
const isImage = file.type.startsWith('image/');
const wrapper = document.createElement('div');
wrapper.className = 'position-relative';
wrapper.style.cssText = 'display:inline-block;';
if (isImage) {
const img = document.createElement('img');
img.style.cssText = 'width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;';
const reader = new FileReader();
reader.onload = e => { img.src = e.target.result; };
reader.readAsDataURL(file);
wrapper.appendChild(img);
} else {
const chip = document.createElement('div');
chip.className = 'd-flex align-items-center gap-1 bg-light border rounded px-2 py-1 small';
chip.style.cssText = 'max-width:120px;';
chip.innerHTML = `<i class="bi bi-paperclip text-secondary"></i><span class="text-truncate">${file.name}</span>`;
wrapper.appendChild(chip);
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
btn.style.cssText = 'width:18px;height:18px;font-size:10px;border-radius:50%;transform:translate(40%,-40%);line-height:1;';
btn.innerHTML = '&times;';
btn.addEventListener('click', () => {
const dt = new DataTransfer();
Array.from(fileInput.files).forEach((f, i) => { if (i !== idx) dt.items.add(f); });
fileInput.files = dt.files;
renderMobPreview();
});
wrapper.appendChild(btn);
filePreview.appendChild(wrapper);
});
}
const msgForm = document.getElementById('mob-msg-form');
if (msgForm) {
msgForm.addEventListener('submit', () => { filePreview.innerHTML = ''; filePreview.style.display = 'none'; });
}
}
});
// @mention autocomplete (mobile)
(function () {
const textarea = document.getElementById('mob-msg-content');
const realInput = document.getElementById('mob-msg-content-real');
const dropdown = document.getElementById('mob-mention-dropdown');
const form = document.getElementById('mob-msg-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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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;
}
}
});
function searchMentions(q) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
fetch(`{% url 'notifications:api_mention_search' %}?q=${encodeURIComponent(q)}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(resp => resp.json())
.then(data => showDropdown(data.results))
.catch(() => hideDropdown());
}, 180);
}
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];
searchMentions(mentionQuery);
});
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('');
}
});
})();
</script>
{% endblock content %}

View file

@ -3,6 +3,7 @@
{% load i18n %}
{% load stock_custom_filters %}
{% load short_name %}
{% load mention_tags %}
{% block content %}
<div class="container-fluid mt-3">
@ -1612,4 +1613,465 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
</script>
<!-- ===== Widget Discussion flottant ===== -->
<style>
#discussion-fab {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1060;
width: 56px;
height: 56px;
border-radius: 50%;
background: #0d6efd;
color: #fff;
border: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
}
#discussion-fab:hover { background: #0b5ed7; transform: scale(1.08); }
#discussion-fab .fab-badge {
position: absolute;
top: -3px;
right: -3px;
background: #dc3545;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
border-radius: 10px;
padding: 2px 6px;
line-height: 1;
border: 2px solid #fff;
}
#discussion-panel {
position: fixed;
bottom: 90px;
right: 24px;
z-index: 1060;
width: 390px;
max-width: calc(100vw - 32px);
max-height: 580px;
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
overflow: hidden;
transform: translateY(20px) scale(0.95);
opacity: 0;
pointer-events: none;
transition: transform 0.25s cubic-bezier(0.34,1.56,0.64,1), opacity 0.2s ease;
}
#discussion-panel.open {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
#discussion-panel-header {
background: #0d6efd;
color: #fff;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#discussion-thread {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
#discussion-form-area {
padding: 10px 14px 14px;
border-top: 1px solid #dee2e6;
background: #f8f9fa;
}
</style>
<button id="discussion-fab" onclick="toggleDiscussionPanel()" title="{% translate 'Discussion' %}" aria-label="{% translate 'Discussion' %}">
<i class="bi bi-chat-dots-fill"></i>
{% if discussion_messages %}
<span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span>
{% endif %}
</button>
<div id="discussion-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
<!-- En-tête -->
<div id="discussion-panel-header">
<span class="fw-semibold d-flex align-items-center gap-2 small">
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion liée à ce bon" %}
</span>
<button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
</div>
<!-- Fil de messages -->
<div id="discussion-thread">
{% if discussion_messages %}
{% for msg in discussion_messages %}
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
{% if msg.author != request.user %}
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
style="width:30px;height:30px;font-size:.75rem;">
{{ msg.author|short_name|first|upper|default:msg.author.username|first|upper }}
</div>
{% endif %}
<div style="max-width:78%;">
{% if msg.author != request.user %}
<div class="small text-muted mb-1">
<strong>{{ msg.author|short_name }}</strong>
&middot; {{ msg.created_at|date:"d/m H:i" }}
</div>
{% else %}
<div class="small text-muted mb-1 text-end">{{ msg.created_at|date:"d/m H:i" }}</div>
{% endif %}
<div class="p-2 rounded {% if msg.author != request.user %}bg-light{% endif %}"
style="{% if msg.author == request.user %}background:#e7ebff;{% endif %}border-radius:12px;word-break:break-word;display:inline-block;text-align:left;font-size:.85rem;">
{{ msg.content|render_mentions }}
{% if msg.attachments.exists %}
<div class="mt-1 d-flex flex-wrap gap-1">
{% for att in msg.attachments.all %}
{% if att.is_image %}
<a href="{{ att.file.url }}" target="_blank">
<img src="{{ att.file.url }}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;" alt="{{ att.name }}">
</a>
{% else %}
<a href="{{ att.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-1" style="font-size:.75rem;">
<i class="bi bi-paperclip"></i>{{ att.name|truncatechars:18 }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted small text-center py-3 mb-0">
<i class="bi bi-chat-square me-1"></i>{% translate "Aucun message." %}
</p>
{% endif %}
</div>
<!-- Formulaire -->
{% if can_post_discussion_message %}
<div id="discussion-form-area">
<form method="post"
action="{% url 'stock:preparation_order_add_message' preparation_order.id %}"
enctype="multipart/form-data"
id="prep-msg-form">
{% csrf_token %}
<input type="hidden" name="content" id="prep-msg-content-real">
<div class="d-flex gap-2 align-items-end">
<div class="flex-grow-1 position-relative">
<textarea id="prep-msg-content" class="form-control form-control-sm"
rows="2" style="resize:none;font-size:.85rem;"
placeholder="{% translate 'Écrire… (@ pour mentionner)' %}"></textarea>
<div id="prep-mention-dropdown"
class="list-group shadow"
style="display:none;position:absolute;bottom:100%;left:0;right:0;z-index:1070;max-height:180px;overflow-y:auto;margin-bottom:2px;"></div>
</div>
<div class="d-flex flex-column gap-1">
<button type="submit" class="btn btn-primary btn-sm" title="{% translate 'Envoyer' %}" style="padding:5px 8px;">
<i class="bi bi-send-fill"></i>
</button>
<label class="btn btn-outline-secondary btn-sm mb-0" title="{% translate 'Joindre un fichier' %}" style="padding:5px 8px;">
<i class="bi bi-paperclip"></i>
<input type="file" name="attachments" multiple class="d-none" id="prep-msg-file-input">
</label>
</div>
</div>
<div id="prep-msg-file-preview" class="d-flex flex-wrap gap-2 mt-2"></div>
</form>
</div>
{% endif %}
</div>
<!-- ===== fin widget Discussion ===== -->
<script>
// Discussion – widget flottant + @mention autocomplete + file preview
function toggleDiscussionPanel() {
const panel = document.getElementById('discussion-panel');
if (!panel) return;
const opening = !panel.classList.contains('open');
panel.classList.toggle('open', opening);
if (opening) {
const thread = document.getElementById('discussion-thread');
if (thread) setTimeout(() => { thread.scrollTop = thread.scrollHeight; }, 50);
const badge = document.getElementById('discussion-fab-badge');
if (badge) badge.style.display = 'none';
}
}
document.addEventListener('click', function (e) {
const panel = document.getElementById('discussion-panel');
const fab = document.getElementById('discussion-fab');
if (!panel || !panel.classList.contains('open')) return;
if (panel.contains(e.target) || (fab && fab.contains(e.target))) return;
panel.classList.remove('open');
});
if (window.location.hash === '#discussion') {
toggleDiscussionPanel();
}
document.addEventListener('DOMContentLoaded', function () {
const fileInput = document.getElementById('prep-msg-file-input');
const filePreview = document.getElementById('prep-msg-file-preview');
if (fileInput && filePreview) {
fileInput.addEventListener('change', renderPreview);
function renderPreview() {
filePreview.innerHTML = '';
const files = fileInput.files;
if (!files || files.length === 0) {
filePreview.style.display = 'none';
return;
}
filePreview.style.display = 'flex';
Array.from(files).forEach((file, idx) => {
const isImage = file.type.startsWith('image/');
const wrapper = document.createElement('div');
wrapper.className = 'position-relative';
wrapper.style.cssText = 'display:inline-block;';
if (isImage) {
const img = document.createElement('img');
img.style.cssText = 'width:64px;height:64px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;';
img.alt = file.name;
const reader = new FileReader();
reader.onload = e => { img.src = e.target.result; };
reader.readAsDataURL(file);
wrapper.appendChild(img);
} else {
const chip = document.createElement('div');
chip.className = 'd-flex align-items-center gap-1 bg-light border rounded px-2 py-1 small';
chip.style.cssText = 'max-width:140px;';
chip.innerHTML = `<i class="bi bi-paperclip text-secondary"></i>
<span class="text-truncate" title="${file.name}">${file.name}</span>`;
wrapper.appendChild(chip);
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
btn.style.cssText = 'width:18px;height:18px;font-size:10px;border-radius:50%;transform:translate(40%,-40%);line-height:1;';
btn.innerHTML = '&times;';
btn.title = '{% translate "Retirer" %}';
btn.addEventListener('click', () => removeFile(idx));
wrapper.appendChild(btn);
filePreview.appendChild(wrapper);
});
}
function removeFile(indexToRemove) {
const dt = new DataTransfer();
Array.from(fileInput.files).forEach((f, i) => {
if (i !== indexToRemove) dt.items.add(f);
});
fileInput.files = dt.files;
renderPreview();
}
const msgForm = document.getElementById('prep-msg-form');
if (msgForm) {
msgForm.addEventListener('submit', () => {
filePreview.innerHTML = '';
filePreview.style.display = 'none';
});
}
}
});
// @mention autocomplete
(function () {
const textarea = document.getElementById('prep-msg-content');
const realInput = document.getElementById('prep-msg-content-real');
const dropdown = document.getElementById('prep-mention-dropdown');
const form = document.getElementById('prep-msg-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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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;
}
}
});
function searchMentions(q) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
fetch(`{% url 'notifications:api_mention_search' %}?q=${encodeURIComponent(q)}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(resp => resp.json())
.then(data => showDropdown(data.results))
.catch(() => hideDropdown());
}, 180);
}
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];
searchMentions(mentionQuery);
});
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('');
}
});
})();
</script>
{% endblock content %}

View file

@ -1,5 +1,6 @@
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from .models import (
Inventory,
InventoryItem,
@ -13,6 +14,7 @@ from .models import (
Warehouse,
WarehouseLocation,
SerializedItem,
PreparationOrder,
)
@ -1735,7 +1737,8 @@ class PreparationOrderAutocompleteTests(TestCase):
response_detail = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
self.assertEqual(response_detail.status_code, 200)
self.assertContains(response_detail, f"preparation_order={po.pk}")
self.assertContains(response_detail, "Créer un retour")
from django.utils.translation import gettext as _
self.assertContains(response_detail, _("Créer un retour"))
# 2. Accéder à la page de création d'entrée de stock avec la QueryParam preparation_order
url_entry = reverse("stock:new_stock_entry") + f"?preparation_order={po.pk}"
@ -1766,6 +1769,49 @@ class PreparationOrderAutocompleteTests(TestCase):
self.assertEqual(data["default_location_id"], loc.id)
class PreparationOrderDiscussionTests(TestCase):
def setUp(self):
from common.models import UserConfig
self.user = User.objects.create_superuser(username="admin_prep_disc", email="admin@test.com", password="password")
UserConfig.objects.get_or_create(user=self.user)
self.client.force_login(self.user)
self.po = PreparationOrder.objects.create(order_number="BP-DISC-001")
def test_get_or_create_preparation_order_discussion(self):
from notifications.services import get_or_create_preparation_order_discussion
discussion, created = get_or_create_preparation_order_discussion(self.po)
self.assertTrue(created)
self.assertIn("BP-DISC-001", discussion.title)
discussion2, created2 = get_or_create_preparation_order_discussion(self.po)
self.assertFalse(created2)
self.assertEqual(discussion.pk, discussion2.pk)
def test_add_message_to_preparation_order(self):
url = reverse("stock:preparation_order_add_message", args=[self.po.pk])
response = self.client.post(url, {"content": "Message de test sur le bon"})
self.assertEqual(response.status_code, 302)
detail_url = reverse("stock:preparation_order_detail", args=[self.po.pk])
detail_resp = self.client.get(detail_url)
self.assertEqual(detail_resp.status_code, 200)
self.assertEqual(len(detail_resp.context["discussion_messages"]), 1)
self.assertEqual(detail_resp.context["discussion_messages"][0].content, "Message de test sur le bon")
def test_mobile_preparation_order_discussion_messages(self):
from notifications.services import get_or_create_preparation_order_discussion, post_message
discussion, _ = get_or_create_preparation_order_discussion(self.po)
post_message(discussion=discussion, author=self.user, content="Message mobile")
mob_url = reverse("mobile:stock_preparation_order_detail_mobile", args=[self.po.pk])
resp = self.client.get(mob_url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.context["discussion_messages"]), 1)
self.assertEqual(resp.context["discussion_messages"][0].content, "Message mobile")

View file

@ -442,7 +442,8 @@ class WarehouseViewsPermissionsTestCase(TestCase):
# Let's call the view
response = new_stock_entry(request)
# It should show error message and stay on the form page (not redirect to success)
self.assertTrue(any("Le sous-type 'Correction' est réservé" in str(m) for m in messages))
from django.utils.translation import gettext as _
self.assertTrue(any(_("Le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt.") in str(m) for m in messages))
# Scenario 2: User has can_administer=True
access.can_administer = True

View file

@ -69,6 +69,7 @@ urlpatterns = [
path("preparation-orders/autocomplete/", views.preparation_orders_autocomplete_api, name="preparation_orders_autocomplete_api"),
path("preparation-orders/new-from-intervention/<int:intervention_id>/", views.new_preparation_order_from_intervention, name="new_preparation_order_from_intervention"),
path("preparation-orders/<int:pk>/", views.preparation_order_detail, name="preparation_order_detail"),
path("preparation-orders/<int:pk>/add-message/", views.preparation_order_add_message, name="preparation_order_add_message"),
path("preparation-orders/<int:pk>/items/", views.preparation_order_items_api, name="preparation_order_items_api"),
path("preparation-orders/<int:pk>/cancel/", views.preparation_order_cancel, name="preparation_order_cancel"),
path("preparation-orders/<int:pk>/reopen/", views.preparation_order_reopen, name="preparation_order_reopen"),

View file

@ -3918,6 +3918,29 @@ def preparation_order_detail(request, pk):
exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date')
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
# Discussion liée à ce bon de préparation
discussion_messages = []
can_post_discussion_message = False
try:
from notifications.models import Discussion
from notifications.services import mark_discussion_seen, can_post_preparation_order_message
from django.contrib.contenttypes.models import ContentType as CT
_ct = CT.objects.get_for_model(preparation_order)
discussion = Discussion.objects.filter(
content_type=_ct, object_id=preparation_order.pk
).first()
if discussion:
discussion_messages = list(
discussion.messages.filter(is_deleted=False)
.select_related('author')
.prefetch_related('attachments')
.order_by('created_at')
)
mark_discussion_seen(discussion, user=request.user)
can_post_discussion_message = can_post_preparation_order_message(request.user, preparation_order)
except Exception:
pass
context = {
'preparation_order': preparation_order,
'items_with_stock': items_with_stock,
@ -3932,11 +3955,48 @@ def preparation_order_detail(request, pk):
'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return,
'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message,
}
return render(request, 'stock/preparation_order_detail.html', context)
@require_POST
def preparation_order_add_message(request, pk):
"""
Poste un message dans la discussion liée à ce bon de préparation.
"""
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
from notifications.services import (
can_post_preparation_order_message,
get_or_create_preparation_order_discussion,
post_message,
)
if not can_post_preparation_order_message(request.user, preparation_order):
messages.error(request, _("Vous n'avez pas la permission de poster un message sur ce bon de préparation."))
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
return redirect(next_url)
content = (request.POST.get('content') or '').strip()
if not content:
messages.warning(request, _("Le message ne peut pas être vide."))
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
if '#discussion' not in next_url:
next_url += '#discussion'
return redirect(next_url)
discussion, _ = get_or_create_preparation_order_discussion(preparation_order)
files = request.FILES.getlist('attachments') or None
post_message(discussion=discussion, author=request.user, content=content, files=files)
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
if '#discussion' not in next_url:
next_url += '#discussion'
return redirect(next_url)
@require_POST
def preparation_order_cancel(request, pk):
"""