feat: add discussion thread support to preparation orders with messaging and access control
This commit is contained in:
parent
2ae78f70f6
commit
d3cc08cdce
9 changed files with 1172 additions and 32 deletions
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
· {{ 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 = '×';
|
||||||
|
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,'&').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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
· {{ 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 = '×';
|
||||||
|
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,'&').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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 %}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue