feat: add inventory validation notifications with single-claim auto-archiving
This commit is contained in:
parent
5ebcc1e3c9
commit
dcf5477199
12 changed files with 501 additions and 14 deletions
|
|
@ -813,6 +813,11 @@ msgstr "Reset"
|
||||||
msgid "Nouveau"
|
msgid "Nouveau"
|
||||||
msgstr "New"
|
msgstr "New"
|
||||||
|
|
||||||
|
#: notifications/templates/notifications/inbox.html:255
|
||||||
|
#: notifications/templates/notifications/inbox.html:259
|
||||||
|
msgid "Marquer comme traitée"
|
||||||
|
msgstr "Mark as processed"
|
||||||
|
|
||||||
#: notifications/templates/notifications/inbox.html:255
|
#: notifications/templates/notifications/inbox.html:255
|
||||||
#: notifications/templates/notifications/inbox.html:259
|
#: notifications/templates/notifications/inbox.html:259
|
||||||
msgid "Prendre en charge"
|
msgid "Prendre en charge"
|
||||||
|
|
|
||||||
|
|
@ -814,6 +814,11 @@ msgstr "Réinitialiser"
|
||||||
msgid "Nouveau"
|
msgid "Nouveau"
|
||||||
msgstr "Nouveau"
|
msgstr "Nouveau"
|
||||||
|
|
||||||
|
#: notifications/templates/notifications/inbox.html:255
|
||||||
|
#: notifications/templates/notifications/inbox.html:259
|
||||||
|
msgid "Marquer comme traitée"
|
||||||
|
msgstr "Marquer comme traitée"
|
||||||
|
|
||||||
#: notifications/templates/notifications/inbox.html:255
|
#: notifications/templates/notifications/inbox.html:255
|
||||||
#: notifications/templates/notifications/inbox.html:259
|
#: notifications/templates/notifications/inbox.html:259
|
||||||
msgid "Prendre en charge"
|
msgid "Prendre en charge"
|
||||||
|
|
|
||||||
|
|
@ -813,6 +813,11 @@ msgstr "Opnieuw instellen"
|
||||||
msgid "Nouveau"
|
msgid "Nouveau"
|
||||||
msgstr "Nieuw"
|
msgstr "Nieuw"
|
||||||
|
|
||||||
|
#: notifications/templates/notifications/inbox.html:255
|
||||||
|
#: notifications/templates/notifications/inbox.html:259
|
||||||
|
msgid "Marquer comme traitée"
|
||||||
|
msgstr "Markeren als verwerkt"
|
||||||
|
|
||||||
#: notifications/templates/notifications/inbox.html:255
|
#: notifications/templates/notifications/inbox.html:255
|
||||||
#: notifications/templates/notifications/inbox.html:259
|
#: notifications/templates/notifications/inbox.html:259
|
||||||
msgid "Prendre en charge"
|
msgid "Prendre en charge"
|
||||||
|
|
|
||||||
|
|
@ -193,10 +193,20 @@ class NotificationRecipient(models.Model):
|
||||||
|
|
||||||
def archive(self):
|
def archive(self):
|
||||||
if not self.archived_at:
|
if not self.archived_at:
|
||||||
if not self.read_at:
|
was_unread = self.read_at is None
|
||||||
|
if was_unread:
|
||||||
self.read_at = timezone.now()
|
self.read_at = timezone.now()
|
||||||
self.archived_at = timezone.now()
|
self.archived_at = timezone.now()
|
||||||
self.save(update_fields=['read_at', 'archived_at'])
|
self.save(update_fields=['read_at', 'archived_at'])
|
||||||
|
if was_unread and self.notification.is_single_claim:
|
||||||
|
now = timezone.now()
|
||||||
|
NotificationRecipient.objects.filter(
|
||||||
|
notification=self.notification,
|
||||||
|
read_at__isnull=True,
|
||||||
|
).exclude(pk=self.pk).update(
|
||||||
|
read_at=now,
|
||||||
|
archived_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.notification} → {self.user.username}"
|
return f"{self.notification} → {self.user.username}"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ def notify_user(
|
||||||
sender=None,
|
sender=None,
|
||||||
expires_at=None,
|
expires_at=None,
|
||||||
is_system=False,
|
is_system=False,
|
||||||
|
is_single_claim=False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Envoie une notification personnelle à un utilisateur.
|
Envoie une notification personnelle à un utilisateur.
|
||||||
|
|
@ -91,6 +92,7 @@ def notify_user(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
is_system=is_system,
|
is_system=is_system,
|
||||||
|
is_single_claim=is_single_claim,
|
||||||
)
|
)
|
||||||
recipient_obj = NotificationRecipient.objects.create(
|
recipient_obj = NotificationRecipient.objects.create(
|
||||||
notification=notification,
|
notification=notification,
|
||||||
|
|
@ -117,6 +119,7 @@ def notify_users(
|
||||||
sender=None,
|
sender=None,
|
||||||
expires_at=None,
|
expires_at=None,
|
||||||
is_system=False,
|
is_system=False,
|
||||||
|
is_single_claim=False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Envoie une notification à plusieurs utilisateurs (crée un seul objet Notification
|
Envoie une notification à plusieurs utilisateurs (crée un seul objet Notification
|
||||||
|
|
@ -134,6 +137,7 @@ def notify_users(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
is_system=is_system,
|
is_system=is_system,
|
||||||
|
is_single_claim=is_single_claim,
|
||||||
)
|
)
|
||||||
NotificationRecipient.objects.bulk_create([
|
NotificationRecipient.objects.bulk_create([
|
||||||
NotificationRecipient(notification=notification, user=user)
|
NotificationRecipient(notification=notification, user=user)
|
||||||
|
|
|
||||||
|
|
@ -252,11 +252,11 @@
|
||||||
id="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}"
|
id="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}"
|
||||||
data-pk="{{ item.recipient_id }}"
|
data-pk="{{ item.recipient_id }}"
|
||||||
data-type="{{ item.recipient_type }}"
|
data-type="{{ item.recipient_type }}"
|
||||||
title="{% if notif.is_single_claim %}{% translate 'Prendre en charge' %}{% else %}{% translate 'Marquer comme lu' %}{% endif %}">
|
title="{% if notif.is_single_claim %}{% translate 'Marquer comme traitée' %}{% else %}{% translate 'Marquer comme lu' %}{% endif %}">
|
||||||
<label class="form-check-label small text-muted"
|
<label class="form-check-label small text-muted"
|
||||||
for="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}">
|
for="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}">
|
||||||
{% if notif.is_single_claim %}
|
{% if notif.is_single_claim %}
|
||||||
{% translate "Prendre en charge" %}
|
{% translate "Marquer comme traitée" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% translate "Marquer comme lu" %}
|
{% translate "Marquer comme lu" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
data-pk="{{ item.recipient_id }}"
|
data-pk="{{ item.recipient_id }}"
|
||||||
data-type="{{ item.recipient_type }}">
|
data-type="{{ item.recipient_type }}">
|
||||||
{% if notif.is_single_claim %}
|
{% if notif.is_single_claim %}
|
||||||
{% translate "Prendre" %}
|
{% translate "Traité" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% translate "Lu" %}
|
{% translate "Lu" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -644,3 +644,83 @@ class DiscussionFlagTests(TestCase):
|
||||||
# Icon should NOT be escaped as <i
|
# Icon should NOT be escaped as <i
|
||||||
self.assertNotContains(res, '<i class=')
|
self.assertNotContains(res, '<i class=')
|
||||||
self.assertContains(res, '<i class="bi bi-flag-fill text-success me-1"></i>')
|
self.assertContains(res, '<i class="bi bi-flag-fill text-success me-1"></i>')
|
||||||
|
|
||||||
|
|
||||||
|
class SingleClaimNotificationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user1 = User.objects.create_user(username="user1", email="user1@example.com", password="pwd")
|
||||||
|
self.user2 = User.objects.create_user(username="user2", email="user2@example.com", password="pwd")
|
||||||
|
self.user3 = User.objects.create_user(username="user3", email="user3@example.com", password="pwd")
|
||||||
|
|
||||||
|
def test_single_claim_mark_read_archives_for_others(self):
|
||||||
|
from notifications.services import notify_users, get_unread_count
|
||||||
|
from notifications.models import NotificationRecipient
|
||||||
|
|
||||||
|
notif = notify_users(
|
||||||
|
recipients=[self.user1, self.user2, self.user3],
|
||||||
|
title="Single Claim Task",
|
||||||
|
content="Someone please handle this",
|
||||||
|
is_single_claim=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(get_unread_count(self.user1), 1)
|
||||||
|
self.assertEqual(get_unread_count(self.user2), 1)
|
||||||
|
self.assertEqual(get_unread_count(self.user3), 1)
|
||||||
|
|
||||||
|
nr1 = NotificationRecipient.objects.get(notification=notif, user=self.user1)
|
||||||
|
nr2 = NotificationRecipient.objects.get(notification=notif, user=self.user2)
|
||||||
|
nr3 = NotificationRecipient.objects.get(notification=notif, user=self.user3)
|
||||||
|
|
||||||
|
# User 1 marks as read / traitée
|
||||||
|
nr1.mark_read()
|
||||||
|
|
||||||
|
nr1.refresh_from_db()
|
||||||
|
nr2.refresh_from_db()
|
||||||
|
nr3.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertTrue(nr1.is_read)
|
||||||
|
self.assertFalse(nr1.is_archived)
|
||||||
|
|
||||||
|
# Other recipients must now be marked as read and archived
|
||||||
|
self.assertTrue(nr2.is_read)
|
||||||
|
self.assertTrue(nr2.is_archived)
|
||||||
|
self.assertTrue(nr3.is_read)
|
||||||
|
self.assertTrue(nr3.is_archived)
|
||||||
|
|
||||||
|
self.assertEqual(get_unread_count(self.user1), 0)
|
||||||
|
self.assertEqual(get_unread_count(self.user2), 0)
|
||||||
|
self.assertEqual(get_unread_count(self.user3), 0)
|
||||||
|
|
||||||
|
def test_single_claim_mark_all_read_view(self):
|
||||||
|
from notifications.services import notify_users, get_unread_count
|
||||||
|
from notifications.models import NotificationRecipient
|
||||||
|
|
||||||
|
notif = notify_users(
|
||||||
|
recipients=[self.user1, self.user2],
|
||||||
|
title="Single Claim Batch",
|
||||||
|
is_single_claim=True,
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user1)
|
||||||
|
response = self.client.post(reverse("notifications:mark_all_read"), HTTP_X_REQUESTED_WITH="XMLHttpRequest")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
nr2 = NotificationRecipient.objects.get(notification=notif, user=self.user2)
|
||||||
|
self.assertTrue(nr2.is_read)
|
||||||
|
self.assertTrue(nr2.is_archived)
|
||||||
|
self.assertEqual(get_unread_count(self.user2), 0)
|
||||||
|
|
||||||
|
def test_single_claim_archive_propagates(self):
|
||||||
|
from notifications.services import notify_users, get_unread_count
|
||||||
|
from notifications.models import NotificationRecipient
|
||||||
|
|
||||||
|
notif = notify_users(
|
||||||
|
recipients=[self.user1, self.user2],
|
||||||
|
title="Single Claim Archive",
|
||||||
|
is_single_claim=True,
|
||||||
|
)
|
||||||
|
nr1 = NotificationRecipient.objects.get(notification=notif, user=self.user1)
|
||||||
|
nr1.archive()
|
||||||
|
|
||||||
|
nr2 = NotificationRecipient.objects.get(notification=notif, user=self.user2)
|
||||||
|
self.assertTrue(nr2.is_read)
|
||||||
|
self.assertTrue(nr2.is_archived)
|
||||||
|
self.assertEqual(get_unread_count(self.user2), 0)
|
||||||
|
|
|
||||||
|
|
@ -334,11 +334,27 @@ def mark_read(request, pk):
|
||||||
@require_POST
|
@require_POST
|
||||||
def mark_all_read(request):
|
def mark_all_read(request):
|
||||||
"""Marque toutes les notifications (personnelles + équipes) comme lues."""
|
"""Marque toutes les notifications (personnelles + équipes) comme lues."""
|
||||||
|
now = timezone.now()
|
||||||
# Personal
|
# Personal
|
||||||
NotificationRecipient.objects.filter(
|
unread_personal = NotificationRecipient.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
read_at__isnull=True,
|
read_at__isnull=True,
|
||||||
).update(read_at=timezone.now())
|
).select_related('notification')
|
||||||
|
|
||||||
|
single_claim_notif_ids = [
|
||||||
|
nr.notification_id for nr in unread_personal if nr.notification.is_single_claim
|
||||||
|
]
|
||||||
|
|
||||||
|
unread_personal.update(read_at=now)
|
||||||
|
|
||||||
|
if single_claim_notif_ids:
|
||||||
|
NotificationRecipient.objects.filter(
|
||||||
|
notification_id__in=single_claim_notif_ids,
|
||||||
|
read_at__isnull=True,
|
||||||
|
).exclude(user=request.user).update(
|
||||||
|
read_at=now,
|
||||||
|
archived_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
# Team
|
# Team
|
||||||
user_team_ids = request.user.notification_teams.values_list('id', flat=True)
|
user_team_ids = request.user.notification_teams.values_list('id', flat=True)
|
||||||
|
|
|
||||||
|
|
@ -668,17 +668,147 @@ class Inventory(models.Model):
|
||||||
filename = f"inventory_{self.pk}.png"
|
filename = f"inventory_{self.pk}.png"
|
||||||
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
self.qr_code.save(filename, ContentFile(buffer.read()), save=False)
|
||||||
|
|
||||||
|
def get_validators(self):
|
||||||
|
"""
|
||||||
|
Retourne la liste des utilisateurs actifs ayant la permission de valider
|
||||||
|
un inventaire pour l'entrepôt concerné (ou cet emplacement spécifique).
|
||||||
|
"""
|
||||||
|
if not self.warehouse_location_id:
|
||||||
|
return []
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from stock.permissions import user_can_validate_inventory, user_can_validate_inventory_on
|
||||||
|
|
||||||
|
warehouse = self.warehouse_location.warehouse
|
||||||
|
active_users = User.objects.filter(is_active=True).select_related("config")
|
||||||
|
validators = []
|
||||||
|
for user in active_users:
|
||||||
|
if user_can_validate_inventory(user, warehouse) or user_can_validate_inventory_on(user, self.warehouse_location):
|
||||||
|
validators.append(user)
|
||||||
|
return validators
|
||||||
|
|
||||||
|
def notify_validators(self, sender=None):
|
||||||
|
"""
|
||||||
|
Crée une notification avec prise en charge unique pour tous les utilisateurs
|
||||||
|
autorisés à valider cet inventaire lorsqu'il doit être validé.
|
||||||
|
"""
|
||||||
|
if not self.pk or not self.warehouse_location_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from notifications.models import NotificationRecipient
|
||||||
|
from notifications.services import notify_users
|
||||||
|
|
||||||
|
validators = self.get_validators()
|
||||||
|
if not validators:
|
||||||
|
return None
|
||||||
|
|
||||||
|
action_url = reverse("stock:inventory_detail", args=[self.pk])
|
||||||
|
|
||||||
|
# Éviter de renvoyer une notification s'il en existe déjà une non lue/active pour cet inventaire
|
||||||
|
has_active_notif = NotificationRecipient.objects.filter(
|
||||||
|
notification__source_app="stock",
|
||||||
|
notification__action_url=action_url,
|
||||||
|
read_at__isnull=True,
|
||||||
|
archived_at__isnull=True,
|
||||||
|
).exists()
|
||||||
|
if has_active_notif:
|
||||||
|
return None
|
||||||
|
|
||||||
|
location_name = self.warehouse_location.name
|
||||||
|
warehouse_name = self.warehouse_location.warehouse.name
|
||||||
|
title = _("Inventaire à valider : %(location)s (%(warehouse)s)") % {
|
||||||
|
"location": location_name,
|
||||||
|
"warehouse": warehouse_name,
|
||||||
|
}
|
||||||
|
performed = f" ({self.performed_by})" if self.performed_by else ""
|
||||||
|
content = _(
|
||||||
|
"L'inventaire #%(id)s pour %(location)s (entrepôt %(warehouse)s) "
|
||||||
|
"a été finalisé%(performed)s et doit être validé."
|
||||||
|
) % {
|
||||||
|
"id": self.id,
|
||||||
|
"location": location_name,
|
||||||
|
"warehouse": warehouse_name,
|
||||||
|
"performed": performed,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return notify_users(
|
||||||
|
recipients=validators,
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
notification_type="task",
|
||||||
|
action_url=action_url,
|
||||||
|
action_label=_("Valider l'inventaire"),
|
||||||
|
source_app="stock",
|
||||||
|
sender=sender or getattr(self, "_current_user", None),
|
||||||
|
is_system=True,
|
||||||
|
is_single_claim=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
f"Error sending validation notification for inventory #{self.pk}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dismiss_validation_notifications(self):
|
||||||
|
"""
|
||||||
|
Marque comme traitée (lue et archivée) toute notification de validation
|
||||||
|
en cours pour cet inventaire pour tous ses destinataires.
|
||||||
|
"""
|
||||||
|
if not self.pk:
|
||||||
|
return
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from notifications.models import NotificationRecipient
|
||||||
|
|
||||||
|
action_url = reverse("stock:inventory_detail", args=[self.pk])
|
||||||
|
now = timezone.now()
|
||||||
|
NotificationRecipient.objects.filter(
|
||||||
|
notification__source_app="stock",
|
||||||
|
notification__action_url=action_url,
|
||||||
|
read_at__isnull=True,
|
||||||
|
).update(read_at=now, archived_at=now)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = self._state.adding
|
||||||
|
old_status = None
|
||||||
|
if not is_new and self.pk:
|
||||||
|
old_status = Inventory.objects.filter(pk=self.pk).values_list("status", flat=True).first()
|
||||||
|
|
||||||
update_fields = kwargs.get('update_fields')
|
update_fields = kwargs.get('update_fields')
|
||||||
if update_fields is not None:
|
if update_fields is not None:
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
return
|
else:
|
||||||
needs_qr = self.pk is None or not self.qr_code
|
needs_qr = self.pk is None or not self.qr_code
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if needs_qr:
|
if needs_qr:
|
||||||
self.generate_qr_code()
|
self.generate_qr_code()
|
||||||
super().save(update_fields=['qr_code'])
|
super().save(update_fields=['qr_code'])
|
||||||
|
|
||||||
|
status_became_done = (
|
||||||
|
(is_new and self.status == self.STATUS_DONE)
|
||||||
|
or (old_status and old_status != self.STATUS_DONE and self.status == self.STATUS_DONE)
|
||||||
|
)
|
||||||
|
if status_became_done:
|
||||||
|
try:
|
||||||
|
self.notify_validators()
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
f"Error in notify_validators for inventory #{self.pk}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (old_status == self.STATUS_DONE and self.status != self.STATUS_DONE) or self.status == self.STATUS_VALIDATED:
|
||||||
|
try:
|
||||||
|
self.dismiss_validation_notifications()
|
||||||
|
except Exception:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
f"Error in dismiss_validation_notifications for inventory #{self.pk}"
|
||||||
|
)
|
||||||
|
|
||||||
def validate(self, user=None):
|
def validate(self, user=None):
|
||||||
if self.is_validated and self.validated_at:
|
if self.is_validated and self.validated_at:
|
||||||
return
|
return
|
||||||
|
|
@ -689,6 +819,10 @@ class Inventory(models.Model):
|
||||||
self.validated_by = user
|
self.validated_by = user
|
||||||
self.save(update_fields=["status", "validated_at", "validated_by", "updated_at"])
|
self.save(update_fields=["status", "validated_at", "validated_by", "updated_at"])
|
||||||
self.apply_adjustments()
|
self.apply_adjustments()
|
||||||
|
try:
|
||||||
|
self.dismiss_validation_notifications()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def apply_adjustments(self):
|
def apply_adjustments(self):
|
||||||
for item in self.items.select_related("product"):
|
for item in self.items.select_related("product"):
|
||||||
|
|
|
||||||
|
|
@ -1903,6 +1903,225 @@ class PreparationOrderDiscussionTests(TestCase):
|
||||||
self.assertEqual(resp.context["discussion_messages"][0].content, "Message mobile")
|
self.assertEqual(resp.context["discussion_messages"][0].content, "Message mobile")
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryValidationNotificationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from common.models import UserConfig, UserWarehouseAccess
|
||||||
|
from stock.models import Warehouse, WarehouseLocation, Inventory
|
||||||
|
|
||||||
|
self.warehouse = Warehouse.objects.create(name="Entrepôt Principal", code="WH-MAIN")
|
||||||
|
self.location = WarehouseLocation.objects.create(warehouse=self.warehouse, name="Zone A", code="LOC-A", is_default=True)
|
||||||
|
|
||||||
|
self.other_warehouse = Warehouse.objects.create(name="Autre Entrepôt", code="WH-OTHER")
|
||||||
|
self.other_location = WarehouseLocation.objects.create(warehouse=self.other_warehouse, name="Zone B", code="LOC-B")
|
||||||
|
|
||||||
|
# Worker (can manage, but cannot validate)
|
||||||
|
self.worker = User.objects.create_user(username="worker1", password="password")
|
||||||
|
worker_cfg = UserConfig.objects.create(user=self.worker, is_intern=True)
|
||||||
|
UserWarehouseAccess.objects.create(
|
||||||
|
user_config=worker_cfg,
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
can_manage=True,
|
||||||
|
can_validate_inventory=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validator 1 (can validate on this warehouse)
|
||||||
|
self.val1 = User.objects.create_user(username="val1", password="password")
|
||||||
|
val1_cfg = UserConfig.objects.create(user=self.val1, is_intern=True)
|
||||||
|
UserWarehouseAccess.objects.create(
|
||||||
|
user_config=val1_cfg,
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
can_manage=True,
|
||||||
|
can_validate_inventory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validator 2 (can validate on this warehouse)
|
||||||
|
self.val2 = User.objects.create_user(username="val2", password="password")
|
||||||
|
val2_cfg = UserConfig.objects.create(user=self.val2, is_intern=True)
|
||||||
|
UserWarehouseAccess.objects.create(
|
||||||
|
user_config=val2_cfg,
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
can_manage=True,
|
||||||
|
can_validate_inventory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other user (validates only for a different warehouse)
|
||||||
|
self.other_val = User.objects.create_user(username="other_val", password="password")
|
||||||
|
other_cfg = UserConfig.objects.create(user=self.other_val, is_intern=True)
|
||||||
|
UserWarehouseAccess.objects.create(
|
||||||
|
user_config=other_cfg,
|
||||||
|
warehouse=self.other_warehouse,
|
||||||
|
can_manage=True,
|
||||||
|
can_validate_inventory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.inventory = Inventory.objects.create(
|
||||||
|
warehouse_location=self.location,
|
||||||
|
performed_by="Alice",
|
||||||
|
status=Inventory.STATUS_IN_PROGRESS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_done_triggers_single_claim_notification_for_warehouse_validators(self):
|
||||||
|
from notifications.models import Notification, NotificationRecipient
|
||||||
|
from notifications.services import get_unread_count
|
||||||
|
|
||||||
|
self.client.force_login(self.worker)
|
||||||
|
url = reverse("stock:inventory_change_status", args=[self.inventory.pk])
|
||||||
|
resp = self.client.post(url, {"status": "done"})
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
|
||||||
|
self.inventory.refresh_from_db()
|
||||||
|
self.assertEqual(self.inventory.status, "done")
|
||||||
|
|
||||||
|
# Notification should have been created
|
||||||
|
notifs = Notification.objects.filter(
|
||||||
|
source_app="stock",
|
||||||
|
action_url=reverse("stock:inventory_detail", args=[self.inventory.pk]),
|
||||||
|
)
|
||||||
|
self.assertEqual(notifs.count(), 1)
|
||||||
|
notif = notifs.first()
|
||||||
|
self.assertTrue(notif.is_single_claim)
|
||||||
|
self.assertEqual(notif.notification_type, "task")
|
||||||
|
|
||||||
|
# Recipients should only be val1 and val2
|
||||||
|
recipients = set(notif.recipients.values_list("user__username", flat=True))
|
||||||
|
self.assertEqual(recipients, {"val1", "val2"})
|
||||||
|
self.assertNotIn("worker1", recipients)
|
||||||
|
self.assertNotIn("other_val", recipients)
|
||||||
|
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 1)
|
||||||
|
self.assertEqual(get_unread_count(self.val2), 1)
|
||||||
|
|
||||||
|
def test_single_claim_traitée_marks_as_read_and_archived_for_other_validators(self):
|
||||||
|
from notifications.models import Notification, NotificationRecipient
|
||||||
|
from notifications.services import get_unread_count
|
||||||
|
|
||||||
|
# Mark done
|
||||||
|
self.inventory.status = "done"
|
||||||
|
self.inventory.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
|
notif = Notification.objects.filter(
|
||||||
|
source_app="stock",
|
||||||
|
action_url=reverse("stock:inventory_detail", args=[self.inventory.pk]),
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(notif)
|
||||||
|
|
||||||
|
nr_val1 = NotificationRecipient.objects.get(notification=notif, user=self.val1)
|
||||||
|
nr_val2 = NotificationRecipient.objects.get(notification=notif, user=self.val2)
|
||||||
|
|
||||||
|
# val1 marks notification as traitée (read)
|
||||||
|
self.client.force_login(self.val1)
|
||||||
|
mark_read_url = reverse("notifications:mark_read", args=[nr_val1.pk])
|
||||||
|
resp = self.client.post(mark_read_url, {"type": "personal"}, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
nr_val1.refresh_from_db()
|
||||||
|
nr_val2.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertTrue(nr_val1.is_read)
|
||||||
|
self.assertFalse(nr_val1.is_archived)
|
||||||
|
|
||||||
|
# val2 must no longer see it as unread (it is read and archived)
|
||||||
|
self.assertTrue(nr_val2.is_read)
|
||||||
|
self.assertTrue(nr_val2.is_archived)
|
||||||
|
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 0)
|
||||||
|
self.assertEqual(get_unread_count(self.val2), 0)
|
||||||
|
|
||||||
|
def test_direct_inventory_validation_dismisses_notifications_for_all(self):
|
||||||
|
from notifications.models import Notification, NotificationRecipient
|
||||||
|
from notifications.services import get_unread_count
|
||||||
|
|
||||||
|
self.inventory.status = "done"
|
||||||
|
self.inventory.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 1)
|
||||||
|
self.assertEqual(get_unread_count(self.val2), 1)
|
||||||
|
|
||||||
|
# val1 validates the inventory directly
|
||||||
|
self.client.force_login(self.val1)
|
||||||
|
validate_url = reverse("stock:inventory_validate", args=[self.inventory.pk])
|
||||||
|
resp = self.client.post(validate_url)
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
|
||||||
|
self.inventory.refresh_from_db()
|
||||||
|
self.assertTrue(self.inventory.is_validated)
|
||||||
|
|
||||||
|
# All pending notifications must be marked read and archived for all users
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 0)
|
||||||
|
self.assertEqual(get_unread_count(self.val2), 0)
|
||||||
|
|
||||||
|
notif = Notification.objects.filter(
|
||||||
|
source_app="stock",
|
||||||
|
action_url=reverse("stock:inventory_detail", args=[self.inventory.pk]),
|
||||||
|
).first()
|
||||||
|
for nr in notif.recipients.all():
|
||||||
|
self.assertTrue(nr.is_read)
|
||||||
|
self.assertTrue(nr.is_archived)
|
||||||
|
|
||||||
|
def test_demotion_from_done_dismisses_notifications(self):
|
||||||
|
from notifications.models import Notification
|
||||||
|
from notifications.services import get_unread_count
|
||||||
|
|
||||||
|
self.inventory.status = "done"
|
||||||
|
self.inventory.save(update_fields=["status", "updated_at"])
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 1)
|
||||||
|
|
||||||
|
# Demote back to in_progress
|
||||||
|
self.client.force_login(self.val1)
|
||||||
|
url = reverse("stock:inventory_change_status", args=[self.inventory.pk])
|
||||||
|
resp = self.client.post(url, {"status": "in_progress"})
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 0)
|
||||||
|
self.assertEqual(get_unread_count(self.val2), 0)
|
||||||
|
|
||||||
|
def test_create_inventory_as_done_triggers_notification(self):
|
||||||
|
from stock.models import Inventory
|
||||||
|
from notifications.models import Notification
|
||||||
|
from notifications.services import get_unread_count
|
||||||
|
|
||||||
|
inv = Inventory.objects.create(
|
||||||
|
warehouse_location=self.location,
|
||||||
|
performed_by="Bob",
|
||||||
|
status=Inventory.STATUS_DONE,
|
||||||
|
)
|
||||||
|
notif = Notification.objects.filter(
|
||||||
|
source_app="stock",
|
||||||
|
action_url=reverse("stock:inventory_detail", args=[inv.pk]),
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(notif)
|
||||||
|
self.assertEqual(set(notif.recipients.values_list("user__username", flat=True)), {"val1", "val2"})
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 1)
|
||||||
|
self.assertEqual(get_unread_count(self.val2), 1)
|
||||||
|
|
||||||
|
def test_edit_inventory_to_done_triggers_notification(self):
|
||||||
|
from notifications.models import Notification
|
||||||
|
from notifications.services import get_unread_count
|
||||||
|
|
||||||
|
# Use web view to edit status to done
|
||||||
|
self.client.force_login(self.val1)
|
||||||
|
edit_url = reverse("stock:inventory_edit", args=[self.inventory.pk])
|
||||||
|
resp = self.client.post(edit_url, {
|
||||||
|
"warehouse_location": self.location.pk,
|
||||||
|
"performed_by": "Alice Edited",
|
||||||
|
"status": "done",
|
||||||
|
"items-TOTAL_FORMS": "0",
|
||||||
|
"items-INITIAL_FORMS": "0",
|
||||||
|
})
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
|
||||||
|
self.inventory.refresh_from_db()
|
||||||
|
self.assertEqual(self.inventory.status, "done")
|
||||||
|
notif = Notification.objects.filter(
|
||||||
|
source_app="stock",
|
||||||
|
action_url=reverse("stock:inventory_detail", args=[self.inventory.pk]),
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(notif)
|
||||||
|
self.assertEqual(get_unread_count(self.val1), 1)
|
||||||
|
self.assertEqual(get_unread_count(self.val2), 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2660,7 +2660,11 @@ def inventory_create(request):
|
||||||
messages.error(request, _("Vous n'avez pas la permission de valider les inventaires pour cet emplacement."))
|
messages.error(request, _("Vous n'avez pas la permission de valider les inventaires pour cet emplacement."))
|
||||||
return redirect('stock:stock_movements')
|
return redirect('stock:stock_movements')
|
||||||
|
|
||||||
inventory = form.save()
|
inventory = form.save(commit=False)
|
||||||
|
inventory._current_user = request.user
|
||||||
|
inventory.save()
|
||||||
|
form.save_m2m()
|
||||||
|
formset.instance = inventory
|
||||||
formset.save()
|
formset.save()
|
||||||
if inventory.status == Inventory.STATUS_VALIDATED:
|
if inventory.status == Inventory.STATUS_VALIDATED:
|
||||||
inventory.validate(user=request.user)
|
inventory.validate(user=request.user)
|
||||||
|
|
@ -2764,7 +2768,11 @@ def inventory_edit(request, pk):
|
||||||
messages.error(request, _("Vous n'avez pas la permission de valider les inventaires pour cet emplacement."))
|
messages.error(request, _("Vous n'avez pas la permission de valider les inventaires pour cet emplacement."))
|
||||||
return redirect("stock:inventory_detail", pk=pk)
|
return redirect("stock:inventory_detail", pk=pk)
|
||||||
|
|
||||||
inventory = form.save()
|
inventory = form.save(commit=False)
|
||||||
|
inventory._current_user = request.user
|
||||||
|
inventory.save()
|
||||||
|
form.save_m2m()
|
||||||
|
formset.instance = inventory
|
||||||
formset.save()
|
formset.save()
|
||||||
messages.success(request, _("Inventaire mis à jour."))
|
messages.success(request, _("Inventaire mis à jour."))
|
||||||
return redirect("stock:inventory_detail", pk=inventory.pk)
|
return redirect("stock:inventory_detail", pk=inventory.pk)
|
||||||
|
|
@ -2821,6 +2829,7 @@ def inventory_change_status(request, pk):
|
||||||
next_url = request.POST.get('next') or ''
|
next_url = request.POST.get('next') or ''
|
||||||
return redirect(next_url) if next_url else redirect("stock:inventory_detail", pk=pk)
|
return redirect(next_url) if next_url else redirect("stock:inventory_detail", pk=pk)
|
||||||
|
|
||||||
|
inventory._current_user = request.user
|
||||||
inventory.status = new_status
|
inventory.status = new_status
|
||||||
inventory.save(update_fields=['status', 'updated_at'])
|
inventory.save(update_fields=['status', 'updated_at'])
|
||||||
messages.success(request, _("Statut mis à jour."))
|
messages.success(request, _("Statut mis à jour."))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue