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

Merged master

This commit is contained in:
eeintech
2021-01-07 13:50:29 -05:00
145 changed files with 23900 additions and 2396 deletions

View File

@ -23,6 +23,9 @@ from part.serializers import PartBriefSerializer
from company.models import SupplierPart
from company.serializers import SupplierPartSerializer
import common.settings
import common.models
from .serializers import StockItemSerializer
from .serializers import LocationSerializer, LocationBriefSerializer
from .serializers import StockTrackingSerializer
@ -35,6 +38,8 @@ from InvenTree.api import AttachmentMixin
from decimal import Decimal, InvalidOperation
from datetime import datetime, timedelta
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from rest_framework.response import Response
@ -342,10 +347,18 @@ class StockList(generics.ListCreateAPIView):
# A location was *not* specified - try to infer it
if 'location' not in request.data:
location = item.part.get_default_location()
if location is not None:
item.location = location
item.save()
# An expiry date was *not* specified - try to infer it!
if 'expiry_date' not in request.data:
if item.part.default_expiry > 0:
item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
item.save()
# Return a response
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@ -525,6 +538,38 @@ class StockList(generics.ListCreateAPIView):
# Exclude items which are instaled in another item
queryset = queryset.filter(belongs_to=None)
if common.settings.stock_expiry_enabled():
# Filter by 'expired' status
expired = params.get('expired', None)
if expired is not None:
expired = str2bool(expired)
if expired:
queryset = queryset.filter(StockItem.EXPIRED_FILTER)
else:
queryset = queryset.exclude(StockItem.EXPIRED_FILTER)
# Filter by 'stale' status
stale = params.get('stale', None)
if stale is not None:
stale = str2bool(stale)
# How many days to account for "staleness"?
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
if stale_days > 0:
stale_date = datetime.now().date() + timedelta(days=stale_days)
stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
if stale:
queryset = queryset.filter(stale_filter)
else:
queryset = queryset.exclude(stale_filter)
# Filter by customer
customer = params.get('customer', None)

View File

@ -81,7 +81,7 @@
part: 25
batch: 'ABCDE'
location: 7
quantity: 3
quantity: 0
level: 0
tree_id: 0
lft: 0
@ -232,6 +232,7 @@
tree_id: 0
lft: 0
rght: 0
expiry_date: "1990-10-10"
- model: stock.stockitem
pk: 521
@ -244,6 +245,7 @@
tree_id: 0
lft: 0
rght: 0
status: 60
- model: stock.stockitem
pk: 522
@ -255,4 +257,6 @@
level: 0
tree_id: 0
lft: 0
rght: 0
rght: 0
expiry_date: "1990-10-10"
status: 70

View File

@ -16,6 +16,7 @@ from mptt.fields import TreeNodeChoiceField
from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.fields import DatePickerFormField
from report.models import TestReport
@ -109,6 +110,10 @@ class ConvertStockItemForm(HelperForm):
class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """
expiry_date = DatePickerFormField(
help_text=('Expiration date for this stock item'),
)
serial_numbers = forms.CharField(label=_('Serial numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
def __init__(self, *args, **kwargs):
@ -130,6 +135,7 @@ class CreateStockItemForm(HelperForm):
'batch',
'serial_numbers',
'purchase_price',
'expiry_date',
'link',
'delete_on_deplete',
'status',
@ -243,7 +249,7 @@ class TestReportFormatForm(HelperForm):
templates = TestReport.objects.filter(enabled=True)
for template in templates:
if template.matches_stock_item(self.stock_item):
if template.enabled and template.matches_stock_item(self.stock_item):
choices.append((template.pk, template))
return choices
@ -394,6 +400,10 @@ class EditStockItemForm(HelperForm):
part - Cannot be edited after creation
"""
expiry_date = DatePickerFormField(
help_text=('Expiration date for this stock item'),
)
class Meta:
model = StockItem
@ -402,6 +412,7 @@ class EditStockItemForm(HelperForm):
'serial',
'batch',
'status',
'expiry_date',
'purchase_price',
'link',
'delete_on_deplete',

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-01-03 12:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0055_auto_20201117_1453'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='expiry_date',
field=models.DateField(blank=True, help_text='Expiry date for stock item. Stock will be considered expired after this date', null=True, verbose_name='Expiry Date'),
),
]

