mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 03:00:54 +00:00
* Add stale stock email notifications Implement automated email notifications for stock items approaching their expiry dates. Users receive consolidated daily emails for all their subscribed parts that are within the configured stale threshold. Fixes #7866 * Fix for tracing init (#9860) - Circular include means that settings.DB_ENGINE may not be available * [bug] Custom state fix (#9858) * Set status correctly when returning from customer * Fix for stock item status change - Reduced set of changes from #9781 * Handle API updates * Fix variable shadowing * More intelligent comparison * Remove debug statement * fix syntax again (#9863) * fix(backend): change notification for INVE-W10 (#9864) implements changes requested in https://github.com/inventree/InvenTree/pull/9769#issuecomment-3004193476 * Tweak for tracing setup (#9865) - DB_ENGINE is of the form "django.db.backends.postgresql", not "postgesql" * Update README.md (#9867) Update sponsors list * Remove sleep call (#9866) * New Crowdin translations by GitHub Action (#9813) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Table default cols (#9868) * Work in progress - Seems to reset the columns on page refresh - Probably related to the useLocalStorage hook * Do not overwrite until the tablestate is loaded * Prevent table fetch until data has been loaded from localStorage * Improved persistance * Adjust default column visibility * Adjust playwright test * Clear data tweak (#9870) * Tweaks for config path checks * Update delete-data task * Tweaks for config path checks (#9869) * fix instrumentation code (#9872) * [UI] About tweak (#9874) * Cleanup server info modal * Sort package info * De-sync useLocalStorage (#9873) * [UI] Fix thumbnail rendering (#9875) - Fix typo which caused full image to be rendered - This could cause significant network loading time * Add stale stock email notifications Implement automated email notifications for stock items approaching their expiry dates. Users receive consolidated daily emails for all their subscribed parts that are within the configured stale threshold. Revert django.po Fixes #7866 * fixed pull request issues #9875) * unit test notifications for stale stock items --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com> Co-authored-by: Matthias Mair <code@mjmair.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""Unit tests for Part stale stock notification functionality."""
|
|
|
|
from datetime import timedelta
|
|
from unittest.mock import patch
|
|
|
|
from allauth.account.models import EmailAddress
|
|
|
|
import part.models
|
|
import part.tasks
|
|
import stock.models
|
|
from common.models import NotificationEntry, NotificationMessage
|
|
from common.settings import set_global_setting
|
|
from InvenTree import helpers
|
|
from InvenTree.unit_test import InvenTreeTestCase, addUserPermission
|
|
|
|
|
|
class StaleStockNotificationTests(InvenTreeTestCase):
|
|
"""Unit tests for stale stock notification functionality."""
|
|
|
|
fixtures = ['category', 'part', 'location', 'stock']
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
"""Create test data as part of initialization."""
|
|
super().setUpTestData()
|
|
|
|
# Add email address for user
|
|
EmailAddress.objects.create(user=cls.user, email='test@testing.com')
|
|
|
|
# Create test parts
|
|
cls.part1 = part.models.Part.objects.create(
|
|
name='Test Part 1',
|
|
description='A test part for stale stock testing',
|
|
component=True,
|
|
)
|
|
|
|
cls.part2 = part.models.Part.objects.create(
|
|
name='Test Part 2', description='Another test part', component=True
|
|
)
|
|
|
|
# Create test stock location
|
|
cls.location = stock.models.StockLocation.objects.first()
|
|
|
|
def setUp(self):
|
|
"""Setup routines."""
|
|
super().setUp()
|
|
|
|
# Enable stock expiry functionality
|
|
set_global_setting('STOCK_ENABLE_EXPIRY', True, self.user)
|
|
set_global_setting('STOCK_STALE_DAYS', 7, self.user)
|
|
|
|
# Clear notifications
|
|
NotificationEntry.objects.all().delete() # type: ignore[attr-defined]
|
|
NotificationMessage.objects.all().delete() # type: ignore[attr-defined]
|
|
|
|
def create_stock_items_with_expiry(self):
|
|
"""Create stock items with various expiry dates for testing."""
|
|
today = helpers.current_date()
|
|
|
|
# Create stock items with different expiry scenarios
|
|
# Item 1: Expires tomorrow (stale)
|
|
self.stock_item_stale = stock.models.StockItem.objects.create(
|
|
part=self.part1,
|
|
location=self.location,
|
|
quantity=10,
|
|
expiry_date=today + timedelta(days=1),
|
|
)
|
|
|
|
# Item 2: Already expired
|
|
self.stock_item_expired = stock.models.StockItem.objects.create(
|
|
part=self.part1,
|
|
location=self.location,
|
|
quantity=5,
|
|
expiry_date=today - timedelta(days=1),
|
|
)
|
|
|
|
# Item 3: Expires in 2 weeks (not stale)
|
|
self.stock_item_future = stock.models.StockItem.objects.create(
|
|
part=self.part2,
|
|
location=self.location,
|
|
quantity=15,
|
|
expiry_date=today + timedelta(days=14),
|
|
)
|
|
|
|
# Item 4: No expiry date
|
|
self.stock_item_no_expiry = stock.models.StockItem.objects.create(
|
|
part=self.part2, location=self.location, quantity=20
|
|
)
|
|
|
|
# Item 5: Out of stock but stale (should be ignored)
|
|
self.stock_item_out_of_stock = stock.models.StockItem.objects.create(
|
|
part=self.part1,
|
|
location=self.location,
|
|
quantity=0,
|
|
expiry_date=today + timedelta(days=1),
|
|
)
|
|
|
|
def test_notify_stale_stock_with_empty_list(self):
|
|
"""Test notify_stale_stock with empty stale_items list."""
|
|
# Should return early and do nothing
|
|
part.tasks.notify_stale_stock(self.user, [])
|
|
|
|
# No notifications should be created
|
|
self.assertEqual(NotificationMessage.objects.count(), 0) # type: ignore[attr-defined]
|
|
|
|
def test_notify_stale_stock_single_item(self):
|
|
"""Test notify_stale_stock with a single stale item."""
|
|
self.create_stock_items_with_expiry()
|
|
|
|
# Mock the trigger_notification to verify it's called correctly
|
|
with patch('common.notifications.trigger_notification') as mock_trigger:
|
|
part.tasks.notify_stale_stock(self.user, [self.stock_item_stale])
|
|
|
|
# Check that trigger_notification was called
|
|
self.assertTrue(mock_trigger.called)
|
|
_args, kwargs = mock_trigger.call_args
|
|
|
|
# Check trigger object and category
|
|
self.assertEqual(_args[0], self.stock_item_stale)
|
|
self.assertEqual(_args[1], 'stock.notify_stale_stock')
|
|
|
|
# Check context data
|
|
context = kwargs['context']
|
|
self.assertIn('1 stock item approaching', context['message'])
|
|
|
|
def test_notify_stale_stock_multiple_items(self):
|
|
"""Test notify_stale_stock with multiple stale items."""
|
|
self.create_stock_items_with_expiry()
|
|
|
|
# Mock the trigger_notification to verify it's called correctly
|
|
with patch('common.notifications.trigger_notification') as mock_trigger:
|
|
# Call notify_stale_stock with multiple items
|
|
stale_items = [self.stock_item_stale, self.stock_item_expired]
|
|
part.tasks.notify_stale_stock(self.user, stale_items)
|
|
|
|
# Check that trigger_notification was called
|
|
self.assertTrue(mock_trigger.called)
|
|
_args, kwargs = mock_trigger.call_args
|
|
|
|
# Check context data
|
|
context = kwargs['context']
|
|
self.assertIn('2 stock items approaching', context['message'])
|
|
|
|
def test_check_stale_stock_disabled_expiry(self):
|
|
"""Test check_stale_stock when stock expiry is disabled."""
|
|
# Disable stock expiry
|
|
set_global_setting('STOCK_ENABLE_EXPIRY', False, self.user)
|
|
|
|
# Create stale stock items
|
|
self.create_stock_items_with_expiry()
|
|
|
|
# Call check_stale_stock
|
|
with patch('part.tasks.logger') as mock_logger:
|
|
part.tasks.check_stale_stock()
|
|
mock_logger.info.assert_called_with(
|
|
'Stock expiry functionality is not enabled - exiting'
|
|
)
|
|
|
|
def test_check_stale_stock_zero_stale_days(self):
|
|
"""Test check_stale_stock when STOCK_STALE_DAYS is 0."""
|
|
# Set stale days to 0
|
|
set_global_setting('STOCK_STALE_DAYS', 0, self.user)
|
|
|
|
# Create stale stock items
|
|
self.create_stock_items_with_expiry()
|
|
|
|
# Call check_stale_stock
|
|
with patch('part.tasks.logger') as mock_logger:
|
|
part.tasks.check_stale_stock()
|
|
mock_logger.info.assert_called_with(
|
|
'Stock stale days is not configured or set to 0 - exiting'
|
|
)
|
|
|
|
def test_check_stale_stock_no_stale_items(self):
|
|
"""Test check_stale_stock when no stale items exist."""
|
|
# Clear all existing stock items
|
|
stock.models.StockItem.objects.all().delete()
|
|
|
|
# Create only future expiry items
|
|
today = helpers.current_date()
|
|
stock.models.StockItem.objects.create(
|
|
part=self.part1,
|
|
location=self.location,
|
|
quantity=10,
|
|
expiry_date=today + timedelta(days=30),
|
|
)
|
|
|
|
# Call check_stale_stock
|
|
with patch('part.tasks.logger') as mock_logger:
|
|
part.tasks.check_stale_stock()
|
|
mock_logger.info.assert_called_with('No stale stock items found')
|
|
|
|
@patch('InvenTree.tasks.offload_task')
|
|
def test_check_stale_stock_with_stale_items(self, mock_offload):
|
|
"""Test check_stale_stock when stale items exist."""
|
|
# Clear existing stock items
|
|
stock.models.StockItem.objects.all().delete()
|
|
|
|
self.create_stock_items_with_expiry()
|
|
|
|
# Subscribe user to parts
|
|
addUserPermission(self.user, 'part', 'part', 'view')
|
|
self.user.is_active = True
|
|
self.user.save()
|
|
self.part1.set_starred(self.user, True)
|
|
|
|
# Call check_stale_stock
|
|
with patch('part.tasks.logger') as mock_logger:
|
|
part.tasks.check_stale_stock()
|
|
|
|
# Check that stale items were found (stale and expired items)
|
|
found_calls = []
|
|
for call in mock_logger.info.call_args_list:
|
|
call_str = str(call)
|
|
if 'Found' in call_str and 'stale stock items' in call_str:
|
|
found_calls.append(call)
|
|
self.assertGreater(len(found_calls), 0)
|
|
|
|
# Check that notifications were scheduled
|
|
scheduled_calls = []
|
|
for call in mock_logger.info.call_args_list:
|
|
if 'Scheduled stale stock notifications' in str(call):
|
|
scheduled_calls.append(call)
|
|
self.assertGreater(len(scheduled_calls), 0)
|
|
|
|
# Verify offload_task was called at least once
|
|
self.assertTrue(mock_offload.called)
|
|
|
|
def test_check_stale_stock_filtering(self):
|
|
"""Test that check_stale_stock properly filters stock items."""
|
|
# Clear all existing stock items first
|
|
stock.models.StockItem.objects.all().delete()
|
|
|
|
today = helpers.current_date()
|
|
|
|
# Create various stock items
|
|
# Should be included: in stock + has expiry + within stale threshold
|
|
_included_item = stock.models.StockItem.objects.create(
|
|
part=self.part1,
|
|
location=self.location,
|
|
quantity=10,
|
|
expiry_date=today + timedelta(days=3), # Within 7-day threshold
|
|
)
|
|
|
|
# Should be excluded: no expiry date
|
|
stock.models.StockItem.objects.create(
|
|
part=self.part1, location=self.location, quantity=10
|
|
)
|
|
|
|
# Should be excluded: out of stock
|
|
stock.models.StockItem.objects.create(
|
|
part=self.part1,
|
|
location=self.location,
|
|
quantity=0,
|
|
expiry_date=today + timedelta(days=3),
|
|
)
|
|
|
|
# Should be excluded: expiry too far in future
|
|
stock.models.StockItem.objects.create(
|
|
part=self.part1,
|
|
location=self.location,
|
|
quantity=10,
|
|
expiry_date=today + timedelta(days=20),
|
|
)
|
|
|
|
# Call check_stale_stock and verify stale items were found
|
|
with patch('InvenTree.tasks.offload_task') as _mock_offload:
|
|
with patch('part.tasks.logger') as mock_logger:
|
|
part.tasks.check_stale_stock()
|
|
|
|
# Should find exactly 1 stale item
|
|
mock_logger.info.assert_any_call('Found %s stale stock items', 1)
|