2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 03:00:54 +00:00

Add stale stock email notifications (#9861)

* 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>
This commit is contained in:
gitbock
2025-06-28 00:21:04 +02:00
committed by GitHub
parent 486838b7e7
commit b3feebb53b
4 changed files with 522 additions and 0 deletions

View File

@ -55,6 +55,48 @@ The Part expiry time can be altered using the Part editing form.
{{ image("stock/part_expiry.png", title="Edit part expiry") }}
## Stale Stock Notifications
InvenTree can automatically notify users when stock items are approaching their expiry dates. This feature helps prevent stock from expiring unnoticed by providing advance warning.
### Configuration
The stale stock notification system uses the `STOCK_STALE_DAYS` global setting to determine when to send notifications. This setting specifies how many days before expiry (or after expiry) to consider stock items as "stale".
For example, if `STOCK_STALE_DAYS` is set to 10:
- Stock items expiring within the next 10 days will trigger notifications
- Stock items that expired within the last 10 days will also trigger notifications
### How It Works
The system runs a daily background task that:
1. **Checks for stale stock**: Identifies stock items with expiry dates within the configured threshold
2. **Groups by user**: Organizes stale items by users who are subscribed to notifications for the relevant parts
3. **Sends consolidated emails**: Each user receives a single email containing all their stale stock items
### Prerequisites
For stale stock notifications to work:
1. **Stock expiry must be enabled**: The `STOCK_ENABLE_EXPIRY` setting must be enabled
2. **Stale days configured**: The `STOCK_STALE_DAYS` setting must be greater than 0
3. **Email configuration**: [Email settings](../settings/email.md) must be properly configured
4. **User subscriptions**: Users must be subscribed to notifications for the relevant parts
### User Subscriptions
Users will only receive notifications for parts they are subscribed to. To subscribe to part notifications:
1. Navigate to the part detail page
2. Click the notification bell icon to subscribe
3. Users can also subscribe to entire part categories
For more information on part subscriptions, see the [Part Notifications](../part/notification.md) documentation.
## Sales and Build Orders
By default, expired Stock Items cannot be added to neither a Sales Order nor a Build Order. This behavior can be adjusted using the *Sell Expired Stock* and *Build Expired Stock* settings:

View File

@ -15,6 +15,7 @@ import InvenTree.helpers_model
import InvenTree.tasks
import part.models as part_models
import part.stocktake
import stock.models as stock_models
from common.settings import get_global_setting
from InvenTree.tasks import (
ScheduledTask,
@ -52,6 +53,82 @@ def notify_low_stock(part: part_models.Part):
)
@tracer.start_as_current_span('notify_stale_stock')
def notify_stale_stock(user, stale_items):
"""Notify a user about all their stale stock items in one consolidated email.
Rules:
- Triggered when stock items' expiry dates are within the configured STOCK_STALE_DAYS
- One notification is delivered per user containing all their stale stock items
Arguments:
user: The user to notify
stale_items: List of stale stock items for this user
"""
if not stale_items:
return
name = _('Stale stock notification')
item_count = len(stale_items)
if item_count == 1:
message = _('You have 1 stock item approaching its expiry date')
else:
message = _(f'You have {item_count} stock items approaching their expiry dates')
# Add absolute URLs and days until expiry for each stock item
stale_items_enhanced = []
today = InvenTree.helpers.current_date()
for stock_item in stale_items:
# Calculate days until expiry to print it clearly in table in email later
days_until_expiry = None
expiry_status = _('No expiry date')
if stock_item.expiry_date:
days_diff = (stock_item.expiry_date - today).days
if days_diff < 0:
days_until_expiry = days_diff # Keep negative value for template logic
expiry_status = _(f'Expired {abs(days_diff)} days ago')
elif days_diff == 0:
days_until_expiry = 0
expiry_status = _('Expires today')
else:
days_until_expiry = days_diff
expiry_status = _(f'{days_until_expiry} days')
item_data = {
'stock_item': stock_item,
'absolute_url': InvenTree.helpers_model.construct_absolute_url(
stock_item.get_absolute_url()
),
'days_until_expiry': days_until_expiry,
'expiry_status': expiry_status,
}
stale_items_enhanced.append(item_data)
context = {
'stale_items': stale_items_enhanced,
'item_count': item_count,
'name': name,
'message': message,
'template': {'html': 'email/stale_stock_notification.html', 'subject': name},
}
# Use the first stock item as the trigger object for the notification system
trigger_object = stale_items[0] if stale_items else None
if trigger_object:
common.notifications.trigger_notification(
trigger_object,
'stock.notify_stale_stock',
targets=[user],
context=context,
check_recent=False,
)
@tracer.start_as_current_span('notify_low_stock_if_required')
def notify_low_stock_if_required(part_id: int):
"""Check if the stock quantity has fallen below the minimum threshold of part.
@ -74,6 +151,76 @@ def notify_low_stock_if_required(part_id: int):
InvenTree.tasks.offload_task(notify_low_stock, p, group='notification')
@tracer.start_as_current_span('check_stale_stock')
@scheduled_task(ScheduledTask.DAILY)
def check_stale_stock():
"""Check all stock items for stale stock.
This function runs daily and checks if any stock items are approaching their expiry date
based on the STOCK_STALE_DAYS global setting.
For any stale stock items found, notifications are sent to users who have subscribed
to notifications for the respective parts. Each user receives one consolidated email
containing all their stale stock items.
"""
# Check if stock expiry functionality is enabled
if not get_global_setting('STOCK_ENABLE_EXPIRY', False, cache=False):
logger.info('Stock expiry functionality is not enabled - exiting')
return
# Check if STOCK_STALE_DAYS is configured
stale_days = int(get_global_setting('STOCK_STALE_DAYS', 0, cache=False))
if stale_days <= 0:
logger.info('Stock stale days is not configured or set to 0 - exiting')
return
today = InvenTree.helpers.current_date()
stale_threshold = today + timedelta(days=stale_days)
# Find stock items that are stale (expiry date within STOCK_STALE_DAYS)
stale_stock_items = stock_models.StockItem.objects.filter(
stock_models.StockItem.IN_STOCK_FILTER, # Only in-stock items
expiry_date__isnull=False, # Must have an expiry date
expiry_date__lt=stale_threshold, # Expiry date is within stale threshold
).select_related('part', 'location') # Optimize queries
if not stale_stock_items.exists():
logger.info('No stale stock items found')
return
logger.info('Found %s stale stock items', stale_stock_items.count())
# Group stale stock items by user subscriptions
user_stale_items: dict[stock_models.StockItem, list[stock_models.StockItem]] = {}
for stock_item in stale_stock_items:
# Get all subscribers for this part
subscribers = stock_item.part.get_subscribers()
for user in subscribers:
if user not in user_stale_items:
user_stale_items[user] = []
user_stale_items[user].append(stock_item)
# Send one consolidated notification per user
for user, items in user_stale_items.items():
try:
InvenTree.tasks.offload_task(
notify_stale_stock, user, items, group='notification'
)
except Exception as e:
logger.error(
'Error scheduling stale stock notification for user %s: %s',
user.username,
str(e),
)
logger.info(
'Scheduled stale stock notifications for %s users', len(user_stale_items)
)
@tracer.start_as_current_span('update_part_pricing')
def update_part_pricing(pricing: part_models.PartPricing, counter: int = 0):
"""Update cached pricing data for the specified PartPricing instance.

View File

@ -0,0 +1,272 @@
"""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)

View File

@ -0,0 +1,61 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block header %}
<tr style='background: #eef3f7; height: 4rem; text-align: center;'>
<td colspan="7" style="padding-bottom: 1rem; color: #68686a; font-weight: bold;">
<p style='font-size: 1.25rem;'>{{ message }}</p>
<p>{% trans "The following stock items are approaching their expiry dates:" %}</p>
</td>
</tr>
{% endblock header %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid #68686a; background-color: #f5f5f5;">
<td style="text-align: center; padding: 10px; font-weight: bold;">{% trans "Part" %}</td>
<td style="text-align: center; padding: 10px; font-weight: bold;">{% trans "Location" %}</td>
<td style="text-align: center; padding: 10px; font-weight: bold;">{% trans "Quantity" %}</td>
<td style="text-align: center; padding: 10px; font-weight: bold;">{% trans "Batch" %}</td>
<td style="text-align: center; padding: 10px; font-weight: bold;">{% trans "Serial Number" %}</td>
<td style="text-align: center; padding: 10px; font-weight: bold;">{% trans "Expiry Date" %}</td>
<td style="text-align: center; padding: 10px; font-weight: bold;">{% trans "Days Until Expiry" %}</td>
</tr>
{% for item_data in stale_items %}
<tr style="height: 3rem; border-bottom: 1px solid #ddd;">
<td style="text-align: center; padding: 10px;">
<a href="{{ item_data.absolute_url }}" style="color: #007bff; text-decoration: none;">
{{ item_data.stock_item.part.full_name }}
</a>
</td>
<td style="text-align: center; padding: 10px;">{{ item_data.stock_item.location|default:"-" }}</td>
<td style="text-align: center; padding: 10px;">{% decimal item_data.stock_item.quantity %}</td>
<td style="text-align: center; padding: 10px;">{{ item_data.stock_item.batch|default:"-" }}</td>
<td style="text-align: center; padding: 10px;">{{ item_data.stock_item.serial|default:"-" }}</td>
<td style="text-align: center; padding: 10px;">{{ item_data.stock_item.expiry_date|date:"Y-m-d"|default:"-" }}</td>
<td style="text-align: center; padding: 10px;">
{% if item_data.days_until_expiry is not None %}
{% if item_data.days_until_expiry < 0 %}
<span style="color: red; font-weight: bold;">{{ item_data.expiry_status }}</span>
{% elif item_data.days_until_expiry == 0 %}
<span style="color: orange; font-weight: bold;">{{ item_data.expiry_status }}</span>
{% else %}
{{ item_data.expiry_status }}
{% endif %}
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
{% endblock body %}
{% block footer %}
<tr style='background: #eef3f7; height: 2rem;'>
<td colspan="7" style="padding-top:1rem; text-align: center">
<p><em>{% trans "You are receiving this email because you are subscribed to notifications for these parts" %}.</em></p>
<p><em><small>{% inventree_version shortstring=True %} - <a href='https://docs.inventree.org'>InvenTree Documentation</a></small></em></p>
</td>
</tr>
{% endblock footer %}