View File

@ -27,9 +27,12 @@ from mptt.models import MPTTModel, TreeForeignKey
from djmoney.models.fields import MoneyField
from decimal import Decimal, InvalidOperation
from datetime import datetime
from datetime import datetime, timedelta
from InvenTree import helpers
import common.models
import report.models
from InvenTree.status_codes import StockStatus
from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
@ -129,6 +132,7 @@ class StockItem(MPTTModel):
serial: Unique serial number for this StockItem
link: Optional URL to link to external resource
updated: Date that this stock item was last updated (auto)
expiry_date: Expiry date of the StockItem (optional)
stocktake_date: Date of last stocktake for this item
stocktake_user: User that performed the most recent stocktake
review_needed: Flag if StockItem needs review
@ -153,6 +157,9 @@ class StockItem(MPTTModel):
status__in=StockStatus.AVAILABLE_CODES
)
# A query filter which can be used to filter StockItem objects which have expired
EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
def save(self, *args, **kwargs):
"""
Save this StockItem to the database. Performs a number of checks:
@ -432,11 +439,19 @@ class StockItem(MPTTModel):
related_name='stock_items',
null=True, blank=True)
# last time the stock was checked / counted
expiry_date = models.DateField(
blank=True, null=True,
verbose_name=_('Expiry Date'),
help_text=_('Expiry date for stock item. Stock will be considered expired after this date'),
)
stocktake_date = models.DateField(blank=True, null=True)
stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
related_name='stocktake_stock')
stocktake_user = models.ForeignKey(
User, on_delete=models.SET_NULL,
blank=True, null=True,
related_name='stocktake_stock'
)
review_needed = models.BooleanField(default=False)
@ -467,6 +482,55 @@ class StockItem(MPTTModel):
help_text='Owner (User)',
related_name='owner_stockitems')
def is_stale(self):
"""
Returns True if this Stock item is "stale".
To be "stale", the following conditions must be met:
- Expiry date is not None
- Expiry date will "expire" within the configured stale date
- The StockItem is otherwise "in stock"
"""
if self.expiry_date is None:
return False
if not self.in_stock:
return False
today = datetime.now().date()
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
if stale_days <= 0:
return False
expiry_date = today + timedelta(days=stale_days)
return self.expiry_date < expiry_date
def is_expired(self):
"""
Returns True if this StockItem is "expired".
To be "expired", the following conditions must be met:
- Expiry date is not None
- Expiry date is "in the past"
- The StockItem is otherwise "in stock"
"""
if self.expiry_date is None:
return False
if not self.in_stock:
return False
today = datetime.now().date()
return self.expiry_date < today
def clearAllocations(self):
"""
Clear all order allocations for this StockItem:
@ -729,36 +793,16 @@ class StockItem(MPTTModel):
@property
def in_stock(self):
"""
Returns True if this item is in stock
Returns True if this item is in stock.
See also: IN_STOCK_FILTER
"""
# Quantity must be above zero (unless infinite)
if self.quantity <= 0 and not self.infinite:
return False
query = StockItem.objects.filter(pk=self.pk)
# Not 'in stock' if it has been installed inside another StockItem
if self.belongs_to is not None:
return False
# Not 'in stock' if it has been sent to a customer
if self.sales_order is not None:
return False
query = query.filter(StockItem.IN_STOCK_FILTER)
# Not 'in stock' if it has been assigned to a customer
if self.customer is not None:
return False
# Not 'in stock' if it is building
if self.is_building:
return False
# Not 'in stock' if the status code makes it unavailable
if self.status in StockStatus.UNAVAILABLE_CODES:
return False
return True
return query.exists()
@property
def tracking_info_count(self):
@ -1271,6 +1315,41 @@ class StockItem(MPTTModel):
return status['passed'] >= status['total']
def available_test_reports(self):
"""
Return a list of TestReport objects which match this StockItem.
"""
reports = []
item_query = StockItem.objects.filter(pk=self.pk)
for test_report in report.models.TestReport.objects.filter(enabled=True):
filters = helpers.validateFilterString(test_report.filters)
if item_query.filter(**filters).exists():
reports.append(test_report)
return reports
@property
def has_test_reports(self):
"""
Return True if there are test reports available for this stock item
"""
return len(self.available_test_reports()) > 0
@property
def has_labels(self):
"""
Return True if there are any label templates available for this stock item
"""
# TODO - Implement this
return True
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
def before_delete_stock_item(sender, instance, using, **kwargs):

