diff --git a/streetup/mobile/views.py b/streetup/mobile/views.py
index c52cbc5..52893bc 100644
--- a/streetup/mobile/views.py
+++ b/streetup/mobile/views.py
@@ -1063,6 +1063,27 @@ def stock_preparation_order_detail_mobile(request, pk):
exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date')
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
+ # Discussion liée à ce bon de préparation (mobile)
+ discussion_messages_mobile = []
+ can_post_discussion_message_mobile = False
+ try:
+ from notifications.models import Discussion as _Discussion
+ from notifications.services import mark_discussion_seen as _mark_seen, can_post_preparation_order_message as _can_post
+ from django.contrib.contenttypes.models import ContentType as _CT
+ _ct = _CT.objects.get_for_model(preparation_order)
+ _discussion = _Discussion.objects.filter(content_type=_ct, object_id=preparation_order.pk).first()
+ if _discussion:
+ discussion_messages_mobile = list(
+ _discussion.messages.filter(is_deleted=False)
+ .select_related('author')
+ .prefetch_related('attachments')
+ .order_by('created_at')
+ )
+ _mark_seen(_discussion, user=request.user)
+ can_post_discussion_message_mobile = _can_post(request.user, preparation_order)
+ except Exception:
+ pass
+
return render(request, "stock/mobile/stock_preparation_order_detail_mobile.html", {
'preparation_order': preparation_order,
'items_with_stock': items_with_stock,
@@ -1076,6 +1097,8 @@ def stock_preparation_order_detail_mobile(request, pk):
'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return,
+ 'discussion_messages': discussion_messages_mobile,
+ 'can_post_discussion_message': can_post_discussion_message_mobile,
})
diff --git a/streetup/notifications/services.py b/streetup/notifications/services.py
index 3bd9c6f..ebf1fcd 100644
--- a/streetup/notifications/services.py
+++ b/streetup/notifications/services.py
@@ -603,8 +603,8 @@ def get_or_create_intervention_discussion(intervention):
"""
Trouve ou crée la discussion associée à une intervention.
- Les participants sont : les utilisateurs ayant un accès contrat à cette
- intervention + les membres de l'équipe assignée (si présente).
+ Les participants initiaux sont : le créateur de l'intervention et le gestionnaire (si présents).
+ Les autres intervenants sont ajoutés lorsqu'ils répondent ou sont mentionnés.
Retourne (discussion, created).
"""
from .models import Discussion
@@ -636,34 +636,117 @@ def get_or_create_intervention_discussion(intervention):
)
participants = set()
-
- # Membres de l'équipe assignée
- if intervention.assigned_team:
- try:
- from contracts.models import CompanyMember
- from django.contrib.auth.models import User as AuthUser
- team_users = AuthUser.objects.filter(
- companymember__teams=intervention.assigned_team,
- ).distinct()
- participants.update(team_users)
- except Exception:
- pass
-
- # Utilisateurs avec accès contrat
- if intervention.contract:
- try:
- from common.models import UserContractAccess
- from django.contrib.auth.models import User as AuthUser
- contract_users = AuthUser.objects.filter(
- config__usercontractaccess__contract=intervention.contract,
- config__usercontractaccess__can_view_interventions=True,
- ).distinct()
- participants.update(contract_users)
- except Exception:
- pass
+ if hasattr(intervention, 'intervention_manager') and intervention.intervention_manager:
+ participants.add(intervention.intervention_manager)
if participants:
discussion.participants.set(participants)
return discussion, True
+def can_post_preparation_order_message(user, preparation_order):
+ """
+ Retourne True si l'utilisateur peut poster un message dans la discussion
+ d'un bon de préparation. Critères :
+ - Il est superuser, OU
+ - Il est admin/operator/stock_manager, OU
+ - Il a la permission can_manage_preparation_orders_any, OU
+ - L'intervention liée peut être vue (can_view_intervention).
+ """
+ if not user or not user.is_authenticated:
+ return False
+ if user.is_superuser:
+ return True
+ user_config = getattr(user, 'config', None)
+ if user_config and hasattr(user_config, 'has_role'):
+ if user_config.has_role('admin') or user_config.has_role('operator') or user_config.has_role('stock_manager'):
+ return True
+ if hasattr(user_config, 'can_manage_preparation_orders_any') and user_config.can_manage_preparation_orders_any():
+ return True
+ if preparation_order and preparation_order.intervention:
+ try:
+ from interventions.permissions import can_view_intervention
+ if can_view_intervention(user, preparation_order.intervention):
+ return True
+ except Exception:
+ pass
+ return False
+
+
+def get_or_create_preparation_order_discussion(preparation_order):
+ """
+ Trouve ou crée la discussion associée à un bon de préparation.
+ """
+ from .models import Discussion
+ from django.contrib.contenttypes.models import ContentType as CT
+ from django.urls import reverse
+
+ ct = CT.objects.get_for_model(preparation_order)
+ discussion = Discussion.objects.filter(
+ content_type=ct, object_id=preparation_order.pk
+ ).first()
+
+ if discussion:
+ return discussion, False
+
+ try:
+ obj_url = reverse('stock:preparation_order_detail', args=[preparation_order.pk])
+ except Exception:
+ obj_url = ''
+
+ obj_label = f"Bon de préparation {preparation_order.order_number}"
+
+ created_by_user = None
+ if preparation_order.intervention and hasattr(preparation_order.intervention, 'created_by'):
+ created_by_user = preparation_order.intervention.created_by
+
+ discussion = Discussion.objects.create(
+ title=obj_label,
+ created_by=created_by_user,
+ content_type=ct,
+ object_id=preparation_order.pk,
+ object_url=obj_url,
+ object_label=obj_label,
+ )
+
+ participants = set()
+ if preparation_order.intervention:
+ interv = preparation_order.intervention
+ if hasattr(interv, 'intervention_manager') and interv.intervention_manager:
+ participants.add(interv.intervention_manager)
+
+ if participants:
+ discussion.participants.set(participants)
+ return discussion, True
+
+
+def cleanup_linked_discussion_participants(discussion):
+ """
+ Conserve uniquement comme participants le gestionnaire d'intervention,
+ ainsi que les auteurs de messages et personnes mentionnées.
+ Ne conserve pas l'auteur/créateur de l'intervention par défaut.
+ """
+ if not discussion.content_type_id:
+ return
+ obj = discussion.content_object
+ if not obj:
+ return
+
+ keep_user_ids = set()
+
+ if hasattr(obj, 'intervention_manager_id') and obj.intervention_manager_id:
+ keep_user_ids.add(obj.intervention_manager_id)
+
+ msg_author_ids = discussion.messages.values_list('author_id', flat=True)
+ keep_user_ids.update(msg_author_ids)
+
+ for msg in discussion.messages.all():
+ for m in _MENTION_PATTERN.finditer(msg.content or ''):
+ if m.group(2) == 'u':
+ keep_user_ids.add(int(m.group(3)))
+
+ discussion.participants.set(keep_user_ids)
+
+
+
+
diff --git a/streetup/notifications/views.py b/streetup/notifications/views.py
index 04b65e8..13ffc4e 100644
--- a/streetup/notifications/views.py
+++ b/streetup/notifications/views.py
@@ -522,12 +522,31 @@ def discussion_detail(request, pk):
pk=pk,
)
- # Vérifier que l'utilisateur est participant (ou admin)
+ # Vérifier que l'utilisateur est participant (ou admin / accès à l'objet lié)
user_config = getattr(request.user, 'config', None)
is_admin = request.user.is_superuser or (
- user_config and (user_config.has_role('admin') or user_config.has_role('manager'))
+ user_config and (user_config.has_role('admin') or user_config.has_role('manager') or user_config.has_role('operator') or user_config.has_role('stock_manager'))
)
- if not is_admin and not discussion.participants.filter(pk=request.user.pk).exists():
+ is_participant = discussion.participants.filter(pk=request.user.pk).exists()
+
+ has_access = is_admin or is_participant
+ if not has_access and discussion.content_type_id and discussion.object_id:
+ obj = discussion.content_object
+ if obj:
+ from stock.models import PreparationOrder
+ from interventions.models import Intervention
+ from observations.models import Observation
+ if isinstance(obj, Intervention):
+ from notifications.services import can_post_intervention_message
+ has_access = can_post_intervention_message(request.user, obj)
+ elif isinstance(obj, PreparationOrder):
+ from notifications.services import can_post_preparation_order_message
+ has_access = can_post_preparation_order_message(request.user, obj)
+ elif isinstance(obj, Observation):
+ from notifications.services import can_post_observation_message
+ has_access = can_post_observation_message(request.user, obj)
+
+ if not has_access:
messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
return redirect('notifications:discussion_list')
diff --git a/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html b/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html
index 408582d..52d8fb3 100644
--- a/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html
+++ b/streetup/stock/templates/stock/mobile/stock_preparation_order_detail_mobile.html
@@ -3,6 +3,7 @@
{% load i18n %}
{% load stock_custom_filters %}
{% load short_name %}
+{% load mention_tags %}
{% block title %}{% translate "Bon de Préparation" %} #{{ preparation_order.order_number }}{% endblock title %}
@@ -741,4 +742,448 @@ document.addEventListener('DOMContentLoaded', function() {
+
+
+
+
+
+
+
+
+
+
+ {% if discussion_messages %}
+ {% for msg in discussion_messages %}
+
+ {% if msg.author != request.user %}
+
+ {{ msg.author|short_name|first|upper|default:msg.author.username|first|upper }}
+
+ {% endif %}
+
+ {% if msg.author != request.user %}
+
+ {{ msg.author|short_name }}
+ · {{ msg.created_at|date:"d/m H:i" }}
+
+ {% else %}
+
{{ msg.created_at|date:"d/m H:i" }}
+ {% endif %}
+
+ {{ msg.content|render_mentions }}
+ {% if msg.attachments.exists %}
+
+ {% endif %}
+
+
+
+ {% endfor %}
+ {% else %}
+
+ {% translate "Aucun message." %}
+
+ {% endif %}
+
+
+ {% if can_post_discussion_message %}
+
+ {% endif %}
+
+
+
+
{% endblock content %}
diff --git a/streetup/stock/templates/stock/preparation_order_detail.html b/streetup/stock/templates/stock/preparation_order_detail.html
index a8b04b8..fad8f9d 100644
--- a/streetup/stock/templates/stock/preparation_order_detail.html
+++ b/streetup/stock/templates/stock/preparation_order_detail.html
@@ -3,6 +3,7 @@
{% load i18n %}
{% load stock_custom_filters %}
{% load short_name %}
+{% load mention_tags %}
{% block content %}
@@ -1612,4 +1613,465 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if discussion_messages %}
+ {% for msg in discussion_messages %}
+
+ {% if msg.author != request.user %}
+
+ {{ msg.author|short_name|first|upper|default:msg.author.username|first|upper }}
+
+ {% endif %}
+
+ {% if msg.author != request.user %}
+
+ {{ msg.author|short_name }}
+ · {{ msg.created_at|date:"d/m H:i" }}
+
+ {% else %}
+
{{ msg.created_at|date:"d/m H:i" }}
+ {% endif %}
+
+ {{ msg.content|render_mentions }}
+ {% if msg.attachments.exists %}
+
+ {% endif %}
+
+
+
+ {% endfor %}
+ {% else %}
+
+ {% translate "Aucun message." %}
+
+ {% endif %}
+
+
+
+ {% if can_post_discussion_message %}
+
+ {% endif %}
+
+
+
+
{% endblock content %}
diff --git a/streetup/stock/tests.py b/streetup/stock/tests.py
index c183639..61f9971 100644
--- a/streetup/stock/tests.py
+++ b/streetup/stock/tests.py
@@ -1,5 +1,6 @@
from django.test import TestCase
from django.urls import reverse
+from django.contrib.auth.models import User
from .models import (
Inventory,
InventoryItem,
@@ -13,6 +14,7 @@ from .models import (
Warehouse,
WarehouseLocation,
SerializedItem,
+ PreparationOrder,
)
@@ -1735,7 +1737,8 @@ class PreparationOrderAutocompleteTests(TestCase):
response_detail = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
self.assertEqual(response_detail.status_code, 200)
self.assertContains(response_detail, f"preparation_order={po.pk}")
- self.assertContains(response_detail, "Créer un retour")
+ from django.utils.translation import gettext as _
+ self.assertContains(response_detail, _("Créer un retour"))
# 2. Accéder à la page de création d'entrée de stock avec la QueryParam preparation_order
url_entry = reverse("stock:new_stock_entry") + f"?preparation_order={po.pk}"
@@ -1766,6 +1769,49 @@ class PreparationOrderAutocompleteTests(TestCase):
self.assertEqual(data["default_location_id"], loc.id)
+class PreparationOrderDiscussionTests(TestCase):
+ def setUp(self):
+ from common.models import UserConfig
+ self.user = User.objects.create_superuser(username="admin_prep_disc", email="admin@test.com", password="password")
+ UserConfig.objects.get_or_create(user=self.user)
+ self.client.force_login(self.user)
+ self.po = PreparationOrder.objects.create(order_number="BP-DISC-001")
+
+ def test_get_or_create_preparation_order_discussion(self):
+ from notifications.services import get_or_create_preparation_order_discussion
+ discussion, created = get_or_create_preparation_order_discussion(self.po)
+ self.assertTrue(created)
+ self.assertIn("BP-DISC-001", discussion.title)
+
+ discussion2, created2 = get_or_create_preparation_order_discussion(self.po)
+ self.assertFalse(created2)
+ self.assertEqual(discussion.pk, discussion2.pk)
+
+ def test_add_message_to_preparation_order(self):
+ url = reverse("stock:preparation_order_add_message", args=[self.po.pk])
+ response = self.client.post(url, {"content": "Message de test sur le bon"})
+ self.assertEqual(response.status_code, 302)
+
+ detail_url = reverse("stock:preparation_order_detail", args=[self.po.pk])
+ detail_resp = self.client.get(detail_url)
+ self.assertEqual(detail_resp.status_code, 200)
+ self.assertEqual(len(detail_resp.context["discussion_messages"]), 1)
+ self.assertEqual(detail_resp.context["discussion_messages"][0].content, "Message de test sur le bon")
+
+ def test_mobile_preparation_order_discussion_messages(self):
+ from notifications.services import get_or_create_preparation_order_discussion, post_message
+ discussion, _ = get_or_create_preparation_order_discussion(self.po)
+ post_message(discussion=discussion, author=self.user, content="Message mobile")
+
+ mob_url = reverse("mobile:stock_preparation_order_detail_mobile", args=[self.po.pk])
+ resp = self.client.get(mob_url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(len(resp.context["discussion_messages"]), 1)
+ self.assertEqual(resp.context["discussion_messages"][0].content, "Message mobile")
+
+
+
+
diff --git a/streetup/stock/tests_permissions.py b/streetup/stock/tests_permissions.py
index 3a661dc..fc9615b 100644
--- a/streetup/stock/tests_permissions.py
+++ b/streetup/stock/tests_permissions.py
@@ -442,7 +442,8 @@ class WarehouseViewsPermissionsTestCase(TestCase):
# Let's call the view
response = new_stock_entry(request)
# It should show error message and stay on the form page (not redirect to success)
- self.assertTrue(any("Le sous-type 'Correction' est réservé" in str(m) for m in messages))
+ from django.utils.translation import gettext as _
+ self.assertTrue(any(_("Le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt.") in str(m) for m in messages))
# Scenario 2: User has can_administer=True
access.can_administer = True
diff --git a/streetup/stock/urls.py b/streetup/stock/urls.py
index 8bf884f..6f14fea 100644
--- a/streetup/stock/urls.py
+++ b/streetup/stock/urls.py
@@ -69,6 +69,7 @@ urlpatterns = [
path("preparation-orders/autocomplete/", views.preparation_orders_autocomplete_api, name="preparation_orders_autocomplete_api"),
path("preparation-orders/new-from-intervention/
/", views.new_preparation_order_from_intervention, name="new_preparation_order_from_intervention"),
path("preparation-orders//", views.preparation_order_detail, name="preparation_order_detail"),
+ path("preparation-orders//add-message/", views.preparation_order_add_message, name="preparation_order_add_message"),
path("preparation-orders//items/", views.preparation_order_items_api, name="preparation_order_items_api"),
path("preparation-orders//cancel/", views.preparation_order_cancel, name="preparation_order_cancel"),
path("preparation-orders//reopen/", views.preparation_order_reopen, name="preparation_order_reopen"),
diff --git a/streetup/stock/views.py b/streetup/stock/views.py
index fd04ff1..d0c910c 100644
--- a/streetup/stock/views.py
+++ b/streetup/stock/views.py
@@ -3918,6 +3918,29 @@ def preparation_order_detail(request, pk):
exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date')
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
+ # Discussion liée à ce bon de préparation
+ discussion_messages = []
+ can_post_discussion_message = False
+ try:
+ from notifications.models import Discussion
+ from notifications.services import mark_discussion_seen, can_post_preparation_order_message
+ from django.contrib.contenttypes.models import ContentType as CT
+ _ct = CT.objects.get_for_model(preparation_order)
+ discussion = Discussion.objects.filter(
+ content_type=_ct, object_id=preparation_order.pk
+ ).first()
+ if discussion:
+ discussion_messages = list(
+ discussion.messages.filter(is_deleted=False)
+ .select_related('author')
+ .prefetch_related('attachments')
+ .order_by('created_at')
+ )
+ mark_discussion_seen(discussion, user=request.user)
+ can_post_discussion_message = can_post_preparation_order_message(request.user, preparation_order)
+ except Exception:
+ pass
+
context = {
'preparation_order': preparation_order,
'items_with_stock': items_with_stock,
@@ -3932,11 +3955,48 @@ def preparation_order_detail(request, pk):
'has_executed_outputs': has_executed_outputs,
'is_reopened': is_reopened,
'deleted_items_to_return': deleted_items_to_return,
+ 'discussion_messages': discussion_messages,
+ 'can_post_discussion_message': can_post_discussion_message,
}
return render(request, 'stock/preparation_order_detail.html', context)
+@require_POST
+def preparation_order_add_message(request, pk):
+ """
+ Poste un message dans la discussion liée à ce bon de préparation.
+ """
+ preparation_order = get_object_or_404(PreparationOrder, pk=pk)
+
+ from notifications.services import (
+ can_post_preparation_order_message,
+ get_or_create_preparation_order_discussion,
+ post_message,
+ )
+ if not can_post_preparation_order_message(request.user, preparation_order):
+ messages.error(request, _("Vous n'avez pas la permission de poster un message sur ce bon de préparation."))
+ next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
+ return redirect(next_url)
+
+ content = (request.POST.get('content') or '').strip()
+ if not content:
+ messages.warning(request, _("Le message ne peut pas être vide."))
+ next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
+ if '#discussion' not in next_url:
+ next_url += '#discussion'
+ return redirect(next_url)
+
+ discussion, _ = get_or_create_preparation_order_discussion(preparation_order)
+ files = request.FILES.getlist('attachments') or None
+ post_message(discussion=discussion, author=request.user, content=content, files=files)
+
+ next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
+ if '#discussion' not in next_url:
+ next_url += '#discussion'
+ return redirect(next_url)
+
+
@require_POST
def preparation_order_cancel(request, pk):
"""