Part stocktake (#4069)

* Remove stat context variables

* Revert "Remove stat context variables"

This reverts commit 0989c308d0.

* Allow longer timeout for image download tests

* Create PartStocktake model

Model for representing stocktake entries against any given part

* Admin interface support for new model

* Adds API endpoint for listing stocktake information

* Bump API version

* Enable filtering and ordering for API endpoint

* Add model to permission group

* Add UI hooks for displaying stocktake data for a particular part

* Fix encoded type for 'quantity' field

* Load stocktake table for part

* Add "stocktake" button

* Add "note" field for stocktake

* Add user information when performing stocktake

* First pass at UI elements for performing stocktake

* Add user information to stocktake table

* Auto-calculate quantity based on available stock items

* add stocktake data as tabular inline (admin)

* js linting

* Add indication that a stock item has not been updated recently

* Display last stocktake information on part page

* js style fix

* Test fix for ongoing CI issues

* Add configurable option for controlling default "delete_on_deplete" behaviour

* Add id values to cells

* Hide action buttons (at least for now)

* Adds refresh button to table

* Add API endpoint to delete or edit stocktake entries

* Adds unit test for API list endpoint

* javascript linting

* More unit testing

* Add Part API filter for stocktake

* Add 'last_stocktake' field to Part model

- Gets filled out automatically when a new PartStocktake instance is created

* Update part table to include last_stocktake date

* Add simple unit test

* Fix test
This commit is contained in:
Oliver 2022-12-31 23:14:43 +11:00 committed by GitHub
parent 4ae278d119
commit ab4e2aa8bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 803 additions and 22 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 85 INVENTREE_API_VERSION = 86
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v86 -> 2022-12-22 : https://github.com/inventree/InvenTree/pull/4069
- Adds API endpoints for part stocktake
v85 -> 2022-12-21 : https://github.com/inventree/InvenTree/pull/3858 v85 -> 2022-12-21 : https://github.com/inventree/InvenTree/pull/3858
- Add endpoints serving ICS calendars for purchase and sales orders through API - Add endpoints serving ICS calendars for purchase and sales orders through API

View File

@ -466,7 +466,7 @@ $('#btn-create-output').click(function() {
createBuildOutput( createBuildOutput(
{{ build.pk }}, {{ build.pk }},
{ {
trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%}, trackable_parts: {% js_bool build.part.has_trackable_parts %},
} }
); );
}); });

View File

@ -1774,6 +1774,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'DISPLAY_STOCKTAKE_TAB': {
'name': _('Part Stocktake'),
'description': _('Display part stocktake information'),
'default': True,
'validator': bool,
},
'TABLE_STRING_MAX_LENGTH': { 'TABLE_STRING_MAX_LENGTH': {
'name': _('Table String Length'), 'name': _('Table String Length'),
'description': _('Maximimum length limit for strings displayed in table views'), 'description': _('Maximimum length limit for strings displayed in table views'),

View File

@ -104,6 +104,11 @@ class PartResource(InvenTreeResource):
models.Part.objects.rebuild() models.Part.objects.rebuild()
class StocktakeInline(admin.TabularInline):
"""Inline for part stocktake data"""
model = models.PartStocktake
class PartAdmin(ImportExportModelAdmin): class PartAdmin(ImportExportModelAdmin):
"""Admin class for the Part model""" """Admin class for the Part model"""
@ -122,6 +127,10 @@ class PartAdmin(ImportExportModelAdmin):
'default_supplier', 'default_supplier',
] ]
inlines = [
StocktakeInline,
]
class PartPricingAdmin(admin.ModelAdmin): class PartPricingAdmin(admin.ModelAdmin):
"""Admin class for PartPricing model""" """Admin class for PartPricing model"""
@ -133,6 +142,12 @@ class PartPricingAdmin(admin.ModelAdmin):
] ]
class PartStocktakeAdmin(admin.ModelAdmin):
"""Admin class for PartStocktake model"""
list_display = ['part', 'date', 'quantity', 'user']
class PartCategoryResource(InvenTreeResource): class PartCategoryResource(InvenTreeResource):
"""Class for managing PartCategory data import/export.""" """Class for managing PartCategory data import/export."""
@ -400,3 +415,4 @@ admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin) admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin) admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.PartPricing, PartPricingAdmin) admin.site.register(models.PartPricing, PartPricingAdmin)
admin.site.register(models.PartStocktake, PartStocktakeAdmin)