View File

@ -11,10 +11,17 @@ from .models import StockItemTestResult
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models import Q
from sql_util.utils import SubquerySum, SubqueryCount
from decimal import Decimal
from datetime import datetime, timedelta
import common.models
from company.serializers import SupplierPartSerializer
from part.serializers import PartBriefSerializer
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
@ -106,6 +113,30 @@ class StockItemSerializer(InvenTreeModelSerializer):
tracking_items=SubqueryCount('tracking_info')
)
# Add flag to indicate if the StockItem has expired
queryset = queryset.annotate(
expired=Case(
When(
StockItem.EXPIRED_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField())
)
)
# Add flag to indicate if the StockItem is stale
stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
stale_date = datetime.now().date() + timedelta(days=stale_days)
stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
queryset = queryset.annotate(
stale=Case(
When(
stale_filter, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField()),
)
)
return queryset
status_text = serializers.CharField(source='get_status_display', read_only=True)
@ -122,6 +153,10 @@ class StockItemSerializer(InvenTreeModelSerializer):
allocated = serializers.FloatField(source='allocation_count', required=False)
expired = serializers.BooleanField(required=False, read_only=True)
stale = serializers.BooleanField(required=False, read_only=True)
serial = serializers.CharField(required=False)
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
@ -155,6 +190,8 @@ class StockItemSerializer(InvenTreeModelSerializer):
'belongs_to',
'build',
'customer',
'expired',
'expiry_date',
'in_stock',
'is_building',
'link',
@ -168,6 +205,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'required_tests',
'sales_order',
'serial',
'stale',
'status',
'status_text',
'supplier_part',

View File

@ -81,7 +81,14 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<h3>
{% trans "Stock Item" %}
{% if item.is_expired %}
<span class='label label-large label-large-red'>{% trans "Expired" %}</span>
{% else %}
{% stock_status_label item.status large=True %}
{% if item.is_stale %}
<span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
{% endif %}
{% endif %}
</h3>
<hr>
<h4>
@ -112,16 +119,29 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% if roles.stock.change %}
{% if item.uid %}
<li><a href='#' id='unlink-barcode'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
{% if item.uid %}
<li><a href='#' id='unlink-barcode'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
{% else %}
<li><a href='#' id='link-barcode'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
{% endif %}
{% endif %}
{% endif %}
</ul>
</div>
<!-- Document / label menu -->
{% if item.has_labels or item.has_test_reports %}
<div class='btn-group'>
<button id='document-options' title='{% trans "Document actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-file-alt'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if item.has_labels %}
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
{% if item.has_test_reports %}
<li><a href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<!-- Stock adjustment menu -->
<!-- Check permissions and owner -->
{% if owner_control.value == "False" or owner_control.value == "True" and item.owner == user or user.is_superuser %}
@ -175,9 +195,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
</div>
{% endif %}
{% endif %}
<button type='button' class='btn btn-default' id='stock-test-report' title='{% trans "Generate test report" %}'>
<span class='fas fa-file-invoice'/>
</button>
</div>
{% endblock %}
@ -307,6 +324,20 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<td><a href="{% url 'supplier-part-detail' item.supplier_part.id %}">{{ item.supplier_part.SKU }}</a></td>
</tr>
{% endif %}
{% if item.expiry_date %}
<tr>
<td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
<td>{% trans "Expiry Date" %}</td>
<td>
{{ item.expiry_date }}
{% if item.is_expired %}
<span title='{% trans "This StockItem expired on" %} {{ item.expiry_date }}' class='label label-red'>{% trans "Expired" %}</span>
{% elif item.is_stale %}
<span title='{% trans "This StockItem expires on" %} {{ item.expiry_date }}' class='label label-yellow'>{% trans "Stale" %}</span>
{% endif %}
</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Updated" %}</td>

