From ab4e2aa8bb13f55126bf7c9560d83ec5a4ad172b Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 31 Dec 2022 23:14:43 +1100 Subject: [PATCH] Part stocktake (#4069) * Remove stat context variables * Revert "Remove stat context variables" This reverts commit 0989c308d0cea9b9405a1338d257b542c6d33d73. * 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 --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/build/templates/build/detail.html | 2 +- InvenTree/common/models.py | 7 + InvenTree/part/admin.py | 16 + InvenTree/part/api.py | 82 ++++- .../part/migrations/0091_partstocktake.py | 28 ++ .../migrations/0092_part_last_stocktake.py | 18 ++ InvenTree/part/models.py | 72 +++++ InvenTree/part/serializers.py | 45 ++- InvenTree/part/templates/part/detail.html | 42 ++- InvenTree/part/templates/part/part_base.html | 14 + .../part/templates/part/part_sidebar.html | 5 + .../part/templates/part/part_stocktake.html | 10 + .../part/templatetags/inventree_extras.py | 10 + InvenTree/part/test_api.py | 140 ++++++++- InvenTree/part/test_part.py | 16 +- .../InvenTree/settings/user_display.html | 1 + .../templates/js/translated/attachment.js | 2 +- InvenTree/templates/js/translated/forms.js | 3 + InvenTree/templates/js/translated/part.js | 287 +++++++++++++++++- InvenTree/templates/js/translated/stock.js | 15 +- .../templates/js/translated/table_filters.js | 4 + InvenTree/users/models.py | 1 + 23 files changed, 803 insertions(+), 22 deletions(-) create mode 100644 InvenTree/part/migrations/0091_partstocktake.py create mode 100644 InvenTree/part/migrations/0092_part_last_stocktake.py create mode 100644 InvenTree/part/templates/part/part_stocktake.html diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 0fee6711b9..dcf30151d5 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # 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 +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 - Add endpoints serving ICS calendars for purchase and sales orders through API diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index e6ba0bb60b..97653c9a8b 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -466,7 +466,7 @@ $('#btn-create-output').click(function() { createBuildOutput( {{ build.pk }}, { - trackable_parts: {% if build.part.has_trackable_parts %}true{% else %}false{% endif%}, + trackable_parts: {% js_bool build.part.has_trackable_parts %}, } ); }); diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 8e7e61303e..4caa3dbfb6 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1774,6 +1774,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'DISPLAY_STOCKTAKE_TAB': { + 'name': _('Part Stocktake'), + 'description': _('Display part stocktake information'), + 'default': True, + 'validator': bool, + }, + 'TABLE_STRING_MAX_LENGTH': { 'name': _('Table String Length'), 'description': _('Maximimum length limit for strings displayed in table views'), diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index ff4ae2bb67..d622330b9a 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -104,6 +104,11 @@ class PartResource(InvenTreeResource): models.Part.objects.rebuild() +class StocktakeInline(admin.TabularInline): + """Inline for part stocktake data""" + model = models.PartStocktake + + class PartAdmin(ImportExportModelAdmin): """Admin class for the Part model""" @@ -122,6 +127,10 @@ class PartAdmin(ImportExportModelAdmin): 'default_supplier', ] + inlines = [ + StocktakeInline, + ] + class PartPricingAdmin(admin.ModelAdmin): """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 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.PartInternalPriceBreak, PartInternalPriceBreakAdmin) admin.site.register(models.PartPricing, PartPricingAdmin) +admin.site.register(models.PartStocktake, PartStocktakeAdmin) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 77e0dbd03e..0b7d000c7d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -13,6 +13,7 @@ from django_filters import rest_framework as rest_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, serializers, status from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response import order.models @@ -27,6 +28,7 @@ from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI, ListAPI, ListCreateAPI, RetrieveAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, UpdateAPI) +from InvenTree.permissions import RolePermission from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, SalesOrderStatus) from part.admin import PartCategoryResource, PartResource @@ -38,7 +40,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, PartParameter, PartParameterTemplate, PartRelated, PartSellPriceBreak, - PartTestTemplate) + PartStocktake, PartTestTemplate) class CategoryList(APIDownloadMixin, ListCreateAPI): @@ -1061,6 +1063,20 @@ class PartFilter(rest_filters.FilterSet): 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() assembly = rest_filters.BooleanFilter() @@ -1537,6 +1553,7 @@ class PartList(APIDownloadMixin, ListCreateAPI): 'in_stock', 'unallocated_stock', 'category', + 'last_stocktake', ] # Default ordering @@ -1696,6 +1713,63 @@ class PartParameterDetail(RetrieveUpdateDestroyAPI): 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): """Custom filters for the BOM list.""" @@ -2111,6 +2185,12 @@ part_api_urls = [ re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), ])), + # Part stocktake data + re_path(r'^stocktake/', include([ + re_path(r'^(?P\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([ path('', PartThumbs.as_view(), name='api-part-thumbs'), re_path(r'^(?P\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'), diff --git a/InvenTree/part/migrations/0091_partstocktake.py b/InvenTree/part/migrations/0091_partstocktake.py new file mode 100644 index 0000000000..948ee48b59 --- /dev/null +++ b/InvenTree/part/migrations/0091_partstocktake.py @@ -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')), + ], + ), + ] diff --git a/InvenTree/part/migrations/0092_part_last_stocktake.py b/InvenTree/part/migrations/0092_part_last_stocktake.py new file mode 100644 index 0000000000..d5035e4716 --- /dev/null +++ b/InvenTree/part/migrations/0092_part_last_stocktake.py @@ -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'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 72e6e0e8de..754d6fc7d1 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -372,6 +372,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): creation_date: Date that this part was added to the database creation_user: User who added this part to the database responsible: User who is responsible for this part (optional) + last_stocktake: Date at which last stocktake was performed for this Part """ 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') + last_stocktake = models.DateField( + blank=True, null=True, + verbose_name=_('Last Stocktake'), + ) + @property def category_path(self): """Return the category path of this Part instance""" @@ -2161,6 +2167,12 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): 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 def has_variants(self): """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): """Model for storing file attachments against a Part object.""" diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 45a0a5e55e..a20a57911a 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -24,14 +24,16 @@ from InvenTree.serializers import (DataFileExtractSerializer, InvenTreeDecimalField, InvenTreeImageSerializerField, InvenTreeModelSerializer, - InvenTreeMoneySerializer, RemoteImageMixin) + InvenTreeMoneySerializer, RemoteImageMixin, + UserSerializer) from InvenTree.status_codes import BuildStatus from .models import (BomItem, BomItemSubstitute, Part, PartAttachment, PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, PartParameter, PartParameterTemplate, PartPricing, PartRelated, - PartSellPriceBreak, PartStar, PartTestTemplate) + PartSellPriceBreak, PartStar, PartStocktake, + PartTestTemplate) class CategorySerializer(InvenTreeModelSerializer): @@ -451,6 +453,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): 'IPN', 'is_template', 'keywords', + 'last_stocktake', 'link', 'minimum_stock', 'name', @@ -504,6 +507,44 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): 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): """Serializer for Part pricing information""" diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index f72a2cc173..866a6c27e4 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -53,6 +53,29 @@ {% endif %} +{% settings_value 'DISPLAY_STOCKTAKE_TAB' user=request.user as show_stocktake %} +{% if show_stocktake %} +
+
+
+

{% trans "Part Stocktake" %}

+ {% include "spacer.html" %} +
+ {% if roles.part.add %} + + {% endif %} + +
+
+
+
+ {% include "part/part_stocktake.html" %} +
+
+{% endif %} +
@@ -423,7 +446,7 @@ 'part-notes', '{% 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 onPanelLoad('suppliers', function() { diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index f358e4ec01..bfa9c04c04 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -338,6 +338,20 @@ {% endif %} {% endwith %} + {% with part.latest_stocktake as stocktake %} + {% if stocktake %} + + + {% trans "Last Stocktake" %} + + {% decimal stocktake.quantity %} + + {{ stocktake.user.username }} + + + + {% endif %} + {% endwith %} {% with part.get_latest_serial_number as sn %} {% if part.trackable and sn %} diff --git a/InvenTree/part/templates/part/part_sidebar.html b/InvenTree/part/templates/part/part_sidebar.html index a7eb993ab8..ff35246739 100644 --- a/InvenTree/part/templates/part/part_sidebar.html +++ b/InvenTree/part/templates/part/part_sidebar.html @@ -44,6 +44,11 @@ {% trans "Scheduling" as text %} {% include "sidebar_item.html" with label="scheduling" text=text icon="fa-calendar-alt" %} {% 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 %} {% trans "Test Templates" as text %} {% include "sidebar_item.html" with label="test-templates" text=text icon="fa-vial" %} diff --git a/InvenTree/part/templates/part/part_stocktake.html b/InvenTree/part/templates/part/part_stocktake.html new file mode 100644 index 0000000000..cb1d97cdc4 --- /dev/null +++ b/InvenTree/part/templates/part/part_stocktake.html @@ -0,0 +1,10 @@ +{% load i18n %} +{% load inventree_extras %} + +
+
+ {% include "filter_list.html" with id="partstocktake" %} +
+
+ +
diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 7ee58181d9..1917ff8a82 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -484,6 +484,16 @@ def primitive_to_javascript(primitive): 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 def keyvalue(dict, key): """Access to key of supplied dict. diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 57a35775d8..4d723a2a86 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -20,7 +20,7 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, StockStatus) from part.models import (BomItem, BomItemSubstitute, Part, PartCategory, PartCategoryParameterTemplate, PartParameterTemplate, - PartRelated) + PartRelated, PartStocktake) from stock.models import StockItem, StockLocation @@ -2779,3 +2779,141 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase): with self.assertRaises(Part.DoesNotExist): 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) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 47b23f9b78..64959855f2 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -17,7 +17,8 @@ from InvenTree import version from InvenTree.helpers import InvenTreeTestCase from .models import (Part, PartCategory, PartCategoryStar, PartRelated, - PartStar, PartTestTemplate, rename_part_image) + PartStar, PartStocktake, PartTestTemplate, + rename_part_image) from .templatetags import inventree_extras @@ -338,6 +339,19 @@ class PartTest(TestCase): self.r2.delete() 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): """Unit test for the TestTemplate class""" diff --git a/InvenTree/templates/InvenTree/settings/user_display.html b/InvenTree/templates/InvenTree/settings/user_display.html index 136725b951..5ca3b6e316 100644 --- a/InvenTree/templates/InvenTree/settings/user_display.html +++ b/InvenTree/templates/InvenTree/settings/user_display.html @@ -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="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_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 %} diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 2e6b53ffa7..1cbd241e20 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -292,7 +292,7 @@ function loadAttachmentTable(url, options) { var html = renderDate(value); if (row.user_detail) { - html += `${row.user_detail.username}
`; + html += `${row.user_detail.username}`; } return html; diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 50f8d45914..a2293a6c1d 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -343,6 +343,9 @@ function constructForm(url, options) { // Request OPTIONS endpoint from the API 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 options.context = OPTIONS.context || {}; diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index cd8d6e9484..ec313a9a4d 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -33,10 +33,13 @@ loadPartTable, loadPartTestTemplateTable, loadPartSchedulingChart, + loadPartStocktakeTable, loadPartVariantTable, loadRelatedPartsTable, loadSimplePartTable, + partDetail, partStockLabel, + performStocktake, toggleStar, validateBom, */ @@ -678,13 +681,281 @@ function makePartIcons(part) { } return html; - } +/* + * 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 += `
`; + } + } + + // Actions + var actions = `
`; + + // 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 += `
`; + + return ` + + ${part} + ${location} + ${quantity} + ${update_rendered} + ${actions} + `; + } + + // 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 += ` + + + + + + + + + + + + `; + + response.forEach(function(item) { + html += buildStockItemRow(item); + }); + + html += `
{% trans "Stock Item" %}{% trans "Location" %}{% trans "Quantity" %}{% trans "Updated" %}
`; + + 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 += `${row.user_detail.username}`; + } + + return html; + } + }, + { + field: 'actions', + title: '', + visible: options.admin, + switchable: false, + sortable: false, + formatter: function(value, row) { + var html = `
`; + + 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 += `
`; + + 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={}) { - /* Load part variant table - */ 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 if (options.actions) { columns.push({ diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index fa1c6e7944..f8e79c92ba 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -19,7 +19,6 @@ makeIconBadge, makeIconButton, makeOptionsList, - makePartIcons, modalEnable, modalSetContent, modalSetTitle, @@ -1742,15 +1741,11 @@ function loadStockTable(table, options) { switchable: params['part_detail'], formatter: function(value, row) { - var url = `/part/${row.part}/`; - var thumb = row.part_detail.thumbnail; - var name = row.part_detail.full_name; - - var html = imageHoverIcon(thumb) + renderLink(shortenString(name), url); - - html += makePartIcons(row.part_detail); - - return html; + return partDetail(row.part_detail, { + thumb: true, + link: true, + icons: true, + }); } }; diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 9c462f3cfc..c9b5347769 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -499,6 +499,10 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: '{% trans "Subscribed" %}', }, + stocktake: { + type: 'bool', + title: '{% trans "Has stocktake entries" %}', + }, is_template: { type: 'bool', title: '{% trans "Template" %}', diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index ad5b6b281e..407bdb3714 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -97,6 +97,7 @@ class RuleSet(models.Model): 'part_partrelated', 'part_partstar', 'part_partcategorystar', + 'part_partstocktake', 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter',