From d3cc08cdceffe8121247fc72d92e84886a6db39d Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 12 Aug 2026 14:50:33 +0200 Subject: [PATCH] feat: add discussion thread support to preparation orders with messaging and access control --- streetup/mobile/views.py | 23 + streetup/notifications/services.py | 137 +++++- streetup/notifications/views.py | 25 +- ...stock_preparation_order_detail_mobile.html | 445 +++++++++++++++++ .../stock/preparation_order_detail.html | 462 ++++++++++++++++++ streetup/stock/tests.py | 48 +- streetup/stock/tests_permissions.py | 3 +- streetup/stock/urls.py | 1 + streetup/stock/views.py | 60 +++ 9 files changed, 1172 insertions(+), 32 deletions(-) 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() { + + + + + + + + + + {% 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() { } }); + + + + + + + + + + {% 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): """