View File

@ -1,11 +1,23 @@
"""
Unit testing for the Stock API
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import datetime, timedelta
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from InvenTree.helpers import addUserPermissions
from InvenTree.status_codes import StockStatus
from .models import StockLocation
from common.models import InvenTreeSetting
from .models import StockItem, StockLocation
class StockAPITestCase(APITestCase):
@ -26,6 +38,9 @@ class StockAPITestCase(APITestCase):
self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
self.user.is_staff = True
self.user.save()
# Add the necessary permissions to the user
perms = [
'view_stockitemtestresult',
@ -76,6 +91,177 @@ class StockLocationTest(StockAPITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
class StockItemListTest(StockAPITestCase):
"""
Tests for the StockItem API LIST endpoint
"""
list_url = reverse('api-stock-list')
def get_stock(self, **kwargs):
"""
Filter stock and return JSON object
"""
response = self.client.get(self.list_url, format='json', data=kwargs)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Return JSON-ified data
return response.data
def test_get_stock_list(self):
"""
List *all* StockItem objects.
"""
response = self.get_stock()
self.assertEqual(len(response), 19)
def test_filter_by_part(self):
"""
Filter StockItem by Part reference
"""
response = self.get_stock(part=25)
self.assertEqual(len(response), 7)
response = self.get_stock(part=10004)
self.assertEqual(len(response), 12)
def test_filter_by_IPN(self):
"""
Filter StockItem by IPN reference
"""
response = self.get_stock(IPN="R.CH")
self.assertEqual(len(response), 3)
def test_filter_by_location(self):
"""
Filter StockItem by StockLocation reference
"""
response = self.get_stock(location=5)
self.assertEqual(len(response), 1)
response = self.get_stock(location=1, cascade=0)
self.assertEqual(len(response), 0)
response = self.get_stock(location=1, cascade=1)
self.assertEqual(len(response), 2)
response = self.get_stock(location=7)
self.assertEqual(len(response), 16)
def test_filter_by_depleted(self):
"""
Filter StockItem by depleted status
"""
response = self.get_stock(depleted=1)
self.assertEqual(len(response), 1)
response = self.get_stock(depleted=0)
self.assertEqual(len(response), 18)
def test_filter_by_in_stock(self):
"""
Filter StockItem by 'in stock' status
"""
response = self.get_stock(in_stock=1)
self.assertEqual(len(response), 16)
response = self.get_stock(in_stock=0)
self.assertEqual(len(response), 3)
def test_filter_by_status(self):
"""
Filter StockItem by 'status' field
"""
codes = {
StockStatus.OK: 17,
StockStatus.DESTROYED: 1,
StockStatus.LOST: 1,
StockStatus.DAMAGED: 0,
StockStatus.REJECTED: 0,
}
for code in codes.keys():
num = codes[code]
response = self.get_stock(status=code)
self.assertEqual(len(response), num)
def test_filter_by_batch(self):
"""
Filter StockItem by batch code
"""
response = self.get_stock(batch='B123')
self.assertEqual(len(response), 1)
def test_filter_by_serialized(self):
"""
Filter StockItem by serialized status
"""
response = self.get_stock(serialized=1)
self.assertEqual(len(response), 12)
for item in response:
self.assertIsNotNone(item['serial'])
response = self.get_stock(serialized=0)
self.assertEqual(len(response), 7)
for item in response:
self.assertIsNone(item['serial'])
def test_filter_by_expired(self):
"""
Filter StockItem by expiry status
"""
# First, we can assume that the 'stock expiry' feature is disabled
response = self.get_stock(expired=1)
self.assertEqual(len(response), 19)
# Now, ensure that the expiry date feature is enabled!
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
response = self.get_stock(expired=1)
self.assertEqual(len(response), 1)
for item in response:
self.assertTrue(item['expired'])
response = self.get_stock(expired=0)
self.assertEqual(len(response), 18)
for item in response:
self.assertFalse(item['expired'])
# Mark some other stock items as expired
today = datetime.now().date()
for pk in [510, 511, 512]:
item = StockItem.objects.get(pk=pk)
item.expiry_date = today - timedelta(days=pk)
item.save()
response = self.get_stock(expired=1)
self.assertEqual(len(response), 4)
response = self.get_stock(expired=0)
self.assertEqual(len(response), 15)
class StockItemTest(StockAPITestCase):
"""
Series of API tests for the StockItem API
@ -94,10 +280,6 @@ class StockItemTest(StockAPITestCase):
StockLocation.objects.create(name='B', description='location b', parent=top)
StockLocation.objects.create(name='C', description='location c', parent=top)
def test_get_stock_list(self):
response = self.client.get(self.list_url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_create_default_location(self):
"""
Test the default location functionality,
@ -198,6 +380,56 @@ class StockItemTest(StockAPITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_default_expiry(self):
"""
Test that the "default_expiry" functionality works via the API.
- If an expiry_date is specified, use that
- Otherwise, check if the referenced part has a default_expiry defined
- If so, use that!
- Otherwise, no expiry
Notes:
- Part <25> has a default_expiry of 10 days
"""
# First test - create a new StockItem without an expiry date
data = {
'part': 4,
'quantity': 10,
}
response = self.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIsNone(response.data['expiry_date'])
# Second test - create a new StockItem with an explicit expiry date
data['expiry_date'] = '2022-12-12'
response = self.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIsNotNone(response.data['expiry_date'])
self.assertEqual(response.data['expiry_date'], '2022-12-12')
# Third test - create a new StockItem for a Part which has a default expiry time
data = {
'part': 25,
'quantity': 10
}
response = self.client.post(self.list_url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Expected expiry date is 10 days in the future
expiry = datetime.now().date() + timedelta(10)
self.assertEqual(response.data['expiry_date'], expiry.isoformat())
class StocktakeTest(StockAPITestCase):
"""

View File

@ -5,7 +5,10 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from common.models import InvenTreeSetting
import json
from datetime import datetime, timedelta
from common.models import InvenTreeSetting
from InvenTree.status_codes import StockStatus
@ -34,6 +37,9 @@ class StockViewTestCase(TestCase):
password='password'
)
self.user.is_staff = True
self.user.save()
# Put the user into a group with the correct permissions
group = Group.objects.create(name='mygroup')
self.user.groups.add(group)
@ -138,21 +144,56 @@ class StockItemTest(StockViewTestCase):
self.assertEqual(response.status_code, 200)
def test_create_item(self):
# Test creation of StockItem
response = self.client.get(reverse('stock-item-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
"""
Test creation of StockItem
"""
url = reverse('stock-item-create')
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('stock-item-create'), {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Copy from a valid item, valid location
response = self.client.get(reverse('stock-item-create'), {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# Copy from an invalid item, invalid location
response = self.client.get(reverse('stock-item-create'), {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
def test_create_stock_with_expiry(self):
"""
Test creation of stock item of a part with an expiry date.
The initial value for the "expiry_date" field should be pre-filled,
and should be in the future!
"""
# First, ensure that the expiry date feature is enabled!
InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
url = reverse('stock-item-create')
response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
# We are expecting 10 days in the future
expiry = datetime.now().date() + timedelta(10)
expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"'
self.assertIn(expected, str(response.content))
# Now check with a part which does *not* have a default expiry period
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"'
self.assertIn(expected, str(response.content))
def test_serialize_item(self):
# Test the serialization view

View File

@ -49,6 +49,40 @@ class StockTest(TestCase):
Part.objects.rebuild()
StockItem.objects.rebuild()
def test_expiry(self):
"""
Test expiry date functionality for StockItem model.
"""
today = datetime.datetime.now().date()
item = StockItem.objects.create(
location=self.office,
part=Part.objects.get(pk=1),
quantity=10,
)
# Without an expiry_date set, item should not be "expired"
self.assertFalse(item.is_expired())
# Set the expiry date to today
item.expiry_date = today
item.save()
self.assertFalse(item.is_expired())
# Set the expiry date in the future
item.expiry_date = today + datetime.timedelta(days=5)
item.save()
self.assertFalse(item.is_expired())
# Set the expiry date in the past
item.expiry_date = today - datetime.timedelta(days=5)
item.save()
self.assertTrue(item.is_expired())
def test_is_building(self):
"""
Test that the is_building flag does not count towards stock.
@ -143,8 +177,10 @@ class StockTest(TestCase):
# There should be 9000 screws in stock
self.assertEqual(part.total_stock, 9000)
# There should be 18 widgets in stock
self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19)
# There should be 16 widgets "in stock"
self.assertEqual(
StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 16
)
def test_delete_location(self):

View File

@ -27,7 +27,7 @@ from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
from InvenTree.helpers import extract_serial_numbers
from decimal import Decimal, InvalidOperation
from datetime import datetime
from datetime import datetime, timedelta
from company.models import Company, SupplierPart
from part.models import Part
@ -1330,12 +1330,17 @@ class StockItemEdit(AjaxUpdateView):
form = super(AjaxUpdateView, self).get_form()
# Hide the "expiry date" field if the feature is not enabled
if not common.settings.stock_expiry_enabled():
form.fields.pop('expiry_date')
item = self.get_object()
# If the part cannot be purchased, hide the supplier_part field
if not item.part.purchaseable:
form.fields['supplier_part'].widget = HiddenInput()
form.fields['purchase_price'].widget = HiddenInput()
form.fields.pop('purchase_price')
else:
query = form.fields['supplier_part'].queryset
query = query.filter(part=item.part.id)
@ -1629,6 +1634,10 @@ class StockItemCreate(AjaxCreateView):
form = super().get_form()
# Hide the "expiry date" field if the feature is not enabled
if not common.settings.stock_expiry_enabled():
form.fields.pop('expiry_date')
part = self.get_part(form=form)
if part is not None:
@ -1639,8 +1648,8 @@ class StockItemCreate(AjaxCreateView):
form.rebuild_layout()
if not part.purchaseable:
form.fields['purchase_price'].widget = HiddenInput()
form.fields.pop('purchase_price')
# Hide the 'part' field (as a valid part is selected)
# form.fields['part'].widget = HiddenInput()
@ -1734,6 +1743,11 @@ class StockItemCreate(AjaxCreateView):
initials['location'] = part.get_default_location()
initials['supplier_part'] = part.default_supplier
# If the part has a defined expiry period, extrapolate!
if part.default_expiry > 0:
expiry_date = datetime.now().date() + timedelta(days=part.default_expiry)
initials['expiry_date'] = expiry_date
currency_code = common.settings.currency_code_default()
# SupplierPart field has been specified