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') 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') 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", { return render(request, "stock/mobile/stock_preparation_order_detail_mobile.html", {
'preparation_order': preparation_order, 'preparation_order': preparation_order,
'items_with_stock': items_with_stock, 'items_with_stock': items_with_stock,
@ -1076,6 +1097,8 @@ def stock_preparation_order_detail_mobile(request, pk):
'has_executed_outputs': has_executed_outputs, 'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened, 'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return, '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. Trouve ou crée la discussion associée à une intervention.
Les participants sont : les utilisateurs ayant un accès contrat à cette Les participants initiaux sont : le créateur de l'intervention et le gestionnaire (si présents).
intervention + les membres de l'équipe assignée (si présente). Les autres intervenants sont ajoutés lorsqu'ils répondent ou sont mentionnés.
Retourne (discussion, created). Retourne (discussion, created).
""" """
from .models import Discussion from .models import Discussion
@ -636,34 +636,117 @@ def get_or_create_intervention_discussion(intervention):
) )
participants = set() participants = set()
if hasattr(intervention, 'intervention_manager') and intervention.intervention_manager:
# Membres de l'équipe assignée participants.add(intervention.intervention_manager)
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 participants: if participants:
discussion.participants.set(participants) discussion.participants.set(participants)
return discussion, True 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, 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) user_config = getattr(request.user, 'config', None)
is_admin = request.user.is_superuser or ( 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.")) messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
return redirect('notifications:discussion_list') return redirect('notifications:discussion_list')

View file

@ -3,6 +3,7 @@
{% load i18n %} {% load i18n %}
{% load stock_custom_filters %} {% load stock_custom_filters %}
{% load short_name %} {% load short_name %}
{% load mention_tags %}
{% block title %}{% translate "Bon de Préparation" %} #{{ preparation_order.order_number }}{% endblock title %} {% block title %}{% translate "Bon de Préparation" %} #{{ preparation_order.order_number }}{% endblock title %}
@ -741,4 +742,448 @@ document.addEventListener('DOMContentLoaded', function() {
</div> </div>
</div> </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 %} {% endblock content %}

View file

@ -3,6 +3,7 @@
{% load i18n %} {% load i18n %}
{% load stock_custom_filters %} {% load stock_custom_filters %}
{% load short_name %} {% load short_name %}
{% load mention_tags %}
{% block content %} {% block content %}
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
@ -1612,4 +1613,465 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
</script> </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 %} {% endblock content %}

View file

@ -1,5 +1,6 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.contrib.auth.models import User
from .models import ( from .models import (
Inventory, Inventory,
InventoryItem, InventoryItem,
@ -13,6 +14,7 @@ from .models import (
Warehouse, Warehouse,
WarehouseLocation, WarehouseLocation,
SerializedItem, SerializedItem,
PreparationOrder,
) )
@ -1735,7 +1737,8 @@ class PreparationOrderAutocompleteTests(TestCase):
response_detail = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk])) response_detail = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
self.assertEqual(response_detail.status_code, 200) self.assertEqual(response_detail.status_code, 200)
self.assertContains(response_detail, f"preparation_order={po.pk}") 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 # 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}" 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) 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 # Let's call the view
response = new_stock_entry(request) response = new_stock_entry(request)
# It should show error message and stay on the form page (not redirect to success) # 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 # Scenario 2: User has can_administer=True
access.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/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/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>/", 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>/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>/cancel/", views.preparation_order_cancel, name="preparation_order_cancel"),
path("preparation-orders/<int:pk>/reopen/", views.preparation_order_reopen, name="preparation_order_reopen"), 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') 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') 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 = { context = {
'preparation_order': preparation_order, 'preparation_order': preparation_order,
'items_with_stock': items_with_stock, 'items_with_stock': items_with_stock,
@ -3932,11 +3955,48 @@ def preparation_order_detail(request, pk):
'has_executed_outputs': has_executed_outputs, 'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened, 'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return, '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) 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 @require_POST
def preparation_order_cancel(request, pk): def preparation_order_cancel(request, pk):
""" """