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:
@ -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:
|
||||
|
@ -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.
|
||||
|
272
src/backend/InvenTree/part/test_notification_stale.py
Normal file
272
src/backend/InvenTree/part/test_notification_stale.py
Normal 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)
|
@ -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 %}
|
Reference in New Issue
Block a user