View File

@ -13,6 +13,7 @@ from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, serializers, status from rest_framework import filters, serializers, status
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
import order.models import order.models
@ -27,6 +28,7 @@ from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
ListAPI, ListCreateAPI, RetrieveAPI, ListAPI, ListCreateAPI, RetrieveAPI,
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI,
UpdateAPI) UpdateAPI)
from InvenTree.permissions import RolePermission
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus) SalesOrderStatus)
from part.admin import PartCategoryResource, PartResource from part.admin import PartCategoryResource, PartResource
@ -38,7 +40,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate, PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter, PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartRelated, PartSellPriceBreak, PartParameterTemplate, PartRelated, PartSellPriceBreak,
PartTestTemplate) PartStocktake, PartTestTemplate)
class CategoryList(APIDownloadMixin, ListCreateAPI): class CategoryList(APIDownloadMixin, ListCreateAPI):
@ -1061,6 +1063,20 @@ class PartFilter(rest_filters.FilterSet):
return queryset return queryset
stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake')
def filter_has_stocktake(self, queryset, name, value):
"""Filter the queryset based on whether stocktake data is available"""
value = str2bool(value)
if (value):
queryset = queryset.exclude(last_stocktake=None)
else:
queryset = queryset.filter(last_stocktake=None)
return queryset
is_template = rest_filters.BooleanFilter() is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter() assembly = rest_filters.BooleanFilter()
@ -1537,6 +1553,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
'in_stock', 'in_stock',
'unallocated_stock', 'unallocated_stock',
'category', 'category',
'last_stocktake',
] ]
# Default ordering # Default ordering
@ -1696,6 +1713,63 @@ class PartParameterDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.PartParameterSerializer serializer_class = part_serializers.PartParameterSerializer
class PartStocktakeFilter(rest_filters.FilterSet):
"""Custom fitler for the PartStocktakeList endpoint"""
class Meta:
"""Metaclass options"""
model = PartStocktake
fields = [
'part',
'user',
]
class PartStocktakeList(ListCreateAPI):
"""API endpoint for listing part stocktake information"""
queryset = PartStocktake.objects.all()
serializer_class = part_serializers.PartStocktakeSerializer
filterset_class = PartStocktakeFilter
def get_serializer_context(self):
"""Extend serializer context data"""
context = super().get_serializer_context()
context['request'] = self.request
return context
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
]
ordering_fields = [
'part',
'quantity',
'date',
'user',
]
# Reverse date ordering by default
ordering = '-pk'
class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
"""Detail API endpoint for a single PartStocktake instance.
Note: Only staff (admin) users can access this endpoint.
"""
queryset = PartStocktake.objects.all()
serializer_class = part_serializers.PartStocktakeSerializer
permission_classes = [
IsAdminUser,
RolePermission,
]
class BomFilter(rest_filters.FilterSet): class BomFilter(rest_filters.FilterSet):
"""Custom filters for the BOM list.""" """Custom filters for the BOM list."""
@ -2111,6 +2185,12 @@ part_api_urls = [
re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
])), ])),
# Part stocktake data
re_path(r'^stocktake/', include([
re_path(r'^(?P<pk>\d+)/', PartStocktakeDetail.as_view(), name='api-part-stocktake-detail'),
re_path(r'^.*$', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
])),
re_path(r'^thumbs/', include([ re_path(r'^thumbs/', include([
path('', PartThumbs.as_view(), name='api-part-thumbs'), path('', PartThumbs.as_view(), name='api-part-thumbs'),
re_path(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'), re_path(r'^(?P<pk>\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'),

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.16 on 2022-12-21 11:26
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0090_auto_20221115_0816'),
]
operations = [
migrations.CreateModel(
name='PartStocktake',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=5, help_text='Total available stock at time of stocktake', max_digits=19, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('date', models.DateField(auto_now_add=True, help_text='Date stocktake was performed', verbose_name='Date')),
('note', models.CharField(blank=True, help_text='Additional notes', max_length=250, verbose_name='Notes')),
('part', models.ForeignKey(help_text='Part for stocktake', on_delete=django.db.models.deletion.CASCADE, related_name='stocktakes', to='part.part', verbose_name='Part')),
('user', models.ForeignKey(blank=True, help_text='User who performed this stocktake', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='part_stocktakes', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2022-12-31 09:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0091_partstocktake'),
]
operations = [
migrations.AddField(
model_name='part',
name='last_stocktake',
field=models.DateField(blank=True, null=True, verbose_name='Last Stocktake'),
),
]

View File

@ -372,6 +372,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
creation_date: Date that this part was added to the database creation_date: Date that this part was added to the database
creation_user: User who added this part to the database creation_user: User who added this part to the database
responsible: User who is responsible for this part (optional) responsible: User who is responsible for this part (optional)
last_stocktake: Date at which last stocktake was performed for this Part
""" """
objects = PartManager() objects = PartManager()
@ -1004,6 +1005,11 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible') responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible')
last_stocktake = models.DateField(
blank=True, null=True,
verbose_name=_('Last Stocktake'),
)
@property @property
def category_path(self): def category_path(self):
"""Return the category path of this Part instance""" """Return the category path of this Part instance"""
@ -2161,6 +2167,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return params return params
@property
def latest_stocktake(self):
"""Return the latest PartStocktake object associated with this part (if one exists)"""
return self.stocktakes.order_by('-pk').first()
@property @property
def has_variants(self): def has_variants(self):
"""Check if this Part object has variants underneath it.""" """Check if this Part object has variants underneath it."""
@ -2878,6 +2890,66 @@ class PartPricing(models.Model):
) )
class PartStocktake(models.Model):
"""Model representing a 'stocktake' entry for a particular Part.
A 'stocktake' is a representative count of available stock:
- Performed on a given date
- Records quantity of part in stock (across multiple stock items)
- Records user information
"""
part = models.ForeignKey(
Part,
on_delete=models.CASCADE,
related_name='stocktakes',
verbose_name=_('Part'),
help_text=_('Part for stocktake'),
)
quantity = models.DecimalField(
max_digits=19, decimal_places=5,
validators=[MinValueValidator(0)],
verbose_name=_('Quantity'),
help_text=_('Total available stock at time of stocktake'),
)
date = models.DateField(
verbose_name=_('Date'),
help_text=_('Date stocktake was performed'),
auto_now_add=True
)
note = models.CharField(
max_length=250,
blank=True,
verbose_name=_('Notes'),
help_text=_('Additional notes'),
)
user = models.ForeignKey(
User, blank=True, null=True,
on_delete=models.SET_NULL,
related_name='part_stocktakes',
verbose_name=_('User'),
help_text=_('User who performed this stocktake'),
)
@receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake')
def update_last_stocktake(sender, instance, created, **kwargs):
"""Callback function when a PartStocktake instance is created / edited"""
# When a new PartStocktake instance is create, update the last_stocktake date for the Part
if created:
try:
part = instance.part
part.last_stocktake = instance.date
part.save()
except Exception:
pass
class PartAttachment(InvenTreeAttachment): class PartAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a Part object.""" """Model for storing file attachments against a Part object."""

View File

@ -24,14 +24,16 @@ from InvenTree.serializers import (DataFileExtractSerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, RemoteImageMixin) InvenTreeMoneySerializer, RemoteImageMixin,
UserSerializer)
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate, PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter, PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartPricing, PartRelated, PartParameterTemplate, PartPricing, PartRelated,
PartSellPriceBreak, PartStar, PartTestTemplate) PartSellPriceBreak, PartStar, PartStocktake,
PartTestTemplate)
class CategorySerializer(InvenTreeModelSerializer): class CategorySerializer(InvenTreeModelSerializer):
@ -451,6 +453,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'IPN', 'IPN',
'is_template', 'is_template',
'keywords', 'keywords',
'last_stocktake',
'link', 'link',
'minimum_stock', 'minimum_stock',
'name', 'name',
@ -504,6 +507,44 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
return self.instance return self.instance
class PartStocktakeSerializer(InvenTreeModelSerializer):
"""Serializer for the PartStocktake model"""
quantity = serializers.FloatField()
user_detail = UserSerializer(source='user', read_only=True, many=False)
class Meta:
"""Metaclass options"""
model = PartStocktake
fields = [
'pk',
'date',
'part',
'quantity',
'note',
'user',
'user_detail',
]
read_only_fields = [
'date',
'user',
]
def save(self):
"""Called when this serializer is saved"""
data = self.validated_data
# Add in user information automatically
request = self.context['request']
data['user'] = request.user
super().save()
class PartPricingSerializer(InvenTreeModelSerializer): class PartPricingSerializer(InvenTreeModelSerializer):
"""Serializer for Part pricing information""" """Serializer for Part pricing information"""

View File

@ -53,6 +53,29 @@
</div> </div>
{% endif %} {% endif %}
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
{% if show_stocktake %}
<div class='panel panel-hidden' id='panel-stocktake'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Part Stocktake" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.part.add %}
<button class='btn btn-success' type='button' id='btn-stocktake' title='{% trans "Add stocktake information" %}'>
<span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %}
</button>
{% endif %}
<!-- TODO: Buttons -->
</div>
</div>
</div>
<div class='panel-content'>
{% include "part/part_stocktake.html" %}
</div>
</div>
{% endif %}
<div class='panel panel-hidden' id='panel-test-templates'> <div class='panel panel-hidden' id='panel-test-templates'>
<div class='panel-heading'> <div class='panel-heading'>
<div class='d-flex flex-wrap'> <div class='d-flex flex-wrap'>
@ -423,7 +446,7 @@
'part-notes', 'part-notes',
'{% url "api-part-detail" part.pk %}', '{% url "api-part-detail" part.pk %}',
{ {
editable: {% if roles.part.change %}true{% else %}false{% endif %}, editable: {% js_bool roles.part.change %},
} }
); );
}); });
@ -442,6 +465,23 @@
}); });
}); });
// Load the "stocktake" tab
onPanelLoad('stocktake', function() {
loadPartStocktakeTable({{ part.pk }}, {
admin: {% js_bool user.is_staff %},
allow_edit: {% js_bool roles.part.change %},
allow_delete: {% js_bool roles.part.delete %},
});
$('#btn-stocktake').click(function() {
performStocktake({{ part.pk }}, {
onSuccess: function() {
$('#part-stocktake-table').bootstrapTable('refresh');
}
});
});
});
// Load the "suppliers" tab // Load the "suppliers" tab
onPanelLoad('suppliers', function() { onPanelLoad('suppliers', function() {

View File

@ -338,6 +338,20 @@
</tr> </tr>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% with part.latest_stocktake as stocktake %}
{% if stocktake %}
<tr>
<td><span class='fas fa-clipboard-check'></span></td>
<td>{% trans "Last Stocktake" %}</td>
<td>
{% decimal stocktake.quantity %} <span class='fas fa-calendar-alt' title='{% render_date stocktake.date %}'></span>
<span class='badge bg-dark rounded-pill float-right'>
{{ stocktake.user.username }}
</span>
</td>
</tr>
{% endif %}
{% endwith %}
{% with part.get_latest_serial_number as sn %} {% with part.get_latest_serial_number as sn %}
{% if part.trackable and sn %} {% if part.trackable and sn %}
<tr> <tr>

View File

@ -44,6 +44,11 @@
{% trans "Scheduling" as text %} {% trans "Scheduling" as text %}
{% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %} {% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %}
{% endif %} {% endif %}
{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %}
{% if show_stocktake %}
{% trans "Stocktake" as text %}
{% include "sidebar_item.html" with label="stocktake" text=text icon="fa-clipboard-check" %}
{% endif %}
{% if part.trackable %} {% if part.trackable %}
{% trans "Test Templates" as text %} {% trans "Test Templates" as text %}
{% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %} {% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %}

View File

@ -0,0 +1,10 @@
{% load i18n %}
{% load inventree_extras %}
<div id='part-stocktake-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="partstocktake" %}
</div>
</div>
<table class='table table-condensed table-striped' id='part-stocktake-table' data-toolbar='#part-stocktake-toolbar'>
</table>

View File

@ -484,6 +484,16 @@ def primitive_to_javascript(primitive):
return format_html("'{}'", primitive) # noqa: P103 return format_html("'{}'", primitive) # noqa: P103
@register.simple_tag()
def js_bool(val):
"""Return a javascript boolean value (true or false)"""
if val:
return 'true'
else:
return 'false'
@register.filter @register.filter
def keyvalue(dict, key): def keyvalue(dict, key):
"""Access to key of supplied dict. """Access to key of supplied dict.

View File

@ -20,7 +20,7 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
StockStatus) StockStatus)
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory, from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
PartCategoryParameterTemplate, PartParameterTemplate, PartCategoryParameterTemplate, PartParameterTemplate,
PartRelated) PartRelated, PartStocktake)
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@ -2779,3 +2779,141 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
with self.assertRaises(Part.DoesNotExist): with self.assertRaises(Part.DoesNotExist):
p.refresh_from_db() p.refresh_from_db()
class PartStocktakeTest(InvenTreeAPITestCase):
"""Unit tests for the part stocktake functionality"""
superuser = False
is_staff = False
fixtures = [
'category',
'part',
'location',
]
def test_list_endpoint(self):
"""Test the list endpoint for the stocktake data"""
url = reverse('api-part-stocktake-list')
self.assignRole('part.view')
# Initially, no stocktake entries
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 0)
total = 0
# Create some entries
for p in Part.objects.all():
for n in range(p.pk):
PartStocktake.objects.create(
part=p,
quantity=(n + 1) * 100,
)
total += p.pk
response = self.get(
url,
{
'part': p.pk,
},
expected_code=200,
)
# List by part ID
self.assertEqual(len(response.data), p.pk)
# List all entries
response = self.get(url, {}, expected_code=200)
self.assertEqual(len(response.data), total)
def test_create_stocktake(self):
"""Test that stocktake entries can be created via the API"""
url = reverse('api-part-stocktake-list')
self.assignRole('part.add')
self.assignRole('part.view')
for p in Part.objects.all():
# Initially no stocktake information available
self.assertIsNone(p.latest_stocktake)
note = f"Note {p.pk}"
quantity = p.pk + 5
self.post(
url,
{
'part': p.pk,
'quantity': quantity,
'note': note,
},
expected_code=201,
)
p.refresh_from_db()
stocktake = p.latest_stocktake
self.assertIsNotNone(stocktake)
self.assertEqual(stocktake.quantity, quantity)
self.assertEqual(stocktake.part, p)
self.assertEqual(stocktake.note, note)
def test_edit_stocktake(self):
"""Test that a Stoctake instance can be edited and deleted via the API.
Note that only 'staff' users can perform these actions.
"""
p = Part.objects.all().first()
st = PartStocktake.objects.create(part=p, quantity=10)
url = reverse('api-part-stocktake-detail', kwargs={'pk': st.pk})
self.assignRole('part.view')
# Test we can retrieve via API
self.get(url, expected_code=403)
# Assign staff permission
self.user.is_staff = True
self.user.save()
self.get(url, expected_code=200)
# Try to edit data
self.patch(
url,
{
'note': 'Another edit',
},
expected_code=403
)
# Assign 'edit' role permission
self.assignRole('part.change')
# Try again
self.patch(
url,
{
'note': 'Editing note field again',
},
expected_code=200,
)
# Try to delete
self.delete(url, expected_code=403)
self.assignRole('part.delete')
self.delete(url, expected_code=204)

View File

@ -17,7 +17,8 @@ from InvenTree import version
from InvenTree.helpers import InvenTreeTestCase from InvenTree.helpers import InvenTreeTestCase
from .models import (Part, PartCategory, PartCategoryStar, PartRelated, from .models import (Part, PartCategory, PartCategoryStar, PartRelated,
PartStar, PartTestTemplate, rename_part_image) PartStar, PartStocktake, PartTestTemplate,
rename_part_image)
from .templatetags import inventree_extras from .templatetags import inventree_extras
@ -338,6 +339,19 @@ class PartTest(TestCase):
self.r2.delete() self.r2.delete()
self.assertEqual(PartRelated.objects.count(), 0) self.assertEqual(PartRelated.objects.count(), 0)
def test_stocktake(self):
"""Test for adding stocktake data"""
# Grab a part
p = Part.objects.all().first()
self.assertIsNone(p.last_stocktake)
ps = PartStocktake.objects.create(part=p, quantity=100)
self.assertIsNotNone(p.last_stocktake)
self.assertEqual(p.last_stocktake, ps.date)
class TestTemplateTest(TestCase): class TestTemplateTest(TestCase):
"""Unit test for the TestTemplate class""" """Unit test for the TestTemplate class"""

View File

@ -19,6 +19,7 @@
{% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="FORMS_CLOSE_USING_ESCAPE" icon="fa-window-close" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="DISPLAY_SCHEDULE_TAB" icon="fa-calendar-alt" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="DISPLAY_STOCKTAKE_TAB" icon="fa-clipboard-check" user_setting=True %}
{% include "InvenTree/settings/setting.html" with key="TABLE_STRING_MAX_LENGTH" icon="fa-table" user_setting=True %} {% include "InvenTree/settings/setting.html" with key="TABLE_STRING_MAX_LENGTH" icon="fa-table" user_setting=True %}
</tbody> </tbody>
</table> </table>

View File

@ -292,7 +292,7 @@ function loadAttachmentTable(url, options) {
var html = renderDate(value); var html = renderDate(value);
if (row.user_detail) { if (row.user_detail) {
html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</div>`; html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</span>`;
} }
return html; return html;

View File

@ -343,6 +343,9 @@ function constructForm(url, options) {
// Request OPTIONS endpoint from the API // Request OPTIONS endpoint from the API
getApiEndpointOptions(url, function(OPTIONS) { getApiEndpointOptions(url, function(OPTIONS) {
// Copy across entire actions struct
options.actions = OPTIONS.actions.POST || OPTIONS.actions.PUT || OPTIONS.actions.PATCH || OPTIONS.actions.DELETE || {};
// Extract any custom 'context' information from the OPTIONS data // Extract any custom 'context' information from the OPTIONS data
options.context = OPTIONS.context || {}; options.context = OPTIONS.context || {};

View File

@ -33,10 +33,13 @@
loadPartTable, loadPartTable,
loadPartTestTemplateTable, loadPartTestTemplateTable,
loadPartSchedulingChart, loadPartSchedulingChart,
loadPartStocktakeTable,
loadPartVariantTable, loadPartVariantTable,
loadRelatedPartsTable, loadRelatedPartsTable,
loadSimplePartTable, loadSimplePartTable,
partDetail,
partStockLabel, partStockLabel,
performStocktake,
toggleStar, toggleStar,
validateBom, validateBom,
*/ */
@ -678,13 +681,281 @@ function makePartIcons(part) {
} }
return html; return html;
} }
function loadPartVariantTable(table, partId, options={}) { /*
/* Load part variant table * Render part information for a table view
*
* part: JSON part object
* options:
* icons: Display part icons
* thumb: Display part thumbnail
* link: Display URL
*/ */
function partDetail(part, options={}) {
var html = '';
var name = part.full_name;
if (options.thumb) {
html += imageHoverIcon(part.thumbnail || part.image);
}
if (options.link) {
var url = `/part/${part.pk}/`;
html += renderLink(shortenString(name), url);
} else {
html += shortenString(name);
}
if (options.icons) {
html += makePartIcons(part);
}
return html;
}
/*
* Guide user through "stocktake" process
*/
function performStocktake(partId, options={}) {
var part_quantity = 0;
var date_threshold = moment().subtract(30, 'days');
// Helper function for formatting a StockItem row
function buildStockItemRow(item) {
var pk = item.pk;
// Part detail
var part = partDetail(item.part_detail, {
thumb: true,
});
// Location detail
var location = locationDetail(item);
// Quantity detail
var quantity = item.quantity;
part_quantity += item.quantity;
if (item.serial && item.quantity == 1) {
quantity = `{% trans "Serial" %}: ${item.serial}`;
}
quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
// Last update
var updated = item.stocktake_date || item.updated;
var update_rendered = renderDate(updated);
if (updated) {
if (moment(updated) < date_threshold) {
update_rendered += `<div class='float-right' title='{% trans "Stock item has not been checked recently" %}'><span class='fas fa-calendar-alt icon-red'></span></div>`;
}
}
// Actions
var actions = `<div class='btn-group float-right' role='group'>`;
// TODO: Future work
// actions += makeIconButton('fa-check-circle icon-green', 'button-line-count', pk, '{% trans "Update item" %}');
// actions += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete item" %}');
actions += `</div>`;
return `
<tr>
<td id='part-${pk}'>${part}</td>
<td id='loc-${pk}'>${location}</td>
<td id='quantity-${pk}'>${quantity}</td>
<td id='updated-${pk}'>${update_rendered}</td>
<td id='actions-${pk}'>${actions}</td>
</tr>`;
}
// First, load stock information for the part
inventreeGet(
'{% url "api-stock-list" %}',
{
part: partId,
in_stock: true,
location_detail: true,
part_detail: true,
include_variants: true,
ordering: '-stock',
},
{
success: function(response) {
var html = '';
html += `
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>{% trans "Stock Item" %}</th>
<th>{% trans "Location" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Updated" %}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
`;
response.forEach(function(item) {
html += buildStockItemRow(item);
});
html += `</tbody></table>`;
constructForm(`/api/part/stocktake/`, {
preFormContent: html,
method: 'POST',
title: '{% trans "Part Stocktake" %}',
confirm: true,
fields: {
part: {
value: partId,
hidden: true,
},
quantity: {
value: part_quantity,
},
note: {},
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
});
}
}
);
}
/*
* Load table for part stocktake information
*/
function loadPartStocktakeTable(partId, options={}) {
var table = options.table || '#part-stocktake-table';
var params = options.params || {};
params.part = partId;
var filters = loadTableFilters('stocktake');
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('stocktake', $(table), '#filter-list-partstocktake');
$(table).inventreeTable({
url: '{% url "api-part-stocktake-list" %}',
queryParams: filters,
name: 'partstocktake',
original: options.params,
showColumns: true,
sortable: true,
formatNoMatches: function() {
return '{% trans "No stocktake information available" %}';
},
columns: [
{
field: 'quantity',
title: '{% trans "Quantity" %}',
switchable: false,
sortable: true,
},
{
field: 'note',
title: '{% trans "Notes" %}',
switchable: true,
},
{
field: 'date',
title: '{% trans "Date" %}',
switchable: false,
sortable: true,
formatter: function(value, row) {
var html = renderDate(value);
if (row.user_detail) {
html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</span>`;
}
return html;
}
},
{
field: 'actions',
title: '',
visible: options.admin,
switchable: false,
sortable: false,
formatter: function(value, row) {
var html = `<div class='btn-group float-right' role='group'>`;
if (options.allow_edit) {
html += makeIconButton('fa-edit icon-blue', 'button-edit-stocktake', row.pk, '{% trans "Edit Stocktake Entry" %}');
}
if (options.allow_delete) {
html += makeIconButton('fa-trash-alt icon-red', 'button-delete-stocktake', row.pk, '{% trans "Delete Stocktake Entry" %}');
}
html += `</div>`;
return html;
}
}
],
onPostBody: function() {
// Button callbacks
$(table).find('.button-edit-stocktake').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/part/stocktake/${pk}/`, {
fields: {
quantity: {},
note: {},
},
title: '{% trans "Edit Stocktake Entry" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
$(table).find('.button-delete-stocktake').click(function() {
var pk = $(this).attr('pk');
constructForm(`/api/part/stocktake/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Stocktake Entry" %}',
onSuccess: function() {
$(table).bootstrapTable('refresh');
}
});
});
}
});
}
/* Load part variant table
*/
function loadPartVariantTable(table, partId, options={}) {
var params = options.params || {}; var params = options.params || {};
@ -1625,6 +1896,16 @@ function loadPartTable(table, url, options={}) {
} }
}); });
columns.push({
field: 'last_stocktake',
title: '{% trans "Last Stocktake" %}',
sortable: true,
switchable: true,
formatter: function(value) {
return renderDate(value);
}
});
// Push an "actions" column // Push an "actions" column
if (options.actions) { if (options.actions) {
columns.push({ columns.push({

View File

@ -19,7 +19,6 @@
makeIconBadge, makeIconBadge,
makeIconButton, makeIconButton,
makeOptionsList, makeOptionsList,
makePartIcons,
modalEnable, modalEnable,
modalSetContent, modalSetContent,
modalSetTitle, modalSetTitle,
@ -1742,15 +1741,11 @@ function loadStockTable(table, options) {
switchable: params['part_detail'], switchable: params['part_detail'],
formatter: function(value, row) { formatter: function(value, row) {
var url = `/part/${row.part}/`; return partDetail(row.part_detail, {
var thumb = row.part_detail.thumbnail; thumb: true,
var name = row.part_detail.full_name; link: true,
icons: true,
var html = imageHoverIcon(thumb) + renderLink(shortenString(name), url); });
html += makePartIcons(row.part_detail);
return html;
} }
}; };

View File

@ -499,6 +499,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool', type: 'bool',
title: '{% trans "Subscribed" %}', title: '{% trans "Subscribed" %}',
}, },
stocktake: {
type: 'bool',
title: '{% trans "Has stocktake entries" %}',
},
is_template: { is_template: {
type: 'bool', type: 'bool',
title: '{% trans "Template" %}', title: '{% trans "Template" %}',

View File

@ -97,6 +97,7 @@ class RuleSet(models.Model):
'part_partrelated', 'part_partrelated',
'part_partstar', 'part_partstar',
'part_partcategorystar', 'part_partcategorystar',
'part_partstocktake',
'company_supplierpart', 'company_supplierpart',
'company_manufacturerpart', 'company_manufacturerpart',
'company_manufacturerpartparameter', 'company_manufacturerpartparameter',